Compare commits
965
Commits
a8144e9acf
...
73a21b1560
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a21b1560 | ||
|
|
ab82e4fb19 | ||
|
|
a7d180fe61 | ||
|
|
11f97f9dfe | ||
|
|
c103708b15 | ||
|
|
b8276f2abf | ||
|
|
62b8d9d7ad | ||
|
|
c33606a140 | ||
|
|
a768b67452 | ||
|
|
99d06ddf79 | ||
|
|
eabb72366b | ||
|
|
95a572331b | ||
|
|
85de202786 | ||
|
|
75395771ed | ||
|
|
8b56e6ed94 | ||
|
|
c95e0be410 | ||
|
|
9eb681f8ff | ||
|
|
4cfc1f87c0 | ||
|
|
0db64ec982 | ||
|
|
382fadc0ca | ||
|
|
fecddcf9b4 | ||
|
|
7018083223 | ||
|
|
e50f960a0f | ||
|
|
1817cd6baa | ||
|
|
bbff62b345 | ||
|
|
375b833ee3 | ||
|
|
3a752d5a8e | ||
|
|
9a806223bd | ||
|
|
d52199ff6d | ||
|
|
fde1aa58f4 | ||
|
|
652917e74e | ||
|
|
61c3e5a785 | ||
|
|
539b9ab69c | ||
|
|
1e3600b047 | ||
|
|
011faeea5a | ||
|
|
516ae67ef7 | ||
|
|
fed8468188 | ||
|
|
620a85826f | ||
|
|
bfa0133c0d | ||
|
|
8d84c11ab6 | ||
|
|
4da304fe0c | ||
|
|
b7218bb503 | ||
|
|
bec189f2ee | ||
|
|
39579413cc | ||
|
|
73967e476c | ||
|
|
22bfd087a7 | ||
|
|
f7ab8a0111 | ||
|
|
757e8ba9f7 | ||
|
|
93edbd7028 | ||
|
|
6ad0e8d259 | ||
|
|
ef13b3169f | ||
|
|
e0b236caae | ||
|
|
c7d2ebaab8 | ||
|
|
04739840a7 | ||
|
|
da37633fc2 | ||
|
|
890ac578b9 | ||
|
|
e701c089be | ||
|
|
e23e1d2ff6 | ||
|
|
0af65ff5b3 | ||
|
|
2e21ad98ff | ||
|
|
569a79ae69 | ||
|
|
50aa514311 | ||
|
|
cd0fd87b68 | ||
|
|
2a50c5563a | ||
|
|
6454236f0d | ||
|
|
261f007c41 | ||
|
|
c095c5948a | ||
|
|
4df1862ecc | ||
|
|
3eb86996f5 | ||
|
|
e855a3fc54 | ||
|
|
5353a18445 | ||
|
|
eb511ad0aa | ||
|
|
9c0712b493 | ||
|
|
205a85fcbb | ||
|
|
7c0f35fbdb | ||
|
|
9efd9a1581 | ||
|
|
7af56b93eb | ||
|
|
f736c106a3 | ||
|
|
002d04beb5 | ||
|
|
0157409f0d | ||
|
|
e816448e30 | ||
|
|
58d3026171 | ||
|
|
3ed96c32ba | ||
|
|
8f40de5a5c | ||
|
|
d8c2a600c7 | ||
|
|
4b69f49809 | ||
|
|
71933ecf31 | ||
|
|
c8741e34bf | ||
|
|
59fa3181b0 | ||
|
|
7f8f52d8bf | ||
|
|
89b15ac806 | ||
|
|
6479fb6428 | ||
|
|
348a25b8f8 | ||
|
|
a9e50e9bc1 | ||
|
|
72f6985825 | ||
|
|
749c37b09f | ||
|
|
bd9a9b70b8 | ||
|
|
1e5b79dac6 | ||
|
|
cd04c93851 | ||
|
|
d2f12a3e72 | ||
|
|
c63713c587 | ||
|
|
452653c81c | ||
|
|
6a859902e9 | ||
|
|
d528066646 | ||
|
|
e8c3bf6ee5 | ||
|
|
db3ed92cef | ||
|
|
58363fc01f | ||
|
|
2d0192a98b | ||
|
|
2dc45d7ecf | ||
|
|
218d453859 | ||
|
|
612004243c | ||
|
|
a69c55944b | ||
|
|
5222f418cf | ||
|
|
5c690c7211 | ||
|
|
5904e615a1 | ||
|
|
8c0369727d | ||
|
|
fde1179bb1 | ||
|
|
c8fa7a263c | ||
|
|
7b2eda9636 | ||
|
|
7fe894df86 | ||
|
|
ded3e52ebc | ||
|
|
b913ef67eb | ||
|
|
5140803980 | ||
|
|
f56a3d398f | ||
|
|
d5ea0ec519 | ||
|
|
9a7f60aca5 | ||
|
|
3470c0cb21 | ||
|
|
96e43b6e1d | ||
|
|
0a690913c2 | ||
|
|
10d1b69aaf | ||
|
|
453951ee1a | ||
|
|
2c1ccd7678 | ||
|
|
5ed1eb5472 | ||
|
|
8aa7a13bfe | ||
|
|
0a59f57f9e | ||
|
|
8a4f6260bb | ||
|
|
34a9fd4302 | ||
|
|
fe04869194 | ||
|
|
b13243afd6 | ||
|
|
d8accd822c | ||
|
|
ed61a2aaeb | ||
|
|
422d2f918c | ||
|
|
93d666c5e6 | ||
|
|
84eff84a6a | ||
|
|
283752d908 | ||
|
|
b2bac34888 | ||
|
|
c5540a92e3 | ||
|
|
3f7e9a1c2a | ||
|
|
6da76b7e01 | ||
|
|
e109ae69c8 | ||
|
|
8374fc4cf1 | ||
|
|
1b6420d804 | ||
|
|
e04c08bf79 | ||
|
|
96f7bc58b4 | ||
|
|
7b6d219a53 | ||
|
|
457ebebdaa | ||
|
|
4e8271a0b9 | ||
|
|
dfdbefd0ea | ||
|
|
b71427a829 | ||
|
|
ebc1ff8d85 | ||
|
|
65bab10f32 | ||
|
|
b9507ee12b | ||
|
|
97083c618b | ||
|
|
dffa8fe03f | ||
|
|
830122cffa | ||
|
|
e5a99c778e | ||
|
|
6d9079c162 | ||
|
|
9fdb316f3a | ||
|
|
960f595f31 | ||
|
|
bcdaaf5e8f | ||
|
|
eac1e92755 | ||
|
|
4ab478cb56 | ||
|
|
e14e627d9b | ||
|
|
a4e9222235 | ||
|
|
c3b284055f | ||
|
|
bca8ecf91b | ||
|
|
be32143bc4 | ||
|
|
ae1cb2d19e | ||
|
|
c4e73777fe | ||
|
|
211baefbf9 | ||
|
|
32290a22c8 | ||
|
|
7d4767c96d | ||
|
|
6c743bb4d2 | ||
|
|
6d5c1678a6 | ||
|
|
170722e31b | ||
|
|
43c444292c | ||
|
|
27e6dbde0f | ||
|
|
33448d7d7a | ||
|
|
71ed54d91f | ||
|
|
f1ed0cbba6 | ||
|
|
f4c5a78223 | ||
|
|
6e4b5aedb4 | ||
|
|
e6148b0938 | ||
|
|
e10fd912dd | ||
|
|
599e714a7e | ||
|
|
5ad54fd16d | ||
|
|
2cb4f90f71 | ||
|
|
2db128c862 | ||
|
|
ceaa899487 | ||
|
|
2e82d8e2f6 | ||
|
|
f359b71154 | ||
|
|
f10cb68660 | ||
|
|
17414f7415 | ||
|
|
bebdb2dc61 | ||
|
|
f401c3640a | ||
|
|
7cf43195ff | ||
|
|
fec31c1ce5 | ||
|
|
08c84e8f0f | ||
|
|
23174f28b7 | ||
|
|
a6a57b2562 | ||
|
|
0f7983e595 | ||
|
|
b11ccc7c8b | ||
|
|
ae435887ee | ||
|
|
b7b942fb1d | ||
|
|
98f8544ac6 | ||
|
|
64b751503e | ||
|
|
5e5c5bb7de | ||
|
|
a30271ddf9 | ||
|
|
2dddec63f4 | ||
|
|
9d05d5856e | ||
|
|
c2da481bff | ||
|
|
98038eeae7 | ||
|
|
74cdab3b28 | ||
|
|
a88de6caab | ||
|
|
805a75713c | ||
|
|
5432364770 | ||
|
|
298e3ec1d0 | ||
|
|
c671be6fba | ||
|
|
87cab5ffa5 | ||
|
|
ef04bee26c | ||
|
|
329b054128 | ||
|
|
761d52155c | ||
|
|
9889a74320 | ||
|
|
7adb2c6fb8 | ||
|
|
43331c3221 | ||
|
|
8cb536fa61 | ||
|
|
a066e5545e | ||
|
|
4e7ddc5e4a | ||
|
|
f2c7256d56 | ||
|
|
93fa241fc8 | ||
|
|
f06d399388 | ||
|
|
5eb45502c7 | ||
|
|
d538bfa369 | ||
|
|
3f0e6b8c94 | ||
|
|
f1697117c1 | ||
|
|
566eb86ba3 | ||
|
|
be0d0a7c2c | ||
|
|
6fffd52541 | ||
|
|
e8241034e9 | ||
|
|
c350cddf8c | ||
|
|
65e624ab8d | ||
|
|
d4d4e2762b | ||
|
|
92f0f6e35d | ||
|
|
90def21855 | ||
|
|
3ab407d799 | ||
|
|
78650f74aa | ||
|
|
d69be31f10 | ||
|
|
f14e701501 | ||
|
|
19c1cbc6e4 | ||
|
|
0f3a2d3aa0 | ||
|
|
9563c1a110 | ||
|
|
4c6f23cb25 | ||
|
|
2b01bf46d0 | ||
|
|
91c7cb23da | ||
|
|
ab3e8076d6 | ||
|
|
c0f794c2c9 | ||
|
|
e76e1dc6fb | ||
|
|
d88a591272 | ||
|
|
3074b684e1 | ||
|
|
bd610d0b67 | ||
|
|
afd0046cbd | ||
|
|
0b9df59cfe | ||
|
|
13f71e5a6a | ||
|
|
d92e362cd6 | ||
|
|
6f9e304987 | ||
|
|
5f9144946d | ||
|
|
9ad5c9acf4 | ||
|
|
fc6ee69f4b | ||
|
|
b2953a86bb | ||
|
|
a44e1be357 | ||
|
|
a8bf593d94 | ||
|
|
8033844364 | ||
|
|
c79d9a3e56 | ||
|
|
555dcec560 | ||
|
|
4cbb9c3243 | ||
|
|
6ffa63be8d | ||
|
|
251e7e0987 | ||
|
|
89ac9eb896 | ||
|
|
bcf318d676 | ||
|
|
cab87bd40a | ||
|
|
2fe20faaf9 | ||
|
|
857ceac313 | ||
|
|
171185ffd5 | ||
|
|
2867365845 | ||
|
|
9216e4baa3 | ||
|
|
f3069b2f03 | ||
|
|
5066f99f4d | ||
|
|
585b8f69cd | ||
|
|
7abc2a8a03 | ||
|
|
76e8f518f0 | ||
|
|
aaa1061b78 | ||
|
|
c1f9f96a60 | ||
|
|
2400bbcbaa | ||
|
|
8d27badecd | ||
|
|
0c86356a77 | ||
|
|
11b4f62340 | ||
|
|
5278469d0d | ||
|
|
949ccad594 | ||
|
|
b7c62c68a1 | ||
|
|
c2151a2269 | ||
|
|
099948ec01 | ||
|
|
8c627b00f7 | ||
|
|
add7b3f364 | ||
|
|
7ba3bf2f5d | ||
|
|
3b9dba55eb | ||
|
|
e796a34dd6 | ||
|
|
0802a535a6 | ||
|
|
205c940c99 | ||
|
|
015e1f7e3e | ||
|
|
7eff24ddf0 | ||
|
|
8b4dcaf313 | ||
|
|
2b31effe13 | ||
|
|
57034a0fbc | ||
|
|
7558b8b86a | ||
|
|
707ceb1c2b | ||
|
|
45b21ff516 | ||
|
|
2c679b2a23 | ||
|
|
dd5494f92c | ||
|
|
f72f3ff822 | ||
|
|
2d35e8000a | ||
|
|
3bd64c3b1f | ||
|
|
63dee9a081 | ||
|
|
d84c00f778 | ||
|
|
1260ce4704 | ||
|
|
6ffdc220f4 | ||
|
|
d1f83c096c | ||
|
|
3fda400b76 | ||
|
|
be7cf96383 | ||
|
|
b52f2edd74 | ||
|
|
a0546c5b19 | ||
|
|
aeff42d776 | ||
|
|
a63a880c92 | ||
|
|
497274c688 | ||
|
|
39ea99a4d9 | ||
|
|
1ba3565819 | ||
|
|
4834bddaed | ||
|
|
57087d2ac5 | ||
|
|
cfca5504b1 | ||
|
|
0a6a6344dd | ||
|
|
00adec5256 | ||
|
|
57f08ebe50 | ||
|
|
6dc76e1ad3 | ||
|
|
97e762e51c | ||
|
|
cf51bc322b | ||
|
|
668389bdea | ||
|
|
1845e41122 | ||
|
|
9d185ba357 | ||
|
|
7b2b75fb5c | ||
|
|
41ba61bcc8 | ||
|
|
f42d1b3af0 | ||
|
|
5c6ec6c4c3 | ||
|
|
c1277e197b | ||
|
|
d55de48d42 | ||
|
|
6628c4a833 | ||
|
|
1af03bb312 | ||
|
|
bb7a2cc00e | ||
|
|
a08525ed18 | ||
|
|
a4c3652935 | ||
|
|
159bffbef9 | ||
|
|
b8ccf2bc3f | ||
|
|
78d51b4eb5 | ||
|
|
d4beae4b59 | ||
|
|
b64d73ea32 | ||
|
|
743a498255 | ||
|
|
fe141023da | ||
|
|
b79e4c54bd | ||
|
|
0b6bf15b65 | ||
|
|
e743a41de2 | ||
|
|
5efdc7c0f5 | ||
|
|
c304485dd1 | ||
|
|
ec052ffab2 | ||
|
|
a8f75628d6 | ||
|
|
a1ec1c0cb4 | ||
|
|
e4498d403f | ||
|
|
2f35bce832 | ||
|
|
106edbdb41 | ||
|
|
7cd998da1f | ||
|
|
4275b85fe6 | ||
|
|
1ad07f316c | ||
|
|
eb518947aa | ||
|
|
9cc7d273ab | ||
|
|
dbaefaa00e | ||
|
|
0f88c50fbe | ||
|
|
96f7a9174c | ||
|
|
3aa7aebef7 | ||
|
|
0bcc2d5937 | ||
|
|
b1964cdedb | ||
|
|
7d69947327 | ||
|
|
ef680a4213 | ||
|
|
64e748035f | ||
|
|
a8af0ab7ef | ||
|
|
80afd09c8f | ||
|
|
5089373025 | ||
|
|
a43c519fbb | ||
|
|
e227a8b735 | ||
|
|
bae719209c | ||
|
|
1c34a5b725 | ||
|
|
fb4b7a365a | ||
|
|
b9325075ee | ||
|
|
f684178dd2 | ||
|
|
29a6ec2c89 | ||
|
|
fddcd5b2b0 | ||
|
|
af6e77a078 | ||
|
|
d76b819a5c | ||
|
|
c4aff5e10a | ||
|
|
81b8d0d331 | ||
|
|
cf26d30aab | ||
|
|
6b61f9402d | ||
|
|
69ad82b4e5 | ||
|
|
b95c1c2a1a | ||
|
|
df24ecf955 | ||
|
|
d824b502e9 | ||
|
|
b7cd2c98b2 | ||
|
|
c2a20d2209 | ||
|
|
cd8acfc814 | ||
|
|
23d87084d8 | ||
|
|
435f1b85ff | ||
|
|
691a9bcc85 | ||
|
|
2d82a300bf | ||
|
|
3705564ed3 | ||
|
|
adb5b4d6c6 | ||
|
|
919e96caf1 | ||
|
|
eafdd4bf9f | ||
|
|
d95626d4e6 | ||
|
|
d9cbce553b | ||
|
|
ac9889a5ba | ||
|
|
e1d6a3558b | ||
|
|
3bb1ff2f84 | ||
|
|
c500ae93e0 | ||
|
|
358166f3c7 | ||
|
|
90f5dd0a25 | ||
|
|
7d39cf3b49 | ||
|
|
0476b710d6 | ||
|
|
cc51644b90 | ||
|
|
30a533add2 | ||
|
|
0e814a8b43 | ||
|
|
9a64a4f249 | ||
|
|
682efbb40e | ||
|
|
41785db633 | ||
|
|
7ce5765bca | ||
|
|
593dbe4f94 | ||
|
|
ee776133b7 | ||
|
|
10e3be933c | ||
|
|
10e9a8d9e5 | ||
|
|
9577f2daca | ||
|
|
8a8132aab8 | ||
|
|
facc97cec1 | ||
|
|
618650c298 | ||
|
|
ff4effb16d | ||
|
|
65446854f8 | ||
|
|
06a90715f2 | ||
|
|
fd137d697d | ||
|
|
2056e8fb57 | ||
|
|
0f24c456df | ||
|
|
1b2a84c34c | ||
|
|
0fe877e1e2 | ||
|
|
a8289ac7ae | ||
|
|
f92c1a2c5c | ||
|
|
d892a53134 | ||
|
|
3ca02b56fc | ||
|
|
c536ef5ef7 | ||
|
|
20fdfb135a | ||
|
|
ef6712a83f | ||
|
|
5d813c5953 | ||
|
|
7fc5fbf43f | ||
|
|
12b55a8b73 | ||
|
|
2fb6dd7666 | ||
|
|
c5897e6758 | ||
|
|
03adce85a3 | ||
|
|
f21d8620fd | ||
|
|
f73aa90e55 | ||
|
|
c7bff740b8 | ||
|
|
4c4f7df53a | ||
|
|
5c976c3f08 | ||
|
|
70eaf95a4f | ||
|
|
e238a6b296 | ||
|
|
2962790a43 | ||
|
|
5ce9a04dca | ||
|
|
0142f0d4f7 | ||
|
|
0940c15b33 | ||
|
|
6a38df34ff | ||
|
|
12aec9b257 | ||
|
|
ec86d9b90f | ||
|
|
b364d793e4 | ||
|
|
f429063e61 | ||
|
|
8d61745504 | ||
|
|
74c54de5b8 | ||
|
|
b0793637fe | ||
|
|
869e78b294 | ||
|
|
8dc13d0a79 | ||
|
|
26af89352e | ||
|
|
e5a7b70f52 | ||
|
|
e62582b554 | ||
|
|
b51b9413ce | ||
|
|
a1070a1b12 | ||
|
|
3896822a14 | ||
|
|
df10ac1eeb | ||
|
|
dcebbfe523 | ||
|
|
41ad3396e3 | ||
|
|
1e214ef1c8 | ||
|
|
086acc4230 | ||
|
|
f6e88870c6 | ||
|
|
165c0aded5 | ||
|
|
0fecf31d73 | ||
|
|
2ad7036d1c | ||
|
|
60f3c63059 | ||
|
|
2248737bff | ||
|
|
9747eec2a1 | ||
|
|
abc4300dd9 | ||
|
|
a5e446e8e3 | ||
|
|
ae2e110b4f | ||
|
|
f39ec95ec6 | ||
|
|
4e0e312b2b | ||
|
|
22867b01d9 | ||
|
|
e04c98ca5b | ||
|
|
db2ff578ae | ||
|
|
eecb86e1bc | ||
|
|
ea89dd17d5 | ||
|
|
0de5a78ccd | ||
|
|
c5f6afabaf | ||
|
|
b5770489f3 | ||
|
|
d3aa5847a9 | ||
|
|
bca9d49593 | ||
|
|
021bcd502d | ||
|
|
606f20d3c2 | ||
|
|
8e4fd16646 | ||
|
|
d35e6d8f9b | ||
|
|
038bf64ccb | ||
|
|
8b4bfc0da0 | ||
|
|
2f49127a36 | ||
|
|
58bf8818a4 | ||
|
|
3eb598faf5 | ||
|
|
baaf07d1ee | ||
|
|
c1dab8331f | ||
|
|
f036a2f29d | ||
|
|
100a832a42 | ||
|
|
80b564436b | ||
|
|
a45b02486e | ||
|
|
bd51782647 | ||
|
|
682467cfcf | ||
|
|
1ece159b61 | ||
|
|
83e482ef46 | ||
|
|
5f5d97bf03 | ||
|
|
2eceb1ed80 | ||
|
|
89f1883016 | ||
|
|
8d71bd74a3 | ||
|
|
c7975ea7f5 | ||
|
|
57c019453f | ||
|
|
c382d614b3 | ||
|
|
9fa3a641c0 | ||
|
|
fbcd335fcf | ||
|
|
92728ae3bc | ||
|
|
dcb2f757b8 | ||
|
|
e6d0617f83 | ||
|
|
415422ca1c | ||
|
|
e6032cc57d | ||
|
|
624f496925 | ||
|
|
f9971f617e | ||
|
|
d1a6c90434 | ||
|
|
b09917debc | ||
|
|
63a26adf83 | ||
|
|
c8be539f8b | ||
|
|
a5cb2c4827 | ||
|
|
c95f64fbb4 | ||
|
|
2a06987982 | ||
|
|
73c8ba9bca | ||
|
|
e2a158f167 | ||
|
|
772b5c090a | ||
|
|
1b8f843505 | ||
|
|
53d790a397 | ||
|
|
1d4c0740a6 | ||
|
|
5c299c0ce4 | ||
|
|
2c72ff8f31 | ||
|
|
960f186c04 | ||
|
|
8f7405a319 | ||
|
|
7cd7dad3f1 | ||
|
|
b4d859d8e9 | ||
|
|
8ff1a12fa0 | ||
|
|
322fed6631 | ||
|
|
92e07677e2 | ||
|
|
dde6e13a76 | ||
|
|
0088fba682 | ||
|
|
350e216bda | ||
|
|
57c54a2b4e | ||
|
|
3454e6a2bb | ||
|
|
eb0f71d7dd | ||
|
|
63043ea4a1 | ||
|
|
5bf48ee854 | ||
|
|
5fe512dcdf | ||
|
|
06d742c4b6 | ||
|
|
93220830dd | ||
|
|
7d7f294164 | ||
|
|
ac426cc440 | ||
|
|
fd59b4a2df | ||
|
|
6b26f6bb0e | ||
|
|
46e2e6cd7f | ||
|
|
cdd231cb41 | ||
|
|
cfe579086f | ||
|
|
f82a21d843 | ||
|
|
7b3dc47107 | ||
|
|
8855e654ed | ||
|
|
4f0d5aa04a | ||
|
|
43f803dee2 | ||
|
|
8b00697a8c | ||
|
|
41d971efac | ||
|
|
fc5d873d9d | ||
|
|
bd25a6dcfe | ||
|
|
a45f465e9e | ||
|
|
9ce904a388 | ||
|
|
b15c90640e | ||
|
|
eda3e767dd | ||
|
|
b5defc41fa | ||
|
|
cef21a58f7 | ||
|
|
4669bf1810 | ||
|
|
bddbce9fef | ||
|
|
f1f2822351 | ||
|
|
5cd3a26419 | ||
|
|
9c5cbc4506 | ||
|
|
c5a4b9bca8 | ||
|
|
985f0e1b18 | ||
|
|
9daccb427f | ||
|
|
7d912f7623 | ||
|
|
b410eba3cf | ||
|
|
fb6713e303 | ||
|
|
4e3c91c77a | ||
|
|
7296ef9ca7 | ||
|
|
b159284af4 | ||
|
|
84c57e8829 | ||
|
|
9a746b9e95 | ||
|
|
1b295344d2 | ||
|
|
2f4ba89733 | ||
|
|
87b17daa55 | ||
|
|
9af8cb8c1e | ||
|
|
c0e3bbd1ec | ||
|
|
dc5b901a75 | ||
|
|
4b21586e10 | ||
|
|
39241e0ac3 | ||
|
|
b92eecdb77 | ||
|
|
75bcd0cf3e | ||
|
|
68b8434b1a | ||
|
|
5cd46bc521 | ||
|
|
4d81d6e647 | ||
|
|
ebb98a0a0b | ||
|
|
e55b2c3a6e | ||
|
|
4f78bf9e3a | ||
|
|
e854606586 | ||
|
|
65fb112c5f | ||
|
|
59aa5ee909 | ||
|
|
dc8337d6c3 | ||
|
|
0ec7f96fd7 | ||
|
|
2b55b41dfd | ||
|
|
fa5e098143 | ||
|
|
3a9e72f2f6 | ||
|
|
ecd43ad7d2 | ||
|
|
85599573cd | ||
|
|
3e0d95b469 | ||
|
|
d16716ee1f | ||
|
|
a40f680b00 | ||
|
|
3090c418b7 | ||
|
|
430b10f939 | ||
|
|
2b27aed383 | ||
|
|
aaa9945792 | ||
|
|
ff04624d13 | ||
|
|
1c3953ff05 | ||
|
|
8e7e16313e | ||
|
|
dbad71d7d9 | ||
|
|
d593377668 | ||
|
|
df92a1a0be | ||
|
|
5b8aaaf124 | ||
|
|
f0213c14bc | ||
|
|
848d1392cb | ||
|
|
8c2fd3e127 | ||
|
|
8234058562 | ||
|
|
ef0335286c | ||
|
|
9c128a6544 | ||
|
|
7f5a6ea766 | ||
|
|
2c62367ed8 | ||
|
|
b37da118fb | ||
|
|
0320f050ce | ||
|
|
035673b065 | ||
|
|
be117a24b4 | ||
|
|
7c15396a79 | ||
|
|
8bbadc4ece | ||
|
|
39627475af | ||
|
|
976a1c71f3 | ||
|
|
a2b68e8e6b | ||
|
|
0b867a7444 | ||
|
|
474f5e4361 | ||
|
|
74d69e8498 | ||
|
|
824c4d8e47 | ||
|
|
2157738d85 | ||
|
|
0ec8f2400e | ||
|
|
c00de34676 | ||
|
|
8324ed070a | ||
|
|
c15437c441 | ||
|
|
a6b82efba8 | ||
|
|
e2f02a6e4f | ||
|
|
a974372a1a | ||
|
|
9f2be9dc61 | ||
|
|
a9835776ad | ||
|
|
bb2c2b538e | ||
|
|
f4da7e6523 | ||
|
|
c906d2fe93 | ||
|
|
c19da874f3 | ||
|
|
6c09d25c34 | ||
|
|
760a490b87 | ||
|
|
60bc141f95 | ||
|
|
b3531409e4 | ||
|
|
fa52c0069b | ||
|
|
c3f440ee55 | ||
|
|
b054afe9de | ||
|
|
b9eaa31656 | ||
|
|
5d9fd088ea | ||
|
|
23004d5303 | ||
|
|
bab15351f1 | ||
|
|
fdc7e198d1 | ||
|
|
ab782c9189 | ||
|
|
e3afb93f4b | ||
|
|
6f23860dc9 | ||
|
|
b74a179c53 | ||
|
|
4551fa2f08 | ||
|
|
f3640e976a | ||
|
|
5dd1194be4 | ||
|
|
dcb42d2e5d | ||
|
|
5c438c0d37 | ||
|
|
a265a0a174 | ||
|
|
1b674eaf1a | ||
|
|
93da172460 | ||
|
|
366e82385b | ||
|
|
2442fa2e1f | ||
|
|
1cb15d1f3c | ||
|
|
5cc9c44baa | ||
|
|
5c4e1ae1b3 | ||
|
|
cfe397c360 | ||
|
|
7a1c7c9d42 | ||
|
|
76725699b9 | ||
|
|
b84518066c | ||
|
|
3be0738f1e | ||
|
|
ef74a22370 | ||
|
|
b1c6fcec23 | ||
|
|
5084e03fd6 | ||
|
|
63f825ca77 | ||
|
|
5f6e572219 | ||
|
|
43be0ca4d1 | ||
|
|
4d71bd60df | ||
|
|
2e5ea4fc17 | ||
|
|
bae9d21828 | ||
|
|
936480b1f1 | ||
|
|
077ceae759 | ||
|
|
c3af4a9dd2 | ||
|
|
180aa2d053 | ||
|
|
44f8cad928 | ||
|
|
f96795c6ee | ||
|
|
6af4c72e4d | ||
|
|
7b9d519386 | ||
|
|
3ce2ee3ed1 | ||
|
|
199c59113b | ||
|
|
88b5945ee2 | ||
|
|
b0baa9c211 | ||
|
|
01e211853f | ||
|
|
88dfc97877 | ||
|
|
b574af8528 | ||
|
|
2cd47cd166 | ||
|
|
6ec6bd1314 | ||
|
|
4709f4c33c | ||
|
|
87e675c344 | ||
|
|
d8f88d45da | ||
|
|
e22e06ac89 | ||
|
|
041e1cf643 | ||
|
|
18d97e8bef | ||
|
|
80c811eadd | ||
|
|
1b9a6700cb | ||
|
|
fda61aa4c5 | ||
|
|
3b78745b1f | ||
|
|
3234a82e45 | ||
|
|
2b9647a5be | ||
|
|
df6b760ea4 | ||
|
|
2d08b9550c | ||
|
|
101306e916 | ||
|
|
6f6b1794a3 | ||
|
|
4ad497dfc4 | ||
|
|
eb1c23db48 | ||
|
|
2416a82373 | ||
|
|
59a1dc1853 | ||
|
|
ddb44e0be2 | ||
|
|
4ac49e8582 | ||
|
|
b17ad71761 | ||
|
|
6ed89f6a94 | ||
|
|
b4430b3b40 | ||
|
|
399b5b4e23 | ||
|
|
7ec94c6d2a | ||
|
|
d46db51750 | ||
|
|
e5ca46aa6a | ||
|
|
866d4e5683 | ||
|
|
20ea6d6f5a | ||
|
|
2c92714340 | ||
|
|
1bcb8ee336 | ||
|
|
ae9aee9282 | ||
|
|
5bde656fbf | ||
|
|
e1feee31af | ||
|
|
8633bc51dd | ||
|
|
b5246ca999 | ||
|
|
cb1aa92f7c | ||
|
|
1a3cee4012 | ||
|
|
d1aab2be76 | ||
|
|
53135139f3 | ||
|
|
efff920eee | ||
|
|
9a9e4da5b2 | ||
|
|
454bceed6c | ||
|
|
3f7a3c8b4d | ||
|
|
022d042c6a | ||
|
|
f5900c5f33 | ||
|
|
0cf7d691f8 | ||
|
|
93efeb76e0 | ||
|
|
28f9a26a1e | ||
|
|
632a6778f2 | ||
|
|
ab50a0afca | ||
|
|
049a5afa82 | ||
|
|
1792715145 | ||
|
|
955a1d02d2 | ||
|
|
df731256f4 | ||
|
|
ec7e65015f | ||
|
|
1cadda4199 | ||
|
|
a351a258ae | ||
|
|
987630e7f8 | ||
|
|
3241aa736c | ||
|
|
91764e74eb | ||
|
|
5561a548d7 | ||
|
|
360a0696c6 | ||
|
|
88bfc6d419 | ||
|
|
45df314d2f | ||
|
|
5155016c4b | ||
|
|
dafdb036fa | ||
|
|
57f73fe583 | ||
|
|
9b127f453b | ||
|
|
1c897e3642 | ||
|
|
02c65b818e | ||
|
|
86b98214ec | ||
|
|
dc8a5eb060 | ||
|
|
4a64f0e2b9 | ||
|
|
8f7dc0aa3d | ||
|
|
974ad59a68 | ||
|
|
eeb11bf303 | ||
|
|
f3bdc3b9ad | ||
|
|
9addc95a4b | ||
|
|
f87f94db88 | ||
|
|
2e7761a554 | ||
|
|
33527a7f77 | ||
|
|
94f03c9fc2 | ||
|
|
f855be3103 | ||
|
|
12bdca5c1c | ||
|
|
01d31f589f | ||
|
|
2a5557edb6 | ||
|
|
adfbc82303 | ||
|
|
ca9ded74fd | ||
|
|
84bbf871c5 | ||
|
|
0d0ef439bb | ||
|
|
e7a95106da | ||
|
|
83a33dfef1 | ||
|
|
b20af90b8d | ||
|
|
8346def2aa | ||
|
|
69545dbabd | ||
|
|
83ec532564 | ||
|
|
d6bf209303 | ||
|
|
cf32a6dfaa | ||
|
|
43d33a036f | ||
|
|
078ce6fcd2 | ||
|
|
27c74932b1 | ||
|
|
1387b213d6 | ||
|
|
7aa6b1d960 | ||
|
|
d0ff3599c1 | ||
|
|
43ffd041c0 | ||
|
|
ecbbd72eff | ||
|
|
97c67c9bb5 | ||
|
|
0568527cd6 | ||
|
|
e5ee704e2e | ||
|
|
73d3e1153b | ||
|
|
205e4c1843 | ||
|
|
9ba1f7bf7b | ||
|
|
f4ce8b44d4 | ||
|
|
d8c55f3112 | ||
|
|
f63849e55e | ||
|
|
f0ba648b1f | ||
|
|
9b9e9f50a0 | ||
|
|
541c4e5d8c | ||
|
|
04480e8a03 | ||
|
|
5399a078e8 | ||
|
|
59d98a741b | ||
|
|
9f5b8afe7b | ||
|
|
9e7c1a8772 | ||
|
|
88dd54c7c3 | ||
|
|
8678c289ce | ||
|
|
ccfe50fff2 | ||
|
|
f470947c81 | ||
|
|
b215e99805 | ||
|
|
4207392433 | ||
|
|
b2573b4689 | ||
|
|
8a02b7c87f | ||
|
|
2317947c4e | ||
|
|
8b0199d4b5 | ||
|
|
4f70258d40 | ||
|
|
4c4565a50f | ||
|
|
65660e4087 | ||
|
|
fa2ad4b664 | ||
|
|
38582c564d | ||
|
|
c4dec47576 | ||
|
|
c390aa8462 | ||
|
|
2ba6ae2f0c | ||
|
|
b6fd9b1fd4 | ||
|
|
dfd71b4bc2 | ||
|
|
d7d1654f32 | ||
|
|
d1ed1019c0 | ||
|
|
7436986246 | ||
|
|
a80b4431bf | ||
|
|
f735e4ca51 | ||
|
|
05e492a99a | ||
|
|
bbd8466ded | ||
|
|
6b37c040c1 | ||
|
|
4389246350 | ||
|
|
7dd9e58870 | ||
|
|
0f0fcb6280 | ||
|
|
f194e51f4d | ||
|
|
d77f6b3379 | ||
|
|
0e4ffc8c66 | ||
|
|
b31e9b4af1 | ||
|
|
b110bdc3b2 | ||
|
|
9b9e83fe6c | ||
|
|
36c5a4457c | ||
|
|
d17faacefc | ||
|
|
44f14b67f8 | ||
|
|
51b207f0ba | ||
|
|
dda7a5739b | ||
|
|
c2257fe6f3 | ||
|
|
ee28390a2c | ||
|
|
cb82c6262c | ||
|
|
e2a42aa4ab | ||
|
|
6fac55d795 | ||
|
|
25b6ee5d72 | ||
|
|
1cd4ba19df | ||
|
|
ff56134b91 | ||
|
|
1a5f096496 | ||
|
|
2c2c072319 | ||
|
|
d1309b4f73 | ||
|
|
65a14c632e | ||
|
|
b5efee66ab | ||
|
|
0df32f2eb3 | ||
|
|
8563d5e78b | ||
|
|
ede8a8b33b | ||
|
|
de2a180ebd | ||
|
|
bcc3ae6405 | ||
|
|
15c7a9b577 | ||
|
|
5a06371a94 | ||
|
|
df70288dee | ||
|
|
d2f853233f | ||
|
|
a0da6c7727 |
No files matched your search
+4
-1
@@ -43,8 +43,11 @@ build/
|
||||
dist/
|
||||
|
||||
# Project docs
|
||||
# NOTE: `*` never crosses `/` in Docker ignore matching, so `/*.md` (anchored)
|
||||
# excludes only ROOT-level markdown. Nested skill docs (agent/src/skills/**/*.md)
|
||||
# and agent/SKILL.md are intentionally NOT excluded — they ship in the image.
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
*.md
|
||||
/*.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
@@ -1,7 +1,10 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/2vDYc2w5
|
||||
url: https://discord.gg/6TdQnT5xcF
|
||||
about: Join the HKUDS community Discord
|
||||
- name: GitHub Discussions
|
||||
url: https://github.com/HKUDS/Vibe-Trading/discussions
|
||||
about: Ask questions, share strategies, get help
|
||||
- name: Documentation
|
||||
url: https://github.com/HKUDS/Vibe-Trading#-quick-started
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Root Python project (pyproject.toml).
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 2
|
||||
groups:
|
||||
# Minor/patch bumps are low-risk and reviewed as a single batch; majors
|
||||
# are excluded so they still arrive individually and get real scrutiny
|
||||
# (a batched PR mixing 10 patch bumps with 1 breaking major is worse
|
||||
# than either alone — you can't reject just the major).
|
||||
pip-minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types: ["minor", "patch"]
|
||||
ignore:
|
||||
# pandas 3.x has real breaking changes (Copy-on-Write always-on, removed
|
||||
# deprecated APIs) we haven't vetted; the <3.0.0 ceiling is deliberate,
|
||||
# not an oversight. Revisit as a dedicated migration, not a dep bump.
|
||||
- dependency-name: "pandas"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# caio is capped by our own dependency tree, not by choice: aiofile
|
||||
# 3.11.1 (latest) requires caio~=0.9.0, so any bump to caio 0.10.x makes
|
||||
# `pip install` unresolvable. Dependabot kept proposing it and the group
|
||||
# PR failed CI with ResolutionImpossible. Drop this ignore once aiofile
|
||||
# ships a release that accepts caio 0.10.
|
||||
- dependency-name: "caio"
|
||||
# websockets is likewise capped by our tree: langgraph-sdk 0.4.2 requires
|
||||
# websockets<16, so a bump to 16.x is unresolvable. Drop this once
|
||||
# langgraph-sdk widens its range.
|
||||
- dependency-name: "websockets"
|
||||
# ccxt does not use ranges — it exact-pins (``==``) its entire transitive
|
||||
# closure, and every one of the 23 packages below is currently held by it
|
||||
# at precisely the version in our lock. None of them can move until ccxt
|
||||
# itself releases, so a Dependabot PR touching any of them is unmergeable
|
||||
# by construction: PR #882 proposed five of them and died with
|
||||
# ResolutionImpossible. Verified by re-running the full resolver
|
||||
# (`pip-compile --upgrade`, Python 3.11 on Linux) — it reproduces these
|
||||
# exact versions, so this is the resolver's ceiling and not a stale lock.
|
||||
#
|
||||
# This suppresses only the automated PRs. Dependabot *alerts* come from
|
||||
# the dependency graph and are unaffected by this file, so a CVE in
|
||||
# cryptography/urllib3/requests/certifi still surfaces in the Security
|
||||
# tab — the remedy there is to bump ccxt, which carries the whole set.
|
||||
# Drop an entry here if ccxt ever loosens that pin.
|
||||
- dependency-name: "aiohappyeyeballs"
|
||||
- dependency-name: "aiohttp"
|
||||
- dependency-name: "aiohttp-fast-zlib"
|
||||
- dependency-name: "aiosignal"
|
||||
- dependency-name: "attrs"
|
||||
- dependency-name: "certifi"
|
||||
- dependency-name: "cffi"
|
||||
- dependency-name: "charset-normalizer"
|
||||
- dependency-name: "coincurve"
|
||||
- dependency-name: "cryptography"
|
||||
- dependency-name: "frozenlist"
|
||||
- dependency-name: "idna"
|
||||
- dependency-name: "multidict"
|
||||
- dependency-name: "orjson"
|
||||
- dependency-name: "propcache"
|
||||
- dependency-name: "pycparser"
|
||||
- dependency-name: "requests"
|
||||
- dependency-name: "setuptools"
|
||||
- dependency-name: "typing-extensions"
|
||||
- dependency-name: "urllib3"
|
||||
- dependency-name: "uvloop"
|
||||
- dependency-name: "yarl"
|
||||
- dependency-name: "zlib-ng"
|
||||
# pydantic-core is exact-pinned by pydantic (``pydantic-core==2.46.4``),
|
||||
# so it only ever moves as part of a pydantic release. It was the sixth
|
||||
# and last package in #882.
|
||||
- dependency-name: "pydantic-core"
|
||||
|
||||
# Frontend npm dependencies.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 2
|
||||
groups:
|
||||
npm-minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types: ["minor", "patch"]
|
||||
ignore:
|
||||
# @vitejs/plugin-react 6.x peers vite@^8 and we are on vite 6.x, so it can
|
||||
# only sit red. Same rationale as the pandas pin above: revisit as a
|
||||
# dedicated migration, not a dependency bump.
|
||||
#
|
||||
# The jsdom and @testing-library/jest-dom majors used to be ignored here
|
||||
# too, both blocked on Node >=22 while CI and Docker ran Node 20. That
|
||||
# wall is gone — Node 20 reached EOL on 2026-04-30 and the toolchain moved
|
||||
# to Node 22 — so both were taken and the ignores removed rather than left
|
||||
# with a rationale that no longer holds.
|
||||
- dependency-name: "@vitejs/plugin-react"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# GitHub Actions workflow pins.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 2
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types: ["minor", "patch"]
|
||||
@@ -12,15 +12,37 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Verify hash-locked dependencies
|
||||
run: >-
|
||||
pip install
|
||||
--dry-run
|
||||
--ignore-installed
|
||||
--require-hashes
|
||||
-r requirements-lock.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
# `openbb` is included so agent/tests/test_openbb_bridge actually runs:
|
||||
# those tests importorskip on openbb_ai, so without the extra the whole
|
||||
# OpenBB Workspace bridge would report green while never being exercised.
|
||||
run: pip install -e ".[dev,openbb]"
|
||||
|
||||
- name: Verify optional extras under test are importable
|
||||
# Fails loudly if the openbb extra stops resolving, instead of letting
|
||||
# the bridge suite silently degrade to skips.
|
||||
run: python -c "import openbb_ai, importlib.metadata as m; print('openbb-ai', m.version('openbb-ai'))"
|
||||
|
||||
- name: Repository safety gates
|
||||
run: bash tools/ci_grep_gates.sh
|
||||
|
||||
- name: Test environment variable gate
|
||||
run: pytest tools/test_ci_env_var_gate.py -q
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
@@ -36,11 +58,11 @@ jobs:
|
||||
# test_e2e_harness_v2.py is gated by VIBE_TRADING_RUN_LIVE_E2E
|
||||
# (real-LLM only); list --ignore here so CI's intent is explicit and
|
||||
# the suite never silently switches state on env presence.
|
||||
run: pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --tb=short -q
|
||||
run: pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --cov=agent --cov-report=term-missing --cov-report=xml --tb=short -q
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -49,3 +71,46 @@ jobs:
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Frontend tests
|
||||
run: |
|
||||
cd frontend
|
||||
npx vitest run --reporter=verbose
|
||||
|
||||
windows-background:
|
||||
name: ${{ matrix.name }} / Python 3.14 background lifecycle
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows 11 ARM64
|
||||
runner: windows-11-arm
|
||||
architecture: arm64
|
||||
- name: Windows Server 2025 x64
|
||||
runner: windows-2025
|
||||
architecture: x64
|
||||
|
||||
steps:
|
||||
# actions/checkout v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
# actions/setup-python v7.0.0
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
||||
with:
|
||||
python-version: "3.14"
|
||||
architecture: ${{ matrix.architecture }}
|
||||
cache: pip
|
||||
|
||||
- name: Install focused test dependencies
|
||||
run: python -m pip install pytest pydantic pyyaml python-dotenv
|
||||
|
||||
- name: Run Windows background safety regressions
|
||||
env:
|
||||
PYTHONPATH: agent
|
||||
run: >-
|
||||
python -m pytest
|
||||
agent/tests/test_background_tools.py
|
||||
agent/tests/test_bash_tool.py
|
||||
-q
|
||||
@@ -21,12 +21,18 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Deploy wiki
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy wiki --project-name=vibetrading-wiki --branch=main
|
||||
# Deploy from inside wiki/ (config mode) so wrangler reads
|
||||
# wiki/wrangler.toml and compiles wiki/functions/ as Pages Functions.
|
||||
# Deploying the dir positionally (`pages deploy wiki`) uploads the
|
||||
# functions as static files and never compiles them.
|
||||
workingDirectory: wiki
|
||||
wranglerVersion: "4.98.0"
|
||||
command: pages deploy --project-name=vibetrading-wiki --branch=main
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -38,6 +38,9 @@ jobs:
|
||||
- name: Check locale JSON
|
||||
run: python -m json.tool wiki/locales/en.json >/dev/null
|
||||
|
||||
- name: Check social card metadata
|
||||
run: python wiki/scripts/check_social_cards.py
|
||||
|
||||
- name: Check static site files
|
||||
run: |
|
||||
test -f wiki/index.html
|
||||
@@ -49,4 +52,7 @@ jobs:
|
||||
test -f wiki/_redirects
|
||||
test -f wiki/_headers
|
||||
test -f wiki/assets/icon.png
|
||||
! grep -R "localhost\\|nanobot" wiki --exclude=README.md
|
||||
test -f wiki/assets/alpha-library-og.png
|
||||
blocked="localhost\\|nano"
|
||||
blocked="${blocked}bot"
|
||||
! grep -R "$blocked" wiki --exclude=README.md
|
||||
+26
@@ -4,6 +4,16 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
|
||||
# Coverage reports
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# Node dependencies (root level)
|
||||
node_modules/
|
||||
|
||||
# Backtest output artifacts
|
||||
results/
|
||||
|
||||
.env
|
||||
agent/.env
|
||||
|
||||
@@ -17,6 +27,19 @@ agent/runs/
|
||||
agent/.ui_runtime/
|
||||
agent/.tasks/
|
||||
agent/uploads/
|
||||
# Same runtime artifacts, but written to the repo root when the CLI or server
|
||||
# is started from there — sessions.db holds real session transcripts and
|
||||
# live/ holds broker audit and kill-switch state.
|
||||
/sessions.db
|
||||
/sessions.db-shm
|
||||
/sessions.db-wal
|
||||
/sessions/
|
||||
/runs/
|
||||
/shadow_runs/
|
||||
/swarm/runs/
|
||||
/live/
|
||||
/uploads/
|
||||
/imports/
|
||||
agent/tests/*_result.json
|
||||
.ruff_cache/
|
||||
frontend/dist/
|
||||
@@ -26,6 +49,7 @@ wiki/dist/
|
||||
wiki/.astro/
|
||||
wiki/.vite/
|
||||
wiki/.wrangler/
|
||||
.wrangler/
|
||||
wiki/.cache/
|
||||
.vibe-dev/
|
||||
.vibe-record/
|
||||
@@ -70,6 +94,7 @@ agent/tests/test_e2e_*.py
|
||||
|
||||
# Local working notes
|
||||
NOTES.md
|
||||
.workbuddy/
|
||||
|
||||
# Agent tool aliases (auto-generated mirror of CLAUDE.md)
|
||||
AGENTS.md
|
||||
@@ -79,3 +104,4 @@ AGENTS.md
|
||||
# Alpha Zoo wiki artifacts (generated, not source)
|
||||
wiki/alpha-library/manifest.json
|
||||
wiki/alpha-library/content/
|
||||
bench_report.json
|
||||
@@ -0,0 +1,139 @@
|
||||
# Agent Contributor Guide
|
||||
|
||||
This guide is for AI-assisted and automation-assisted contributors working on
|
||||
Vibe-Trading. It does not replace `CONTRIBUTING.md`, `SECURITY.md`, or the pull
|
||||
request template. It makes the repo's agent-facing safety and verification
|
||||
expectations explicit.
|
||||
|
||||
## Repository Shape
|
||||
|
||||
- Backend and package code live under `agent/`.
|
||||
- Frontend code lives under `frontend/`.
|
||||
- Public wiki content lives under `wiki/` and has separate GitHub Actions checks.
|
||||
- MCP entry point: `vibe-trading-mcp` / `agent/mcp_server.py`.
|
||||
- CLI entry point: `vibe-trading` / `agent/cli/`.
|
||||
- Broker connector, mandate, order gate, halt, and audit-ledger logic are safety
|
||||
critical even when a change appears small.
|
||||
- Community commits must include the DCO `Signed-off-by:` trailer; see
|
||||
`CONTRIBUTING.md`.
|
||||
|
||||
Do not copy local `.env` files, token caches, broker exports, run artifacts,
|
||||
private notebooks, generated reports, or local agent memory into this repository
|
||||
unless they are explicitly sanitized fixtures.
|
||||
|
||||
## Safe Local Checks
|
||||
|
||||
These commands are normally safe for local validation:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
git diff --check
|
||||
python -m compileall -q agent/cli
|
||||
python -m py_compile agent/api_server.py agent/mcp_server.py
|
||||
pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --tb=short -q
|
||||
pytest agent/tests/test_sdk_order_gate.py agent/tests/test_mandate_enforcement.py -q
|
||||
cd frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
Use the narrowest test command that matches the changed files when a full suite
|
||||
is too expensive, and state what was not run.
|
||||
|
||||
## High-Risk Surfaces
|
||||
|
||||
Get explicit maintainer or operator approval before running commands that:
|
||||
|
||||
- place, cancel, approve, flatten, or otherwise affect broker orders,
|
||||
- authorize broker, OAuth, MCP, exchange, payment, wallet, or cloud accounts,
|
||||
- write real credentials into `agent/.env`, `~/.vibe-trading/`, token caches, or
|
||||
external services,
|
||||
- start externally reachable API, MCP, SSE, webhook, or dashboard servers,
|
||||
- deploy the wiki, publish packages, trigger releases, or change CI secrets,
|
||||
- rewrite history, force-push shared branches, delete backups, or remove
|
||||
persistent run or memory data.
|
||||
|
||||
Never run live trading, payment, wallet, contract, or broker-write flows as part
|
||||
of routine PR validation.
|
||||
|
||||
## Targeted Test Hints
|
||||
|
||||
For general Python changes:
|
||||
|
||||
```bash
|
||||
pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --tb=short -q
|
||||
```
|
||||
|
||||
For live/order safety changes:
|
||||
|
||||
```bash
|
||||
pytest agent/tests/test_sdk_order_gate.py \
|
||||
agent/tests/test_mandate_enforcement.py \
|
||||
agent/tests/test_killswitch_blocks_orders.py \
|
||||
agent/tests/test_readonly_default.py -q
|
||||
```
|
||||
|
||||
For factor-zoo changes:
|
||||
|
||||
```bash
|
||||
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
|
||||
```
|
||||
|
||||
For frontend changes:
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
## Documentation Rules
|
||||
|
||||
- Update `README.md` for user-facing CLI, Web, MCP, connector, provider, or
|
||||
safety-boundary changes.
|
||||
- Update `CONTRIBUTING.md` when contributor workflow, DCO expectations, style
|
||||
rules, or test expectations change.
|
||||
- Update `SECURITY.md` only for vulnerability reporting policy changes.
|
||||
- Keep wiki edits under `wiki/`; avoid mixing generated assets or local drafts
|
||||
into public docs.
|
||||
- If a change affects live/order behavior, document the safety boundary and the
|
||||
rollback or halt path.
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Do not commit API keys, access tokens, OAuth caches, session cookies, private
|
||||
keys, seed phrases, broker credentials, `.env` files with real values, or
|
||||
private trading exports.
|
||||
- Treat secrets as access, not text. If one appears, redact it, stop repeating
|
||||
it, and recommend rotation.
|
||||
- API or Web deployments beyond loopback must use `API_AUTH_KEY`.
|
||||
- External MCP servers are operator-trust surfaces. Do not allow caller-provided
|
||||
MCP command, URL, environment, or allowlist injection unless the code path
|
||||
explicitly documents and tests that opt-in.
|
||||
- Broker connector writes must remain mandate-gated, kill-switch-aware,
|
||||
fail-closed, and audit-logged.
|
||||
- Prefer sanitized fixtures over real financial data.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
Every PR should explain:
|
||||
|
||||
- the goal,
|
||||
- the affected areas,
|
||||
- what is deliberately out of scope,
|
||||
- the test plan,
|
||||
- any live, broker, MCP, network, secret, or deployment risk,
|
||||
- rollback or close path.
|
||||
|
||||
Every community commit must be signed:
|
||||
|
||||
```bash
|
||||
git commit -s -m "docs(agent): add contributor guide"
|
||||
```
|
||||
|
||||
Do not add AI-assistant attribution trailers; follow `CONTRIBUTING.md`.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Documentation-only changes can usually be reverted with `git revert <sha>`.
|
||||
- Code changes touching order gates, mandate enforcement, OAuth, providers, MCP,
|
||||
settings, or external network behavior need a targeted regression test before
|
||||
merge and a clear revert path in the PR.
|
||||
- If a safety regression is found after merge, prefer a small revert or
|
||||
fail-closed hotfix over broad refactors.
|
||||
+426
-6
@@ -5,16 +5,436 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### UI
|
||||
- Redesigned the interactive CLI startup and run-state visuals with a large
|
||||
figlet-style banner plus a Claude Code-style activity rail using `●`, `│`,
|
||||
and `·` glyphs. Non-TTY prompt runs now fall back to plain text output.
|
||||
### Added
|
||||
- **Memory Tier 2: Structural Organization** — four independently-gated modules for memory lifecycle enhancement:
|
||||
- H-MEM hierarchical directory routing (`VT_MEMORY_HIERARCHY`)
|
||||
- A-MEM semantic linking via BM25 (`VT_MEMORY_LINKS`)
|
||||
- Auto-compression pipeline with TF-IDF key-sentence extraction (`VT_MEMORY_COMPRESSION`)
|
||||
- FTS5 full-text search index with CJK bigram tokenization (`VT_MEMORY_FTS_INDEX`)
|
||||
- **`VT_MEMORY=off|on|full` preset** — one-line business-friendly configuration replacing 7 individual flags
|
||||
- `off`: no lifecycle management (default)
|
||||
- `on`: quality scoring + auto-decay + garbage collection (Tier 1)
|
||||
- `full`: all features including hierarchy, links, compression, and FTS (Tier 1 + Tier 2)
|
||||
- Individual `VT_MEMORY_*` flags still work as explicit overrides
|
||||
- Dependency-conflict warnings when `VT_MEMORY_COMPRESSION=true` without `VT_MEMORY_GC`
|
||||
- Lazy FTS5 auto-rebuild on first search when index is empty
|
||||
- 9 end-to-end integration tests + 23 benchmark tests against real production corpus
|
||||
|
||||
## [0.1.13] — 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
- **Read-only sentiment tool** (#939): local lexicon scoring for arbitrary text
|
||||
plus the crypto Fear & Greed Index from Alternative.me's free, no-auth API.
|
||||
|
||||
### Fixed
|
||||
- **Agent identity ordering and evidence grounding** (#887/#886): market-sensitive
|
||||
consumers now require a canonical symbol/venue locked before their assistant
|
||||
tool-call batch starts, so a resolver and its consumer cannot race in one
|
||||
parallel batch. Silent exchange-suffix rewrites are rejected, listed-company
|
||||
evidence cannot be overwritten by model memory, and ambiguous/not-found/
|
||||
conflicting/invalidated identities are persisted as first-class states.
|
||||
- **Final numeric consistency gate** (#886): full untruncated OHLC tool results
|
||||
are recorded as structured evidence with symbol, venue, currency, actual data
|
||||
source, and explicit conversion status. Contradictory final prices are rejected
|
||||
before streaming; derived levels require a visible, arithmetically correct
|
||||
formula anchored to observed inputs, and repeated bad drafts fail closed to
|
||||
verified observations.
|
||||
Tool envelopes with `ok: false`, `success: false`, or failed status are no
|
||||
longer recorded as successful calls.
|
||||
- **Robinhood Agentic MCP response serialization** (#922): nested FastMCP
|
||||
dataclasses containing `date`/`datetime` values serialize to JSON instead of
|
||||
surfacing the misleading `Circular reference detected` error. A representative
|
||||
Robinhood portfolio/account shape now has an end-to-end regression test.
|
||||
|
||||
## [0.1.12] — 2026-07-22
|
||||
|
||||
### Added
|
||||
- **User swarm-presets directory**: preset YAMLs dropped into
|
||||
`~/.vibe-trading/swarm/presets/` are discovered alongside the bundled
|
||||
roster (same-name files override it — the same rule as user skills) and
|
||||
survive `pip install -U`. `list_presets()` entries now carry a
|
||||
`source: "user" | "bundled"` field; explicitly named user presets run
|
||||
through `run_swarm(preset_name=...)`, while keyword auto-routing stays
|
||||
limited to the curated table. Preset names are validated to a single path
|
||||
segment before any filesystem lookup.
|
||||
- **Security hardening**: all 10 findings from the 2026-07-10 external audit
|
||||
closed (#476, tracking discussion #468) — Docker multi-stage rebuild with
|
||||
digest-pinned base images, AST-hardened backtest sandbox (blocks
|
||||
network/subprocess/eval/os.environ/unsafe-open reachable from generated
|
||||
code, including inside nested function bodies), short-lived single-use SSE
|
||||
auth tickets replacing a long-lived key in the URL/logs, hardened Compose
|
||||
(`read_only`, dropped capabilities, `no-new-privileges`, resource limits),
|
||||
auth + rate limiting on `/correlation`, security headers (CSP
|
||||
Report-Only, `X-Content-Type-Options`, `Permissions-Policy`), `/live` +
|
||||
`/ready` health split, hash-locked dependencies wired into the Docker
|
||||
build, GitHub Actions pinned by commit SHA, and an HMAC-authenticated
|
||||
factor cache.
|
||||
- Opt-in **TAP mode** for Alpaca (#377, thanks @0xZKnw) — routes all broker
|
||||
egress through a self-hosted TAP proxy so the agent process never holds
|
||||
the raw API key, with writes blocking on human approval.
|
||||
- Realized portfolio turnover (`avg_turnover` / `total_turnover`) surfaced
|
||||
in backtest metrics for every optimizer (#478, thanks @Robin1987China).
|
||||
- **Frazzini-Pedersen betting-against-beta** academic factor (#480, thanks
|
||||
@YogeshModi24) — Alpha Zoo: 460 → **461**.
|
||||
- **MetaTrader 5 connector + data source** (Exness-style MT5 brokers,
|
||||
Windows-only `pip install "vibe-trading-ai[mt5]"`). Broker connectors:
|
||||
11 → **12** — full read surface plus order placement against a locally
|
||||
running terminal, with a bidirectional identity guard (paper profile ⇔
|
||||
demo `trade_mode`, login pinned, contest rejected), connector-level
|
||||
`max_order_volume`/`max_order_notional_usd` guards on demo AND live, and
|
||||
hedging-safe position close by ticket. The live mandate gate gains
|
||||
`forex`/`cfd` instrument vocabulary (schema v1 unchanged) and a lot-aware
|
||||
`quantity_notional_usd` sizing hook so USD caps bind on lot-sized orders
|
||||
(0.1 lot EURUSD ≈ $10,800, never 0.1 × quote). Market-data sources:
|
||||
20 → **21** — the `mt5` loader heads the forex fallback chain (broker-exact
|
||||
symbols with Exness suffix discovery, 1m–1D bars), `get_market_data` learns
|
||||
forex/metal symbol routing (`EUR/USD`, `XAUUSD.FX` previously fell through
|
||||
to tushare), and akshare's forex path accepts the canonical slash form so
|
||||
degradation off-Windows keeps working.
|
||||
- **Strategy Development Manager** skill (#457, thanks @shadowinlife, closes
|
||||
#455) — `sdm_register` / `sdm_status` / `sdm_decay_scan` turn academic
|
||||
papers and broker research into registered factors/strategies with a
|
||||
persistent SQLite artifact store (`UNIQUE(name, universe)`) and automated
|
||||
IC/Sharpe decay monitoring driving an active → monitoring → decayed →
|
||||
disabled lifecycle. Pluggable OCR for `read_document` (local RapidOCR by
|
||||
default; cloud Qwen-VL is explicit opt-in only via
|
||||
`VIBE_TRADING_OCR_ENGINE=qwen-vl`, never auto-selected). Skills: 86 → **87**.
|
||||
- **Requesty** as an OpenAI-compatible LLM gateway provider (#474, thanks
|
||||
@Thibaultjaigu) — same `provider/model` naming and capability shape as
|
||||
OpenRouter, wired through CLI onboarding, provider menu, and Settings.
|
||||
- Binance USD-M perpetual routing, slice 1 of #462 (#470, thanks @honginp) —
|
||||
explicit `BTC-USDT-PERP` symbol contract with execution/mark price
|
||||
separation, fail-closed when the two aren't timestamp-synchronized.
|
||||
- **Correlation regime timeline** (#756, thanks @ebujinovch, closes #719) — a
|
||||
new additive `GET /correlation/regime` endpoint plus an opt-in "Regime
|
||||
timeline" strip on the Correlation tab: rolling pairwise correlations reduce
|
||||
to an edge-density series, causally smoothed, and run through a two-threshold
|
||||
hysteresis state machine that marks FUSED episodes ("when did the market fuse
|
||||
into one bloc?"). Descriptive risk context, not a trading signal; shares
|
||||
`/correlation`'s auth + rate-limit budget. Backed by the **correlation-regime
|
||||
skill** (#557, thanks @ebujinovch).
|
||||
- **Three new LLM providers** — SiliconFlow CN + Global (#565, thanks @UNHNQ),
|
||||
iFlytek Spark (#537, thanks @FenjuFu), and a **native Anthropic Messages API**
|
||||
adapter (#695, thanks @jelech; `pip install "vibe-trading-ai[anthropic]"`).
|
||||
MiniMax now exposes its regional API endpoints (#731, thanks @octo-patch).
|
||||
- **Historical USD-M funding settlements** for Binance perpetuals (#716, thanks
|
||||
@honginp); maintenance brackets are supplied as a validated, versioned
|
||||
artifact rather than a live authenticated fetch (#757, thanks @honginp), so a
|
||||
plain `-PERP` backtest stays zero-credential.
|
||||
- **Pluggable OCR** for `read_document` with optional LLM-vision extraction and
|
||||
a configurable text-density threshold (#548, thanks @shadowinlife) — local
|
||||
RapidOCR by default; cloud engines are explicit opt-in, never auto-selected.
|
||||
- New academic factor `academic_corr_rewire` (#705, thanks @ebujinovch) and the
|
||||
fundamental zoo wired into the `_VALID_ZOOS` whitelist (#707, thanks
|
||||
@sambazhu). Binance crypto fallback loader (#643) and bounded OKX history
|
||||
fetches with rate-limit handling (#644, thanks @tyj147454413-cmd).
|
||||
- QVeris premium-track hardening — session budget applied to backtest data
|
||||
calls (#685) and atomic credit accounting (#686, thanks @xkam7ar).
|
||||
|
||||
### Changed
|
||||
- Correlation tab accepts bare tickers like `AAPL,SPY` and walks the full
|
||||
loader fallback chain instead of failing with `Fetched: []` (#472, thanks
|
||||
@yxhuang, closes #471).
|
||||
- `local` loader honors the requested interval via OHLCV resampling instead
|
||||
of silently returning daily bars (#467, thanks @Shizoqua).
|
||||
- Provider credentials are resolved through one centralized path, fixing a
|
||||
gateway misroute (#563, thanks @shadowinlife, closes #549/#553). When no
|
||||
`*_BASE_URL` is set, the backend now falls back to the provider catalog's
|
||||
canonical `default_base_url` (the same default Web Settings already used), so
|
||||
a CLI / manual-`.env` user reaches the right endpoint instead of defaulting
|
||||
to `api.openai.com`.
|
||||
- Signal alignment is vectorized for an ~80× speedup on wide panels (#698,
|
||||
thanks @shadowinlife); swarm workers cache MCP tool-discovery specs to avoid
|
||||
redundant RPC round-trips (#704, thanks @shadowinlife).
|
||||
|
||||
### Fixed
|
||||
- Explicit `source: local` backtests now route US/HK equities to the
|
||||
global-equity engine instead of the crypto default, and explicit benchmarks
|
||||
are fetched through the configured source's loader — `local` fails closed
|
||||
(no yfinance fallback) so offline runs stay offline (#550).
|
||||
- Loading `.env` now invalidates an `EnvConfig` singleton cached during early
|
||||
CLI imports, so the welcome panel, `/settings`, and dotenv diagnostic report
|
||||
the configured provider and model consistently (#541).
|
||||
- FastMCP transport imports work across both module layouts (#469, thanks
|
||||
@roberttidball).
|
||||
- Portfolio optimizers no longer include the decision bar's close-to-close
|
||||
return in weights executed at that bar's open (#487, thanks @YZY0108).
|
||||
- Backtest turnover metrics now use actual filled and rounded position sizes;
|
||||
targets rejected by market rules no longer inflate reported turnover.
|
||||
- End-of-backtest liquidations now apply exit slippage and include their
|
||||
commission in the final reported equity.
|
||||
- Open-price rebalances no longer use the decision bar's close for sizing or
|
||||
depend on whether a replacement symbol sorts before the position it closes.
|
||||
- Preflight (`vibe-trading run`) no longer resolves provider/model against a
|
||||
stale `EnvConfig` snapshot cached before dotenv loads (#479, thanks
|
||||
@ananaymital, closes #477).
|
||||
- Switching providers no longer leaves a stale `OPENAI_BASE_URL` from a
|
||||
previous configuration silently overriding the newly-resolved endpoint
|
||||
(#484, thanks @Bortlesboat, closes #482).
|
||||
- **Strict-JSON / finite-number hardening** across the backtest + tools stack
|
||||
(thanks @santhreal): risk ratios stay finite when equity crosses zero mid-path
|
||||
(#765) or annualizes an explosive path (#739/#740); scalar backtest metrics
|
||||
(#766), factor IC std (#767), and pattern trend-slope (#764) emit strict
|
||||
RFC-8259 JSON (`null`, never bare `NaN`/`Infinity`); Black-Scholes helpers
|
||||
treat non-positive spot/strike as intrinsic (#744).
|
||||
- **Loader / data correctness** — yahoo `1m` stays minute bars instead of being
|
||||
uppercased to monthly (#761, @santhreal); the composite engine falls back to
|
||||
the first available sub-engine for unknown symbols (#734, thanks @Marnie0415);
|
||||
mootdx history that doesn't reach the requested start is rejected (#692, thanks
|
||||
@xkam7ar).
|
||||
- **Session / journal robustness** — one corrupt `session.json` (#762) or a
|
||||
schema-bad `messages.jsonl` line (#763) no longer aborts listing/reading;
|
||||
Excel float-stringified A-share codes (#770), unicode-dash PDF page ranges
|
||||
(#769), and `export KEY=` dotenv lines (#768) all parse correctly (@santhreal).
|
||||
- **Native `zai` provider on glm-5.1** (#758) — endpoints that stream zero
|
||||
chunks fall back to a non-streaming invoke instead of raising, and an HTML
|
||||
error page surfaces an actionable base-URL hint.
|
||||
- Partial market-data results are completed through the loader fallback chain
|
||||
instead of silently shrinking the universe (#689, closes #681).
|
||||
- Cancellation is honored before the first AgentLoop iteration (#641, thanks
|
||||
@xkam7ar, closes #638); streaming output no longer triggers an `insertBefore`
|
||||
DOM race in the frontend (#717, thanks @Marnie0415); codex stream HTTP
|
||||
failures are classified for correct retry (#663, thanks @tyj147454413-cmd).
|
||||
- Robinhood connector `account_number` wiring and remote-MCP display shape
|
||||
(#726, thanks @nareshkps).
|
||||
- Broad reliability fixes across packaging, web, scheduler, swarm, and CLI
|
||||
(#584, thanks @xkam7ar).
|
||||
|
||||
## [0.1.11] — 2026-07-11
|
||||
|
||||
### Added
|
||||
- **Indian equity (NSE/BSE) as a first-class market** (#305, thanks
|
||||
@muku314115). A dedicated `IndiaEquityEngine` — T+1 delivery, no overnight
|
||||
shorts (opt-in intraday), configurable circuit bands, 1-share lots, and a
|
||||
config-driven STT / stamp-duty / exchange / SEBI / GST cost stack — with
|
||||
`.NS`/`.BO` symbol routing (`yahoo → yfinance → india_broker → local`), an
|
||||
opt-in read-only Shoonya/Dhan `india_broker` data bridge, and 255
|
||||
alpha101/qlib158 factors opted into the new `equity_in` universe. Backtest
|
||||
engines: 7 → **8**; market-data sources: 19 → **20**.
|
||||
- **Fundamental factor layer, Phase 1.** PIT-safe SEC fundamentals flow into
|
||||
dense daily `fund:*` factor panels — filed-date anchoring, first-filed
|
||||
restatement policy, true-quarter `(start, end)` frame selection with Q4
|
||||
synthesis (so YTD/annual frames can't contaminate TTM), and rolling TTM —
|
||||
plus a `get_fundamentals` tool and 4 quality/value factors in a new
|
||||
`fundamental` zoo family. Alpha Zoo: 456 → **460** across **5** families.
|
||||
- **Research Autopilot Phase 3 — the loop closes** (#267, thanks
|
||||
@Robin1987China). `scaffold_signal_engine` writes a contract-correct signal
|
||||
engine from a hypothesis and `link_autopilot_backtest` runs it, completing
|
||||
hypothesis → signal-engine → backtest end to end.
|
||||
- **4 canonical academic alphas** (#277, thanks @Robin1987China) — Jegadeesh
|
||||
short-term reversal, George–Hwang 52-week high, Amihud illiquidity, and
|
||||
Harvey–Siddique co-skewness join the academic family (452 → 456), with a
|
||||
**central OHLC-invariant guard** at the runner fetch boundary dropping
|
||||
malformed bars from every loader (#274, thanks @Shizoqua).
|
||||
- **Scheduled research runs end to end** (#278 closing #254, thanks
|
||||
@mvanhorn). A default-off background executor
|
||||
(`VIBE_TRADING_ENABLE_SCHEDULER`) fires due interval/cron jobs through the
|
||||
session runtime, on top of a crash-safe atomic job store, 3 auth-gated
|
||||
`/scheduled-runs` REST routes, a Reports library, and post-backtest
|
||||
attribution. Route test coverage followed in #452 (thanks @Robin1987China).
|
||||
- **IM channel runtime — research delivery over 16 adapters.** The agent
|
||||
session runtime now attaches to 16 built-in message adapters (WebSocket,
|
||||
Telegram, Slack, Discord, Matrix, WhatsApp, Signal, QQ/NapCat,
|
||||
WeChat/WeCom, Feishu, DingTalk, email, MS Teams, MoChat), dependency-gated
|
||||
with install hints, configurable via `AgentConfig.channels`, and surfaced in
|
||||
REST (`/channels/*`), CLI (`vibe-trading channels ...`), and Web Settings —
|
||||
in all 5 UI locales.
|
||||
- **QVeris optional premium data track.** The 19 free sources stay the
|
||||
default; an explicit-only QVeris mode (Settings → QVeris or
|
||||
`vibe-trading data mode paid`) unlocks 63+ providers behind 3 key-gated
|
||||
tools (`qveris_search` / `qveris_inspect` / `qveris_execute`) with
|
||||
preview-by-default and a session budget gate. Never enters auto-fallback.
|
||||
- **Trading 212 read-only connector** (#321, thanks @mvanhorn) — 11 brokers
|
||||
total. Trading 212 exposes no runtime paper/live discriminator, so the
|
||||
connector is fully read-only: `place_order`/`cancel_order` hard-refuse
|
||||
every order, paper included. Live order guards also gained an opt-in,
|
||||
broker-agnostic `PreTradeAdvisoryInterface` that records advisory reviews
|
||||
without bypassing the mandate gate.
|
||||
- **Turnover-aware portfolio optimizer** (#466, thanks @Robin1987China) —
|
||||
fifth optimizer: mean-variance utility with an L1 penalty on weight changes
|
||||
versus the previous rebalance (SLSQP, long-only simplex), so the portfolio
|
||||
only trades when expected improvement outweighs churn. Optimizers: 4 → **5**.
|
||||
- **`analyze_image` vision tool** (#464, thanks @fei-moss) — send a local
|
||||
chart / K-line screenshot / app screenshot to the session model as a
|
||||
multimodal message and get a semantic read (complements `read_document`'s
|
||||
OCR). Path-validated against the allowed file roots; requires a
|
||||
vision-capable model. Tools: 71 → **72** free-mode (75 with QVeris).
|
||||
- **Value-investing toolkit** (thanks @sambazhu): financial-rigor +
|
||||
report-audit tools, 4 skills, and a `value_investing_committee` swarm
|
||||
preset. Swarm presets: 29 → **30**.
|
||||
- **CN-friendly search fallbacks** — `web_search` gains China-reachable
|
||||
backends in the ordered no-key engine chain.
|
||||
- **Provider roster additions**: Kimi for Coding as a distinct provider
|
||||
(#435, thanks @yxhuang), opencode provider mappings (#444, thanks
|
||||
@imsankz), Codex OAuth default model bumped to `openai-codex/gpt-5.4`
|
||||
(#446, thanks @morluto).
|
||||
- **SKILL.md manifest guard test** (#461, thanks @asahikiko) — the packaged
|
||||
skill's capability counts (skills / presets / zoo / sources / MCP tools /
|
||||
engines) are now asserted against source, so distribution paperwork can't
|
||||
silently drift again.
|
||||
|
||||
### Changed
|
||||
- **`api_server.py` modularization completed** — 1,103 → 371 lines (#424
|
||||
closing #331, thanks @shadowinlife), after route slices for channels,
|
||||
settings, and the remaining route groups, plus a shared compat layer with
|
||||
session-service writeback fixes.
|
||||
- **Centralized environment variable management** (#440 closing #438, thanks
|
||||
@shadowinlife) — every env var flows through a single Pydantic `EnvConfig`
|
||||
schema, enforced by an AST-based CI gate that rejects raw
|
||||
`os.getenv`/`os.environ` outside `agent/src/config/`.
|
||||
- **Factor engine acceleration** — hot rolling operators use
|
||||
`bottleneck`/NumPy fast paths, and alpha-bench parallelism stops resending
|
||||
large panel payloads to workers.
|
||||
- **Robinhood Agentic MCP refresh** — current MCP tool names across generic
|
||||
reads, live-runner plumbing, default read-only seeds, and mandate-gate
|
||||
tests; interactive OAuth holds the handshake open through multi-minute
|
||||
broker sign-ins (`VIBE_LIVE_AUTHORIZE_TIMEOUT_SECONDS`).
|
||||
- **Loader `fetch()` signatures** now match the loader protocol across
|
||||
OKX / Tushare / yfinance (#437, thanks @shadowinlife).
|
||||
- **Timezone-aware UTC timestamps** across session, goal, channel, and API
|
||||
paths (#397, thanks @mustafakamal88).
|
||||
- **Inbound IM media now lands under `~/.vibe-trading/uploads/<channel>/`**
|
||||
(fixes #465, thanks @fei-moss for the report) — inside the default allowed
|
||||
file roots, so the agent can read what users send over IM channels with
|
||||
zero configuration. The Matrix E2E store moves to the runtime dir (legacy
|
||||
path honored) so credentials never enter the readable root.
|
||||
|
||||
### Fixed
|
||||
- **Docker/server startup crash** when FastAPI route iteration hit an
|
||||
included-router entry without `path` (#450, thanks @Penn-Live).
|
||||
- **GLM thinking models on the zhipu provider** no longer lose their
|
||||
reasoning stream (#458).
|
||||
- **Trading mandate UX guards** — a second-confirmation dialog before
|
||||
committing a real mandate, unified error toasts, and clarified inputs
|
||||
(#453, thanks @wison1717-maker).
|
||||
- **`trading_place_order` treats zero quantity/notional as unset** instead of
|
||||
passing a zero-size order to the broker (#417, thanks @irfanallana-oss).
|
||||
- **Longbridge Decimal values serialize as floats** across quotes, bars,
|
||||
balances, positions, orders, and executions (#459, thanks @fanfpy).
|
||||
- **NapCat private messages now trigger pairing codes** (#463, thanks
|
||||
@fei-moss).
|
||||
- **Backtest validation artifacts**: `validation.json` no longer requires a
|
||||
pre-existing artifacts dir (#429, thanks @isaveall), and nested
|
||||
`NaN`/`Infinity` values are normalized before writing, so strict JSON
|
||||
parsers don't choke.
|
||||
- **CLI**: `resume` preserves the first user message (#448 closing #447,
|
||||
thanks @morluto); `--swarm-run` rejects extra tokens with a clear error
|
||||
(#428, thanks @isaveall); the interactive CLI prints the session-id on
|
||||
exit with a copy-paste resume hint.
|
||||
- **Shadow Account**: extracted rules carry RSI / prior-return entry bounds
|
||||
computed from PIT-safe context fetched through the loader registry, so
|
||||
generated engines enter on real conditions (#302/#314/#316, thanks
|
||||
@Robin1987China); tushare ETF/index/HK symbol routing fixed along the way.
|
||||
- **Content-filter resilience** — event-driven and swarm runs skip individual
|
||||
LLM content-moderation hits and warn when filter rates are high; Gemini
|
||||
safety finish-reasons recognized.
|
||||
- **IM channel reply timeout is configurable** (#413, thanks @dpersek).
|
||||
- **Provider preflight no longer follows redirects** (#404 closing #402,
|
||||
thanks @dpersek).
|
||||
- **Windows baseline green** — `vibe-trading setup`/`dev` handle Windows
|
||||
TypeScript builds, correct cwd, the Vite 5899 port, and child-process
|
||||
shutdown; mootdx batch pulls let `KeyboardInterrupt`/`SystemExit`
|
||||
propagate.
|
||||
- **Security hardening** — loopback API CSRF protection (cross-site POSTs
|
||||
can no longer drive side effects on the local API), SSRF guards on
|
||||
interactive fetch paths, tightened API/Docker/frontend dev defaults, and
|
||||
cleared frontend dependency/CSP alerts.
|
||||
- **Reverted the IRR-AGL reliability/governance stack** (#405/#416) after it
|
||||
broke session chats on day 1 (#433 — thanks @yxhuang for the precise
|
||||
diagnosis); the evidence-bound research-pipeline direction continues in
|
||||
reviewable slices on #442.
|
||||
|
||||
## [0.1.10] — 2026-06-19
|
||||
|
||||
Roll-up release; see the
|
||||
[v0.1.10 release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.10)
|
||||
for the full narrative.
|
||||
|
||||
### Added
|
||||
- **Global data layer** — market-data sources 10 → 18 (direct-API Eastmoney /
|
||||
Sina / Stooq / Yahoo + key-gated Finnhub / Alpha Vantage / Tiingo / FMP)
|
||||
with ban-risk-ordered fallback chains behind a shared throttled HTTP gate,
|
||||
plus **18 read-only data tools** (fund flow, dragon-tiger, northbound,
|
||||
margin, block trades, shareholder count, lockup, sector, research, news,
|
||||
SEC filings, financial statements, options chains, institutional holdings,
|
||||
screening, symbol search, FRED macro, iwencai) — all MCP-exposed.
|
||||
- **10 broker SDK connectors** — Tiger / Longbridge / Alpaca / OKX / Binance /
|
||||
Futu / Dhan / Shoonya join IBKR (local read-only) and Robinhood (Agentic
|
||||
MCP); direct-SDK live orders pass the fail-closed bounded-autonomy gate;
|
||||
brokers without a runtime paper/live discriminator are structurally capped
|
||||
at paper + read-only.
|
||||
- **Alpha Zoo `alpha compare`** across CLI, REST, Web UI, and agent tool.
|
||||
- **Research Autopilot Phase 1** (`run_research_autopilot`,
|
||||
`generate_backtest_config`) and the local Data Bridge loader.
|
||||
- **Opt-in local data cache** (`VIBE_TRADING_DATA_CACHE`) for settled bars
|
||||
under `~/.vibe-trading/cache/`.
|
||||
- Per-run token usage (`llm_usage.json`) + progressive Run Detail charts;
|
||||
CLI `resume <session-id>`.
|
||||
|
||||
### Changed
|
||||
- **Provider reliability overhaul** — DeepSeek hang fixes, Kimi access,
|
||||
streaming liveness watchdog, Gemini 3.x multi-turn tool-calling fix.
|
||||
- Swarm workers pull market data through the loader layer; live swarm status
|
||||
cards stream in the chat timeline.
|
||||
- Baseline install slimmed (`pyharmonics`/`ta` behind
|
||||
`vibe-trading-ai[harmonic]`).
|
||||
|
||||
### Fixed
|
||||
- Community security-hardening wave (#241–#258): Settings write auth, shell
|
||||
tools opt-in, LAN-access 403 clarity, Docker-to-host Ollama URL rewrite,
|
||||
web_search multi-engine fallback, and more.
|
||||
|
||||
## [0.1.9] — 2026-06-01
|
||||
|
||||
### Added
|
||||
- **Connector-first broker profiles (IBKR + Robinhood).** Trading access now
|
||||
starts from a selectable connector profile rather than separate broker/live
|
||||
entry points; `vibe-trading connector list/use/check/account/positions/orders/quote/history`
|
||||
and the MCP `trading_*` tools share the selected profile, with paper/live as
|
||||
a property under the connector. IBKR is usable immediately as a local
|
||||
read-only TWS / IB Gateway profile; the official IBKR remote MCP path is
|
||||
seeded as an OAuth `mcp.read` probe until stable read tool names ship.
|
||||
Robinhood Agentic Trading is a bounded connector behind OAuth, a committed
|
||||
mandate, an order guard, an audit ledger, and an instant halt switch.
|
||||
- **Research Goal runtime.** Long-running, research-only goals with auditable
|
||||
checklist criteria, budgets, and a `/goal` CLI command, plus REST + MCP
|
||||
endpoints (`start_research_goal`, `get_research_goal`, `add_goal_evidence`,
|
||||
`update_research_goal_status`) and a Web `GoalDrawer`.
|
||||
- **Swarm `retry_run`.** Re-launch a failed/stale/cancelled run with the
|
||||
original preset + variables; exposed as both `POST /swarm/runs/{id}/retry`
|
||||
and an MCP `retry_run` tool (the `list_runs → retry` loop). 36 MCP tools now.
|
||||
- **Operator-configured external MCP tools in swarm workers** (#142) and
|
||||
**remote MCP transports** for the built-in agent.
|
||||
- **`mootdx` A-share OHLCV loader** — native 通达信 TCP, no token, sits between
|
||||
tushare and akshare in the fallback chain. CCXT loader now reads proxy env
|
||||
for restricted networks (#126).
|
||||
- **Hypothesis Registry CLI** — `list / show / invalidate`.
|
||||
- **Strict alpha-bench mode** with a mandatory random control (#143).
|
||||
|
||||
### Changed
|
||||
- **CLI split into the `agent/cli/` package** (from a 3216-LOC single file),
|
||||
with a refreshed interactive terminal UI (figlet banner + activity rail) and
|
||||
a single `cli/_version.py` version source.
|
||||
- Swarm status reconciles from live task files on every read; `run_swarm`
|
||||
sends MCP progress heartbeats, and the stale-run reaper uses per-run
|
||||
thresholds (#132).
|
||||
- Refreshed provider default model ids; bumped `langgraph` for CVE-2026-28277.
|
||||
|
||||
### Fixed
|
||||
- **`--version` no longer drifts (#156).** The version derives from package
|
||||
metadata, falling back to reading `pyproject.toml` directly — no hardcoded
|
||||
constant left to forget on release.
|
||||
- **Session running-status indicator** now survives reconnect / page reload /
|
||||
sidebar navigation; **swarm DAG** blocks downstream tasks when an upstream
|
||||
task fails (#145).
|
||||
- **Robustness pass:** pre-flight validation for LLM-generated signal engines
|
||||
with clean JSON errors (#149), graceful agent-loop exit at the iteration
|
||||
budget instead of an output-less `failed` (#148), `flush + fsync` session
|
||||
message writes that skip corrupted JSONL lines on read (#147), and IME Enter
|
||||
handling in the Web composer (#146).
|
||||
- **Full Report** link now always renders when a `runId` exists, even cross-browser
|
||||
(#150); SSE idle timeout is configurable via `VIBE_TRADING_SSE_TIMEOUT` (#157);
|
||||
cross-market correlation normalizes timestamps so crypto-vs-equity pairs align (#158).
|
||||
|
||||
## [0.1.8] — 2026-05-17
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ For general project setup (`pip install -e ".[dev]"`, dev servers,
|
||||
`pytest --ignore=agent/tests/e2e_backtest`), see the README. For bug reports
|
||||
and feature requests, use the GitHub issue templates.
|
||||
|
||||
For AI-assisted or automation-assisted contributions, also see
|
||||
[`AGENT_CONTRIBUTOR_GUIDE.md`](AGENT_CONTRIBUTOR_GUIDE.md). It summarizes safe
|
||||
local checks, higher-risk broker/MCP/credential surfaces, and the expected PR
|
||||
risk notes for agent-authored changes.
|
||||
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
Every commit in a community pull request MUST carry a `Signed-off-by:`
|
||||
@@ -135,6 +140,14 @@ merging. Authors are strongly encouraged to self-check first.
|
||||
## Code Style
|
||||
|
||||
- Format with `black`; lint with `ruff` (config in `pyproject.toml`).
|
||||
- Install both tools with the development extra: `pip install -e ".[dev]"`.
|
||||
- Run them on the Python files you changed, for example:
|
||||
```bash
|
||||
black --check agent/src/example.py agent/tests/test_example.py
|
||||
ruff check agent/src/example.py agent/tests/test_example.py
|
||||
```
|
||||
The repository does not yet enforce a whole-tree Black or Ruff check, so
|
||||
avoid mixing unrelated formatting cleanup into a focused pull request.
|
||||
- Type-annotate all public function and method signatures.
|
||||
- Google-style docstrings (`Args:` / `Returns:` / `Raises:`).
|
||||
- Keep files under 400 lines where practical, 800 hard cap.
|
||||
|
||||
+69
-21
@@ -1,7 +1,8 @@
|
||||
# ============================================================================
|
||||
# Stage 1: Build frontend
|
||||
# ============================================================================
|
||||
FROM node:20-slim AS frontend-build
|
||||
FROM node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS frontend-build
|
||||
# node:22-slim digest resolved 2026-07-28
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
@@ -10,50 +11,97 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2: Python runtime
|
||||
# Stage 2: Python builder — compiles wheels + builds a self-contained venv.
|
||||
# build-essential lives ONLY here; it never reaches the runtime image.
|
||||
# ============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 AS builder
|
||||
# python:3.11-slim digest resolved 2026-07-13
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Isolated venv we can copy wholesale into the runtime stage.
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
RUN python -m venv "$VIRTUAL_ENV"
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python deps first for layer caching. Installed from the hash-pinned lock
|
||||
# (agent/requirements.txt is the human-edited source; regenerate the lock
|
||||
# with the command documented at the top of requirements-lock.txt whenever
|
||||
# agent/requirements.txt changes).
|
||||
COPY agent/requirements.txt agent/requirements.txt
|
||||
COPY requirements-lock.txt requirements-lock.txt
|
||||
RUN pip install --no-cache-dir --require-hashes -r requirements-lock.txt
|
||||
|
||||
# Copy project + install the CLI entrypoint (editable — the runtime stage
|
||||
# re-creates the same /app/agent source tree the .pth file points at).
|
||||
COPY pyproject.toml LICENSE README.md ./
|
||||
COPY agent/ agent/
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# ============================================================================
|
||||
# Stage 3: Runtime — carries the prebuilt venv only, no compilers/dev headers.
|
||||
# ============================================================================
|
||||
FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 AS runtime
|
||||
# python:3.11-slim digest resolved 2026-07-13
|
||||
|
||||
LABEL org.opencontainers.image.title="Vibe-Trading" \
|
||||
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \
|
||||
org.opencontainers.image.version="0.1.7" \
|
||||
org.opencontainers.image.version="0.1.13" \
|
||||
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Runtime-only native libs. NO build-essential here — these are weasyprint's
|
||||
# shared libraries (Pango/HarfBuzz/Fontconfig/Cairo/gdk-pixbuf) per its official
|
||||
# Debian install list; without them the lazy `from weasyprint import HTML` in
|
||||
# reporter.py fails and PDF rendering silently downgrades to HTML-only.
|
||||
# fonts-dejavu-core gives non-blank PDFs.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libfontconfig1 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libcairo2 \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python deps (install before copying code for layer caching)
|
||||
COPY agent/requirements.txt agent/requirements.txt
|
||||
RUN pip install --no-cache-dir -r agent/requirements.txt
|
||||
# Bring in the prebuilt venv from the builder stage.
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Copy project
|
||||
# Re-materialize the source tree the editable install references, plus the
|
||||
# built frontend static assets.
|
||||
COPY pyproject.toml LICENSE README.md ./
|
||||
COPY agent/ agent/
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend-build /app/frontend/dist frontend/dist
|
||||
|
||||
# Install CLI entrypoint
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# Runtime should not run as root. Keep writable app data directories owned by
|
||||
# the service user so named Docker volumes inherit usable permissions.
|
||||
# Runtime should not run as root. `vibe` owns the writable app-data dirs so
|
||||
# named volumes inherit usable permissions. `vibe-sandbox` is an unprivileged
|
||||
# system account (no home, no shell) that runner.py drops into via
|
||||
# subprocess.run(user="vibe-sandbox") to execute LLM-generated code with the
|
||||
# least privilege — created here by fixed contract, not otherwise used.
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
|
||||
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs \
|
||||
&& chown -R vibe:vibe /app
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin --uid 10001 vibe-sandbox \
|
||||
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs /home/vibe/.vibe-trading \
|
||||
&& chown -R vibe:vibe /app /home/vibe/.vibe-trading
|
||||
USER vibe
|
||||
|
||||
# Default port
|
||||
EXPOSE 8899
|
||||
|
||||
# Health check
|
||||
# Health check — hits /live (liveness probe; /health remains a legacy alias).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')" || exit 1
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/live')" || exit 1
|
||||
|
||||
# Run API server (serves frontend/dist as static files)
|
||||
CMD ["vibe-trading", "serve", "--host", "0.0.0.0", "--port", "8899"]
|
||||
@@ -20,3 +20,6 @@ repository as factual mathematical content (formulas are not copyrightable):
|
||||
Prose, tables, and figures from the source papers and reports are NOT
|
||||
reproduced in this repository. Only the mathematical formulas — which are
|
||||
factual content and not subject to copyright — are reimplemented here.
|
||||
|
||||
This product bundles Inter and JetBrains Mono fonts from @fontsource 5.3.0
|
||||
under the SIL Open Font License 1.1. See frontend/public/fonts/LICENSE.
|
||||
+810
-74
File diff suppressed because it is too large.
Load diff
+817
-75
File diff suppressed because it is too large.
Load diff
+812
-74
File diff suppressed because it is too large.
Load diff
+805
-75
File diff suppressed because it is too large.
Load diff
+21
@@ -20,6 +20,27 @@ We will acknowledge your report within **5 business days** and work with you to
|
||||
|
||||
This policy applies to the [HKUDS/Vibe-Trading](https://github.com/HKUDS/Vibe-Trading) repository.
|
||||
|
||||
## Generated backtest code
|
||||
|
||||
Backtest runs may execute generated Python strategy code locally. Treat generated strategies as local code you review before running. The runner validates run directories and uses a narrow subprocess environment: it preserves OS/Python basics, proxy/certificate settings, allowed run-root configuration, and read-only market-data credentials needed by loaders, but it does not forward LLM provider keys, API server bearer tokens, shell-tool opt-ins, broker trading secrets, or live/advisory toggles by default.
|
||||
|
||||
The backtest subprocess is still network-capable so loaders can fetch public or user-authorized market data. Do not run untrusted generated strategies in an environment that exposes sensitive files, secret-bearing proxy variables, or network services you would not trust local code to access.
|
||||
|
||||
## Official channels & impersonation
|
||||
|
||||
Vibe-Trading is an open-source finance **research** tool. We will **never** ask you to
|
||||
"verify", connect, or sign with a crypto wallet to join our community, claim an airdrop, or
|
||||
unlock features — any such prompt is a scam.
|
||||
|
||||
- Our only official Discord is **https://discord.gg/6TdQnT5xcF** (the HKUDS community server,
|
||||
also linked from the README). Treat any other "Vibe-Trading" Discord as an impostor.
|
||||
- If a Discord or website asks you to connect/sign a wallet for "verification", do not do it.
|
||||
If you already did, move your funds to a fresh wallet and revoke approvals (e.g. via
|
||||
[revoke.cash](https://revoke.cash)).
|
||||
|
||||
See the pinned security announcement in [Discussions](https://github.com/HKUDS/Vibe-Trading/discussions)
|
||||
for the 2026-06-18 impostor-Discord incident.
|
||||
|
||||
## Disclosure
|
||||
|
||||
- Please do not publicly disclose the vulnerability until we have released a fix.
|
||||
|
||||
+178
-13
@@ -1,24 +1,40 @@
|
||||
# NOTE: All defaults are defined in agent/src/config/env_schema.py (EnvConfig).
|
||||
# ============================================================================
|
||||
# LLM Provider — uncomment ONE provider block below
|
||||
# LLM Provider (LLMConfig) — uncomment ONE provider block below
|
||||
# ============================================================================
|
||||
|
||||
# --- OpenRouter (recommended, multi-model gateway) ---
|
||||
LANGCHAIN_PROVIDER=openrouter
|
||||
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v4-pro
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||
OPENROUTER_API_KEY=sk-or-...here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# --- Requesty (OpenAI-compatible multi-model gateway) ---
|
||||
# Same provider/model naming as OpenRouter. Get a key at https://app.requesty.ai/api-keys
|
||||
# EU endpoint: https://router.eu.requesty.ai/v1
|
||||
# LANGCHAIN_PROVIDER=requesty
|
||||
# LANGCHAIN_MODEL_NAME=openai/gpt-4o-mini
|
||||
# REQUESTY_API_KEY=xxx
|
||||
# REQUESTY_BASE_URL=https://router.requesty.ai/v1
|
||||
|
||||
# --- OpenAI ---
|
||||
# LANGCHAIN_PROVIDER=openai
|
||||
# LANGCHAIN_MODEL_NAME=gpt-5.5-instant
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# --- Anthropic Messages API / compatible proxy ---
|
||||
# LANGCHAIN_PROVIDER=anthropic
|
||||
# LANGCHAIN_MODEL_NAME=claude-sonnet-4-6
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
# ANTHROPIC_MAX_TOKENS=16384 # Recommended for proxy model names unknown to LangChain
|
||||
|
||||
# --- OpenAI Codex (ChatGPT OAuth; not the OpenAI API key path) ---
|
||||
# Login first: vibe-trading provider login openai-codex
|
||||
# Requires a ChatGPT account with Codex access. OAuth tokens are stored by oauth-cli-kit.
|
||||
# LANGCHAIN_PROVIDER=openai-codex
|
||||
# LANGCHAIN_MODEL_NAME=openai-codex/gpt-5.3-codex
|
||||
# LANGCHAIN_MODEL_NAME=openai-codex/gpt-5.4
|
||||
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
|
||||
|
||||
# --- DeepSeek ---
|
||||
@@ -26,6 +42,17 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# LANGCHAIN_MODEL_NAME=deepseek-v4-pro
|
||||
# DEEPSEEK_API_KEY=sk-xxx
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
# DeepSeek adapter selection: auto (default; use langchain-deepseek if installed),
|
||||
# native (require langchain-deepseek, error if missing), or openai-compatible
|
||||
# (force the legacy ChatOpenAI path). Install the adapter with:
|
||||
# pip install "vibe-trading-ai[deepseek]"
|
||||
# VIBE_TRADING_DEEPSEEK_ADAPTER=auto
|
||||
|
||||
# --- NVIDIA NIM (hosted API catalog) ---
|
||||
# LANGCHAIN_PROVIDER=nvidia
|
||||
# LANGCHAIN_MODEL_NAME=nvidia/nemotron-3-ultra-550b-a55b
|
||||
# NVIDIA_API_KEY=nvapi-xxx
|
||||
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
|
||||
# --- Gemini ---
|
||||
# LANGCHAIN_PROVIDER=gemini
|
||||
@@ -51,17 +78,26 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# ZHIPU_API_KEY=xxx
|
||||
# ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
|
||||
# --- Moonshot / Kimi ---
|
||||
# --- Moonshot / Kimi (open platform, pay-as-you-go) ---
|
||||
# LANGCHAIN_PROVIDER=moonshot
|
||||
# LANGCHAIN_MODEL_NAME=kimi-k2.6
|
||||
# MOONSHOT_API_KEY=sk-xxx
|
||||
# MOONSHOT_BASE_URL=https://api.moonshot.ai/v1
|
||||
|
||||
# --- Kimi for Coding (subscription plan; NOT the open platform above) ---
|
||||
# Create the key in the Kimi Code console (kimi.com/code/docs). A coding-plan
|
||||
# key is rejected by api.moonshot.ai and vice-versa. temperature is forced to 1.
|
||||
# LANGCHAIN_PROVIDER=kimi-coding
|
||||
# LANGCHAIN_MODEL_NAME=kimi-for-coding
|
||||
# KIMI_CODING_API_KEY=sk-kimi-xxx
|
||||
# KIMI_CODING_BASE_URL=https://api.kimi.com/coding/v1
|
||||
|
||||
# --- MiniMax ---
|
||||
# LANGCHAIN_PROVIDER=minimax
|
||||
# LANGCHAIN_MODEL_NAME=MiniMax-M2.7 # or MiniMax-M2.7-highspeed (faster)
|
||||
# LANGCHAIN_MODEL_NAME=MiniMax-M3 # or MiniMax-M2.7 / MiniMax-M2.7-highspeed
|
||||
# MINIMAX_API_KEY=xxx
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # Global
|
||||
# MINIMAX_BASE_URL=https://api.minimaxi.com/v1 # China
|
||||
# Note: MiniMax requires temperature > 0. Set LANGCHAIN_TEMPERATURE=1.0 (default when using MiniMax)
|
||||
|
||||
# --- Xiaomi MIMO ---
|
||||
@@ -70,6 +106,13 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# MIMO_API_KEY=xxx
|
||||
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1
|
||||
|
||||
# --- iFlytek Spark ---
|
||||
# APIPassword from the iFlytek console (https://console.xfyun.cn), sent as a Bearer token.
|
||||
# LANGCHAIN_PROVIDER=spark
|
||||
# LANGCHAIN_MODEL_NAME=4.0Ultra # or generalv3.5 / pro-128k / lite
|
||||
# SPARK_API_KEY=xxx
|
||||
# SPARK_BASE_URL=https://spark-api-open.xf-yun.com/v1
|
||||
|
||||
# --- Z.ai (for Coding Plan) ---
|
||||
# LANGCHAIN_PROVIDER=zai
|
||||
# LANGCHAIN_MODEL_NAME=glm-5.1
|
||||
@@ -86,13 +129,17 @@ LANGCHAIN_TEMPERATURE=0.0
|
||||
TIMEOUT_SECONDS=120
|
||||
MAX_RETRIES=2
|
||||
|
||||
# Enable reasoning on relays that require opt-in (e.g. OpenRouter thinking models).
|
||||
# Forwarded as extra_body.reasoning.effort = low / medium / high / max.
|
||||
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
|
||||
# Reasoning effort: none / low / medium / high / max. Two delivery paths:
|
||||
# * Relays requiring opt-in (OpenRouter, Requesty) get extra_body.reasoning.effort.
|
||||
# * Direct OpenAI gets a top-level reasoning_effort — gpt-5.6-* models require an
|
||||
# explicit value (including "none") before they accept function tools.
|
||||
# Leave unset for every other provider: Moonshot / DeepSeek official APIs return
|
||||
# reasoning_content by default, and other OpenAI-compatible providers may reject
|
||||
# the field outright.
|
||||
# LANGCHAIN_REASONING_EFFORT=medium
|
||||
|
||||
# ============================================================================
|
||||
# Data Sources
|
||||
# Data Sources (DataConfig)
|
||||
# ============================================================================
|
||||
# A-shares: Tushare Pro token (https://tushare.pro)
|
||||
TUSHARE_TOKEN=your-tushare-token
|
||||
@@ -104,15 +151,89 @@ TUSHARE_TOKEN=your-tushare-token
|
||||
# Download FutuOpenD: https://www.futunn.com/download/openAPI
|
||||
# FUTU_HOST=127.0.0.1
|
||||
# FUTU_PORT=11111
|
||||
# US / HK equities via LongPort OpenAPI (optional, requires LongPort developer account)
|
||||
# Sign up: https://open.longbridge.com
|
||||
# LONGBRIDGE_APP_KEY=your-app-key
|
||||
# LONGBRIDGE_APP_SECRET=your-app-secret
|
||||
# LONGBRIDGE_ACCESS_TOKEN=your-access-token
|
||||
# Install the optional SDK before using Longbridge:
|
||||
# pip install "vibe-trading-ai[longbridge]"
|
||||
# To force Longbridge for a backtest, set "source": "longbridge" in config.json.
|
||||
# In an Agent conversation, ask: "Use Longbridge to fetch QQQ.US historical data."
|
||||
# Free direct-API sources need NO key and auto-join the fallback chain: Eastmoney / Sina / Stooq / Yahoo.
|
||||
|
||||
# Optional API-key data sources (enabled only when the key is set; silently skipped otherwise)
|
||||
# FINNHUB_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
|
||||
# ALPHAVANTAGE_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
|
||||
# TIINGO_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
|
||||
# FMP_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
|
||||
# FRED_API_KEY=xxx # macro series (get_macro_series tool)
|
||||
# VIBE_TRADING_IWENCAI_KEY=xxx # A-share natural-language research search (iwencai_search tool)
|
||||
# VIBE_TRADING_SEC_UA="Your Name your@email.com" # SEC EDGAR compliant contact User-Agent (optional; a built-in default UA also works)
|
||||
# Optional per-source request-spacing overrides for batch jobs — VIBE_TRADING_{EASTMONEY,SINA,STOOQ,YAHOO,SEC,FINNHUB,ALPHAVANTAGE,TIINGO,FMP,FRED,IWENCAI,THS}_MIN_INTERVAL (seconds).
|
||||
|
||||
# OCR engine for scanned PDF pages and images (auto by default).
|
||||
# auto — local engines only (privacy: never sends pages to cloud)
|
||||
# rapid — force RapidOCR (local, ONNX; pip install rapidocr_onnxruntime)
|
||||
# llm-vision — force LLM vision OCR (cloud; any OpenAI-compatible vision model)
|
||||
# none — disable OCR entirely
|
||||
# VIBE_TRADING_OCR_ENGINE=auto
|
||||
# Override the model used for LLM vision OCR (defaults to LANGCHAIN_MODEL_NAME).
|
||||
# Example: qwen3.7-plus, gpt-5.5, gemini-3.5-flash, claude-sonnet-4.6
|
||||
# VIBE_TRADING_OCR_LLM_MODEL=
|
||||
|
||||
# ============================================================================
|
||||
# API Server (optional)
|
||||
# Storage root
|
||||
# ============================================================================
|
||||
# Chat sessions, run artifacts, swarm runs, uploads, and the sessions.db
|
||||
# search/goal index live under ~/.vibe-trading by default. Set
|
||||
# VIBE_TRADING_HOME to relocate them, e.g. to keep deliberately separate
|
||||
# stores per project. (Other state — memory, reports, caches, live configs —
|
||||
# is still anchored to ~/.vibe-trading regardless.) Set it in your shell
|
||||
# environment (export VIBE_TRADING_HOME=...), NOT in this file: path
|
||||
# constants are resolved when the process starts, before this file is read,
|
||||
# so an override here would apply to some code paths and not others.
|
||||
# VIBE_TRADING_HOME=~/.vibe-trading
|
||||
|
||||
# Opt-in local market-data cache for backtest loaders (off by default).
|
||||
# When enabled, every data source caches settled historical bars under
|
||||
# ~/.vibe-trading/cache/loaders/ so repeated/long-horizon backtests skip the
|
||||
# network. Only fully-elapsed days are cached (ranges ending today are always
|
||||
# re-fetched). Clear it any time with: rm -rf ~/.vibe-trading/cache
|
||||
# VIBE_TRADING_DATA_CACHE=1
|
||||
|
||||
# Path to a published Taiwan-market SQLite snapshot. The read-only Taiwan stock
|
||||
# tool registers only when this points at a database with the expected schema,
|
||||
# so leaving it unset simply omits the tool. Keep the file outside the checkout —
|
||||
# no market data may land in the working tree.
|
||||
# VIBE_TW_STOCK_DB=~/.vibe-trading/tw-stock/latest.db
|
||||
|
||||
# Opt-in background scheduler for saved research jobs (off by default).
|
||||
# When enabled, the API server runs a background poller that fires due
|
||||
# interval/cron scheduled-research jobs through the session runtime. Cron
|
||||
# jobs may carry an IANA `timezone` (evaluated on that zone's wall clock,
|
||||
# DST-aware); jobs without one evaluate in UTC.
|
||||
# VIBE_TRADING_ENABLE_SCHEDULER=1
|
||||
# A dispatch failure stays retryable until this many consecutive failures.
|
||||
# Retry timing is max(next scheduled time, exponential delay), capped below.
|
||||
# VIBE_TRADING_SCHEDULER_MAX_CONSECUTIVE_FAILURES=3
|
||||
# VIBE_TRADING_SCHEDULER_RETRY_BASE_DELAY_MS=60000
|
||||
# VIBE_TRADING_SCHEDULER_RETRY_MAX_DELAY_MS=3600000
|
||||
|
||||
# ============================================================================
|
||||
# API Server (APIConfig, optional)
|
||||
# ============================================================================
|
||||
# Bearer token for API auth.
|
||||
# Empty dev mode is loopback-only. Set this before exposing port 8899 beyond localhost.
|
||||
# API_AUTH_KEY=
|
||||
# CORS allowed origins (comma-separated)
|
||||
# CORS allowed origins (comma-separated). Setting this REPLACES the loopback
|
||||
# defaults.
|
||||
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
# Extra CORS origins, ADDED to the loopback defaults instead of replacing them.
|
||||
# Needed to reach the API from a hosted console such as OpenBB Workspace. A
|
||||
# remote browser origin is still refused by the cross-site guard, so this is for
|
||||
# same-host or server-side callers.
|
||||
# VIBE_TRADING_EXTRA_CORS_ORIGINS=https://pro.openbb.co
|
||||
# Enable session runtime
|
||||
# ENABLE_SESSION_RUNTIME=true
|
||||
# Docker compose sets this together with a 127.0.0.1 port bind so browser
|
||||
@@ -130,7 +251,7 @@ TUSHARE_TOKEN=your-tushare-token
|
||||
# VIBE_TRADING_ALLOWED_RUN_ROOTS=
|
||||
|
||||
# ============================================================================
|
||||
# Agent Tuning (optional, defaults are sensible)
|
||||
# Agent Tuning (AgentTuningConfig, optional, defaults are sensible)
|
||||
# ============================================================================
|
||||
# SWARM_WORKER_TIMEOUT=300
|
||||
# SWARM_WORKER_MAX_ITER=50
|
||||
@@ -139,6 +260,50 @@ TUSHARE_TOKEN=your-tushare-token
|
||||
# SUBAGENT_TIMEOUT=300
|
||||
# SUBAGENT_MAX_ITER=25
|
||||
# TOKEN_THRESHOLD=40000
|
||||
# Hard timeout (seconds) for read-only tool execution; write tools only warn.
|
||||
# Default 1800; set 0 to disable.
|
||||
# VIBE_TRADING_TOOL_TIMEOUT_SECONDS=1800
|
||||
# Frontend SSE idle timeout in seconds before showing "Execution timed out".
|
||||
# Increase when using slow local models (e.g. Ollama on CPU/limited VRAM).
|
||||
# VIBE_TRADING_SSE_TIMEOUT=90
|
||||
# Content filter warning threshold (default: 0.05 = 5%)
|
||||
# When the ratio of LLM responses blocked by content moderation exceeds this
|
||||
# threshold, the run card will warn you to switch providers.
|
||||
# CONTENT_FILTER_WARNING_THRESHOLD=0.05
|
||||
|
||||
# ============================================================================
|
||||
# Memory System (optional, OFF by default)
|
||||
# ============================================================================
|
||||
# One-line preset (recommended):
|
||||
# off = no lifecycle management (default)
|
||||
# on = quality scoring + auto-decay + garbage collection
|
||||
# full = on + hierarchy + semantic links + compression + FTS search
|
||||
# VT_MEMORY=off
|
||||
|
||||
# Advanced: override individual features (takes priority over VT_MEMORY)
|
||||
# VT_MEMORY_QUALITY=true
|
||||
# VT_MEMORY_DECAY=true
|
||||
# VT_MEMORY_GC=true
|
||||
# VT_MEMORY_HIERARCHY=true
|
||||
# VT_MEMORY_LINKS=true
|
||||
# VT_MEMORY_COMPRESSION=true
|
||||
# VT_MEMORY_FTS_INDEX=true
|
||||
|
||||
# ============================================================================
|
||||
# TAP Mode — credential isolation for broker orders (optional, OFF by default)
|
||||
# ============================================================================
|
||||
# Route Alpaca order placement through the TAP proxy (https://tap.human.tech)
|
||||
# instead of the local broker SDK: the agent process holds no Alpaca key, and
|
||||
# every order is held for HUMAN APPROVAL before it reaches the broker. Leave
|
||||
# these unset for the default (direct broker SDK) behaviour.
|
||||
#
|
||||
# Setup: in the TAP dashboard create a multi-secret credential named `alpaca`
|
||||
# with fields `key_id` + `secret_key`, allowed host `paper-api.alpaca.markets`,
|
||||
# assigned to your agent. See the README "TAP Mode" section for details.
|
||||
#
|
||||
# TAP_PROXY_URL=https://proxy.tap.human.tech
|
||||
# TAP_AGENT_KEY=your-tap-agent-key
|
||||
# Credential name configured in TAP (default: alpaca)
|
||||
# TAP_ALPACA_CREDENTIAL=alpaca
|
||||
# Seconds to wait for a human approval decision (default: 300)
|
||||
# TAP_APPROVAL_TIMEOUT=300
|
||||
+51
-19
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: vibe-trading
|
||||
version: 0.1.8
|
||||
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, Alpha Zoo (452 pre-built alphas across qlib158/alpha101/gtja191/academic), options pricing, 77 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 7 data sources (tushare, yfinance, okx, akshare, mootdx, ccxt, futu).
|
||||
version: 0.1.13
|
||||
description: Professional finance research toolkit — backtesting (9 engines + benchmark comparison panel), factor analysis, Alpha Zoo (462 pre-built alphas across qlib158/alpha101/gtja191/academic/fundamental), options pricing, 89 finance skills, 30 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 24 market-data sources (tushare, yfinance, okx, binance, akshare, baostock, tencent, mootdx, ccxt, futu, mt5, local, eastmoney, sina, stooq, yahoo, pykrx, india_broker, qveris, longbridge, plus optional-key finnhub/alphavantage/tiingo/fmp).
|
||||
dependencies:
|
||||
python: ">=3.11"
|
||||
pip:
|
||||
@@ -23,7 +23,7 @@ mcp:
|
||||
|
||||
# Vibe-Trading
|
||||
|
||||
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 77 specialized skills, the **Alpha Zoo** (452 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic with one-line CLI benchmarking), and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
|
||||
Professional finance research toolkit with AI-powered backtesting (9 engines), multi-agent teams, 89 specialized skills, the **Alpha Zoo** (462 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic / fundamental with one-line CLI benchmarking), and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -53,12 +53,13 @@ Add to your agent's MCP config:
|
||||
|
||||
### API Key Requirements
|
||||
|
||||
Core research MCP tools work with zero API keys for HK/US/crypto. After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, the Alpha Zoo (452 pre-built alphas), and all 77 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
|
||||
Core research MCP tools work with zero API keys for HK/US/crypto. After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, the Alpha Zoo (462 pre-built alphas), and all 89 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
|
||||
|
||||
| Feature | Key needed | When |
|
||||
|---------|-----------|------|
|
||||
| HK/US equities & crypto | None | Always free (yfinance + OKX) |
|
||||
| China A-share data | `TUSHARE_TOKEN` | Only if you query A-share symbols |
|
||||
| HK/US equities & crypto | None | Always free (yfinance / stooq / yahoo + OKX) |
|
||||
| China A-share data | None | Free via akshare / baostock / tencent / sina / eastmoney / mootdx fallback (`TUSHARE_TOKEN` optional for premium quality) |
|
||||
| Premium US fundamentals/quotes | `FINNHUB_API_KEY` / `ALPHAVANTAGE_API_KEY` / `TIINGO_API_KEY` / `FMP_API_KEY` | Only for optional-key providers (graceful fallback to free sources) |
|
||||
| Multi-agent swarm (`run_swarm`) | `OPENAI_API_KEY` + `LANGCHAIN_MODEL_NAME` | Swarm spawns internal LLM workers |
|
||||
|
||||
## What You Can Do
|
||||
@@ -73,12 +74,19 @@ Feed a CSV broker export (同花顺 / 东财 / 富途 / generic), and the agent
|
||||
5. `scan_shadow_signals` — list today's symbols that match your shadow's entry cadence (research only).
|
||||
|
||||
### Backtesting
|
||||
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 7 data sources:
|
||||
- **HK/US equities** via yfinance (free, no API key)
|
||||
Create and run quantitative strategies across 9 engines (ChinaA, GlobalEquity, IndiaEquity, KoreaEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 24 market-data sources (auto-detect + ordered fallback):
|
||||
- **HK/US equities** via yfinance / stooq / yahoo (free, no API key); optionally via **Longbridge** historical OHLCV (`longbridge`, requires the optional SDK and `LONGBRIDGE_APP_KEY` / `LONGBRIDGE_APP_SECRET` / `LONGBRIDGE_ACCESS_TOKEN`). To force it for a run, set `"source": "longbridge"` in `config.json`.
|
||||
- **India equities (NSE/BSE)** via yahoo / yfinance using `<SYMBOL>.NS` (NSE, e.g. `RELIANCE.NS`) or `<SCRIP>.BO` (BSE, e.g. `500325.BO`) — free, no API key. The `IndiaEquityEngine` models T+1 delivery, no overnight shorts (set `allow_short` for intraday), configurable circuit bands, 1-share lots, and the STT/stamp-duty/exchange/GST cost stack. Optionally back-fill from your live broker via the `india_broker` source (Shoonya/Dhan; requires broker login).
|
||||
- **Korea equities (KRX: KOSPI/KOSDAQ)** via pykrx using `<CODE>.KS` (KOSPI, e.g. `005930.KS`) or `<CODE>.KQ` (KOSDAQ, e.g. `247540.KQ`) — free, no API key (`pip install "vibe-trading-ai[krx]"`; yahoo/yfinance fallback needs no extra). pykrx serves **daily bars only** (an intraday request falls through to another source) and its adjusted series is Naver-backed rather than a verbatim KRX print. The `KoreaEquityEngine` models same-day round trips (no T+1), the ±30% daily price limit measured from the previous close and quantized to the KRX tick grid, tick-rounded fills, the 0.20% sell-side transaction tax (2026 rate), and 1-share lots. It is **long-only**: `allow_short` is refused, because KRX covered-short and uptick rules cannot be enforced on daily bars.
|
||||
- **Cryptocurrency** via OKX or CCXT/100+ exchanges (free, no API key)
|
||||
- **China A-shares** via Tushare (token) or AKShare (free fallback)
|
||||
- **China A-shares** via AKShare / baostock / tencent / sina / eastmoney / mootdx (free, no API key) — `TUSHARE_TOKEN` optional for premium quality
|
||||
- **Futures, forex, macro** via AKShare (free, no API key)
|
||||
- **HK & A-share equities** via Futu (broker login required, optional)
|
||||
- **Local CSV/parquet bars** via the `local` loader (offline, no network)
|
||||
- **Premium cross-market data** via QVeris (optional API key)
|
||||
- **Premium US data** via optional-key finnhub / alphavantage / tiingo / fmp (graceful fallback to free sources)
|
||||
|
||||
Factors: the Alpha101 and QLib158 zoos are tagged for the `equity_in` and `equity_kr` universes, so they compute on NSE/BSE and KRX bars (the GTJA191 zoo stays China-only). Live/paper India trading uses the Shoonya / Dhan connectors (paper + read-only live; live order placement is structurally disabled because those brokers expose no paper/live switch).
|
||||
|
||||
Example workflow:
|
||||
1. Use `list_skills()` to discover strategy patterns
|
||||
@@ -87,7 +95,7 @@ Example workflow:
|
||||
4. Use `backtest()` to run and get metrics (Sharpe, return, drawdown, etc.)
|
||||
|
||||
### Multi-Agent Swarm Teams
|
||||
29 pre-built agent teams for complex research:
|
||||
30 pre-built agent teams for complex research:
|
||||
- **Investment Committee**: bull/bear debate → risk review → PM decision
|
||||
- **Global Equities Desk**: A-share + HK/US + crypto → global strategist
|
||||
- **Crypto Trading Desk**: funding/basis + liquidation + flow → risk manager
|
||||
@@ -95,20 +103,21 @@ Example workflow:
|
||||
- **Macro/Rates/FX Desk**: rates + FX + commodities → macro PM
|
||||
- **Quant Strategy Desk**: screening → factor research → backtest → risk audit
|
||||
- **Risk Committee**: drawdown, tail risk, regime analysis
|
||||
- And 22 more specialized teams
|
||||
- And 23 more specialized teams
|
||||
|
||||
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
|
||||
|
||||
### Alpha Zoo (452 pre-built alphas)
|
||||
One-line cross-sectional IC / IR / alive-reversed-dead categorisation across four bundled zoos:
|
||||
### Alpha Zoo (462 pre-built alphas)
|
||||
One-line cross-sectional IC / IR / alive-reversed-dead categorisation across five bundled zoos:
|
||||
- **qlib158** (154 alphas) — Microsoft Qlib's `Alpha158` feature handler, Apache-2.0 with pinned commit SHA.
|
||||
- **alpha101** (101 alphas) — Kakushadze (2015) "101 Formulaic Alphas" (arXiv:1601.00991), written from the paper appendix.
|
||||
- **gtja191** (191 alphas) — Guotai Junan 2014 "191 Short-period Trading Alpha Factors" research report.
|
||||
- **academic** (6 factors) — Fama-French 5 + Carhart momentum (honest price-based proxies).
|
||||
- **academic** (12 factors) — Fama-French 5 + Carhart momentum + Jegadeesh reversal + George-Hwang 52-week-high + Amihud illiquidity + Harvey-Siddique skew + Frazzini-Pedersen betting-against-beta (price-based proxies) + a correlation-rewiring stability score (from the in-repo correlation-regime skill).
|
||||
- **fundamental** (4 factors) — PIT-safe earnings yield, ROE, gross profitability, and asset growth from daily fundamental panels.
|
||||
|
||||
Each alpha ships with `__alpha_meta__` (formula LaTeX + theme + universe + warmup + columns required), guarded by an AST purity gate + 300-row lookahead sentinel test. Use the `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI, the `/alpha/*` REST routes (browser at `/alpha-zoo`), or compose multi-factor signals via `ZooSignalEngine.from_zoo(...)`.
|
||||
|
||||
### Finance Skills (77)
|
||||
### Finance Skills (88)
|
||||
Comprehensive knowledge base covering:
|
||||
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC, harmonic, chanlun)
|
||||
- Quantitative methods (factor research, ML strategy, pair trading, multi-factor)
|
||||
@@ -121,11 +130,11 @@ Comprehensive knowledge base covering:
|
||||
|
||||
Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
|
||||
## Available MCP Tools (35)
|
||||
## Available MCP Tools (55)
|
||||
|
||||
| Tool | Description | API Key |
|
||||
|------|-------------|---------|
|
||||
| `list_skills` | List all 77 finance skills | None |
|
||||
| `list_skills` | List all 89 finance skills | None |
|
||||
| `load_skill` | Load full skill documentation | None |
|
||||
| `start_research_goal` | Create an auditable research goal | None |
|
||||
| `get_research_goal` | Read the current research goal | None |
|
||||
@@ -134,8 +143,27 @@ Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
| `backtest` | Run vectorized backtest engine | None* |
|
||||
| `factor_analysis` | IC/IR analysis + layered backtest | None* |
|
||||
| `analyze_options` | Black-Scholes price + Greeks | None |
|
||||
| `analyze_options_payoff` | Multi-leg expiry payoff + spot/IV scenarios | None |
|
||||
| `pattern_recognition` | Detect chart patterns (H&S, double top, etc.) | None |
|
||||
| `get_market_data` | Fetch OHLCV data across 6 sources (auto-detect + fallback) | None* |
|
||||
| `get_market_data` | Fetch OHLCV data (auto-detect + ordered fallback across 24 sources) | None* |
|
||||
| `get_fund_flow` | Capital fund-flow (main/retail net inflow) | None* |
|
||||
| `get_dragon_tiger` | Dragon-tiger list (龙虎榜) top buyer/seller seats | None* |
|
||||
| `get_northbound_flow` | Northbound (Stock Connect) net flow | None* |
|
||||
| `get_margin_trading` | Margin trading & short-selling balances | None* |
|
||||
| `get_block_trades` | Block-trade (大宗交易) records | None* |
|
||||
| `get_shareholder_count` | Shareholder-count history per symbol | None* |
|
||||
| `get_lockup_expiry` | Restricted-share lockup release schedule | None* |
|
||||
| `get_sector_info` | Sector / industry constituents & performance | None* |
|
||||
| `get_research_reports` | Sell-side analyst research reports | None* |
|
||||
| `get_stock_news` | Market & company news headlines | None* |
|
||||
| `get_sec_filings` | SEC EDGAR filings (10-K/10-Q/8-K, etc.) | None |
|
||||
| `get_financial_statements` | Income / balance / cash-flow statements | None* |
|
||||
| `get_options_chain` | Options chain (strikes, IV, OI, Greeks) | None* |
|
||||
| `get_stock_profile` | Valuation, analyst estimates & institutional holdings (US/HK) | None |
|
||||
| `screen_market` | Market screener with fundamental/technical filters | None* |
|
||||
| `search_symbol` | Symbol / ticker search across markets | None |
|
||||
| `get_macro_series` | FRED macroeconomic series | FRED_API_KEY |
|
||||
| `iwencai_search` | A-share natural-language research search | IWENCAI_KEY |
|
||||
| `web_search` | Search the web via DuckDuckGo | None |
|
||||
| `read_url` | Fetch web page as Markdown | None |
|
||||
| `read_document` | Extract text from PDF/DOCX/XLSX/PPTX/images | None |
|
||||
@@ -152,6 +180,7 @@ Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
| `get_run_result` | Get final report and task summaries | None |
|
||||
| `list_runs` | List recent swarm runs with metadata | None |
|
||||
| `reap_stale_runs` | Finalize stale swarm runs | None |
|
||||
| `retry_run` | Re-run a failed/stale swarm run | LLM key |
|
||||
| `trading_connections` | List selectable connector profiles | None |
|
||||
| `trading_select_connection` | Select the default connector profile | None |
|
||||
| `trading_check` | Check connector readiness | Connector app/OAuth |
|
||||
@@ -169,7 +198,7 @@ Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
pip install vibe-trading-ai
|
||||
```
|
||||
|
||||
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `analyze_trade_journal`, `extract_shadow_strategy`, `web_search`, the **Alpha Zoo** (`vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`), and all 77 skills immediately.
|
||||
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `analyze_trade_journal`, `extract_shadow_strategy`, `web_search`, the **Alpha Zoo** (`vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`), and all 89 skills immediately.
|
||||
|
||||
## Loading Tools from External MCP Servers
|
||||
|
||||
@@ -325,3 +354,6 @@ Without `ALLOW_SESSION_MCP_SERVERS=1`, any `mcpServers` key in `session.config`
|
||||
|
||||
**Options analysis:**
|
||||
> Use analyze_options: spot=100, strike=105, 90 days, vol=25%, rate=3%
|
||||
|
||||
**Multi-leg options payoff:**
|
||||
> Use analyze_options_payoff for a 95/105 bull call spread at spot 100 with 30 days remaining: long one 95 call at premium 8, short one 105 call at premium 3, multiplier 100, commission rate 0.001.
|
||||
+261
-2965
File diff suppressed because it is too large.
Load diff
@@ -7,11 +7,12 @@ data given a set of strategy codes and a data source.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.yfinance_loader import DataLoader as YfinanceLoader
|
||||
from backtest.metrics import bar_returns, buy_and_hold_return
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -42,6 +43,7 @@ def resolve_benchmark(
|
||||
end_date: str,
|
||||
interval: str = "1D",
|
||||
explicit: Optional[str] = None,
|
||||
loader: Optional[Any] = None,
|
||||
) -> Optional[BenchmarkResult]:
|
||||
"""Resolve the appropriate benchmark ticker and fetch its return series.
|
||||
|
||||
@@ -52,6 +54,10 @@ def resolve_benchmark(
|
||||
end_date: Backtest end date.
|
||||
interval: Bar interval (1m / 5m / 15m / 30m / 1H / 4H / 1D).
|
||||
explicit: Override ticker (e.g. "SPY" passed via config).
|
||||
loader: Loader of the configured data source. When given, the
|
||||
benchmark is fetched through it first, falling back to
|
||||
yfinance if it yields no data — except ``local``,
|
||||
which fails closed to keep offline runs offline.
|
||||
|
||||
Returns:
|
||||
BenchmarkResult with return series and total return, or None if no
|
||||
@@ -61,8 +67,19 @@ def resolve_benchmark(
|
||||
if ticker is None:
|
||||
return None
|
||||
|
||||
offline = source == "local"
|
||||
if offline and getattr(loader, "name", None) != source:
|
||||
# The runtime fallback chain in fetch_data_map() may have swapped in a
|
||||
# network loader while config["source"] still says local — never fetch
|
||||
# the benchmark through it. Fail closed instead.
|
||||
loader = None
|
||||
|
||||
try:
|
||||
bench_df = _fetch_benchmark(ticker, start_date, end_date, interval)
|
||||
bench_df = _fetch_benchmark(
|
||||
ticker, start_date, end_date, interval,
|
||||
loader=loader,
|
||||
allow_fallback=not offline,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -73,10 +90,15 @@ def resolve_benchmark(
|
||||
if len(close) < 2:
|
||||
return None
|
||||
|
||||
ret_series = close.pct_change().fillna(0.0)
|
||||
total_ret = float((1 + ret_series).prod() - 1)
|
||||
ret_series = bar_returns(close, label=f"benchmark {ticker}")
|
||||
# Price relative, not the compounded product: identical while prices are
|
||||
# positive, and it is the only one of the two that stays honest once they
|
||||
# are not (#872).
|
||||
total = buy_and_hold_return(close)
|
||||
if total is None:
|
||||
return None
|
||||
|
||||
return BenchmarkResult(ticker=ticker, ret_series=ret_series, total_ret=total_ret)
|
||||
return BenchmarkResult(ticker=ticker, ret_series=ret_series, total_ret=total)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -134,11 +156,36 @@ def _fetch_benchmark(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
interval: str,
|
||||
loader: Optional[Any] = None,
|
||||
allow_fallback: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch benchmark OHLCV data via yfinance (single symbol, no auth)."""
|
||||
loader = YfinanceLoader()
|
||||
result = loader.fetch([ticker], start_date, end_date, interval=interval)
|
||||
"""Fetch benchmark OHLCV data.
|
||||
|
||||
Tries the configured source's loader first (when given). Falls back to
|
||||
yfinance (single symbol, no auth) when no loader is given or it yields
|
||||
no data — unless ``allow_fallback`` is False (offline sources fail
|
||||
closed instead of making a network request).
|
||||
"""
|
||||
if loader is not None:
|
||||
try:
|
||||
df = _extract_frame(
|
||||
loader.fetch([ticker], start_date, end_date, interval=interval),
|
||||
ticker,
|
||||
)
|
||||
except Exception:
|
||||
df = pd.DataFrame()
|
||||
if not df.empty:
|
||||
return df
|
||||
|
||||
if not allow_fallback:
|
||||
return pd.DataFrame()
|
||||
|
||||
result = YfinanceLoader().fetch([ticker], start_date, end_date, interval=interval)
|
||||
return _extract_frame(result, ticker)
|
||||
|
||||
|
||||
def _extract_frame(result: Any, ticker: str) -> pd.DataFrame:
|
||||
"""Normalise a loader fetch result to a single DataFrame."""
|
||||
if isinstance(result, dict):
|
||||
df = result.get(ticker)
|
||||
elif isinstance(result, pd.DataFrame):
|
||||
@@ -149,4 +196,4 @@ def _fetch_benchmark(
|
||||
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
||||
return pd.DataFrame()
|
||||
|
||||
return df
|
||||
return df
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Composable weight constraints applied on top of any optimizer.
|
||||
|
||||
Portfolio Studio step 2 (#456): per-name cap, per-name floor, and per-group
|
||||
exposure caps that work with every optimizer, not only the bounds baked into
|
||||
``turnover_aware``. Configured via ``constraints`` in config.json::
|
||||
|
||||
"constraints": [
|
||||
{"type": "max_weight", "cap": 0.25},
|
||||
{"type": "min_weight", "floor": 0.02},
|
||||
{"type": "group_exposure",
|
||||
"groups": {"AAPL": "tech", "MSFT": "tech", "XOM": "energy"},
|
||||
"caps": {"tech": 0.6}}
|
||||
]
|
||||
|
||||
The layer runs on the optimizer's output frame (signed weights, one row per
|
||||
rebalance date). Magnitudes are adjusted and signs are preserved, so
|
||||
long/short books work too. Constraints apply in config order, and the layer
|
||||
only acts on names that are already active: it reshapes allocations, it
|
||||
never opens or closes positions. Off by default, so existing configs behave
|
||||
exactly as before.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_EPS = 1e-9
|
||||
_TOL = 1e-12
|
||||
_MAX_PASSES = 50
|
||||
|
||||
|
||||
def _bounded_fraction(value: Any, name: str) -> float:
|
||||
"""Validate a (0, 1] fraction from config, rejecting bools and junk."""
|
||||
if isinstance(value, (bool, np.bool_)):
|
||||
raise ValueError(f"{name} must be numeric, not boolean")
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{name} must be numeric") from exc
|
||||
if not np.isfinite(v) or not 0.0 < v <= 1.0:
|
||||
raise ValueError(f"{name} must be finite and in (0, 1]")
|
||||
return v
|
||||
|
||||
|
||||
class MaxWeight:
|
||||
"""Per-name cap with pro-rata redistribution of the clipped excess.
|
||||
|
||||
Clipped weight is handed to the names still under the cap, in
|
||||
proportion to their current size, until everything fits. When the cap
|
||||
cannot hold the book (n_active * cap < gross), every name lands on the
|
||||
cap and gross exposure shrinks accordingly.
|
||||
"""
|
||||
|
||||
def __init__(self, cap: float) -> None:
|
||||
self.cap = cap
|
||||
|
||||
def apply(self, w: np.ndarray, codes: Sequence[str]) -> np.ndarray:
|
||||
del codes # per-name rule, codes unused
|
||||
w = w.astype(float).copy()
|
||||
for _ in range(_MAX_PASSES):
|
||||
over = w > self.cap + _TOL
|
||||
if not over.any():
|
||||
break
|
||||
excess = float((w[over] - self.cap).sum())
|
||||
w[over] = self.cap
|
||||
room = w < self.cap - _TOL
|
||||
base = float(w[room].sum())
|
||||
if not room.any() or base <= _TOL:
|
||||
break
|
||||
w[room] += w[room] / base * excess
|
||||
return w
|
||||
|
||||
|
||||
class MinWeight:
|
||||
"""Per-name floor: active names below ``floor`` are lifted to it.
|
||||
|
||||
The lift is funded pro-rata by the names above the floor (each gives
|
||||
up weight in proportion to how far it sits above the floor), so gross
|
||||
exposure is preserved. If the book cannot fund the floor
|
||||
(floor * n_active > gross), the date degrades to equal weights.
|
||||
"""
|
||||
|
||||
def __init__(self, floor: float) -> None:
|
||||
self.floor = floor
|
||||
|
||||
def apply(self, w: np.ndarray, codes: Sequence[str]) -> np.ndarray:
|
||||
del codes
|
||||
w = w.astype(float).copy()
|
||||
for _ in range(_MAX_PASSES):
|
||||
below = (w > _EPS) & (w < self.floor - _TOL)
|
||||
if not below.any():
|
||||
break
|
||||
need = float((self.floor - w[below]).sum())
|
||||
above = w > self.floor + _TOL
|
||||
avail = float((w[above] - self.floor).sum())
|
||||
if avail <= _TOL:
|
||||
n = len(w)
|
||||
return np.full(n, w.sum() / n) if n else w
|
||||
w[below] = self.floor
|
||||
w[above] -= (w[above] - self.floor) / avail * need
|
||||
return w
|
||||
|
||||
|
||||
class GroupExposure:
|
||||
"""Cap the summed weight of each configured group.
|
||||
|
||||
A group over its cap is scaled down pro-rata; the freed exposure is
|
||||
not redistributed (pushing it elsewhere could break another group's
|
||||
cap), so gross exposure shrinks by the clipped amount. Names absent
|
||||
from ``groups`` are unconstrained.
|
||||
"""
|
||||
|
||||
def __init__(self, groups: Mapping[str, str], caps: Mapping[str, float]) -> None:
|
||||
self.groups: Dict[str, str] = dict(groups)
|
||||
self.caps: Dict[str, float] = dict(caps)
|
||||
|
||||
def apply(self, w: np.ndarray, codes: Sequence[str]) -> np.ndarray:
|
||||
w = w.astype(float).copy()
|
||||
for group, cap in self.caps.items():
|
||||
idx = [i for i, c in enumerate(codes) if self.groups.get(c) == group]
|
||||
if not idx:
|
||||
continue
|
||||
total = float(w[idx].sum())
|
||||
if total > cap + _TOL:
|
||||
w[idx] *= cap / total
|
||||
return w
|
||||
|
||||
|
||||
_TYPES = ("max_weight", "min_weight", "group_exposure")
|
||||
|
||||
|
||||
def _build_constraint(spec: Mapping[str, Any]) -> Any:
|
||||
if not isinstance(spec, Mapping):
|
||||
raise ValueError(f"constraint spec must be a mapping, got {type(spec).__name__}")
|
||||
kind = spec.get("type")
|
||||
if kind == "max_weight":
|
||||
if "cap" not in spec:
|
||||
raise ValueError("max_weight constraint requires 'cap'")
|
||||
return MaxWeight(_bounded_fraction(spec["cap"], "max_weight cap"))
|
||||
if kind == "min_weight":
|
||||
if "floor" not in spec:
|
||||
raise ValueError("min_weight constraint requires 'floor'")
|
||||
return MinWeight(_bounded_fraction(spec["floor"], "min_weight floor"))
|
||||
if kind == "group_exposure":
|
||||
groups = spec.get("groups")
|
||||
caps = spec.get("caps")
|
||||
if not isinstance(groups, Mapping) or not groups:
|
||||
raise ValueError("group_exposure constraint requires a non-empty 'groups' mapping")
|
||||
if any(not isinstance(c, str) or not isinstance(g, str) for c, g in groups.items()):
|
||||
raise ValueError("groups must map string asset codes to string group names")
|
||||
if not isinstance(caps, Mapping) or not caps:
|
||||
raise ValueError("group_exposure constraint requires a non-empty 'caps' mapping")
|
||||
unknown = set(caps) - set(groups.values())
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
"caps reference groups with no mapped assets: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
return GroupExposure(
|
||||
groups,
|
||||
{g: _bounded_fraction(c, f"cap for group {g!r}") for g, c in caps.items()},
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown constraint type {kind!r}; expected one of {', '.join(_TYPES)}"
|
||||
)
|
||||
|
||||
|
||||
def load_constraints(config: Mapping[str, Any]) -> List[Any]:
|
||||
"""Build the constraint list from ``config['constraints']`` (empty if unset)."""
|
||||
raw = config.get("constraints")
|
||||
if raw in (None, []):
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("constraints must be a list of constraint specs")
|
||||
return [_build_constraint(spec) for spec in raw]
|
||||
|
||||
|
||||
def apply_constraints_frame(
|
||||
frame: pd.DataFrame, constraints: Sequence[Any]
|
||||
) -> pd.DataFrame:
|
||||
"""Apply constraints row by row to a signed weight frame.
|
||||
|
||||
Args:
|
||||
frame: Optimizer output (dates x codes), signed weights.
|
||||
constraints: Constraint objects from ``load_constraints``.
|
||||
|
||||
Returns:
|
||||
Adjusted frame with signs preserved and zero rows/cells untouched.
|
||||
"""
|
||||
if not constraints:
|
||||
return frame
|
||||
out = frame.copy()
|
||||
for dt in frame.index:
|
||||
row = frame.loc[dt]
|
||||
codes = [c for c in row.index if abs(row[c]) > _EPS]
|
||||
if not codes:
|
||||
continue
|
||||
signs = np.sign(row[codes].to_numpy(dtype=float))
|
||||
mags = np.abs(row[codes].to_numpy(dtype=float))
|
||||
for con in constraints:
|
||||
mags = con.apply(mags, codes)
|
||||
out.loc[dt, codes] = signs * mags
|
||||
return out
|
||||
+164
-53
@@ -6,32 +6,111 @@ over a configurable lookback window. Used by the /correlation API endpoint.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Literal
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from scipy.stats import spearmanr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def infer_market(code: str) -> str:
|
||||
"""Infer market key from a ticker symbol."""
|
||||
code_upper = code.upper()
|
||||
"""Infer market key from a ticker symbol.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. Crypto pair spellings (``BTC-USDT``, ``ETH/USD`` …).
|
||||
2. Explicit exchange suffix — always authoritative (``.HK``, ``.SH``/
|
||||
``.SZ``/``.BJ``, ``.US``). Bare HK and A-share codes are both purely
|
||||
numeric, so the suffix is the only reliable disambiguator.
|
||||
3. Bare numeric codes by digit length: A-share codes are exactly 6 digits
|
||||
(600000, 000001, 300750, 688981, 830799); HK codes are at most 5
|
||||
(700, 0700, 9988, 3690). Prefix alone cannot tell them apart — both
|
||||
markets use leading 0 and 3.
|
||||
4. Anything else (alphabetic tickers) is a US equity.
|
||||
"""
|
||||
code_upper = code.strip().upper()
|
||||
crypto_suffixes = ("USDT", "BTC", "ETH", "BNB", "SOL", "ADA", "DOGE")
|
||||
if any(code_upper.endswith(s) for s in crypto_suffixes) or "/" in code:
|
||||
return "crypto"
|
||||
# Check .HK suffix FIRST so leading-zero tickers like 0700.HK / 0005.HK
|
||||
# are correctly classified before the A-share prefix checks
|
||||
if code_upper.endswith(".HK"):
|
||||
return "hk_equity"
|
||||
if code_upper.startswith(("6", "000", "001", "002")):
|
||||
if code_upper.endswith((".SH", ".SZ", ".BJ")):
|
||||
return "a_share"
|
||||
if code_upper.startswith(("0", "399")):
|
||||
return "a_share"
|
||||
if code_upper.startswith(("0", "1", "2", "3", "4")):
|
||||
return "hk_equity"
|
||||
if code_upper.endswith((".KS", ".KQ")):
|
||||
return "kr_equity"
|
||||
if code_upper.endswith(".US"):
|
||||
return "us_equity"
|
||||
if code_upper.isdigit():
|
||||
if len(code_upper) == 6:
|
||||
return "a_share"
|
||||
if len(code_upper) <= 5:
|
||||
return "hk_equity"
|
||||
return "us_equity"
|
||||
|
||||
|
||||
def _normalize_symbol(code: str, market: str) -> str:
|
||||
"""Convert a user-supplied code to the project's canonical loader symbol.
|
||||
|
||||
The data loaders key US/HK/A-share instruments by an exchange-suffixed
|
||||
symbol (``AAPL.US``, ``0700.HK``, ``600000.SH``); a bare ticker such as
|
||||
``AAPL`` or ``600000`` matches no loader and fetches nothing. Crypto pairs
|
||||
(``BTC-USDT``) are already canonical, and any code that already carries a
|
||||
``.`` suffix is left untouched.
|
||||
|
||||
Args:
|
||||
code: The raw code as typed by the user (e.g. ``AAPL``, ``600000``).
|
||||
market: The market key from :func:`infer_market`.
|
||||
|
||||
Returns:
|
||||
The canonical symbol the market's loaders expect.
|
||||
"""
|
||||
cleaned = code.strip()
|
||||
# Crypto pairs and anything already exchange-qualified pass through as-is.
|
||||
if market == "crypto" or "." in cleaned:
|
||||
return cleaned
|
||||
upper = cleaned.upper()
|
||||
if market == "us_equity":
|
||||
return f"{upper}.US"
|
||||
if market == "hk_equity":
|
||||
return f"{upper}.HK"
|
||||
if market == "a_share":
|
||||
# 6xxxxx -> Shanghai; 4xxxxx / 8xxxxx -> Beijing; else (0/3) Shenzhen.
|
||||
if upper[:1] == "6":
|
||||
return f"{upper}.SH"
|
||||
if upper[:1] in ("4", "8"):
|
||||
return f"{upper}.BJ"
|
||||
return f"{upper}.SZ"
|
||||
return cleaned
|
||||
|
||||
|
||||
def _close_series(code: str, df: pd.DataFrame) -> pd.Series:
|
||||
"""Extract the close-price series from a loader frame, date-indexed and sorted.
|
||||
|
||||
Supports ``trade_date`` as the index name, as a column, or a plain
|
||||
DatetimeIndex — the shapes real loaders return.
|
||||
"""
|
||||
if df.empty:
|
||||
raise ValueError(f"Price series for '{code}' is empty")
|
||||
if "close" not in df.columns and "close" not in df.index.names:
|
||||
raise ValueError(f"No 'close' column in price series for '{code}'")
|
||||
# Support trade_date as index name, column, or a plain DatetimeIndex.
|
||||
if "trade_date" in df.columns:
|
||||
ts = df.set_index("trade_date")["close"]
|
||||
elif "close" in df.columns and (
|
||||
"trade_date" in df.index.names
|
||||
or isinstance(df.index, pd.DatetimeIndex)
|
||||
):
|
||||
ts = df["close"]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No trade_date index/column for price series '{code}'"
|
||||
)
|
||||
return ts.sort_index()
|
||||
|
||||
|
||||
def _rolling_correlation_matrix(
|
||||
price_series: Dict[str, pd.DataFrame],
|
||||
window: int,
|
||||
@@ -57,16 +136,7 @@ def _rolling_correlation_matrix(
|
||||
returns_frames = []
|
||||
closes = {}
|
||||
for code, df in price_series.items():
|
||||
if df.empty:
|
||||
raise ValueError(f"Price series for '{code}' is empty")
|
||||
if "close" not in df.columns and "close" not in df.index.names:
|
||||
raise ValueError(f"No 'close' column in price series for '{code}'")
|
||||
# Support both column-based and index-based trade_date
|
||||
if "trade_date" in df.index.names and "trade_date" not in df.columns:
|
||||
ts = df["close"]
|
||||
else:
|
||||
ts = df.set_index("trade_date")["close"]
|
||||
closes[code] = ts.sort_index()
|
||||
closes[code] = _close_series(code, df)
|
||||
|
||||
for code in codes:
|
||||
ts = closes[code]
|
||||
@@ -74,7 +144,10 @@ def _rolling_correlation_matrix(
|
||||
# (e.g. crypto via OKX/CCXT at UTC midnight vs US equity via
|
||||
# yfinance at EDT midnight = 04:00 UTC) align correctly.
|
||||
ts.index = ts.index.normalize()
|
||||
rets = ts.pct_change().dropna()
|
||||
# ``fill_method=None`` is explicit because under the project's
|
||||
# pandas>=2,<3 pin the ``pct_change`` default forward-fills missing
|
||||
# prices, silently manufacturing 0% returns on halted sessions.
|
||||
rets = ts.pct_change(fill_method=None).dropna()
|
||||
rets.name = code
|
||||
returns_frames.append(rets)
|
||||
|
||||
@@ -119,6 +192,75 @@ def _rolling_correlation_matrix(
|
||||
return labels, matrix
|
||||
|
||||
|
||||
def _fetch_price_series(
|
||||
codes: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily price frames for each code via the loader fallback chains.
|
||||
|
||||
Args:
|
||||
codes: Asset codes as supplied by the caller (used as result keys).
|
||||
start_date: Fetch range start, ``YYYY-MM-DD``.
|
||||
end_date: Fetch range end, ``YYYY-MM-DD``.
|
||||
|
||||
Returns:
|
||||
Mapping of original code -> OHLCV DataFrame; codes no loader could
|
||||
serve are omitted (with a warning logged).
|
||||
"""
|
||||
# Import here to avoid circular
|
||||
from backtest.loaders import registry
|
||||
|
||||
registry._ensure_registered()
|
||||
price_series: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
for code in codes:
|
||||
market = infer_market(code)
|
||||
# Loaders key instruments by the canonical exchange-suffixed symbol
|
||||
# (AAPL.US / 600000.SH); a bare ticker fetches nothing. Fetch under the
|
||||
# normalized symbol but keep the user's original code as the label.
|
||||
symbol = _normalize_symbol(code, market)
|
||||
|
||||
# Walk the market's full fallback chain until a loader actually
|
||||
# returns data. A loader can be "available" yet still serve nothing
|
||||
# (network error, unsupported symbol), so stopping at the first
|
||||
# available loader — as resolve_loader does — would silently drop
|
||||
# the asset even when a later loader could serve it.
|
||||
for name in registry.FALLBACK_CHAINS.get(market, []):
|
||||
loader_cls = registry.LOADER_REGISTRY.get(name)
|
||||
if loader_cls is None:
|
||||
continue
|
||||
try:
|
||||
loader = loader_cls()
|
||||
except Exception as exc:
|
||||
logger.debug("correlation: loader %s failed to construct: %s", name, exc)
|
||||
continue
|
||||
if not loader.is_available():
|
||||
continue
|
||||
try:
|
||||
result = loader.fetch(
|
||||
codes=[symbol],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
interval="1D",
|
||||
fields=["trade_date", "open", "high", "low", "close", "volume"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("correlation: %s fetch via %s failed: %s", symbol, name, exc)
|
||||
continue
|
||||
if symbol in result and not result[symbol].empty:
|
||||
price_series[code] = result[symbol]
|
||||
break
|
||||
logger.warning("correlation: %s returned no data via %s", symbol, name)
|
||||
else:
|
||||
logger.warning(
|
||||
"correlation: no loader in the %s chain returned data for %s "
|
||||
"(normalized from %r)", market, symbol, code,
|
||||
)
|
||||
|
||||
return price_series
|
||||
|
||||
|
||||
def compute_correlation_matrix(
|
||||
codes: list[str],
|
||||
days: int = 90,
|
||||
@@ -139,38 +281,7 @@ def compute_correlation_matrix(
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days + 60)).strftime("%Y-%m-%d")
|
||||
|
||||
# Import here to avoid circular
|
||||
from backtest.loaders.registry import resolve_loader
|
||||
|
||||
price_series: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
for code in codes:
|
||||
market = infer_market(code)
|
||||
try:
|
||||
loader = resolve_loader(market)
|
||||
except Exception:
|
||||
# Fall back to yfinance for us_equity / hk_equity
|
||||
try:
|
||||
from backtest.loaders.registry import LOADER_REGISTRY
|
||||
if "yfinance" in LOADER_REGISTRY:
|
||||
loader = LOADER_REGISTRY["yfinance"]()
|
||||
else:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = loader.fetch(
|
||||
codes=[code],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
interval="1D",
|
||||
fields=["trade_date", "open", "high", "low", "close", "volume"],
|
||||
)
|
||||
if code in result and not result[code].empty:
|
||||
price_series[code] = result[code]
|
||||
except Exception:
|
||||
continue
|
||||
price_series = _fetch_price_series(codes, start_date, end_date)
|
||||
|
||||
if len(price_series) < 2:
|
||||
raise ValueError(
|
||||
@@ -184,4 +295,4 @@ def compute_correlation_matrix(
|
||||
"matrix": matrix,
|
||||
"window": days,
|
||||
"method": method,
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ _MARKET_PATTERNS = [
|
||||
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
|
||||
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
|
||||
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
|
||||
# India equities: NSE (RELIANCE.NS) / BSE (500325.BO); tickers may carry
|
||||
# '&' and '-' (e.g. M&M.NS, BAJAJ-AUTO.NS).
|
||||
(re.compile(r"^[A-Z0-9&.\-]+\.(NS|BO)$", re.I), "india_equity"),
|
||||
# Korea equities: KOSPI (005930.KS) / KOSDAQ (247540.KQ), 6-digit codes.
|
||||
(re.compile(r"^\d{6}\.(KS|KQ)$", re.I), "kr_equity"),
|
||||
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
|
||||
# China futures: product+delivery.exchange (e.g. IF2406.CFFEX, rb2410.SHFE)
|
||||
@@ -44,6 +49,58 @@ _MARKET_PATTERNS = [
|
||||
|
||||
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
|
||||
|
||||
# Settlement currency per market. A composite backtest holds one shared capital
|
||||
# pool, so a code set spanning two of these would add CNY to USD to KRW as if
|
||||
# they were the same unit.
|
||||
_MARKET_CURRENCY = {
|
||||
"a_share": "CNY",
|
||||
"us_equity": "USD",
|
||||
"hk_equity": "HKD",
|
||||
"india_equity": "INR",
|
||||
"kr_equity": "KRW",
|
||||
# Every crypto pattern in _MARKET_PATTERNS is USDT-quoted, and USDT is
|
||||
# carried at its USD peg. This is the one approximation in the table: a
|
||||
# depeg would make a crypto+US book wrong by the depeg amount, which is
|
||||
# orders of magnitude below the CNY/USD-style unit error this guard exists
|
||||
# to catch.
|
||||
"crypto": "USD",
|
||||
}
|
||||
|
||||
# Non-US futures venues. The GlobalFuturesEngine is USD-denominated end to end
|
||||
# — margin, commission and contract multipliers are all in USD and it carries
|
||||
# no EUR or JPY product — so anything it handles settles in USD unless the
|
||||
# symbol names a venue that does not.
|
||||
_FUTURES_EXCHANGE_CURRENCY = {"EUREX": "EUR"}
|
||||
|
||||
|
||||
def code_currency(code: str) -> str:
|
||||
"""Return the currency a symbol settles in.
|
||||
|
||||
Args:
|
||||
code: Ticker / symbol string.
|
||||
|
||||
Returns:
|
||||
A currency code such as ``"CNY"``. A forex pair resolves to its quote
|
||||
currency and Chinese futures to ``"CNY"``. A symbol whose currency
|
||||
cannot be established returns a ``"UNKNOWN:<market>"`` marker rather
|
||||
than a guess, so a homogeneous set still compares equal while a mixed
|
||||
one cannot pass a same-currency check by accident.
|
||||
"""
|
||||
market = _detect_market(code)
|
||||
if market in _MARKET_CURRENCY:
|
||||
return _MARKET_CURRENCY[market]
|
||||
if market == "forex":
|
||||
pair = code.upper().replace("/", "")
|
||||
if pair.endswith(".FX"):
|
||||
pair = pair[:-3]
|
||||
return pair[3:6] if len(pair) == 6 else "UNKNOWN:forex"
|
||||
if market == "futures":
|
||||
if _is_china_futures(code):
|
||||
return "CNY"
|
||||
exchange = code.upper().rpartition(".")[2]
|
||||
return _FUTURES_EXCHANGE_CURRENCY.get(exchange, "USD")
|
||||
return f"UNKNOWN:{market}"
|
||||
|
||||
# Known Chinese-futures product codes — used as a heuristic when a symbol
|
||||
# lacks an exchange suffix (e.g. bare ``RB2410``, ``IF2406``). Without this
|
||||
# table composite.py was misrouting such bare codes to GlobalFutures.
|
||||
@@ -161,7 +218,8 @@ def calc_crypto_funding_fee(
|
||||
bar: Current bar data.
|
||||
timestamp: Bar timestamp.
|
||||
positions: Shared positions dict.
|
||||
funding_rate: Fixed rate per settlement.
|
||||
funding_rate: Fallback fixed rate per settlement, used when the bar
|
||||
carries no historical ``funding_rate`` column.
|
||||
applied_set: (symbol, date, hour) dedup set — mutated.
|
||||
daily_done_set: (symbol, date) dedup set — mutated.
|
||||
|
||||
@@ -191,6 +249,12 @@ def calc_crypto_funding_fee(
|
||||
|
||||
mark_price = float(bar.get("close", pos.entry_price))
|
||||
notional = pos.size * mark_price
|
||||
# Prefer the bar's historical funding rate when the loader supplied one
|
||||
# (USD-M perpetual data via BASE-USDT-PERP); fall back to the fixed
|
||||
# config rate otherwise so spot-proxy runs keep their behaviour.
|
||||
hist = bar.get("funding_rate")
|
||||
if hist is not None and pd.notna(hist):
|
||||
funding_rate = float(hist)
|
||||
return notional * funding_rate * pos.direction
|
||||
|
||||
|
||||
|
||||
+701
-95
File diff suppressed because it is too large.
Load diff
@@ -61,14 +61,9 @@ class ChinaAEngine(BaseEngine):
|
||||
if bar_date is not None and entry_date is not None and bar_date == entry_date:
|
||||
return False
|
||||
|
||||
# 3. Price limits
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
limit = _price_limit(symbol)
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # limit-up: can't buy
|
||||
if direction == 0 and pct_chg <= -limit + 0.001:
|
||||
return False # limit-down: can't sell
|
||||
# 3. Price limits, tested at execution time (see _blocked_by_limit).
|
||||
if _blocked_by_limit(self, symbol, direction, bar, _price_limit(symbol)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -117,21 +112,63 @@ def _bar_date(bar: pd.Series):
|
||||
return None
|
||||
|
||||
|
||||
# Note: china_futures and global_futures have variants that prioritise
|
||||
# settle/pre_settle (futures-native); see those modules for the
|
||||
# futures-specific logic.
|
||||
def _calc_pct_change(bar: pd.Series):
|
||||
"""Calculate price change percentage from bar data."""
|
||||
if "pct_chg" in bar.index:
|
||||
val = bar["pct_chg"]
|
||||
if pd.notna(val):
|
||||
return float(val) / 100.0 # tushare pct_chg is in percentage points
|
||||
def _blocked_by_limit(
|
||||
engine,
|
||||
symbol: str,
|
||||
direction: int,
|
||||
bar: pd.Series,
|
||||
limit: float,
|
||||
position_direction: int | None = None,
|
||||
) -> bool:
|
||||
"""Whether a price-limit band blocks a fill on this bar.
|
||||
|
||||
close = bar.get("close")
|
||||
pre_close = bar.get("pre_close")
|
||||
if close is not None and pre_close is not None and pre_close > 0:
|
||||
return (float(close) - float(pre_close)) / float(pre_close)
|
||||
return None
|
||||
Shared by every engine with a daily band (A-share, India, China futures,
|
||||
global futures). The band comes from a base price the market knew before
|
||||
the order — ``pre_close``, else the prior bar's close — and is compared
|
||||
against the price the engine would actually fill at, which is this bar's
|
||||
open plus slippage.
|
||||
|
||||
The earlier implementation derived the day's move from the CURRENT bar's
|
||||
close, which is lookahead and wrong in both directions: a name that opened
|
||||
locked but drifted back by the close was allowed to trade at the locked
|
||||
open, and a name that opened freely but closed limit-up was refused a fill
|
||||
it would have got.
|
||||
|
||||
Args:
|
||||
engine: Engine instance (needs the BaseEngine band helpers).
|
||||
symbol: Symbol being traded.
|
||||
direction: 1 (buy / open long), -1 (sell short), 0 (close).
|
||||
bar: Current bar.
|
||||
limit: Band half-width as a fraction (0.1 for +/-10%).
|
||||
position_direction: For ``direction == 0``, the direction of the
|
||||
position being closed: 1 closes a long (a sell, blocked at the
|
||||
lower band), -1 closes a short (a buy, blocked at the upper band).
|
||||
Defaults to a long close, which is the cash-equity case.
|
||||
|
||||
Returns:
|
||||
True when the band blocks the fill. False when it does not, and also
|
||||
when no historical base price is reachable — an unknown band must not
|
||||
fabricate a block.
|
||||
"""
|
||||
band = engine.limit_band(symbol, bar, limit)
|
||||
if band is None:
|
||||
return False
|
||||
lower, upper = band
|
||||
# BaseEngine books a close with the OPPOSITE of the position's direction,
|
||||
# so slippage moves the price the other way. Checking the raw open here
|
||||
# would approve a fill that is then booked outside the band.
|
||||
fill_direction = -(position_direction or 1) if direction == 0 else direction
|
||||
fill = engine.prospective_fill_price(bar, fill_direction)
|
||||
if fill is None:
|
||||
return False
|
||||
|
||||
# Relative tolerance: a fill within a rounding step of the band counts as
|
||||
# touching it, matching the old check's 0.1pp slack in percentage terms.
|
||||
tol = 1e-9 * max(abs(lower), abs(upper), 1.0)
|
||||
buying = direction == 1 or (direction == 0 and position_direction == -1)
|
||||
if buying:
|
||||
return fill >= upper - tol
|
||||
return fill <= lower + tol
|
||||
|
||||
|
||||
def _price_limit(symbol: str) -> float:
|
||||
|
||||
@@ -16,6 +16,7 @@ import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.china_a import _blocked_by_limit
|
||||
from backtest.engines.futures_base import FuturesBaseEngine
|
||||
|
||||
|
||||
@@ -151,7 +152,10 @@ class ChinaFuturesEngine(FuturesBaseEngine):
|
||||
leverage = 10.0 # ~10% margin default
|
||||
config = {**config, "leverage": leverage}
|
||||
super().__init__(config)
|
||||
# Futures bands come off the previous settlement, not the previous close.
|
||||
self.base_price_fields = ("pre_settle", "pre_close")
|
||||
self.slippage_rate: float = config.get("slippage", 0.0005)
|
||||
self._margin_rate_override = margin_override if margin_override else None
|
||||
self._commission_override = config.get("commission_override")
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
@@ -168,24 +172,20 @@ class ChinaFuturesEngine(FuturesBaseEngine):
|
||||
# T+0: no same-day sell restriction
|
||||
# Both long and short allowed
|
||||
|
||||
# Price limit check
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
product = _extract_product(symbol)
|
||||
limit = _PRICE_LIMIT.get(product, _DEFAULT_PRICE_LIMIT)
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # limit-up: can't open long / can't buy
|
||||
if direction == -1 and pct_chg <= -limit + 0.001:
|
||||
return False # limit-down: can't open short
|
||||
if direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
# Can't close long at limit-down, can't close short at limit-up
|
||||
if pos.direction == 1 and pct_chg <= -limit + 0.001:
|
||||
return False
|
||||
if pos.direction == -1 and pct_chg >= limit - 0.001:
|
||||
return False
|
||||
return True
|
||||
# Price limit, tested at execution time (see _blocked_by_limit).
|
||||
product = _extract_product(symbol)
|
||||
limit = _PRICE_LIMIT.get(product, _DEFAULT_PRICE_LIMIT)
|
||||
pos = self.positions.get(symbol) if direction == 0 else None
|
||||
if pos is None and direction == 0:
|
||||
return True
|
||||
return not _blocked_by_limit(
|
||||
self,
|
||||
symbol,
|
||||
direction,
|
||||
bar,
|
||||
limit,
|
||||
position_direction=pos.direction if pos is not None else None,
|
||||
)
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Minimum 1 contract, integer lots only."""
|
||||
@@ -243,36 +243,12 @@ class ChinaFuturesEngine(FuturesBaseEngine):
|
||||
product = _extract_product(symbol)
|
||||
return _MARGIN_RATE.get(product, 0.10)
|
||||
|
||||
def _leverage_for_symbol(self, symbol: str) -> float:
|
||||
"""Derive leverage from this contract's own margin requirement."""
|
||||
if self._margin_rate_override is not None:
|
||||
return 1.0 / float(self._margin_rate_override)
|
||||
return 1.0 / self.get_margin_rate(symbol)
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
|
||||
# Note: china_a uses close/pre_close-only; global_futures prioritises
|
||||
# close/pre_close before settle. This China-futures variant prefers
|
||||
# settle/pre_settle because tushare reports settlement as the canonical
|
||||
# daily price for domestic contracts. See those modules for the equity /
|
||||
# global-futures equivalents.
|
||||
def _calc_pct_change(bar: pd.Series):
|
||||
"""Calculate bar price change percentage.
|
||||
|
||||
Priority: settle/pre_settle (futures native) > close/pre_close > pct_chg.
|
||||
pct_chg from tushare is always in percentage points (0.5 = 0.5%).
|
||||
"""
|
||||
# Prefer settlement prices (unambiguous for futures)
|
||||
settle = bar.get("settle")
|
||||
pre_settle = bar.get("pre_settle")
|
||||
if settle is not None and pre_settle is not None and pre_settle > 0:
|
||||
return (float(settle) - float(pre_settle)) / float(pre_settle)
|
||||
|
||||
close = bar.get("close")
|
||||
pre_close = bar.get("pre_close")
|
||||
if close is not None and pre_close is not None and pre_close > 0:
|
||||
return (float(close) - float(pre_close)) / float(pre_close)
|
||||
|
||||
# tushare pct_chg is in percentage points (e.g. 0.5 = 0.5%)
|
||||
if "pct_chg" in bar.index:
|
||||
val = bar["pct_chg"]
|
||||
if pd.notna(val):
|
||||
return float(val) / 100.0
|
||||
|
||||
return None
|
||||
@@ -16,6 +16,7 @@ from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import (
|
||||
_detect_market,
|
||||
_is_china_futures,
|
||||
code_currency,
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
calc_forex_swap,
|
||||
@@ -37,6 +38,12 @@ def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]
|
||||
elif market == "hk_equity":
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
engines["hk_equity"] = GlobalEquityEngine(config, market="hk")
|
||||
elif market == "india_equity":
|
||||
from backtest.engines.india_equity import IndiaEquityEngine
|
||||
engines["india_equity"] = IndiaEquityEngine(config)
|
||||
elif market == "kr_equity":
|
||||
from backtest.engines.korea_equity import KoreaEquityEngine
|
||||
engines["kr_equity"] = KoreaEquityEngine(config)
|
||||
elif market == "crypto":
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
engines["crypto"] = CryptoEngine(config)
|
||||
@@ -44,19 +51,51 @@ def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]
|
||||
from backtest.engines.forex import ForexEngine
|
||||
engines["forex"] = ForexEngine(config)
|
||||
elif market == "futures":
|
||||
china = any(
|
||||
_is_china_futures(c) for c in codes if _detect_market(c) == "futures"
|
||||
)
|
||||
if china:
|
||||
futures_codes = [c for c in codes if _detect_market(c) == "futures"]
|
||||
if any(_is_china_futures(c) for c in futures_codes):
|
||||
from backtest.engines.china_futures import ChinaFuturesEngine
|
||||
engines["futures"] = ChinaFuturesEngine(config)
|
||||
else:
|
||||
engines["china_futures"] = ChinaFuturesEngine(config)
|
||||
if any(not _is_china_futures(c) for c in futures_codes):
|
||||
from backtest.engines.global_futures import GlobalFuturesEngine
|
||||
engines["futures"] = GlobalFuturesEngine(config)
|
||||
engines["global_futures"] = GlobalFuturesEngine(config)
|
||||
|
||||
return engines
|
||||
|
||||
|
||||
def _reject_mixed_currency(codes: List[str]) -> None:
|
||||
"""Refuse a code set whose members do not settle in one currency.
|
||||
|
||||
The shared capital pool holds a single scalar of cash and sums position
|
||||
values into a single equity curve. With codes from two currency zones that
|
||||
curve adds CNY to USD to KRW as if the units matched, and every metric
|
||||
derived from it — return, Sharpe, drawdown — is meaningless. There is no FX
|
||||
translation layer yet, so this fails closed rather than reporting a number
|
||||
that looks fine.
|
||||
|
||||
Args:
|
||||
codes: Instrument codes for the backtest.
|
||||
|
||||
Raises:
|
||||
ValueError: If the codes span more than one settlement currency.
|
||||
"""
|
||||
by_currency: Dict[str, List[str]] = {}
|
||||
for code in codes:
|
||||
by_currency.setdefault(code_currency(code), []).append(code)
|
||||
if len(by_currency) <= 1:
|
||||
return
|
||||
breakdown = "; ".join(
|
||||
f"{currency}: {', '.join(sorted(members))}"
|
||||
for currency, members in sorted(by_currency.items())
|
||||
)
|
||||
raise ValueError(
|
||||
"composite backtest requires one settlement currency across all codes, "
|
||||
f"but got {len(by_currency)} — {breakdown}. The shared capital pool has "
|
||||
"no FX translation, so a mixed-currency equity curve would sum "
|
||||
"different units. Split the run by currency, or convert the inputs "
|
||||
"to one currency before loading."
|
||||
)
|
||||
|
||||
|
||||
class CompositeEngine(BaseEngine):
|
||||
"""Cross-market engine with shared capital pool.
|
||||
|
||||
@@ -84,10 +123,37 @@ class CompositeEngine(BaseEngine):
|
||||
# Forex dedup state
|
||||
self._last_swap_dates: dict = {}
|
||||
|
||||
def run_backtest(self, config: dict, *args, **kwargs):
|
||||
"""Run the pipeline, refusing a code set that spans currencies.
|
||||
|
||||
The check lives here rather than in ``__init__`` because the damage is
|
||||
in the shared equity curve, not in constructing the rule-book engines.
|
||||
|
||||
Args:
|
||||
config: Backtest configuration dict.
|
||||
*args: Forwarded to :meth:`BaseEngine.run_backtest`.
|
||||
**kwargs: Forwarded to :meth:`BaseEngine.run_backtest`.
|
||||
|
||||
Returns:
|
||||
The metrics dictionary from :meth:`BaseEngine.run_backtest`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the codes span more than one settlement currency.
|
||||
"""
|
||||
_reject_mixed_currency(config.get("codes") or list(self._symbol_market))
|
||||
return super().run_backtest(config, *args, **kwargs)
|
||||
|
||||
def _rule_for(self, symbol: str) -> BaseEngine:
|
||||
"""Get the sub-engine that provides rules for this symbol."""
|
||||
market = self._symbol_market.get(symbol, "a_share")
|
||||
return self._rule_engines[market]
|
||||
if market == "futures":
|
||||
market = "china_futures" if _is_china_futures(symbol) else "global_futures"
|
||||
engine = self._rule_engines.get(market)
|
||||
if engine is None:
|
||||
if not self._rule_engines:
|
||||
raise ValueError("No sub-engines available for composite backtest")
|
||||
engine = next(iter(self._rule_engines.values()))
|
||||
return engine
|
||||
|
||||
# ── Stateless method dispatch ──
|
||||
|
||||
@@ -152,6 +218,9 @@ class CompositeEngine(BaseEngine):
|
||||
) -> float:
|
||||
return self._rule_for(symbol)._calc_raw_size(symbol, target_notional, price)
|
||||
|
||||
def _leverage_for_symbol(self, symbol: str) -> float:
|
||||
return self._rule_for(symbol)._leverage_for_symbol(symbol)
|
||||
|
||||
# ── Stateful hooks (implemented directly, NO delegation) ──
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
|
||||
@@ -10,6 +10,9 @@ Market rules:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
@@ -17,6 +20,21 @@ from backtest.engines._market_hooks import (
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
)
|
||||
from backtest.perpetual_evidence import (
|
||||
SCHEMA_VERSION,
|
||||
build_perpetual_summary,
|
||||
write_perpetual_evidence,
|
||||
)
|
||||
from backtest.perpetual_risk import (
|
||||
AccountState,
|
||||
CrossMarginRiskModel,
|
||||
ExecutionFrame,
|
||||
MaintenanceSchedule,
|
||||
MarketRiskFrame,
|
||||
PositionState,
|
||||
RiskSnapshot,
|
||||
evaluate_isolated,
|
||||
)
|
||||
|
||||
|
||||
class CryptoEngine(BaseEngine):
|
||||
@@ -37,12 +55,57 @@ class CryptoEngine(BaseEngine):
|
||||
self.taker_rate: float = config.get("taker_rate", 0.0005)
|
||||
self.slippage_rate: float = config.get("slippage", 0.0005)
|
||||
self.funding_rate: float = config.get("funding_rate", 0.0001)
|
||||
self.perpetual_strict = bool(config.get("perpetual_strict", False))
|
||||
self.funding_mode = str(config.get("funding_mode", "fixed"))
|
||||
self.margin_mode = str(config.get("margin_mode", "isolated"))
|
||||
self.liquidation_fee_rate = float(config.get("liquidation_fee_rate", 0.0))
|
||||
if self.perpetual_strict and self.funding_mode != "data":
|
||||
raise ValueError("perpetual_strict requires funding_mode='data'")
|
||||
if self.perpetual_strict and self.margin_mode not in {"isolated", "cross"}:
|
||||
raise ValueError("margin_mode must be 'isolated' or 'cross'")
|
||||
if self.perpetual_strict and not 0 <= self.liquidation_fee_rate < 1:
|
||||
raise ValueError("liquidation_fee_rate must be between zero and one")
|
||||
self._validate_strict_resolution(config)
|
||||
self.terminal_status = "active"
|
||||
self._perpetual_events: list[dict[str, Any]] = []
|
||||
self._run_interval = str(config.get("interval", "1D"))
|
||||
self._maintenance_bracket_versions: dict[str, str] = {}
|
||||
self._market_risk_sources: set[str] = set()
|
||||
self._liquidation_price_source: str | None = None
|
||||
self._strict_funding_applied: set[tuple[str, pd.Timestamp]] = set()
|
||||
self._isolated_margins: dict[str, float] = {}
|
||||
self._schedule_cache: dict[tuple[str, str], MaintenanceSchedule] = {}
|
||||
self._risk_frames: dict[str, MarketRiskFrame] = {}
|
||||
self._blocked_symbols: set[str] = set()
|
||||
self._funding_applied: set = set() # (symbol, date, hour) — per-slot dedup
|
||||
self._funding_daily_done: set = set() # (symbol, date) — daily fallback dedup
|
||||
|
||||
def _validate_strict_resolution(self, config: dict[str, Any]) -> None:
|
||||
if not self.perpetual_strict or self.default_leverage < 100:
|
||||
return
|
||||
interval = str(config.get("interval", "1D")).strip().lower()
|
||||
if interval not in {"1m", "5m", "15m", "30m", "1h"}:
|
||||
raise ValueError(
|
||||
"strict 100x requires a supported interval <= 1H "
|
||||
"(1m/5m/15m/30m/1H); 1H is only a resolution boundary, "
|
||||
"not a liquidation-sequence guarantee"
|
||||
)
|
||||
|
||||
def run_backtest(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
loader: Any,
|
||||
signal_engine: Any,
|
||||
run_dir: Path,
|
||||
bars_per_year: int = 252,
|
||||
) -> dict[str, Any]:
|
||||
self._validate_strict_resolution(config)
|
||||
self._run_interval = str(config.get("interval", "1D"))
|
||||
return super().run_backtest(config, loader, signal_engine, run_dir, bars_per_year)
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""Crypto: 24/7, long/short/close all allowed."""
|
||||
return True
|
||||
return not (self.perpetual_strict and symbol in self._blocked_symbols)
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Crypto supports fractional sizes, round to 6 decimals."""
|
||||
@@ -54,13 +117,358 @@ class CryptoEngine(BaseEngine):
|
||||
``_direction`` is unused — reserved for future funding-rate asymmetry
|
||||
between long/short legs on perp swaps.
|
||||
"""
|
||||
rate = self.taker_rate if is_open else self.maker_rate
|
||||
rate = self.taker_rate if self.perpetual_strict or is_open else self.maker_rate
|
||||
return size * price * rate
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Slippage: unfavourable direction."""
|
||||
return price * (1 + direction * self.slippage_rate)
|
||||
|
||||
def execution_open(self, bar: pd.Series) -> float:
|
||||
if not self.perpetual_strict:
|
||||
return super().execution_open(bar)
|
||||
return ExecutionFrame(bar.name, float(bar["execution_open"])).execution_open
|
||||
|
||||
def valuation_open(self, bar: pd.Series) -> float:
|
||||
if not self.perpetual_strict:
|
||||
return super().valuation_open(bar)
|
||||
return float(bar["mark_open"])
|
||||
|
||||
def _schedule(self, symbol: str, bar: pd.Series) -> MaintenanceSchedule:
|
||||
version = bar["maintenance_bracket_version"]
|
||||
if pd.isna(version):
|
||||
raise ValueError(f"missing maintenance bracket version for {symbol}")
|
||||
version = str(version)
|
||||
previous = self._maintenance_bracket_versions.setdefault(symbol, version)
|
||||
if previous != version:
|
||||
raise ValueError(f"maintenance bracket version changed for {symbol}")
|
||||
key = (symbol, version)
|
||||
if key not in self._schedule_cache:
|
||||
self._schedule_cache[key] = MaintenanceSchedule.from_loader_columns(
|
||||
symbol, bar["maintenance_brackets"], version
|
||||
)
|
||||
return self._schedule_cache[key]
|
||||
|
||||
def _build_strict_frames(
|
||||
self,
|
||||
timestamp: pd.Timestamp,
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
codes: list[str],
|
||||
) -> None:
|
||||
risks: dict[str, MarketRiskFrame] = {}
|
||||
for symbol in codes:
|
||||
frame = data_map.get(symbol)
|
||||
if frame is None or timestamp not in frame.index:
|
||||
raise ValueError(f"missing synchronized frame for {symbol} at {timestamp}")
|
||||
bar = frame.loc[timestamp]
|
||||
if not isinstance(bar, pd.Series):
|
||||
raise ValueError(f"duplicate frame timestamp for {symbol} at {timestamp}")
|
||||
ExecutionFrame(timestamp, float(bar["execution_open"]))
|
||||
rate = bar["funding_rate"]
|
||||
settlement = bar["funding_settlement_time"]
|
||||
if pd.isna(rate):
|
||||
raise ValueError(f"missing funding rate for {symbol} at {timestamp}")
|
||||
if pd.isna(settlement):
|
||||
if float(rate) != 0.0:
|
||||
raise ValueError(
|
||||
f"funding rate without settlement for {symbol} at {timestamp}"
|
||||
)
|
||||
funding_rate = funding_time = None
|
||||
else:
|
||||
funding_rate = float(rate)
|
||||
funding_time = pd.Timestamp(settlement)
|
||||
risks[symbol] = MarketRiskFrame(
|
||||
timestamp=timestamp,
|
||||
mark_open=float(bar["mark_open"]),
|
||||
mark_high=float(bar["mark_high"]),
|
||||
mark_low=float(bar["mark_low"]),
|
||||
mark_close=float(bar["mark_close"]),
|
||||
funding_rate=funding_rate,
|
||||
funding_settlement_time=funding_time,
|
||||
schedule=self._schedule(symbol, bar),
|
||||
source=str(self.config.get("market_risk_source", "ccxt:binanceusdm")),
|
||||
)
|
||||
self._market_risk_sources.add(risks[symbol].source)
|
||||
self._risk_frames = risks
|
||||
|
||||
def _record_event(self, timestamp: pd.Timestamp, event_type: str, **details: Any) -> None:
|
||||
if not self.perpetual_strict:
|
||||
return
|
||||
self._perpetual_events.append(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"sequence": len(self._perpetual_events) + 1,
|
||||
"timestamp": pd.Timestamp(timestamp).isoformat(),
|
||||
"event_type": event_type,
|
||||
**details,
|
||||
}
|
||||
)
|
||||
|
||||
def _record_risk_snapshot(
|
||||
self,
|
||||
timestamp: pd.Timestamp,
|
||||
price_field: str,
|
||||
snapshot: RiskSnapshot,
|
||||
) -> None:
|
||||
self._record_event(
|
||||
timestamp,
|
||||
"risk_snapshot",
|
||||
phase="pre_fill" if price_field == "mark_open" else "post_fill",
|
||||
price_source=("mark_open" if price_field == "mark_open" else "adverse_mark_extrema"),
|
||||
status=snapshot.status,
|
||||
margin_balance=snapshot.margin_balance,
|
||||
initial_margin=snapshot.initial_margin,
|
||||
maintenance_margin=snapshot.maintenance_margin,
|
||||
available_balance=snapshot.available_balance,
|
||||
liquidation_targets=list(snapshot.liquidation_targets),
|
||||
fidelity_flags=list(snapshot.fidelity_flags),
|
||||
positions=[
|
||||
{
|
||||
"symbol": risk.symbol,
|
||||
"mark_price": risk.mark_price,
|
||||
"notional": risk.notional,
|
||||
"unrealized_pnl": risk.unrealized_pnl,
|
||||
"initial_margin": risk.initial_margin,
|
||||
"maintenance_margin": risk.maintenance_margin,
|
||||
"margin_balance": risk.margin_balance,
|
||||
}
|
||||
for risk in snapshot.per_position
|
||||
],
|
||||
)
|
||||
|
||||
def _apply_data_funding(self) -> None:
|
||||
for symbol, position in self.positions.items():
|
||||
frame = self._risk_frames[symbol]
|
||||
settlement = frame.funding_settlement_time
|
||||
if settlement is None or position.entry_time >= settlement:
|
||||
continue
|
||||
key = (symbol, settlement)
|
||||
if key in self._strict_funding_applied:
|
||||
continue
|
||||
payment = (
|
||||
position.direction * position.size * frame.mark_open
|
||||
* float(frame.funding_rate)
|
||||
)
|
||||
self.capital -= payment
|
||||
if self.margin_mode == "isolated":
|
||||
self._isolated_margins[symbol] -= payment
|
||||
self._strict_funding_applied.add(key)
|
||||
self._record_event(
|
||||
settlement,
|
||||
"funding_settlement",
|
||||
symbol=symbol,
|
||||
signed_quantity=position.direction * position.size,
|
||||
mark_price=frame.mark_open,
|
||||
price_source="mark_open",
|
||||
funding_rate=frame.funding_rate,
|
||||
funding_pnl=-payment,
|
||||
)
|
||||
|
||||
def _account_state(self) -> AccountState:
|
||||
positions = tuple(
|
||||
PositionState(
|
||||
symbol=symbol,
|
||||
quantity=position.direction * position.size,
|
||||
entry_price=position.entry_price,
|
||||
leverage=position.leverage,
|
||||
accumulated_entry_fee=position.entry_commission,
|
||||
isolated_margin=self._isolated_margins.get(symbol),
|
||||
)
|
||||
for symbol, position in self.positions.items()
|
||||
)
|
||||
locked_margin = sum(
|
||||
self._calc_margin(
|
||||
position.symbol, position.size, position.entry_price, position.leverage
|
||||
)
|
||||
for position in self.positions.values()
|
||||
)
|
||||
return AccountState(
|
||||
wallet_balance=self.capital + locked_margin,
|
||||
positions=positions,
|
||||
margin_mode=self.margin_mode,
|
||||
terminal_status=self.terminal_status,
|
||||
)
|
||||
|
||||
def _evaluate_and_liquidate(
|
||||
self, timestamp: pd.Timestamp, price_field: str
|
||||
) -> bool:
|
||||
if not self.positions:
|
||||
return False
|
||||
account = self._account_state()
|
||||
snapshot = (
|
||||
evaluate_isolated(account, self._risk_frames, price_field)
|
||||
if self.margin_mode == "isolated"
|
||||
else CrossMarginRiskModel().evaluate(account, self._risk_frames, price_field)
|
||||
)
|
||||
self._record_risk_snapshot(timestamp, price_field, snapshot)
|
||||
if snapshot.status == "healthy":
|
||||
return False
|
||||
prices = {risk.symbol: risk.mark_price for risk in snapshot.per_position}
|
||||
price_source = "mark_open" if price_field == "mark_open" else "adverse_mark_extrema"
|
||||
liquidation_details = []
|
||||
for symbol in snapshot.liquidation_targets:
|
||||
position = self.positions[symbol]
|
||||
price = prices[symbol]
|
||||
fee = position.size * price * self.liquidation_fee_rate
|
||||
self._liquidation_price_source = price_source
|
||||
try:
|
||||
self._close_position(symbol, price, timestamp, snapshot.status)
|
||||
self.capital -= fee
|
||||
finally:
|
||||
self._liquidation_price_source = None
|
||||
liquidation_details.append((symbol, price, fee))
|
||||
if snapshot.status == "account_liquidation":
|
||||
self.terminal_status = "account_liquidation"
|
||||
self._record_event(
|
||||
timestamp,
|
||||
"account_liquidation",
|
||||
symbols=sorted(symbol for symbol, _, _ in liquidation_details),
|
||||
liquidation_prices={symbol: price for symbol, price, _ in liquidation_details},
|
||||
price_source=price_source,
|
||||
liquidation_fee=sum(fee for _, _, fee in liquidation_details),
|
||||
fidelity_flags=list(snapshot.fidelity_flags),
|
||||
)
|
||||
return True
|
||||
for symbol, price, fee in liquidation_details:
|
||||
self._record_event(
|
||||
timestamp,
|
||||
"position_liquidation",
|
||||
symbol=symbol,
|
||||
liquidation_price=price,
|
||||
price_source=price_source,
|
||||
liquidation_fee=fee,
|
||||
fidelity_flags=list(snapshot.fidelity_flags),
|
||||
)
|
||||
self._blocked_symbols.update(snapshot.liquidation_targets)
|
||||
return False
|
||||
|
||||
def before_rebalance_bar(
|
||||
self,
|
||||
timestamp: pd.Timestamp,
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
codes: list[str],
|
||||
) -> bool:
|
||||
if not self.perpetual_strict:
|
||||
return super().before_rebalance_bar(timestamp, data_map, codes)
|
||||
self._blocked_symbols.clear()
|
||||
self._build_strict_frames(timestamp, data_map, codes)
|
||||
self._apply_data_funding()
|
||||
return self._evaluate_and_liquidate(timestamp, "mark_open")
|
||||
|
||||
def after_rebalance_bar(
|
||||
self,
|
||||
timestamp: pd.Timestamp,
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
codes: list[str],
|
||||
) -> bool:
|
||||
if not self.perpetual_strict:
|
||||
return super().after_rebalance_bar(timestamp, data_map, codes)
|
||||
return self._evaluate_and_liquidate(timestamp, "adverse")
|
||||
|
||||
def _execute_bars(self, dates, data_map, close_df, target_pos, codes) -> None:
|
||||
if self.perpetual_strict:
|
||||
try:
|
||||
close_df = pd.DataFrame(
|
||||
{symbol: data_map[symbol]["mark_close"] for symbol in codes}, index=dates
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"missing strict mark-close data: {exc}") from exc
|
||||
super()._execute_bars(dates, data_map, close_df, target_pos, codes)
|
||||
if self.perpetual_strict and self.terminal_status == "active":
|
||||
self.terminal_status = "completed"
|
||||
|
||||
def _execute_open_order(self, order, timestamp: pd.Timestamp) -> None:
|
||||
super()._execute_open_order(order, timestamp)
|
||||
if self.perpetual_strict:
|
||||
self._record_event(
|
||||
timestamp,
|
||||
"market_fill",
|
||||
action="open",
|
||||
symbol=order.symbol,
|
||||
side="buy" if order.direction == 1 else "sell",
|
||||
signed_quantity=order.direction * order.size,
|
||||
execution_price=order.price,
|
||||
execution_price_source="execution_open",
|
||||
trading_fee=order.commission,
|
||||
reason="signal",
|
||||
)
|
||||
if self.perpetual_strict and self.margin_mode == "isolated":
|
||||
self._isolated_margins[order.symbol] = order.margin
|
||||
|
||||
def _close_position(
|
||||
self,
|
||||
symbol: str,
|
||||
exit_price: float,
|
||||
exit_time: pd.Timestamp,
|
||||
reason: str,
|
||||
) -> None:
|
||||
position = self.positions.get(symbol)
|
||||
super()._close_position(symbol, exit_price, exit_time, reason)
|
||||
if self.perpetual_strict and position is not None:
|
||||
trade = self.trades[-1]
|
||||
price_source = self._liquidation_price_source
|
||||
if price_source is None:
|
||||
price_source = "mark_close" if reason == "end_of_backtest" else "execution_open"
|
||||
self._record_event(
|
||||
exit_time,
|
||||
"market_fill",
|
||||
action="close",
|
||||
symbol=symbol,
|
||||
side="sell" if position.direction == 1 else "buy",
|
||||
signed_quantity=-position.direction * position.size,
|
||||
execution_price=exit_price,
|
||||
execution_price_source=price_source,
|
||||
trading_fee=trade.commission - position.entry_commission,
|
||||
reason=reason,
|
||||
)
|
||||
self._isolated_margins.pop(symbol, None)
|
||||
|
||||
def _write_artifacts(
|
||||
self,
|
||||
run_dir: Path,
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
dates: pd.DatetimeIndex,
|
||||
equity_series: pd.Series,
|
||||
bench_equity: pd.Series,
|
||||
bench_ret: pd.Series,
|
||||
target_pos: pd.DataFrame,
|
||||
metrics: dict,
|
||||
codes: list[str],
|
||||
) -> None:
|
||||
summary = None
|
||||
if self.perpetual_strict:
|
||||
summary = build_perpetual_summary(
|
||||
config={**self.config, "interval": self._run_interval},
|
||||
events=self._perpetual_events,
|
||||
trades=self.trades,
|
||||
terminal_status=self.terminal_status,
|
||||
maintenance_bracket_versions=self._maintenance_bracket_versions,
|
||||
market_risk_sources=sorted(self._market_risk_sources),
|
||||
)
|
||||
metrics.update(
|
||||
{
|
||||
"perpetual_funding_settlements": summary["funding_settlement_count"],
|
||||
"perpetual_funding_pnl": summary["total_funding_pnl"],
|
||||
"perpetual_liquidation_events": summary["liquidation_event_count"],
|
||||
"perpetual_liquidated_positions": summary["liquidated_position_count"],
|
||||
"perpetual_trading_fees": summary["total_trading_fee"],
|
||||
"perpetual_liquidation_fees": summary["total_liquidation_fee"],
|
||||
}
|
||||
)
|
||||
super()._write_artifacts(
|
||||
run_dir,
|
||||
data_map,
|
||||
dates,
|
||||
equity_series,
|
||||
bench_equity,
|
||||
bench_ret,
|
||||
target_pos,
|
||||
metrics,
|
||||
codes,
|
||||
)
|
||||
if summary is not None:
|
||||
write_perpetual_evidence(run_dir, self._perpetual_events, summary)
|
||||
|
||||
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
|
||||
"""Crypto per-bar hooks: funding fee + liquidation check."""
|
||||
fee = calc_crypto_funding_fee(
|
||||
|
||||
@@ -11,6 +11,9 @@ Market rules:
|
||||
- Stamp tax 0.1% bilateral + levies
|
||||
- Lot-size rounding (simplified to 100 shares)
|
||||
- Higher slippage than US
|
||||
|
||||
India (NSE/BSE) is handled by the dedicated ``backtest.engines.india_equity``
|
||||
``IndiaEquityEngine`` (T+1 delivery, circuit bands, STT/stamp/GST stack).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,6 +16,7 @@ import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.china_a import _blocked_by_limit
|
||||
from backtest.engines.futures_base import FuturesBaseEngine
|
||||
|
||||
|
||||
@@ -141,6 +142,8 @@ class GlobalFuturesEngine(FuturesBaseEngine):
|
||||
leverage = config.get("leverage", 10.0)
|
||||
config = {**config, "leverage": leverage}
|
||||
super().__init__(config)
|
||||
# Futures bands come off the previous settlement, not the previous close.
|
||||
self.base_price_fields = ("pre_settle", "pre_close")
|
||||
self.slippage_rate: float = config.get("slippage", 0.0003)
|
||||
self._comm_override = config.get("commission_per_contract")
|
||||
|
||||
@@ -160,20 +163,18 @@ class GlobalFuturesEngine(FuturesBaseEngine):
|
||||
if limit is None:
|
||||
return True # no price limit for most commodities
|
||||
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # limit-up
|
||||
if direction == -1 and pct_chg <= -limit + 0.001:
|
||||
return False # limit-down
|
||||
if direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
if pos.direction == 1 and pct_chg <= -limit + 0.001:
|
||||
return False
|
||||
if pos.direction == -1 and pct_chg >= limit - 0.001:
|
||||
return False
|
||||
return True
|
||||
# Tested at execution time (see _blocked_by_limit).
|
||||
pos = self.positions.get(symbol) if direction == 0 else None
|
||||
if pos is None and direction == 0:
|
||||
return True
|
||||
return not _blocked_by_limit(
|
||||
self,
|
||||
symbol,
|
||||
direction,
|
||||
bar,
|
||||
limit,
|
||||
position_direction=pos.direction if pos is not None else None,
|
||||
)
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Integer contracts, minimum 1."""
|
||||
@@ -219,33 +220,3 @@ class GlobalFuturesEngine(FuturesBaseEngine):
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
|
||||
# Note: china_a uses close/pre_close-only; china_futures prioritises
|
||||
# settle/pre_settle. This global-futures variant prefers close/pre_close
|
||||
# because CME data feeds (yfinance/IB) expose continuous close more
|
||||
# reliably than settlement. See those modules for the equity /
|
||||
# China-futures equivalents.
|
||||
def _calc_pct_change(bar: pd.Series):
|
||||
"""Calculate bar price change percentage.
|
||||
|
||||
Priority: close/pre_close > settle/pre_settle > pct_chg.
|
||||
Falls back to pct_chg only when price fields are absent.
|
||||
"""
|
||||
close = bar.get("close")
|
||||
pre_close = bar.get("pre_close")
|
||||
if close is not None and pre_close is not None and pre_close > 0:
|
||||
return (float(close) - float(pre_close)) / float(pre_close)
|
||||
|
||||
settle = bar.get("settle")
|
||||
pre_settle = bar.get("pre_settle")
|
||||
if settle is not None and pre_settle is not None and pre_settle > 0:
|
||||
return (float(settle) - float(pre_settle)) / float(pre_settle)
|
||||
|
||||
if "pct_chg" in bar.index:
|
||||
val = bar["pct_chg"]
|
||||
if pd.notna(val):
|
||||
raw = float(val)
|
||||
# Heuristic: values > 1 are likely percentage points
|
||||
return raw / 100.0 if abs(raw) > 1.0 else raw
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,141 @@
|
||||
"""India equity (NSE / BSE) backtest engine.
|
||||
|
||||
Models the Indian cash-equity **delivery** segment on daily bars. Intraday
|
||||
(MIS) mechanics are not represented by a daily-bar engine, so the defaults
|
||||
reflect overnight delivery rules; the knobs below let advanced users approximate
|
||||
intraday behaviour.
|
||||
|
||||
Market rules:
|
||||
- T+1 settlement: shares bought today cannot be sold the same bar (delivery).
|
||||
- No short selling by default: retail cannot hold overnight short delivery
|
||||
positions. Set ``allow_short=True`` to model intraday (MIS) shorting.
|
||||
- Circuit bands: per-scrip price bands vary (2/5/10/20%); the exact band is
|
||||
not derivable from the symbol alone, so a single configurable band applies
|
||||
(default ±20%, the widest common band). Set ``price_limit`` to ``0`` /
|
||||
``None`` to disable.
|
||||
- Lot size: 1 share for cash equity (F&O lot sizes are not modelled here).
|
||||
|
||||
Cost stack (delivery, discount-broker defaults; all config-driven). NOTE: SEBI/
|
||||
exchange tariffs change periodically — verify ``in_*`` rates against a current
|
||||
broker schedule before relying on absolute cost figures:
|
||||
- Brokerage: ₹0 (delivery on discount brokers) [in_brokerage]
|
||||
- STT: 0.1% on buy + 0.1% on sell (bilateral) [in_stt]
|
||||
- Exchange transaction charge: NSE ~0.00297% (bilateral) [in_exchange_txn]
|
||||
- SEBI turnover fee: ₹10/crore = 0.0001% (bilateral) [in_sebi_fee]
|
||||
- Stamp duty: 0.015% on buy only [in_stamp_duty]
|
||||
- GST: 18% on (brokerage + exchange txn + SEBI fee) [in_gst]
|
||||
- DP charge: flat per-scrip on sell (default ₹0) [in_dp_charge]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines.china_a import _blocked_by_limit
|
||||
|
||||
|
||||
class IndiaEquityEngine(BaseEngine):
|
||||
"""NSE / BSE cash-equity (delivery) engine.
|
||||
|
||||
Config keys (all optional; defaults shown in the module docstring):
|
||||
- allow_short: bool, default False
|
||||
- price_limit: float fraction or None, default 0.20
|
||||
- slippage: default 0.001
|
||||
- in_brokerage / in_stt / in_exchange_txn / in_sebi_fee /
|
||||
in_stamp_duty / in_gst / in_dp_charge
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
config = {**config, "leverage": 1.0} # cash delivery: no leverage
|
||||
super().__init__(config)
|
||||
self.allow_short: bool = bool(config.get("allow_short", False))
|
||||
self.price_limit = config.get("price_limit", 0.20)
|
||||
self.slippage_rate: float = config.get("slippage", 0.001)
|
||||
# Cost stack
|
||||
self.in_brokerage: float = config.get("in_brokerage", 0.0)
|
||||
self.in_stt: float = config.get("in_stt", 0.001)
|
||||
self.in_exchange_txn: float = config.get("in_exchange_txn", 0.0000297)
|
||||
self.in_sebi_fee: float = config.get("in_sebi_fee", 0.000001)
|
||||
self.in_stamp_duty: float = config.get("in_stamp_duty", 0.00015)
|
||||
self.in_gst: float = config.get("in_gst", 0.18)
|
||||
self.in_dp_charge: float = config.get("in_dp_charge", 0.0)
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""India delivery execution rules.
|
||||
|
||||
Args:
|
||||
symbol: NSE/BSE symbol (e.g. ``RELIANCE.NS``).
|
||||
direction: 1 (buy), -1 (short), 0 (sell/close).
|
||||
bar: Current bar (needs ``close`` + ``pre_close``/``pct_chg`` for
|
||||
circuit checks).
|
||||
|
||||
Returns:
|
||||
True if the trade is allowed.
|
||||
"""
|
||||
# 1. Short selling: blocked unless explicitly modelling intraday (MIS).
|
||||
if direction == -1 and not self.allow_short:
|
||||
return False
|
||||
|
||||
# 2. T+1: can't sell shares bought today (delivery).
|
||||
if direction == 0:
|
||||
pos = self.positions.get(symbol)
|
||||
if pos is not None:
|
||||
bar_date = _bar_date(bar)
|
||||
entry_date = pos.entry_time.date() if hasattr(pos.entry_time, "date") else None
|
||||
if bar_date is not None and entry_date is not None and bar_date == entry_date:
|
||||
return False
|
||||
|
||||
# 3. Circuit bands, tested at execution time (see _blocked_by_limit).
|
||||
if self.price_limit and _blocked_by_limit(
|
||||
self, symbol, direction, bar, float(self.price_limit)
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Cash equity trades in 1-share lots."""
|
||||
return float(max(int(raw_size), 0))
|
||||
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""India delivery cost stack (see module docstring).
|
||||
|
||||
``_direction`` is unused — reserved for future asymmetric long/short
|
||||
(intraday MIS) schedules.
|
||||
"""
|
||||
notional = size * price
|
||||
brokerage = notional * self.in_brokerage
|
||||
exchange_txn = notional * self.in_exchange_txn # bilateral
|
||||
sebi_fee = notional * self.in_sebi_fee # bilateral
|
||||
gst = (brokerage + exchange_txn + sebi_fee) * self.in_gst
|
||||
stt = notional * self.in_stt # bilateral (delivery)
|
||||
comm = brokerage + exchange_txn + sebi_fee + gst + stt
|
||||
if is_open:
|
||||
comm += notional * self.in_stamp_duty # stamp duty: buy-only
|
||||
else:
|
||||
comm += self.in_dp_charge # DP charge: sell-only, flat
|
||||
return comm
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""India slippage (configurable)."""
|
||||
return price * (1 + direction * self.slippage_rate)
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
|
||||
def _bar_date(bar: pd.Series):
|
||||
"""Extract date from bar, handling various column names."""
|
||||
for col in ("trade_date", "date"):
|
||||
if col in bar.index:
|
||||
val = bar[col]
|
||||
if hasattr(val, "date"):
|
||||
return val.date()
|
||||
try:
|
||||
return pd.Timestamp(val).date()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(bar, "name") and hasattr(bar.name, "date"):
|
||||
return bar.name.date()
|
||||
return None
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Korea equity (KRX: KOSPI / KOSDAQ) backtest engine.
|
||||
|
||||
Models the Korean cash-equity segment on daily bars. Prices are assumed to be
|
||||
quoted in KRW, which KRX prints as whole won on a coarse tick grid — the grid is
|
||||
modelled explicitly below because a percentage-only model produces fill prices
|
||||
that cannot exist and mis-identifies locked-limit bars.
|
||||
|
||||
Market rules:
|
||||
- Same-day round trips are allowed: shares bought today CAN be sold the same
|
||||
day (unlike China A-share T+1 or India delivery), so there is no same-bar
|
||||
sell block.
|
||||
- **Long-only by construction.** KRX short selling is covered-only and
|
||||
uptick-constrained (차입공매도 / 업틱룰), and neither constraint is
|
||||
representable on daily bars, so ``allow_short=True`` is *refused* at
|
||||
construction rather than silently mis-modelled.
|
||||
- Price limit: ±30% daily band on both KOSPI and KOSDAQ (since June 2015),
|
||||
measured from the base price (기준가격 — normally the previous trading
|
||||
day's close) and quantized to the tick grid. Set ``price_limit`` to ``0`` /
|
||||
``None`` to disable.
|
||||
- Lot size: 1 share (KRX abolished the 10-share odd-lot rule in 2014).
|
||||
|
||||
Cost stack (discount-broker defaults; all config-driven):
|
||||
- Brokerage: 0.015% per side (typical online rate) [kr_brokerage]
|
||||
- Securities transaction tax: 0.20% on the SELL side [kr_tax_sell]
|
||||
From transfers on/after 2026-01-01 the aggregate sell-side rate is 0.20%
|
||||
on both boards, restored after the financial-investment-income tax was
|
||||
scrapped: KOSPI = 0.05% 증권거래세 + 0.15% 농어촌특별세, KOSDAQ = 0.20%
|
||||
증권거래세 with no surtax (증권거래세법 시행령 제5조 탄력세율 as amended
|
||||
by 대통령령 제36001호; 농어촌특별세법 제5조). KONEX stays at 0.10% — pass
|
||||
``kr_tax_sell=0.001`` for KONEX names. Rates are revised periodically;
|
||||
verify ``kr_*`` against the current schedule before trusting absolute cost
|
||||
figures.
|
||||
|
||||
Tick grid source: KRX regulation portal (호가가격단위) —
|
||||
https://regulation.krx.co.kr/contents/RGL/03/03010100/RGL03010100T3.jsp (KOSPI)
|
||||
and https://regulation.krx.co.kr/contents/RGL/03/03020100/RGL03020100.jsp
|
||||
(KOSDAQ). The 2023-01-25 reform **unified** the two boards' tables and removed
|
||||
the old high-price divergence, so one table now serves both. Note that KRX's
|
||||
English "Guide to Trading in the Korean Stock Market" PDF still prints the
|
||||
pre-2023 bands (1,000 / 10,000 / 100,000 boundaries, KOSDAQ capped at 100 KRW)
|
||||
— it is stale; the Korean regulation portal above is authoritative.
|
||||
|
||||
Price-limit arithmetic follows the published KRX procedure rather than a naive
|
||||
``base * 1.3``: the 30% band amount is truncated to the *base price's* tick
|
||||
unit, then added to / subtracted from the base, then truncated again to the
|
||||
resulting price's own tick unit. KRX worked example (기준가격 9,980):
|
||||
9,980 x 0.3 = 2,994 -> 2,990 -> upper 12,970 / lower 6,990.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# KRX quotation price units (호가가격단위): (lower bound inclusive, tick).
|
||||
# Unified across KOSPI / KOSDAQ / KONEX since 2023-01-25. Every coarser tick is
|
||||
# an exact multiple of every finer one, which is what makes the truncation
|
||||
# arithmetic below stable across band boundaries.
|
||||
KRX_TICK_TABLE: tuple[tuple[float, float], ...] = (
|
||||
(0.0, 1.0),
|
||||
(2_000.0, 5.0),
|
||||
(5_000.0, 10.0),
|
||||
(20_000.0, 50.0),
|
||||
(50_000.0, 100.0),
|
||||
(200_000.0, 500.0),
|
||||
(500_000.0, 1_000.0),
|
||||
)
|
||||
|
||||
# Tick-grid comparisons only need to survive float representation error: every
|
||||
# quantized price is a whole number of won, so 1e-9 is far below one tick.
|
||||
_PRICE_EPS = 1e-9
|
||||
|
||||
|
||||
def krx_tick_size(price: float) -> float:
|
||||
"""Return the KRX tick unit applicable at *price*.
|
||||
|
||||
Args:
|
||||
price: Price in KRW. Non-positive input returns the finest tick.
|
||||
|
||||
Returns:
|
||||
The tick unit (KRW) for the band containing *price*; bands are
|
||||
lower-bound inclusive (``이상`` / ``미만``), so 2,000 KRW -> 5 KRW.
|
||||
"""
|
||||
tick = KRX_TICK_TABLE[0][1]
|
||||
for lower, unit in KRX_TICK_TABLE:
|
||||
if price >= lower:
|
||||
tick = unit
|
||||
else:
|
||||
break
|
||||
return tick
|
||||
|
||||
|
||||
def krx_round_down(price: float) -> float:
|
||||
"""Truncate *price* down onto the tick grid (KRX ``절사``)."""
|
||||
tick = krx_tick_size(price)
|
||||
return float(int((price + _PRICE_EPS) / tick) * tick)
|
||||
|
||||
|
||||
def krx_round_up(price: float) -> float:
|
||||
"""Round *price* up onto the tick grid (KRX ``절상``), never below one tick."""
|
||||
tick = krx_tick_size(price)
|
||||
steps = int((price - _PRICE_EPS) / tick)
|
||||
if steps * tick < price - _PRICE_EPS:
|
||||
steps += 1
|
||||
return float(max(steps, 1) * tick)
|
||||
|
||||
|
||||
def krx_price_limits(base_price: float, limit: float) -> tuple[float, float]:
|
||||
"""Return the (upper, lower) KRX limit prices for a base price.
|
||||
|
||||
Implements the published three-step procedure: the band amount is
|
||||
``base * limit`` truncated to the base price's tick unit, and each limit
|
||||
price is then truncated to its own tick unit (the lower limit is already
|
||||
grid-aligned because coarser ticks are multiples of finer ones, so only the
|
||||
upper limit can move).
|
||||
|
||||
Args:
|
||||
base_price: Base price (기준가격), normally the previous close, already
|
||||
snapped onto the tick grid by the caller.
|
||||
limit: Band width as a fraction (0.30 for the standard ±30%).
|
||||
|
||||
Returns:
|
||||
``(upper_limit, lower_limit)`` in KRW.
|
||||
"""
|
||||
base_tick = krx_tick_size(base_price)
|
||||
band_amount = max(int((base_price * limit + _PRICE_EPS) / base_tick), 1) * base_tick
|
||||
return (
|
||||
krx_round_down(base_price + band_amount),
|
||||
krx_round_down(max(base_price - band_amount, base_tick)),
|
||||
)
|
||||
|
||||
|
||||
class KoreaEquityEngine(BaseEngine):
|
||||
"""KRX (KOSPI / KOSDAQ) cash-equity engine — long-only.
|
||||
|
||||
Config keys (all optional; defaults shown in the module docstring):
|
||||
- price_limit: float fraction or None, default 0.30
|
||||
- slippage: default 0.001
|
||||
- kr_brokerage / kr_tax_sell
|
||||
|
||||
Raises:
|
||||
ValueError: When ``allow_short`` is truthy. KRX covered-short and
|
||||
uptick rules are not modelled, so shorting is refused instead of
|
||||
being simulated incorrectly. A cross-market composite run shares one
|
||||
config across its sub-engines, so pairing ``allow_short`` with a KRX
|
||||
symbol fails the whole run — deliberately, since the alternative is
|
||||
a KRX leg that silently never shorts while the rest of the book
|
||||
does.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
config = {**config, "leverage": 1.0} # cash equity: no leverage
|
||||
super().__init__(config)
|
||||
if config.get("allow_short"):
|
||||
raise ValueError(
|
||||
"KoreaEquityEngine is long-only: KRX short selling is "
|
||||
"covered-only (차입공매도) and uptick-constrained, and neither "
|
||||
"rule can be enforced on daily bars. Remove 'allow_short' "
|
||||
"instead of running a mis-modelled short book."
|
||||
)
|
||||
self.price_limit = config.get("price_limit", 0.30)
|
||||
self.slippage_rate: float = config.get("slippage", 0.001)
|
||||
# Cost stack
|
||||
self.kr_brokerage: float = config.get("kr_brokerage", 0.00015)
|
||||
self.kr_tax_sell: float = config.get("kr_tax_sell", 0.0020)
|
||||
# One warning per engine when no base price is derivable (see _base_price).
|
||||
self._limit_base_warned = False
|
||||
|
||||
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
||||
"""KRX execution rules.
|
||||
|
||||
The price-limit test compares the *prospective fill price* — this bar's
|
||||
open plus slippage, which is what :class:`BaseEngine` fills at — against
|
||||
limit prices derived from the previous bar's close. Deriving the move
|
||||
from the current bar's close instead (the china_a/india pattern) would
|
||||
be lookahead: ``BaseEngine`` calls this hook before the fill, and the
|
||||
decision bar's close is not known then.
|
||||
|
||||
Args:
|
||||
symbol: KRX symbol (e.g. ``005930.KS``, ``247540.KQ``).
|
||||
direction: 1 (buy), -1 (short), 0 (sell/close).
|
||||
bar: Current bar (needs ``open``; the base price comes from
|
||||
``pre_close`` when the source supplies it, else from the prior
|
||||
bar of the run's own close panel).
|
||||
|
||||
Returns:
|
||||
True if the trade is allowed.
|
||||
"""
|
||||
# 1. Short selling: structurally unavailable (see class docstring).
|
||||
if direction == -1:
|
||||
return False
|
||||
|
||||
# 2. Same-day sell is allowed on KRX — no T+1 interception.
|
||||
|
||||
# 3. Daily price limit ±30% (disabled when falsy).
|
||||
if not self.price_limit:
|
||||
return True
|
||||
|
||||
base_price = self._base_price(symbol, bar)
|
||||
if base_price is None:
|
||||
if not self._limit_base_warned:
|
||||
self._limit_base_warned = True
|
||||
logger.warning(
|
||||
"%s: no base price available (no 'pre_close' column and no "
|
||||
"prior close panel row) — the KRX ±%.0f%% limit check is "
|
||||
"inactive for this run",
|
||||
symbol,
|
||||
float(self.price_limit) * 100,
|
||||
)
|
||||
return True
|
||||
|
||||
open_price = float(bar.get("open", bar.get("close", 0.0)) or 0.0)
|
||||
if open_price <= 0:
|
||||
return True # order sizing rejects non-positive prices downstream
|
||||
|
||||
upper, lower = krx_price_limits(base_price, float(self.price_limit))
|
||||
fill_price = self.apply_slippage(open_price, direction if direction else -1)
|
||||
if direction == 1 and fill_price >= upper - _PRICE_EPS:
|
||||
return False # limit-up (상한가): no ask to buy from
|
||||
if direction == 0 and fill_price <= lower + _PRICE_EPS:
|
||||
return False # limit-down (하한가): no bid to sell into
|
||||
return True
|
||||
|
||||
def _base_price(self, symbol: str, bar: pd.Series) -> Optional[float]:
|
||||
"""Resolve the KRX base price (기준가격) for *symbol* on this bar.
|
||||
|
||||
Both sources are strictly historical, so neither leaks the decision
|
||||
bar's own close into the pre-fill check:
|
||||
1. ``pre_close`` on the bar, when the data source supplies one.
|
||||
2. The previous row of the close panel that :class:`BaseEngine`
|
||||
pre-extracts for the run (unavailable in a cross-market composite
|
||||
run, whose sub-engines are stateless rule books).
|
||||
|
||||
An off-grid close (pykrx's adjusted series is Naver-rebased and need not
|
||||
land on a tick) is rounded up onto the grid, which is KRX's own rule for
|
||||
a base price that falls between ticks (``절상``).
|
||||
|
||||
Args:
|
||||
symbol: Symbol whose base price is wanted.
|
||||
bar: Current bar.
|
||||
|
||||
Returns:
|
||||
The base price in KRW, or ``None`` when no historical close is
|
||||
reachable (first bar of a run, or a composite sub-engine).
|
||||
"""
|
||||
candidate: Optional[float] = None
|
||||
if "pre_close" in bar.index:
|
||||
raw = bar["pre_close"]
|
||||
if pd.notna(raw) and float(raw) > 0:
|
||||
candidate = float(raw)
|
||||
|
||||
if candidate is None:
|
||||
close_arr = getattr(self, "_close_arr", None)
|
||||
col = getattr(self, "_code_to_col", {}).get(symbol)
|
||||
row = getattr(self, "_bar_idx", 0) - 1
|
||||
if close_arr is not None and col is not None and row >= 0:
|
||||
raw = close_arr[row, col]
|
||||
if pd.notna(raw) and float(raw) > 0:
|
||||
candidate = float(raw)
|
||||
|
||||
return None if candidate is None else krx_round_up(candidate)
|
||||
|
||||
def round_size(self, raw_size: float, price: float) -> float:
|
||||
"""Cash equity trades in 1-share lots."""
|
||||
return float(max(int(raw_size), 0))
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""Korea cost stack: bilateral brokerage + transaction tax on sells.
|
||||
|
||||
The tax follows the actual trade side rather than open/close: a closing
|
||||
trade on a long book is a sell (taxable), while an opening short would
|
||||
be the taxable leg and its buy-to-cover would not be. ``BaseEngine``
|
||||
passes the position direction on both hooks, so the side is derivable
|
||||
even though this engine is long-only today.
|
||||
|
||||
Args:
|
||||
size: Share count.
|
||||
price: Fill price in KRW.
|
||||
direction: 1 for a long book, -1 for a short book.
|
||||
is_open: True for the opening leg, False for the closing leg.
|
||||
|
||||
Returns:
|
||||
Total cost in KRW for this leg.
|
||||
"""
|
||||
notional = size * price
|
||||
comm = notional * self.kr_brokerage
|
||||
is_sell = (direction < 0) if is_open else (direction > 0)
|
||||
if is_sell:
|
||||
comm += notional * self.kr_tax_sell
|
||||
return comm
|
||||
|
||||
def apply_slippage(self, price: float, direction: int) -> float:
|
||||
"""Korea slippage (configurable), quantized onto the KRX tick grid.
|
||||
|
||||
A slipped price is snapped away from the trader — buys up to the next
|
||||
valid ask, sells down to the next valid bid — because no fill can land
|
||||
between ticks. The residual rounding is bounded by one tick unit
|
||||
(<= 0.25% of price across the whole table).
|
||||
|
||||
Args:
|
||||
price: Reference price in KRW.
|
||||
direction: 1 to buy, -1 to sell.
|
||||
|
||||
Returns:
|
||||
A valid KRX price, never below one tick.
|
||||
"""
|
||||
slipped = price * (1 + direction * self.slippage_rate)
|
||||
if direction > 0:
|
||||
return krx_round_up(slipped)
|
||||
return max(krx_round_down(slipped), krx_tick_size(slipped))
|
||||
@@ -12,6 +12,10 @@ v2 enhancements over v1:
|
||||
|
||||
Signal interface: OptionsSignalEngine.generate(data_map) returns a list of trade instructions.
|
||||
Artifacts: equity.csv, metrics.csv, trades.csv, greeks.csv.
|
||||
|
||||
Black-Scholes price and Greeks come from ``src.quantlib.options``. What stays
|
||||
here is the engine's own volatility surface -- historical vol, the smile, and
|
||||
the per-leg vol every pricing site must agree on.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -21,94 +25,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
|
||||
# --- Black-Scholes pricing ---
|
||||
|
||||
|
||||
def bs_price(S: float, K: float, T: float, r: float, sigma: float,
|
||||
option_type: str = "call") -> float:
|
||||
"""Black-Scholes European option pricing.
|
||||
|
||||
Args:
|
||||
S: Underlying spot price.
|
||||
K: Strike price.
|
||||
T: Time to expiry in years.
|
||||
r: Risk-free rate (annualised).
|
||||
sigma: Annualised volatility.
|
||||
option_type: Option type, "call" or "put".
|
||||
|
||||
Returns:
|
||||
Theoretical option price.
|
||||
|
||||
Example:
|
||||
>>> round(bs_price(100, 100, 1.0, 0.05, 0.2, "call"), 2)
|
||||
10.45
|
||||
"""
|
||||
if T <= 0 or sigma <= 0:
|
||||
# Expired: return intrinsic value
|
||||
if option_type == "call":
|
||||
return max(S - K, 0.0)
|
||||
return max(K - S, 0.0)
|
||||
|
||||
d1 = (np.log(S / K) + (r + sigma ** 2 / 2) * T) / (sigma * np.sqrt(T))
|
||||
d2 = d1 - sigma * np.sqrt(T)
|
||||
|
||||
if option_type == "call":
|
||||
return float(S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2))
|
||||
return float(K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1))
|
||||
|
||||
|
||||
# --- Greeks ---
|
||||
|
||||
|
||||
def bs_greeks(S: float, K: float, T: float, r: float, sigma: float,
|
||||
option_type: str = "call") -> Dict[str, float]:
|
||||
"""Calculate Black-Scholes Greeks.
|
||||
|
||||
Args:
|
||||
S: Underlying spot price.
|
||||
K: Strike price.
|
||||
T: Time to expiry in years.
|
||||
r: Risk-free rate (annualised).
|
||||
sigma: Annualised volatility.
|
||||
option_type: Option type, "call" or "put".
|
||||
|
||||
Returns:
|
||||
Dict containing delta, gamma, theta, vega.
|
||||
"""
|
||||
if T <= 0 or sigma <= 0:
|
||||
intrinsic_call = 1.0 if S > K else 0.0
|
||||
delta = intrinsic_call if option_type == "call" else intrinsic_call - 1.0
|
||||
return {"delta": delta, "gamma": 0.0, "theta": 0.0, "vega": 0.0}
|
||||
|
||||
sqrt_T = np.sqrt(T)
|
||||
d1 = (np.log(S / K) + (r + sigma ** 2 / 2) * T) / (sigma * sqrt_T)
|
||||
d2 = d1 - sigma * sqrt_T
|
||||
nd1_pdf = float(norm.pdf(d1))
|
||||
|
||||
# Delta
|
||||
if option_type == "call":
|
||||
delta = float(norm.cdf(d1))
|
||||
else:
|
||||
delta = float(norm.cdf(d1) - 1.0)
|
||||
|
||||
# Gamma (same for call and put)
|
||||
gamma = float(nd1_pdf / (S * sigma * sqrt_T))
|
||||
|
||||
# Theta (daily)
|
||||
theta_common = -(S * nd1_pdf * sigma) / (2 * sqrt_T)
|
||||
if option_type == "call":
|
||||
theta = theta_common - r * K * np.exp(-r * T) * norm.cdf(d2)
|
||||
else:
|
||||
theta = theta_common + r * K * np.exp(-r * T) * norm.cdf(-d2)
|
||||
theta = float(theta / 365.0) # convert to daily
|
||||
|
||||
# Vega (per 1% change in volatility)
|
||||
vega = float(S * nd1_pdf * sqrt_T / 100.0)
|
||||
|
||||
return {"delta": delta, "gamma": gamma, "theta": theta, "vega": vega}
|
||||
from src.quantlib.options import bs_greeks, bs_price, normalise_option_type
|
||||
|
||||
|
||||
# --- Historical volatility ---
|
||||
@@ -155,6 +73,31 @@ def iv_smile_adjustment(S: float, K: float, base_iv: float,
|
||||
return max(adj, 0.01)
|
||||
|
||||
|
||||
def leg_iv(S: float, K: float, base_iv: float, skew: float, curvature: float) -> float:
|
||||
"""Return the implied vol a single leg is priced at.
|
||||
|
||||
Every site that prices a leg must go through this — opening, marking to
|
||||
market, Greeks, and the American continuation value. Opening a leg on the
|
||||
smile and marking it at flat at-the-money vol books a fictitious profit the
|
||||
instant the position exists: on a 30-day 10%-OTM call at ``skew=-0.15`` the
|
||||
gap is +16.7% of premium, and +93.0% at 20% OTM, which then contaminates
|
||||
Sharpe, Calmar and drawdown.
|
||||
|
||||
Args:
|
||||
S: Spot price.
|
||||
K: Strike price.
|
||||
base_iv: At-the-money implied volatility.
|
||||
skew: Slope of the smile; ``0`` with ``curvature`` disables the smile.
|
||||
curvature: Curvature of the smile.
|
||||
|
||||
Returns:
|
||||
The leg's implied volatility.
|
||||
"""
|
||||
if skew == 0 and curvature == 0:
|
||||
return base_iv
|
||||
return iv_smile_adjustment(S, K, base_iv, skew, curvature)
|
||||
|
||||
|
||||
# --- Option positions ---
|
||||
|
||||
|
||||
@@ -162,19 +105,24 @@ class OptionPosition:
|
||||
"""A single option leg position.
|
||||
|
||||
Attributes:
|
||||
option_type: "call" or "put".
|
||||
option_type: "call" or "put", folded to lower case on construction so
|
||||
that settlement here and pricing in ``src.quantlib.options`` cannot
|
||||
disagree about a leg typed ``"Call"``.
|
||||
strike: Strike price.
|
||||
expiry: Expiry date.
|
||||
qty: Quantity (positive = long, negative = short).
|
||||
entry_price: Theoretical option price at entry.
|
||||
entry_date: Entry date string.
|
||||
underlying_code: Underlying instrument code.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``option_type`` is neither call nor put.
|
||||
"""
|
||||
|
||||
def __init__(self, option_type: str, strike: float, expiry: str,
|
||||
qty: int, entry_price: float, entry_date: str,
|
||||
underlying_code: str):
|
||||
self.option_type = option_type
|
||||
self.option_type = normalise_option_type(option_type)
|
||||
self.strike = strike
|
||||
self.expiry = pd.Timestamp(expiry)
|
||||
self.qty = qty
|
||||
@@ -327,7 +275,10 @@ def run_options_backtest(
|
||||
if T_ex <= 0:
|
||||
continue
|
||||
intrinsic = pos.intrinsic_value(spot)
|
||||
continuation = bs_price(spot, pos.strike, T_ex, risk_free_rate, iv_val_ex, pos.option_type)
|
||||
# The continuation value must use the same vol the leg is
|
||||
# marked at, or early exercise triggers off a mispriced hold.
|
||||
iv_ex = leg_iv(spot, pos.strike, iv_val_ex, iv_skew, iv_curvature)
|
||||
continuation = bs_price(spot, pos.strike, T_ex, risk_free_rate, iv_ex, pos.option_type)
|
||||
if intrinsic > 0 and intrinsic > continuation * 1.02:
|
||||
# Early exercise is optimal
|
||||
settlement = intrinsic * pos.qty * contract_multiplier
|
||||
@@ -384,7 +335,10 @@ def run_options_backtest(
|
||||
iv_val = ivs.get(underlying, 0.3)
|
||||
|
||||
for leg in legs:
|
||||
leg_type = leg.get("type", "call")
|
||||
# Fold before it is priced, matched and recorded: config comes
|
||||
# from the user, and a raw "Call" would price as a call and
|
||||
# settle as a put.
|
||||
leg_type = normalise_option_type(leg.get("type", "call"))
|
||||
strike = leg.get("strike", spot)
|
||||
expiry = leg.get("expiry", "")
|
||||
qty = leg.get("qty", 1)
|
||||
@@ -392,12 +346,7 @@ def run_options_backtest(
|
||||
expiry_ts = pd.Timestamp(expiry)
|
||||
T = max((expiry_ts - ts).days / 365.0, 0.001)
|
||||
|
||||
# Apply IV smile adjustment (v2) if configured
|
||||
adj_iv = iv_val
|
||||
if iv_skew != 0 or iv_curvature != 0:
|
||||
adj_iv = iv_smile_adjustment(spot, strike, iv_val, iv_skew, iv_curvature)
|
||||
|
||||
# Black-Scholes price (with smile-adjusted IV if enabled)
|
||||
adj_iv = leg_iv(spot, strike, iv_val, iv_skew, iv_curvature)
|
||||
opt_price = bs_price(spot, strike, T, risk_free_rate, adj_iv, leg_type)
|
||||
|
||||
if action == "open":
|
||||
@@ -432,12 +381,26 @@ def run_options_backtest(
|
||||
})
|
||||
|
||||
elif action == "close":
|
||||
# Close: find matching position
|
||||
# Close: find matching position, honoring a partial-close qty.
|
||||
matched = _find_matching_position(
|
||||
positions, underlying, leg_type, strike, expiry)
|
||||
if matched:
|
||||
pnl = (opt_price - matched.entry_price) * matched.qty * contract_multiplier
|
||||
abs_close = opt_price * abs(matched.qty) * contract_multiplier
|
||||
# An explicit leg ``qty`` closes only that many contracts
|
||||
# (clamped to the open size); a close leg with no ``qty``
|
||||
# closes the whole lot (legacy behavior). Cash/PnL and the
|
||||
# remaining position all scale to the amount actually closed
|
||||
# so a partial close no longer flattens the lot (#577).
|
||||
requested = leg.get("qty")
|
||||
full_mag = abs(matched.qty)
|
||||
close_mag = full_mag if requested is None else min(abs(requested), full_mag)
|
||||
if close_mag <= 0:
|
||||
continue
|
||||
sign = 1 if matched.qty > 0 else -1
|
||||
closed_qty = sign * close_mag
|
||||
remaining_qty = matched.qty - closed_qty
|
||||
|
||||
pnl = (opt_price - matched.entry_price) * closed_qty * contract_multiplier
|
||||
abs_close = opt_price * close_mag * contract_multiplier
|
||||
if matched.qty > 0:
|
||||
# Long close: sell to recover
|
||||
cash += abs_close * (1 - commission)
|
||||
@@ -453,11 +416,24 @@ def run_options_backtest(
|
||||
"expiry": expiry,
|
||||
"side": "close",
|
||||
"price": round(opt_price, 4),
|
||||
"qty": matched.qty,
|
||||
"qty": closed_qty,
|
||||
"pnl": round(pnl, 4),
|
||||
"entry_date": matched.entry_date,
|
||||
})
|
||||
positions.remove(matched)
|
||||
if abs(remaining_qty) < 1e-9:
|
||||
positions.remove(matched)
|
||||
else:
|
||||
# Reduce the open lot to its remainder instead of
|
||||
# removing it (new object; positions stay immutable).
|
||||
positions[positions.index(matched)] = OptionPosition(
|
||||
option_type=matched.option_type,
|
||||
strike=matched.strike,
|
||||
expiry=matched.expiry,
|
||||
qty=remaining_qty,
|
||||
entry_price=matched.entry_price,
|
||||
entry_date=matched.entry_date,
|
||||
underlying_code=matched.underlying_code,
|
||||
)
|
||||
|
||||
# 4. Compute portfolio mark-to-market value and Greeks
|
||||
portfolio_value = cash
|
||||
@@ -465,20 +441,24 @@ def run_options_backtest(
|
||||
total_gamma = 0.0
|
||||
total_theta = 0.0
|
||||
total_vega = 0.0
|
||||
total_rho = 0.0
|
||||
|
||||
for pos in positions:
|
||||
spot = spot_prices.get(pos.underlying_code, 0.0)
|
||||
iv_val = ivs.get(pos.underlying_code, 0.3)
|
||||
T = pos.time_to_expiry(ts)
|
||||
|
||||
mark_price = bs_price(spot, pos.strike, T, risk_free_rate, iv_val, pos.option_type)
|
||||
mark_iv = leg_iv(spot, pos.strike, iv_val, iv_skew, iv_curvature)
|
||||
|
||||
mark_price = bs_price(spot, pos.strike, T, risk_free_rate, mark_iv, pos.option_type)
|
||||
portfolio_value += mark_price * pos.qty * contract_multiplier
|
||||
|
||||
greeks = bs_greeks(spot, pos.strike, T, risk_free_rate, iv_val, pos.option_type)
|
||||
greeks = bs_greeks(spot, pos.strike, T, risk_free_rate, mark_iv, pos.option_type)
|
||||
total_delta += greeks["delta"] * pos.qty * contract_multiplier
|
||||
total_gamma += greeks["gamma"] * pos.qty * contract_multiplier
|
||||
total_theta += greeks["theta"] * pos.qty * contract_multiplier
|
||||
total_vega += greeks["vega"] * pos.qty * contract_multiplier
|
||||
total_rho += greeks["rho"] * pos.qty * contract_multiplier
|
||||
|
||||
equity_records.append({
|
||||
"timestamp": date_str,
|
||||
@@ -493,6 +473,7 @@ def run_options_backtest(
|
||||
"gamma": round(total_gamma, 6),
|
||||
"theta": round(total_theta, 6),
|
||||
"vega": round(total_vega, 6),
|
||||
"rho": round(total_rho, 6),
|
||||
"num_positions": len(positions),
|
||||
})
|
||||
|
||||
@@ -529,9 +510,10 @@ def run_options_backtest(
|
||||
metrics,
|
||||
data_sources=[str(getattr(loader, "name", config.get("source", "")))],
|
||||
strategy_path=run_dir / "code" / "signal_engine.py",
|
||||
warnings=config.get("content_filter_warnings") or None,
|
||||
)
|
||||
|
||||
print(json.dumps(metrics, indent=2))
|
||||
print(json.dumps(metrics, indent=2, allow_nan=False))
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -550,7 +532,9 @@ def _find_matching_position(
|
||||
Args:
|
||||
positions: Current open positions.
|
||||
underlying: Underlying instrument code.
|
||||
option_type: Option type.
|
||||
option_type: Option type, already folded by ``normalise_option_type``;
|
||||
``OptionPosition`` folds its own, so both sides compare in lower
|
||||
case.
|
||||
strike: Strike price.
|
||||
expiry: Expiry date string.
|
||||
|
||||
@@ -584,34 +568,148 @@ def _calc_options_metrics(
|
||||
Returns:
|
||||
Metrics dictionary.
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
n = len(equity)
|
||||
equity_vals = pd.to_numeric(equity, errors="coerce").astype(float)
|
||||
path_is_finite = bool(n and np.isfinite(equity_vals.to_numpy()).all())
|
||||
|
||||
final_raw: float | None = None
|
||||
final_value: float | None = None
|
||||
if n:
|
||||
terminal = float(equity_vals.iloc[-1])
|
||||
if np.isfinite(terminal):
|
||||
final_raw = terminal
|
||||
final_value = round(terminal, 2)
|
||||
else:
|
||||
warnings.append(
|
||||
"Final equity is non-finite; final and return metrics are undefined."
|
||||
)
|
||||
else:
|
||||
warnings.append(
|
||||
"No equity observations were produced; equity metrics are undefined."
|
||||
)
|
||||
|
||||
valid_initial_cash = np.isfinite(initial_cash) and initial_cash > 0
|
||||
total_ret: float | None = None
|
||||
if final_raw is not None and valid_initial_cash:
|
||||
total_ret = final_raw / float(initial_cash) - 1
|
||||
elif final_raw is not None:
|
||||
warnings.append(
|
||||
"Total return is undefined because initial cash is not positive and finite."
|
||||
)
|
||||
|
||||
ann_ret: float | None = None
|
||||
if n < 2:
|
||||
return {
|
||||
"final_value": initial_cash, "total_return": 0, "annual_return": 0,
|
||||
"max_drawdown": 0, "sharpe": 0, "calmar": 0, "sortino": 0,
|
||||
"trade_count": len(trades), "win_rate": 0, "profit_loss_ratio": 0,
|
||||
}
|
||||
warnings.append("Annual return requires at least two equity observations.")
|
||||
elif total_ret is None:
|
||||
warnings.append(
|
||||
"Annual return is undefined because total return is unavailable."
|
||||
)
|
||||
elif final_raw is not None and final_raw < 0:
|
||||
warnings.append("Annual return is undefined when final equity is negative.")
|
||||
elif bars_per_year <= 0:
|
||||
warnings.append(
|
||||
"Annual return is undefined because bars_per_year is not positive."
|
||||
)
|
||||
else:
|
||||
growth = final_raw / float(initial_cash)
|
||||
# Explosive paths (e.g. 1m bars) can OverflowError before isfinite.
|
||||
try:
|
||||
candidate = float(growth ** (bars_per_year / (n - 1)) - 1)
|
||||
except OverflowError:
|
||||
candidate = float("inf")
|
||||
if np.isfinite(candidate):
|
||||
ann_ret = candidate
|
||||
else:
|
||||
warnings.append("Annual return is non-finite for this equity path.")
|
||||
|
||||
equity_vals = equity.astype(float)
|
||||
returns = equity_vals.pct_change().fillna(0.0)
|
||||
returns: pd.Series | None = None
|
||||
if n >= 2 and path_is_finite:
|
||||
candidate_returns = equity_vals.pct_change(fill_method=None).iloc[1:]
|
||||
if np.isfinite(candidate_returns.to_numpy()).all():
|
||||
returns = candidate_returns
|
||||
else:
|
||||
warnings.append(
|
||||
"Risk ratios are undefined because equity returns are non-finite."
|
||||
)
|
||||
elif n >= 2:
|
||||
warnings.append(
|
||||
"Path-dependent metrics are undefined because equity contains non-finite values."
|
||||
)
|
||||
|
||||
total_ret = float(equity_vals.iloc[-1] / initial_cash - 1)
|
||||
ann_ret = float((1 + total_ret) ** (bars_per_year / max(n, 1)) - 1)
|
||||
max_dd: float | None = None
|
||||
if path_is_finite:
|
||||
peak = equity_vals.cummax()
|
||||
if bool((peak > 0).all()):
|
||||
dd = (equity_vals - peak) / peak
|
||||
max_dd = float(dd.min())
|
||||
else:
|
||||
warnings.append(
|
||||
"Maximum drawdown is undefined because peak equity is not positive."
|
||||
)
|
||||
|
||||
vol = float(returns.std())
|
||||
sharpe = float(returns.mean() / (vol + 1e-10) * np.sqrt(bars_per_year))
|
||||
sharpe: float | None = None
|
||||
if returns is not None and len(returns) > 1 and bars_per_year > 0:
|
||||
vol = float(returns.std())
|
||||
if np.isfinite(vol) and vol > 1e-12:
|
||||
sharpe = float(returns.mean() / vol * np.sqrt(bars_per_year))
|
||||
else:
|
||||
warnings.append(
|
||||
"Sharpe ratio is undefined because return volatility is zero."
|
||||
)
|
||||
elif bars_per_year <= 0:
|
||||
warnings.append("Sharpe ratio requires a positive bars_per_year value.")
|
||||
else:
|
||||
warnings.append("Sharpe ratio requires at least two finite returns.")
|
||||
|
||||
peak = equity_vals.cummax()
|
||||
dd = (equity_vals - peak) / peak.replace(0, 1)
|
||||
max_dd = float(dd.min())
|
||||
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
|
||||
calmar: float | None = None
|
||||
if ann_ret is not None and max_dd is not None and abs(max_dd) > 1e-12:
|
||||
calmar = ann_ret / abs(max_dd)
|
||||
else:
|
||||
warnings.append(
|
||||
"Calmar ratio requires a defined annual return and a nonzero drawdown."
|
||||
)
|
||||
|
||||
downside = returns[returns < 0]
|
||||
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
|
||||
sortino = float(returns.mean() / (downside_std + 1e-10) * np.sqrt(bars_per_year))
|
||||
sortino: float | None = None
|
||||
if returns is not None and bars_per_year > 0:
|
||||
downside = returns[returns < 0]
|
||||
if len(downside) > 1:
|
||||
downside_std = float(downside.std())
|
||||
if np.isfinite(downside_std) and downside_std > 1e-12:
|
||||
sortino = float(returns.mean() / downside_std * np.sqrt(bars_per_year))
|
||||
if sortino is None:
|
||||
warnings.append(
|
||||
"Sortino ratio requires at least two varying downside returns."
|
||||
)
|
||||
elif bars_per_year <= 0:
|
||||
warnings.append("Sortino ratio requires a positive bars_per_year value.")
|
||||
else:
|
||||
warnings.append("Sortino ratio requires finite returns.")
|
||||
|
||||
# Trade statistics
|
||||
closed_pnl = [t["pnl"] for t in trades if t.get("pnl", 0) != 0]
|
||||
closed_pnl: List[float] = []
|
||||
ignored_pnl_records = 0
|
||||
for t in trades:
|
||||
raw_pnl = t.get("pnl")
|
||||
if raw_pnl is None:
|
||||
ignored_pnl_records += 1
|
||||
continue
|
||||
try:
|
||||
val = float(raw_pnl)
|
||||
except (TypeError, ValueError):
|
||||
ignored_pnl_records += 1
|
||||
continue
|
||||
if not np.isfinite(val):
|
||||
ignored_pnl_records += 1
|
||||
continue
|
||||
if val != 0:
|
||||
closed_pnl.append(val)
|
||||
if ignored_pnl_records:
|
||||
warnings.append(
|
||||
f"Ignored PnL for {ignored_pnl_records} trade records "
|
||||
"(missing or non-numeric pnl); win rate and profit/loss ratio "
|
||||
"are computed from the remaining trades only."
|
||||
)
|
||||
wins = [p for p in closed_pnl if p > 0]
|
||||
losses = [p for p in closed_pnl if p < 0]
|
||||
win_rate = len(wins) / len(closed_pnl) if closed_pnl else 0.0
|
||||
@@ -620,14 +718,15 @@ def _calc_options_metrics(
|
||||
pl_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
|
||||
|
||||
return {
|
||||
"final_value": round(float(equity_vals.iloc[-1]), 2),
|
||||
"total_return": round(total_ret, 6),
|
||||
"annual_return": round(ann_ret, 6),
|
||||
"max_drawdown": round(max_dd, 6),
|
||||
"sharpe": round(sharpe, 4),
|
||||
"calmar": round(calmar, 4),
|
||||
"sortino": round(sortino, 4),
|
||||
"final_value": final_value,
|
||||
"total_return": round(total_ret, 6) if total_ret is not None else None,
|
||||
"annual_return": round(ann_ret, 6) if ann_ret is not None else None,
|
||||
"max_drawdown": round(max_dd, 6) if max_dd is not None else None,
|
||||
"sharpe": round(sharpe, 4) if sharpe is not None else None,
|
||||
"calmar": round(calmar, 4) if calmar is not None else None,
|
||||
"sortino": round(sortino, 4) if sortino is not None else None,
|
||||
"trade_count": len(trades),
|
||||
"win_rate": round(win_rate, 4),
|
||||
"profit_loss_ratio": round(pl_ratio, 4),
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Trading-cost model for factor backtests: ADV caps, impact, borrow.
|
||||
|
||||
A long-short factor backtest that rebalances daily on a 500-name universe is
|
||||
implicitly claiming it can trade every name, in any size, at the close, for a
|
||||
flat fee. Three things break that claim, and this module prices all three:
|
||||
|
||||
**Capacity.** You cannot buy more of a stock in a day than the market trades.
|
||||
The standard bound is a participation rate -- a fraction of average daily volume
|
||||
you are willing to be. Beyond it you are the market, not a participant in it.
|
||||
When a target weight needs more than the cap allows, the position moves *part of
|
||||
the way* and the shortfall carries into the next period. It is not silently
|
||||
filled, and it is not silently dropped: both would flatter the backtest, in
|
||||
opposite directions.
|
||||
|
||||
**Impact.** The part you do trade moves the price against you, and the amount is
|
||||
not linear in size. :mod:`src.quantlib.impact` already owns those models --
|
||||
this module calls them rather than carrying a second copy.
|
||||
|
||||
**Borrow.** The short leg is not free. Someone lends you the stock and charges
|
||||
for it, and for hard-to-borrow names that fee can exceed the alpha. A long-short
|
||||
factor reported without borrow cost is reporting a portfolio nobody could have
|
||||
held.
|
||||
|
||||
WHY THIS IS NOT IN THE ENGINE
|
||||
-----------------------------
|
||||
``backtest/engines/base.py`` prices costs per *fill*, which is the right model
|
||||
when there is an order book and a fill sequence. A factor bench has neither: it
|
||||
has a weight vector per rebalance date. Forcing one abstraction onto the other
|
||||
would mean inventing fills that never happened. This module therefore works in
|
||||
weight space, and the two never share a code path.
|
||||
|
||||
SIGN AND UNIT CONVENTIONS
|
||||
-------------------------
|
||||
* Weights are fractions of capital; a short is negative.
|
||||
* Every cost this module returns is a POSITIVE drag, to be subtracted from a
|
||||
gross return. A function that returned a signed adjustment would eventually be
|
||||
added somewhere by mistake.
|
||||
* ``adv`` is in the same currency as ``capital``, not in shares. Mixing those is
|
||||
the classic error here, so the argument names say ``_value`` and the
|
||||
docstrings repeat it.
|
||||
* Rates named ``annual_*`` are annualised decimals; the periods-per-year
|
||||
argument converts them, and it has no default because a daily and a monthly
|
||||
bench would silently differ by 21x.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.quantlib.impact import DEFAULT_SLIPPAGE_BPS, linear_impact, sqrt_impact
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_PARTICIPATION",
|
||||
"IMPACT_MODELS",
|
||||
"MARKET_BORROW_RATES",
|
||||
"CapacityResult",
|
||||
"PeriodCost",
|
||||
"apply_adv_capacity",
|
||||
"borrow_cost",
|
||||
"rebalance_cost",
|
||||
]
|
||||
|
||||
#: Fraction of a name's average daily value a strategy is allowed to be. 10% is
|
||||
#: the conventional institutional ceiling; above it the participation itself
|
||||
#: starts to define the price and the impact models stop being descriptive.
|
||||
DEFAULT_MAX_PARTICIPATION: float = 0.10
|
||||
|
||||
#: Impact models callable by name. Both come from ``src.quantlib.impact``; this
|
||||
#: module owns no impact formula of its own.
|
||||
IMPACT_MODELS: tuple[str, ...] = ("fixed", "linear", "sqrt")
|
||||
|
||||
#: Indicative annual stock-borrow rates for a general-collateral name, by
|
||||
#: market. These are STARTING POINTS for a caller who has no better number, not
|
||||
#: measurements: a hard-to-borrow name can cost 20% or more, and nothing in this
|
||||
#: repository knows which names those are. Pass your own rate when you have one.
|
||||
MARKET_BORROW_RATES: dict[str, float] = {
|
||||
"US": 0.0030,
|
||||
"CN": 0.0850,
|
||||
"HK": 0.0100,
|
||||
"JP": 0.0050,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapacityResult:
|
||||
"""Weights actually achievable under an ADV participation cap.
|
||||
|
||||
Attributes:
|
||||
achieved_weights: Weights after capping, indexed by symbol.
|
||||
requested_weights: The weights that were asked for.
|
||||
unfilled_weights: ``requested - achieved`` per symbol. Non-zero means
|
||||
the strategy wanted a position the market could not give it that
|
||||
period; carry this into the next rebalance rather than discarding it.
|
||||
capped_symbols: Symbols whose trade hit the cap.
|
||||
participation: Realised participation per symbol, as a fraction of ADV.
|
||||
max_participation: The cap that was applied.
|
||||
"""
|
||||
|
||||
achieved_weights: pd.Series
|
||||
requested_weights: pd.Series
|
||||
unfilled_weights: pd.Series
|
||||
capped_symbols: tuple[str, ...]
|
||||
participation: pd.Series
|
||||
max_participation: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeriodCost:
|
||||
"""All-in trading drag for one rebalance period.
|
||||
|
||||
Attributes:
|
||||
impact_cost: Drag from market impact and spread, as a fraction of
|
||||
capital. Positive.
|
||||
borrow_cost: Drag from borrowing the short leg, as a fraction of
|
||||
capital. Positive.
|
||||
total_cost: ``impact_cost + borrow_cost``. Positive.
|
||||
turnover: One-way turnover actually executed, as a fraction of capital.
|
||||
unfilled_turnover: Turnover the ADV cap prevented.
|
||||
capped_symbols: Symbols that hit the cap.
|
||||
impact_model: Which model priced the impact.
|
||||
"""
|
||||
|
||||
impact_cost: float
|
||||
borrow_cost: float
|
||||
total_cost: float
|
||||
turnover: float
|
||||
unfilled_turnover: float
|
||||
capped_symbols: tuple[str, ...]
|
||||
impact_model: str
|
||||
|
||||
|
||||
def _as_aligned(
|
||||
target: pd.Series, current: pd.Series
|
||||
) -> tuple[pd.Series, pd.Series, pd.Index]:
|
||||
"""Put two weight vectors on one index, filling absent names with zero.
|
||||
|
||||
A name in the target but not the current book is a new position; a name in
|
||||
the current book but not the target is a full exit. Both are trades, so
|
||||
neither may be dropped by an inner join.
|
||||
|
||||
Args:
|
||||
target: Desired weights.
|
||||
current: Weights held before the rebalance.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(target, current, index)`` on the union index.
|
||||
"""
|
||||
index = target.index.union(current.index)
|
||||
return (
|
||||
target.reindex(index).fillna(0.0).astype(float),
|
||||
current.reindex(index).fillna(0.0).astype(float),
|
||||
index,
|
||||
)
|
||||
|
||||
|
||||
def apply_adv_capacity(
|
||||
target_weights: pd.Series,
|
||||
current_weights: pd.Series,
|
||||
adv_value: pd.Series,
|
||||
capital: float,
|
||||
max_participation: float = DEFAULT_MAX_PARTICIPATION,
|
||||
) -> CapacityResult:
|
||||
"""Cap each name's trade at a fraction of its average daily traded value.
|
||||
|
||||
Args:
|
||||
target_weights: Desired weights by symbol, shorts negative.
|
||||
current_weights: Weights held before this rebalance.
|
||||
adv_value: Average daily traded VALUE by symbol, in the same currency as
|
||||
``capital`` -- not a share count.
|
||||
capital: Portfolio capital in that currency.
|
||||
max_participation: Largest fraction of a name's ADV the strategy may be.
|
||||
|
||||
Returns:
|
||||
A :class:`CapacityResult`. A symbol with no ADV entry, or a non-positive
|
||||
one, is treated as untradeable this period: its weight stays where it
|
||||
was and the whole requested change lands in ``unfilled_weights``. That
|
||||
is deliberately the conservative reading -- assuming a name with unknown
|
||||
volume is infinitely liquid is how a backtest acquires capacity it never
|
||||
had.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``capital`` is not positive or ``max_participation`` is
|
||||
outside ``(0, 1]``.
|
||||
"""
|
||||
if not capital > 0:
|
||||
raise ValueError(f"capital must be positive, got {capital}")
|
||||
if not 0.0 < max_participation <= 1.0:
|
||||
raise ValueError(
|
||||
f"max_participation must be in (0, 1], got {max_participation}"
|
||||
)
|
||||
|
||||
target, current, index = _as_aligned(target_weights, current_weights)
|
||||
adv = pd.Series(adv_value, dtype=float).reindex(index)
|
||||
|
||||
requested_change = target - current
|
||||
# Largest weight change a name can absorb: participation x ADV, expressed
|
||||
# as a fraction of capital.
|
||||
capacity = (adv * max_participation / capital).fillna(0.0)
|
||||
capacity[~np.isfinite(capacity)] = 0.0
|
||||
capacity[capacity < 0.0] = 0.0
|
||||
|
||||
allowed_change = requested_change.clip(lower=-capacity, upper=capacity)
|
||||
achieved = current + allowed_change
|
||||
unfilled = target - achieved
|
||||
|
||||
capped = tuple(
|
||||
str(sym)
|
||||
for sym in index
|
||||
if abs(requested_change[sym]) > capacity[sym] + 1e-15
|
||||
and abs(requested_change[sym]) > 0.0
|
||||
)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
participation = (allowed_change.abs() * capital / adv).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
)
|
||||
|
||||
return CapacityResult(
|
||||
achieved_weights=achieved,
|
||||
requested_weights=target,
|
||||
unfilled_weights=unfilled,
|
||||
capped_symbols=capped,
|
||||
participation=participation,
|
||||
max_participation=max_participation,
|
||||
)
|
||||
|
||||
|
||||
def borrow_cost(
|
||||
weights: pd.Series,
|
||||
periods_per_year: int,
|
||||
annual_rate: float | pd.Series | None = None,
|
||||
market: str | None = None,
|
||||
) -> float:
|
||||
"""Cost of borrowing the short leg for one period.
|
||||
|
||||
Only negative weights are charged: a long position borrows nothing. The
|
||||
charge is the absolute short exposure times the pro-rated annual fee.
|
||||
|
||||
Args:
|
||||
weights: Portfolio weights, shorts negative.
|
||||
periods_per_year: Rebalance periods in a year -- 252 for daily, 12 for
|
||||
monthly. No default: a daily and a monthly bench charging the same
|
||||
per-period rate differ by 21x, and nothing downstream would notice.
|
||||
annual_rate: Annual borrow fee as a decimal, either one rate for the
|
||||
whole book or a per-symbol Series. Takes precedence over ``market``.
|
||||
market: Key into :data:`MARKET_BORROW_RATES` when no explicit rate is
|
||||
supplied. Those values are indicative general-collateral rates, not
|
||||
measurements -- see the constant's own note.
|
||||
|
||||
Returns:
|
||||
Positive cost as a fraction of capital.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``periods_per_year`` is not positive, if neither
|
||||
``annual_rate`` nor ``market`` is supplied, if ``market`` is
|
||||
unknown, or if any rate is negative.
|
||||
"""
|
||||
if periods_per_year <= 0:
|
||||
raise ValueError(f"periods_per_year must be positive, got {periods_per_year}")
|
||||
|
||||
if annual_rate is None:
|
||||
if market is None:
|
||||
raise ValueError(
|
||||
"supply annual_rate, or market to fall back on an indicative "
|
||||
f"general-collateral rate from {sorted(MARKET_BORROW_RATES)}"
|
||||
)
|
||||
if market not in MARKET_BORROW_RATES:
|
||||
raise ValueError(
|
||||
f"unknown market {market!r}; known: {sorted(MARKET_BORROW_RATES)}"
|
||||
)
|
||||
annual_rate = MARKET_BORROW_RATES[market]
|
||||
|
||||
shorts = pd.Series(weights, dtype=float)
|
||||
shorts = -shorts[shorts < 0.0]
|
||||
if shorts.empty:
|
||||
return 0.0
|
||||
|
||||
if isinstance(annual_rate, pd.Series):
|
||||
rates = annual_rate.reindex(shorts.index)
|
||||
if rates.isna().any():
|
||||
missing = sorted(str(s) for s in rates.index[rates.isna()])
|
||||
raise ValueError(
|
||||
f"annual_rate is missing a rate for shorted symbol(s): {missing}"
|
||||
)
|
||||
else:
|
||||
rates = pd.Series(float(annual_rate), index=shorts.index)
|
||||
|
||||
if (rates < 0.0).any():
|
||||
raise ValueError("borrow rates must be non-negative")
|
||||
|
||||
return float((shorts * rates).sum() / periods_per_year)
|
||||
|
||||
|
||||
def rebalance_cost(
|
||||
target_weights: pd.Series,
|
||||
current_weights: pd.Series,
|
||||
capital: float,
|
||||
periods_per_year: int,
|
||||
adv_value: pd.Series | None = None,
|
||||
max_participation: float = DEFAULT_MAX_PARTICIPATION,
|
||||
impact_model: str = "sqrt",
|
||||
slippage_bps: float = DEFAULT_SLIPPAGE_BPS,
|
||||
impact_coefficient: float = 0.1,
|
||||
volatility: float | pd.Series | None = None,
|
||||
borrow_annual_rate: float | pd.Series | None = None,
|
||||
borrow_market: str | None = None,
|
||||
) -> tuple[PeriodCost, CapacityResult | None]:
|
||||
"""Price one rebalance: capacity, then impact, then borrow.
|
||||
|
||||
Args:
|
||||
target_weights: Desired weights, shorts negative.
|
||||
current_weights: Weights held before the rebalance.
|
||||
capital: Portfolio capital.
|
||||
periods_per_year: Rebalance periods in a year, for the borrow charge.
|
||||
adv_value: Average daily traded VALUE by symbol. When None, no capacity
|
||||
cap is applied and ``capped_symbols`` comes back empty -- which is
|
||||
an assumption of infinite liquidity and should be stated wherever
|
||||
the result is reported.
|
||||
max_participation: Participation ceiling for the capacity cap.
|
||||
impact_model: One of :data:`IMPACT_MODELS`.
|
||||
slippage_bps: Half-spread plus fees, in basis points, charged on every
|
||||
unit of turnover regardless of model.
|
||||
impact_coefficient: Coefficient handed to the linear or sqrt model.
|
||||
volatility: Per-period volatility, required by the ``sqrt`` model.
|
||||
borrow_annual_rate: Annual borrow fee, decimal.
|
||||
borrow_market: Fallback market key when no explicit rate is given.
|
||||
|
||||
Returns:
|
||||
Tuple of the :class:`PeriodCost` and the :class:`CapacityResult` (None
|
||||
when no ``adv_value`` was supplied).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``impact_model`` is unknown, if the ``sqrt`` model is
|
||||
chosen without ``volatility``, or from the functions this delegates
|
||||
to.
|
||||
"""
|
||||
if impact_model not in IMPACT_MODELS:
|
||||
raise ValueError(
|
||||
f"impact_model must be one of {IMPACT_MODELS}, got {impact_model!r}"
|
||||
)
|
||||
|
||||
target, current, index = _as_aligned(target_weights, current_weights)
|
||||
|
||||
capacity: CapacityResult | None = None
|
||||
if adv_value is not None:
|
||||
capacity = apply_adv_capacity(
|
||||
target, current, adv_value, capital, max_participation
|
||||
)
|
||||
achieved = capacity.achieved_weights
|
||||
unfilled_turnover = float(capacity.unfilled_weights.abs().sum())
|
||||
capped = capacity.capped_symbols
|
||||
else:
|
||||
achieved = target
|
||||
unfilled_turnover = 0.0
|
||||
capped = ()
|
||||
|
||||
traded = (achieved - current).abs()
|
||||
turnover = float(traded.sum())
|
||||
|
||||
# Spread and fees are charged on every unit of turnover under every model.
|
||||
spread_cost = turnover * slippage_bps / 10_000.0
|
||||
|
||||
if impact_model == "fixed" or turnover == 0.0:
|
||||
impact = 0.0
|
||||
elif impact_model == "linear":
|
||||
participation = (
|
||||
capacity.participation.reindex(index).fillna(0.0)
|
||||
if capacity is not None
|
||||
else pd.Series(0.0, index=index)
|
||||
)
|
||||
# linear_impact prices a fill; here it prices the weighted average
|
||||
# participation, and the drag scales with the traded fraction.
|
||||
impact = float((traded * participation * impact_coefficient).sum())
|
||||
else: # sqrt
|
||||
if volatility is None:
|
||||
raise ValueError(
|
||||
"the sqrt impact model needs a volatility; supply one or choose "
|
||||
"impact_model='linear' or 'fixed'"
|
||||
)
|
||||
vol = (
|
||||
pd.Series(volatility, dtype=float).reindex(index).fillna(0.0)
|
||||
if isinstance(volatility, pd.Series)
|
||||
else pd.Series(float(volatility), index=index)
|
||||
)
|
||||
participation = (
|
||||
capacity.participation.reindex(index).fillna(0.0)
|
||||
if capacity is not None
|
||||
else pd.Series(0.0, index=index)
|
||||
)
|
||||
impact = float(
|
||||
(traded * impact_coefficient * vol * np.sqrt(participation.clip(lower=0.0))).sum()
|
||||
)
|
||||
|
||||
impact_cost = spread_cost + impact
|
||||
borrow = borrow_cost(
|
||||
achieved,
|
||||
periods_per_year=periods_per_year,
|
||||
annual_rate=borrow_annual_rate,
|
||||
market=borrow_market,
|
||||
) if (borrow_annual_rate is not None or borrow_market is not None) else 0.0
|
||||
|
||||
return (
|
||||
PeriodCost(
|
||||
impact_cost=impact_cost,
|
||||
borrow_cost=borrow,
|
||||
total_cost=impact_cost + borrow,
|
||||
turnover=turnover,
|
||||
unfilled_turnover=unfilled_turnover,
|
||||
capped_symbols=capped,
|
||||
impact_model=impact_model,
|
||||
),
|
||||
capacity,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Unified fundamental field schema and SEC XBRL concept aliases.
|
||||
|
||||
This module is deliberately metadata-only. Callers use ``SEC_CONCEPT_MAP`` to
|
||||
extract sparse reported facts; when no concept hits, the caller must emit NaN
|
||||
for that field and log the missing alias coverage. This module does not log or
|
||||
fabricate fallback values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Literal
|
||||
|
||||
import pandas as pd
|
||||
|
||||
FieldKind = Literal["raw", "derived"]
|
||||
RawFieldSpec = dict[str, object]
|
||||
DerivedCompute = Callable[[dict[str, pd.Series]], pd.Series]
|
||||
DerivedFieldSpec = dict[str, object]
|
||||
|
||||
|
||||
def _safe_divide(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
|
||||
"""Divide two aligned series, returning NaN where the denominator is zero."""
|
||||
clean_denominator = denominator.astype("float64").mask(denominator == 0)
|
||||
return numerator.astype("float64") / clean_denominator
|
||||
|
||||
|
||||
RAW_FIELDS: dict[str, RawFieldSpec] = {
|
||||
"revenue": {
|
||||
"statement": "IS",
|
||||
"description": "Revenue / net sales.",
|
||||
},
|
||||
"cogs": {
|
||||
"statement": "IS",
|
||||
"description": "Cost of goods sold or cost of revenue.",
|
||||
},
|
||||
"gross_profit": {
|
||||
"statement": "IS",
|
||||
"description": "Gross profit; derived as revenue minus cogs when not directly reported.",
|
||||
"dependencies": ["revenue", "cogs"],
|
||||
"compute": lambda data: data["revenue"] - data["cogs"],
|
||||
},
|
||||
"operating_income": {
|
||||
"statement": "IS",
|
||||
"description": "Operating income or loss.",
|
||||
},
|
||||
"net_income": {
|
||||
"statement": "IS",
|
||||
"description": "Net income or loss attributable to the reporting entity.",
|
||||
},
|
||||
"total_assets": {
|
||||
"statement": "BS",
|
||||
"description": "Total assets.",
|
||||
},
|
||||
"total_equity": {
|
||||
"statement": "BS",
|
||||
"description": "Total shareholders' equity.",
|
||||
},
|
||||
"total_debt": {
|
||||
"statement": "BS",
|
||||
"description": "Interest-bearing debt; aggregate debt concept when present, otherwise loader may union current and noncurrent debt concepts.",
|
||||
},
|
||||
"cash": {
|
||||
"statement": "BS",
|
||||
"description": "Cash and cash equivalents.",
|
||||
},
|
||||
"shares_diluted": {
|
||||
"statement": "IS/cover",
|
||||
"description": "Weighted-average diluted shares outstanding.",
|
||||
},
|
||||
"cfo": {
|
||||
"statement": "CF",
|
||||
"description": "Net cash provided by or used in operating activities.",
|
||||
},
|
||||
"capex": {
|
||||
"statement": "CF",
|
||||
"description": "Capital expenditures for property, plant, equipment, and similar productive assets.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Ordered by priority. SEC extraction should union concepts across aliases, then
|
||||
# keep the first filed value per period_end for PIT safety. The revenue order is
|
||||
# intentionally new-standard first: recent AAPL filings report revenue under
|
||||
# RevenueFromContractWithCustomer... rather than the old Revenues concept.
|
||||
SEC_CONCEPT_MAP: dict[str, list[str]] = {
|
||||
"revenue": [
|
||||
"RevenueFromContractWithCustomerExcludingAssessedTax",
|
||||
"RevenueFromContractWithCustomerIncludingAssessedTax",
|
||||
"Revenues",
|
||||
"SalesRevenueNet",
|
||||
],
|
||||
"cogs": [
|
||||
"CostOfGoodsAndServicesSold",
|
||||
"CostOfRevenue",
|
||||
"CostOfGoodsSold",
|
||||
],
|
||||
"gross_profit": [
|
||||
"GrossProfit",
|
||||
],
|
||||
"operating_income": [
|
||||
"OperatingIncomeLoss",
|
||||
],
|
||||
"net_income": [
|
||||
"NetIncomeLoss",
|
||||
"ProfitLoss",
|
||||
],
|
||||
"total_assets": [
|
||||
"Assets",
|
||||
],
|
||||
"total_equity": [
|
||||
"StockholdersEquity",
|
||||
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
|
||||
],
|
||||
# US-GAAP has both aggregate and split debt concepts. Prefer aggregate debt
|
||||
# when reported; otherwise a future loader should combine current and
|
||||
# noncurrent concepts instead of treating one leg as total debt.
|
||||
"total_debt": [
|
||||
"LongTermDebtAndFinanceLeaseObligations",
|
||||
"LongTermDebtAndCapitalLeaseObligations",
|
||||
"LongTermDebt",
|
||||
"LongTermDebtNoncurrent",
|
||||
"LongTermDebtCurrent",
|
||||
"DebtCurrent",
|
||||
"ShortTermBorrowings",
|
||||
],
|
||||
# Cash concept choice varies by industry and restricted-cash presentation.
|
||||
# The first concept is the common operating-company aggregate; the rest keep
|
||||
# banks and restricted-cash filers from hard-missing in Phase 1.
|
||||
"cash": [
|
||||
"CashAndCashEquivalentsAtCarryingValue",
|
||||
"CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
|
||||
"Cash",
|
||||
"CashAndDueFromBanks",
|
||||
],
|
||||
"shares_diluted": [
|
||||
"WeightedAverageNumberOfDilutedSharesOutstanding",
|
||||
"WeightedAverageDilutedSharesOutstanding",
|
||||
],
|
||||
"cfo": [
|
||||
"NetCashProvidedByUsedInOperatingActivities",
|
||||
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations",
|
||||
],
|
||||
# Capex is usually reported as PPE purchases. Some filers include broader
|
||||
# productive-asset or PPE-plus-intangibles concepts; they are lower priority
|
||||
# because they may not be strictly comparable to plain PPE capex.
|
||||
"capex": [
|
||||
"PaymentsToAcquirePropertyPlantAndEquipment",
|
||||
"PaymentsToAcquireProductiveAssets",
|
||||
"PaymentsToAcquirePropertyPlantAndEquipmentAndIntangibleAssets",
|
||||
"CapitalExpendituresIncurredButNotYetPaid",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
DERIVED_FIELDS: dict[str, DerivedFieldSpec] = {
|
||||
"roe": {
|
||||
"dependencies": ["net_income", "total_equity"],
|
||||
"compute": lambda data: _safe_divide(data["net_income"], data["total_equity"]),
|
||||
},
|
||||
"roa": {
|
||||
"dependencies": ["net_income", "total_assets"],
|
||||
"compute": lambda data: _safe_divide(data["net_income"], data["total_assets"]),
|
||||
},
|
||||
"gross_profitability": {
|
||||
"dependencies": ["gross_profit", "total_assets"],
|
||||
"compute": lambda data: _safe_divide(data["gross_profit"], data["total_assets"]),
|
||||
},
|
||||
# Annual semantics: input is one observation per annual period_end, sorted by
|
||||
# period_end. Growth is period-over-period, value / value.shift(1) - 1.
|
||||
# Quarterly or TTM callers must pre-aggregate before applying this formula.
|
||||
"asset_growth": {
|
||||
"dependencies": ["total_assets"],
|
||||
"compute": lambda data: data["total_assets"].astype("float64")
|
||||
/ data["total_assets"].astype("float64").shift(1)
|
||||
- 1,
|
||||
},
|
||||
"accruals": {
|
||||
"dependencies": ["net_income", "cfo", "total_assets"],
|
||||
"compute": lambda data: _safe_divide(
|
||||
data["net_income"] - data["cfo"],
|
||||
data["total_assets"],
|
||||
),
|
||||
},
|
||||
"leverage": {
|
||||
"dependencies": ["total_debt", "total_equity"],
|
||||
"compute": lambda data: _safe_divide(data["total_debt"], data["total_equity"]),
|
||||
},
|
||||
}
|
||||
|
||||
# Price-dependent value factors need market_cap = price * shares and therefore
|
||||
# live in factor files after fundamental and price panels have been aligned.
|
||||
# Do not add earnings_yield or book_to_market to DERIVED_FIELDS here.
|
||||
|
||||
|
||||
def resolve_field(field: str) -> tuple[FieldKind, RawFieldSpec | DerivedFieldSpec]:
|
||||
"""Resolve a supported fundamental field to its schema entry.
|
||||
|
||||
Args:
|
||||
field: Unified fundamental field name.
|
||||
|
||||
Returns:
|
||||
A ``("raw", spec)`` or ``("derived", spec)`` tuple.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``field`` is not part of the unified schema.
|
||||
"""
|
||||
if field in RAW_FIELDS:
|
||||
return "raw", RAW_FIELDS[field]
|
||||
if field in DERIVED_FIELDS:
|
||||
return "derived", DERIVED_FIELDS[field]
|
||||
raise ValueError(f"unknown fundamental field: {field}")
|
||||
|
||||
|
||||
def list_supported_fields() -> list[str]:
|
||||
"""Return all supported raw and schema-derived fundamental field names."""
|
||||
return sorted(set(RAW_FIELDS) | set(DERIVED_FIELDS))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DERIVED_FIELDS",
|
||||
"RAW_FIELDS",
|
||||
"SEC_CONCEPT_MAP",
|
||||
"list_supported_fields",
|
||||
"resolve_field",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Shared HTTP helpers for direct-API loaders: per-host throttling + JSON/CSV GET.
|
||||
|
||||
Several free providers — Eastmoney most notably — rate-limit by source IP and
|
||||
will temporarily ban a client that bursts requests. Rather than scatter sleep
|
||||
calls across loaders, every ban-prone call routes through :func:`throttled_get`
|
||||
here, which enforces a minimum spacing between consecutive requests to the same
|
||||
*host bucket* (plus a little jitter so concurrent workers don't lock-step) and
|
||||
reuses one :class:`requests.Session` per process so TCP/TLS setup is amortized.
|
||||
|
||||
This module is intentionally provider-agnostic: it knows nothing about
|
||||
Eastmoney/Sina/Stooq field layouts, only how to space requests politely. A
|
||||
loader picks its own ``host_key`` and ``min_interval`` and stays ignorant of
|
||||
the locking mechanics.
|
||||
|
||||
All spacing is best-effort and process-local — it does not coordinate across
|
||||
machines. For batch jobs raise the relevant ``*_MIN_INTERVAL`` env var.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from backtest.loaders.base import positive_env_float
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default User-Agent. Many free quote endpoints reject the bare urllib/requests
|
||||
# UA, so we present a normal desktop browser string. Loaders may override.
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Upper bound on the random jitter (seconds) added on top of the configured
|
||||
# minimum interval, so parallel callers de-synchronize instead of all firing
|
||||
# the instant the interval elapses.
|
||||
_JITTER_MAX_S = 0.4
|
||||
|
||||
|
||||
class HostThrottle:
|
||||
"""Process-wide minimum-spacing gate keyed by an arbitrary host bucket.
|
||||
|
||||
One instance guards all callers; ``wait(bucket, min_interval)`` blocks until
|
||||
at least ``min_interval`` seconds (plus jitter) have elapsed since the last
|
||||
request tagged with the same ``bucket``. The lock is held only for the
|
||||
bookkeeping arithmetic, not across the sleep, so distinct buckets never block
|
||||
one another.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def wait(self, bucket: str, min_interval: float) -> None:
|
||||
"""Block until ``bucket`` is allowed to fire again, then record the slot.
|
||||
|
||||
The *reserved fire time* — jitter included — is what gets stored, so the
|
||||
next caller spaces off this caller's actual fire instant rather than an
|
||||
earlier un-jittered one. This keeps consecutive requests at least
|
||||
``min_interval`` apart even when many callers burst concurrently (the
|
||||
exact scenario the throttle exists for); the jitter only ever pushes a
|
||||
slot later, never earlier.
|
||||
"""
|
||||
if min_interval <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
last = self._last.get(bucket)
|
||||
if last is None or now >= last + min_interval:
|
||||
# Slot is free right now — fire immediately, no jitter needed.
|
||||
fire_at = now
|
||||
else:
|
||||
# Chain off the previous reservation and add jitter to desync
|
||||
# concurrent callers, baking the jitter into the stored slot.
|
||||
fire_at = last + min_interval + random.uniform(0.0, _JITTER_MAX_S)
|
||||
self._last[bucket] = fire_at
|
||||
sleep_for = fire_at - time.monotonic()
|
||||
if sleep_for > 0:
|
||||
time.sleep(sleep_for)
|
||||
|
||||
|
||||
# One shared gate for the whole process.
|
||||
_THROTTLE = HostThrottle()
|
||||
|
||||
# Per-process session reuse, keyed by host bucket so different providers keep
|
||||
# independent connection pools and cookie jars.
|
||||
_SESSIONS: dict[str, requests.Session] = {}
|
||||
_SESSIONS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _session_for(bucket: str) -> requests.Session:
|
||||
with _SESSIONS_LOCK:
|
||||
session = _SESSIONS.get(bucket)
|
||||
if session is None:
|
||||
session = requests.Session()
|
||||
_SESSIONS[bucket] = session
|
||||
return session
|
||||
|
||||
|
||||
def resolve_min_interval(env_name: str, default: float) -> float:
|
||||
"""Resolve a per-provider minimum request interval from the environment.
|
||||
|
||||
Args:
|
||||
env_name: Env var carrying an override in seconds (e.g.
|
||||
``VIBE_TRADING_EASTMONEY_MIN_INTERVAL``).
|
||||
default: Fallback interval when the env var is absent or invalid.
|
||||
|
||||
Returns:
|
||||
The override when it parses to a positive float, else ``default``.
|
||||
"""
|
||||
return positive_env_float(env_name, default)
|
||||
|
||||
|
||||
def throttled_get(
|
||||
url: str,
|
||||
*,
|
||||
host_key: str,
|
||||
min_interval: float,
|
||||
params: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float = 15.0,
|
||||
) -> requests.Response:
|
||||
"""GET ``url`` after waiting out the per-host minimum interval.
|
||||
|
||||
Args:
|
||||
url: Fully-qualified request URL.
|
||||
host_key: Throttle/session bucket. All calls sharing a key are spaced
|
||||
by ``min_interval`` and reuse one session.
|
||||
min_interval: Minimum seconds between consecutive calls to ``host_key``.
|
||||
params: Optional query parameters.
|
||||
headers: Optional headers merged over the default browser UA.
|
||||
timeout: Per-request socket timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The :class:`requests.Response`; the caller decides how to parse it.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: Propagated unchanged for the caller's retry
|
||||
policy to classify as transient.
|
||||
"""
|
||||
merged_headers = {"User-Agent": DEFAULT_USER_AGENT}
|
||||
if headers:
|
||||
merged_headers.update(headers)
|
||||
_THROTTLE.wait(host_key, min_interval)
|
||||
session = _session_for(host_key)
|
||||
return session.get(url, params=params, headers=merged_headers, timeout=timeout)
|
||||
|
||||
|
||||
def throttled_get_json(
|
||||
url: str,
|
||||
*,
|
||||
host_key: str,
|
||||
min_interval: float,
|
||||
params: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float = 15.0,
|
||||
) -> Any:
|
||||
"""Throttled GET that decodes the response body as JSON.
|
||||
|
||||
Same contract as :func:`throttled_get`, plus ``response.raise_for_status()``
|
||||
and ``response.json()``. A non-2xx status or undecodable body raises, which
|
||||
the caller's bounded-retry wrapper treats as transient.
|
||||
"""
|
||||
response = throttled_get(
|
||||
url,
|
||||
host_key=host_key,
|
||||
min_interval=min_interval,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Shared symbol-type detection utilities for loaders.
|
||||
|
||||
Exchange-listed ETF / LOF prefix codes (same pattern across loaders):
|
||||
SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
|
||||
|
||||
|
||||
def _is_etf_listed(code: str) -> bool:
|
||||
"""Detect exchange-listed ETF / LOF symbols (e.g. 510050.SH, 159915.SZ)."""
|
||||
upper = code.upper()
|
||||
if not upper.endswith((".SH", ".SZ")):
|
||||
return False
|
||||
digits = upper.split(".")[0]
|
||||
if len(digits) != 6 or not digits.isdigit():
|
||||
return False
|
||||
return digits[:2] in _ETF_PREFIXES
|
||||
@@ -11,17 +11,30 @@ from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders._symbol_utils import _is_etf_listed
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTERVAL_MAP_DAILY = {
|
||||
"1D": "daily",
|
||||
"1d": "daily",
|
||||
"1W": "weekly",
|
||||
"1w": "weekly",
|
||||
"1M": "monthly",
|
||||
}
|
||||
|
||||
# US/HK/ETF/forex serve daily bars only.
|
||||
_DAILY_ONLY_ALIASES = frozenset({"1d", "d", "day", "daily"})
|
||||
|
||||
|
||||
def _require_daily_interval(interval: str, market: str) -> None:
|
||||
if str(interval).strip().lower() not in _DAILY_ONLY_ALIASES:
|
||||
raise ValueError(
|
||||
f"Unsupported interval {interval!r}; akshare {market} supports daily bars only"
|
||||
)
|
||||
|
||||
|
||||
def _is_a_share(code: str) -> bool:
|
||||
return code.upper().endswith((".SZ", ".SH", ".BJ"))
|
||||
@@ -39,23 +52,8 @@ def _is_crypto(code: str) -> bool:
|
||||
return "-USDT" in code.upper() or "/USDT" in code.upper()
|
||||
|
||||
|
||||
# Exchange-listed ETF / LOF prefix codes:
|
||||
# SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
|
||||
# Issue #50 — these symbols look like A-shares (.SH / .SZ) but stock_zh_a_hist
|
||||
# can't price them; route through fund_etf_hist_sina instead.
|
||||
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
|
||||
|
||||
|
||||
def _is_etf_listed(code: str) -> bool:
|
||||
"""Detect exchange-listed ETF / LOF symbols (e.g. 518880.SH, 159915.SZ)."""
|
||||
upper = code.upper()
|
||||
if not upper.endswith((".SH", ".SZ")):
|
||||
return False
|
||||
digits = upper.split(".")[0]
|
||||
if len(digits) != 6 or not digits.isdigit():
|
||||
return False
|
||||
return digits[:2] in _ETF_PREFIXES
|
||||
|
||||
|
||||
def _is_forex(code: str) -> bool:
|
||||
"""Detect forex pairs by matching against AKShare's symbol_market_map.
|
||||
@@ -63,7 +61,9 @@ def _is_forex(code: str) -> bool:
|
||||
Issue #54 — forex symbols (EURUSD, GBPUSD, etc.) have no exchange suffix
|
||||
and previously fell through to the A-share endpoint.
|
||||
"""
|
||||
upper = code.upper().removesuffix(".FX")
|
||||
# Accept the canonical slash form (EUR/USD) too, so the forex fallback
|
||||
# chain (mt5 → akshare) actually engages for project-style codes.
|
||||
upper = code.upper().removesuffix(".FX").replace("/", "")
|
||||
try:
|
||||
from akshare.forex.cons import symbol_market_map
|
||||
except Exception:
|
||||
@@ -116,7 +116,15 @@ class DataLoader:
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = self._fetch_one(code, start_date, end_date, interval)
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date, interval),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
@@ -131,14 +139,18 @@ class DataLoader:
|
||||
|
||||
# ETF check must precede A-share — 518880.SH ends with .SH but is an ETF.
|
||||
if _is_etf_listed(code):
|
||||
_require_daily_interval(interval, "etf")
|
||||
return self._fetch_etf(ak, code, start_date, end_date)
|
||||
if _is_a_share(code):
|
||||
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
||||
if _is_us(code):
|
||||
_require_daily_interval(interval, "us")
|
||||
return self._fetch_us(ak, code, start_date, end_date)
|
||||
if _is_hk(code):
|
||||
_require_daily_interval(interval, "hk")
|
||||
return self._fetch_hk(ak, code, start_date, end_date)
|
||||
if _is_forex(code):
|
||||
_require_daily_interval(interval, "forex")
|
||||
return self._fetch_forex(ak, code, start_date, end_date)
|
||||
# Default: try A-share
|
||||
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
||||
@@ -148,7 +160,12 @@ class DataLoader:
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch A-share via stock_zh_a_hist."""
|
||||
symbol = code.split(".")[0]
|
||||
period = _INTERVAL_MAP_DAILY.get(interval, "daily")
|
||||
period = _INTERVAL_MAP_DAILY.get(interval)
|
||||
if period is None:
|
||||
raise ValueError(
|
||||
f"Unsupported interval {interval!r}; akshare a-share supports "
|
||||
f"{sorted(_INTERVAL_MAP_DAILY)}"
|
||||
)
|
||||
sd = start_date.replace("-", "")
|
||||
ed = end_date.replace("-", "")
|
||||
df = ak.stock_zh_a_hist(
|
||||
@@ -204,7 +221,7 @@ class DataLoader:
|
||||
— note ``最新价`` (latest) plays the role of close. Volume isn't reported,
|
||||
so we synthesize a zero column to satisfy the OHLCV contract.
|
||||
"""
|
||||
symbol = code.upper().removesuffix(".FX")
|
||||
symbol = code.upper().removesuffix(".FX").replace("/", "")
|
||||
df = ak.forex_hist_em(symbol=symbol)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Alpha Vantage loader: US-equity daily OHLCV via the free key-gated REST API.
|
||||
|
||||
Alpha Vantage serves daily bars at ``/query?function=TIME_SERIES_DAILY`` and
|
||||
gates access behind a free API key (env ``ALPHAVANTAGE_API_KEY``). The endpoint
|
||||
rate-limits per IP, so every request routes through
|
||||
:mod:`backtest.loaders._http` for per-host throttling and session reuse rather
|
||||
than calling :mod:`requests` directly.
|
||||
|
||||
API format::
|
||||
|
||||
https://www.alphavantage.co/query?function=TIME_SERIES_DAILY
|
||||
&symbol=AAPL&outputsize=full&apikey=<KEY>
|
||||
|
||||
The JSON payload nests the bars under ``"Time Series (Daily)"`` keyed by
|
||||
``YYYY-MM-DD`` date strings, each mapping to numbered OHLCV fields
|
||||
(``"1. open"`` .. ``"5. volume"``). Throttle/quota responses arrive as a plain
|
||||
``"Note"`` / ``"Information"`` envelope, and bad symbols as ``"Error Message"``;
|
||||
all three are surfaced as a transient-classified error so one symbol's failure
|
||||
never aborts the batch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get_json
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://www.alphavantage.co/query"
|
||||
|
||||
# Throttle/session bucket shared by all Alpha Vantage calls. The free tier is
|
||||
# quota-limited per key/IP, so requests are spaced out by default.
|
||||
_HOST_KEY = "alphavantage"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_ALPHAVANTAGE_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL = 1.0
|
||||
|
||||
_API_KEY_ENV = "ALPHAVANTAGE_API_KEY"
|
||||
# Env values that are present-but-meaningless and must be treated as absent.
|
||||
_API_KEY_PLACEHOLDERS = {"", "your-alphavantage-api-key", "demo"}
|
||||
|
||||
# Bars live under this top-level key; the per-bar OHLCV fields are numbered.
|
||||
_TIME_SERIES_KEY = "Time Series (Daily)"
|
||||
_FIELD_MAP = {
|
||||
"open": "1. open",
|
||||
"high": "2. high",
|
||||
"low": "3. low",
|
||||
"close": "4. close",
|
||||
"volume": "5. volume",
|
||||
}
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
|
||||
def _resolve_api_key() -> str:
|
||||
"""Return the configured Alpha Vantage API key, or empty when unset.
|
||||
|
||||
Returns:
|
||||
The trimmed key, or ``""`` when the env var is absent or a placeholder.
|
||||
"""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
key = get_env_config().data.alphavantage_api_key.strip()
|
||||
return "" if key in _API_KEY_PLACEHOLDERS else key
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum Alpha Vantage request spacing in seconds."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL)
|
||||
|
||||
|
||||
def _extract_provider_message(payload: object) -> Optional[str]:
|
||||
"""Return any rate-limit / error envelope message from a payload.
|
||||
|
||||
Alpha Vantage signals quota exhaustion and bad requests in-band with a 200
|
||||
status, using one of three string envelopes instead of the time series.
|
||||
|
||||
Args:
|
||||
payload: The decoded JSON body.
|
||||
|
||||
Returns:
|
||||
The envelope message when present, else ``None``.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
for key in ("Error Message", "Note", "Information"):
|
||||
message = payload.get(key)
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
return None
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Alpha Vantage US-equity daily OHLCV loader (key-gated REST, throttled)."""
|
||||
|
||||
name = "alphavantage"
|
||||
markets = {"us_equity"}
|
||||
requires_auth = True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when a non-placeholder ``ALPHAVANTAGE_API_KEY`` is set."""
|
||||
return bool(_resolve_api_key())
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily OHLCV bars for ``codes`` within ``[start_date, end_date]``.
|
||||
|
||||
Args:
|
||||
codes: US-equity tickers (e.g. ``["AAPL", "MSFT"]``).
|
||||
start_date: Inclusive start date ``YYYY-MM-DD``.
|
||||
end_date: Inclusive end date ``YYYY-MM-DD``.
|
||||
interval: Bar interval; only ``"1D"`` (daily) is supported.
|
||||
fields: Ignored — the full OHLCV column set is always returned.
|
||||
|
||||
Returns:
|
||||
Mapping ``{symbol: DataFrame}`` with a ``trade_date`` DatetimeIndex
|
||||
and float ``open/high/low/close/volume`` columns. Symbols that fail
|
||||
or carry no in-range bars are omitted.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
# Daily-only TIME_SERIES_DAILY; do not silently return day bars for ``1H``.
|
||||
if str(interval).strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning(
|
||||
"alphavantage supports daily bars only; rejecting interval=%r",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
api_key = _resolve_api_key()
|
||||
if not api_key:
|
||||
logger.warning("alphavantage skipped: %s not set", _API_KEY_ENV)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(
|
||||
code, start_date, end_date, api_key
|
||||
),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort the batch
|
||||
logger.warning("alphavantage failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, api_key: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and slice one symbol's daily bars into an OHLCV DataFrame.
|
||||
|
||||
Args:
|
||||
code: US-equity ticker.
|
||||
start_date: Inclusive start date ``YYYY-MM-DD``.
|
||||
end_date: Inclusive end date ``YYYY-MM-DD``.
|
||||
api_key: Resolved Alpha Vantage API key.
|
||||
|
||||
Returns:
|
||||
The in-range OHLCV DataFrame, or ``None`` when the payload carries
|
||||
no usable bars.
|
||||
|
||||
Raises:
|
||||
ValueError: The provider returned a rate-limit / error envelope and
|
||||
no usable time series (a data-bearing payload that also carries
|
||||
an upsell note is parsed, not rejected).
|
||||
requests.RequestException: Network failure, propagated unchanged.
|
||||
"""
|
||||
symbol = code.strip().upper()
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
payload = throttled_get_json(
|
||||
_BASE_URL,
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params={
|
||||
"function": "TIME_SERIES_DAILY",
|
||||
"symbol": symbol,
|
||||
"outputsize": "full",
|
||||
"apikey": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
series = payload.get(_TIME_SERIES_KEY) if isinstance(payload, dict) else None
|
||||
if not isinstance(series, dict) or not series:
|
||||
# No usable series: a premium-upsell payload can carry both the data
|
||||
# key (empty/absent) and a Note/Information envelope, so only now —
|
||||
# with nothing to parse — do we surface the envelope as an error.
|
||||
message = _extract_provider_message(payload)
|
||||
if message is not None:
|
||||
raise ValueError(f"alphavantage rejected {symbol}: {message}")
|
||||
return None
|
||||
|
||||
rows = [
|
||||
row
|
||||
for date, bar in series.items()
|
||||
if (row := _parse_bar(date, bar)) is not None
|
||||
]
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df[_OHLCV_COLUMNS].dropna(subset=["open", "high", "low", "close"])
|
||||
|
||||
start = pd.Timestamp(start_date)
|
||||
end = pd.Timestamp(end_date)
|
||||
df = df.loc[(df.index >= start) & (df.index <= end)]
|
||||
return df
|
||||
|
||||
|
||||
def _parse_bar(date: str, bar: object) -> Optional[dict]:
|
||||
"""Parse one ``{date: {"1. open": ...}}`` entry into an OHLCV row dict.
|
||||
|
||||
Args:
|
||||
date: The bar's ``YYYY-MM-DD`` date string.
|
||||
bar: The per-bar field mapping from the payload.
|
||||
|
||||
Returns:
|
||||
A ``{trade_date, open, high, low, close, volume}`` dict, or ``None``
|
||||
when the entry is malformed or non-numeric.
|
||||
"""
|
||||
if not isinstance(bar, dict):
|
||||
return None
|
||||
try:
|
||||
return {
|
||||
"trade_date": date,
|
||||
"open": float(bar[_FIELD_MAP["open"]]),
|
||||
"high": float(bar[_FIELD_MAP["high"]]),
|
||||
"low": float(bar[_FIELD_MAP["low"]]),
|
||||
"close": float(bar[_FIELD_MAP["close"]]),
|
||||
"volume": float(bar[_FIELD_MAP["volume"]]),
|
||||
}
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,163 @@
|
||||
"""BaoStock loader: free, no-auth A-share data via TCP protocol.
|
||||
|
||||
BaoStock (http://baostock.com) uses its own TCP protocol (not HTTP),
|
||||
bypassing CDN IP blocks that affect HTTP-based data sources like
|
||||
eastmoney.com. Completely free, no API token required.
|
||||
|
||||
Covers: A-shares (SH/SZ), does NOT cover HK/US/crypto.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_a_share(code: str) -> bool:
|
||||
# Support both baostock native format (sh.601398) and tushare-style suffix (601398.SH)
|
||||
code_lower = code.lower()
|
||||
return (
|
||||
code_lower.startswith(("sh.", "sz."))
|
||||
or code.upper().endswith((".SZ", ".SH"))
|
||||
)
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""BaoStock A-share OHLCV loader (free, TCP protocol, no auth)."""
|
||||
|
||||
name = "baostock"
|
||||
markets = {"a_share"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available if baostock is installed."""
|
||||
try:
|
||||
import baostock # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV data via BaoStock.
|
||||
|
||||
Args:
|
||||
codes: Symbol list (e.g. ["601595.SH", "000001.SZ"]).
|
||||
start_date: YYYY-MM-DD.
|
||||
end_date: YYYY-MM-DD.
|
||||
interval: Bar size (only 1D supported).
|
||||
fields: Ignored.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
# Daily-only API; do not silently return day bars for runner ``1H``/``4H``.
|
||||
if str(interval).strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning(
|
||||
"baostock supports daily bars only; rejecting interval=%r",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
import baostock as bs
|
||||
lg = bs.login()
|
||||
if lg.error_code != "0":
|
||||
logger.error("baostock login failed: %s", lg.error_msg)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
try:
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(bs, code, start_date, end_date),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("baostock failed for %s: %s", code, exc)
|
||||
finally:
|
||||
bs.logout()
|
||||
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, bs, code: str, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch a single A-share symbol."""
|
||||
if not _is_a_share(code):
|
||||
return None
|
||||
|
||||
# Support baostock native format (sh.601398 / sz.000001)
|
||||
# and tushare-style suffix (601398.SH / 000001.SZ)
|
||||
code_lower = code.lower()
|
||||
if code_lower.startswith("sh.") or code_lower.startswith("sz."):
|
||||
bs_code = code_lower
|
||||
else:
|
||||
parts = code.upper().split(".")
|
||||
symbol = parts[0]
|
||||
suffix = parts[1] if len(parts) > 1 else ""
|
||||
if suffix == "SH":
|
||||
bs_code = f"sh.{symbol}"
|
||||
elif suffix == "SZ":
|
||||
bs_code = f"sz.{symbol}"
|
||||
else:
|
||||
return None
|
||||
|
||||
rs = bs.query_history_k_data_plus(
|
||||
bs_code,
|
||||
"date,open,high,low,close,volume,amount",
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
frequency="d",
|
||||
adjustflag="2", # 前复权
|
||||
)
|
||||
|
||||
if rs.error_code != "0":
|
||||
logger.warning("baostock query failed for %s: %s", code, rs.error_msg)
|
||||
return None
|
||||
|
||||
rows = []
|
||||
while rs.next():
|
||||
rows.append(rs.get_row_data())
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume", "amount"])
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
for col in ["open", "high", "low", "close", "volume", "amount"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
df = df.rename(columns={"date": "trade_date"})
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DataLoader Protocol, shared exceptions, and bounded-retry helpers.
|
||||
"""DataLoader Protocol, shared exceptions, retry helpers, and loader cache.
|
||||
|
||||
The retry/budget helpers are the canonical pattern for any loader that calls
|
||||
a flaky external API: a wall-clock deadline plus a small backoff schedule
|
||||
@@ -9,11 +9,20 @@ re-implementing the loop.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NoAvailableSourceError(Exception):
|
||||
"""Raised when no data source is available for a given market."""
|
||||
@@ -38,6 +47,78 @@ def validate_date_range(start_date: str, end_date: str) -> None:
|
||||
raise ValueError(f"start_date ({start_date}) > end_date ({end_date})")
|
||||
|
||||
|
||||
def validate_ohlc(
|
||||
frame: pd.DataFrame,
|
||||
*,
|
||||
strategy: str = "drop",
|
||||
allow_nonpositive_prices: bool = False,
|
||||
) -> pd.DataFrame:
|
||||
"""Drop, flag, or reject bars that violate OHLC invariants.
|
||||
|
||||
Loaders only drop NaN rows, so structurally dirty bars — ``high < low``,
|
||||
a non-positive price, or a high/low that fails to bracket open/close —
|
||||
flow straight into the backtest and surface downstream as NaN/inf metrics
|
||||
that break the strict (``allow_nan=False``) JSON serializers. This is the
|
||||
canonical loader-boundary check; call it after the existing ``dropna`` so a
|
||||
single sanity pass guards every source.
|
||||
|
||||
Structural invariants (``high < low`` and high/low failing to bracket
|
||||
open/close) are always enforced. The *positivity* invariant is
|
||||
configurable: some markets clear at or below zero legitimately (European
|
||||
day-ahead power routinely prints negative), and a rolling statistic over a
|
||||
silently gap-filled series is worse than a well-defined negative bar. When
|
||||
``allow_nonpositive_prices`` is set, negative prices pass through and only
|
||||
an exactly-zero price is rejected — zero is genuinely undefined for
|
||||
notional sizing (``size = notional / price``) and margin, whereas a
|
||||
negative price is handled by ``abs()``-based sizing in the engine.
|
||||
|
||||
Args:
|
||||
frame: OHLCV frame with at least ``open``/``high``/``low``/``close``
|
||||
columns. NaN handling is left to the caller's ``dropna``.
|
||||
strategy: ``"drop"`` (remove offending rows, default), ``"warn"``
|
||||
(log and keep), or ``"raise"`` (raise on any violation).
|
||||
allow_nonpositive_prices: when ``True``, keep bars with negative
|
||||
prices and reject only exact zeros; when ``False`` (default,
|
||||
unchanged behavior) reject any price ``<= 0``.
|
||||
|
||||
Returns:
|
||||
The frame with invalid rows removed (``"drop"``) or unchanged
|
||||
(``"warn"``). A frame that is empty or lacks OHLC columns is returned
|
||||
as-is.
|
||||
|
||||
Raises:
|
||||
ValueError: ``strategy="raise"`` and at least one bar is invalid.
|
||||
"""
|
||||
required = ("open", "high", "low", "close")
|
||||
if frame.empty or not all(col in frame.columns for col in required):
|
||||
return frame
|
||||
|
||||
open_, high, low, close = (frame[c] for c in required)
|
||||
structural = (
|
||||
(high < low)
|
||||
| (high < open_)
|
||||
| (high < close)
|
||||
| (low > open_)
|
||||
| (low > close)
|
||||
)
|
||||
if allow_nonpositive_prices:
|
||||
nonpositive = (open_ == 0) | (high == 0) | (low == 0) | (close == 0)
|
||||
else:
|
||||
nonpositive = (open_ <= 0) | (high <= 0) | (low <= 0) | (close <= 0)
|
||||
invalid = structural | nonpositive
|
||||
n_invalid = int(invalid.sum())
|
||||
if n_invalid == 0:
|
||||
return frame
|
||||
|
||||
if strategy == "raise":
|
||||
raise ValueError(f"{n_invalid} bar(s) violate OHLC invariants")
|
||||
if strategy == "warn":
|
||||
logger.warning("OHLC validation: %d bar(s) violate invariants (kept)", n_invalid)
|
||||
return frame
|
||||
logger.warning("OHLC validation: dropping %d invalid bar(s)", n_invalid)
|
||||
return frame[~invalid]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bounded retry / budget helpers (shared by ccxt_loader, okx, and any future
|
||||
# loader calling a flaky external API).
|
||||
@@ -47,6 +128,38 @@ DEFAULT_BACKOFF: tuple[float, ...] = (0.5, 1.5, 4.0)
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
|
||||
def positive_env_int(name: str, default: int) -> int:
|
||||
"""Read a positive integer env var, warning and falling back on invalid values."""
|
||||
raw = os.getenv(name) # noqa: env-gate — generic env var helper
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
logger.warning("invalid %s=%r, using default %s", name, raw, default)
|
||||
return default
|
||||
if value <= 0:
|
||||
logger.warning("non-positive %s=%r, using default %s", name, raw, default)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def positive_env_float(name: str, default: float) -> float:
|
||||
"""Read a positive float env var, warning and falling back on invalid values."""
|
||||
raw = os.getenv(name) # noqa: env-gate — generic env var helper
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
logger.warning("invalid %s=%r, using default %s", name, raw, default)
|
||||
return default
|
||||
if value <= 0:
|
||||
logger.warning("non-positive %s=%r, using default %s", name, raw, default)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def check_budget(deadline: float, label: str, budget_s: float | None = None) -> None:
|
||||
"""Raise :class:`TimeoutError` if the monotonic clock has crossed ``deadline``.
|
||||
|
||||
@@ -123,6 +236,385 @@ def retry_with_budget(
|
||||
raise AssertionError("unreachable: retry loop must return or raise") # pragma: no cover
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt-in local loader cache.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOADER_CACHE_ENV = "VIBE_TRADING_DATA_CACHE"
|
||||
LOADER_CACHE_ROOT_ENV = "VIBE_TRADING_DATA_CACHE_ROOT"
|
||||
_LOADER_CACHE_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
# Bump when the key payload or on-disk layout changes so stale entries are
|
||||
# simply never matched (old files become unreachable garbage, safe to delete).
|
||||
_LOADER_CACHE_VERSION = 3
|
||||
|
||||
|
||||
def loader_cache_enabled() -> bool:
|
||||
"""Return whether the local market-data cache is explicitly enabled.
|
||||
|
||||
Returns:
|
||||
True only when the config yields a real ``True``. Any other value —
|
||||
including a truthy non-bool from a stubbed config — leaves the opt-in
|
||||
cache off.
|
||||
"""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
return get_env_config().data.vibe_trading_data_cache is True
|
||||
|
||||
|
||||
def loader_cache_root() -> Path:
|
||||
"""Return the root directory for opt-in loader cache files.
|
||||
|
||||
The configured override is honored only when it is a genuine non-blank
|
||||
``str``. A non-string value (e.g. a stubbed config in tests) would
|
||||
otherwise reach ``Path()`` via ``__fspath__`` and yield a *relative* path,
|
||||
which resolves against the CWD and writes market data inside the working
|
||||
tree — the repository forbids caching data in the repo.
|
||||
|
||||
Returns:
|
||||
The configured cache root, or the default under the user's home.
|
||||
"""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
root = get_env_config().data.vibe_trading_data_cache_root
|
||||
if isinstance(root, str) and root.strip():
|
||||
return Path(root).expanduser()
|
||||
return Path.home() / ".vibe-trading" / "cache" / "loaders"
|
||||
|
||||
|
||||
def make_loader_cache_key(
|
||||
*,
|
||||
source: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: list[str] | tuple[str, ...] | None = None,
|
||||
) -> str:
|
||||
"""Build a stable content-addressed key for one loader payload."""
|
||||
payload = _loader_cache_payload(
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=fields,
|
||||
)
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(blob).hexdigest()
|
||||
|
||||
|
||||
def loader_cache_path(
|
||||
*,
|
||||
source: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: list[str] | tuple[str, ...] | None = None,
|
||||
) -> Path:
|
||||
"""Return the parquet cache path for one loader payload."""
|
||||
key = make_loader_cache_key(
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=fields,
|
||||
)
|
||||
source_dir = _sanitize_cache_segment(source)
|
||||
return loader_cache_root() / source_dir / f"{key}.parquet"
|
||||
|
||||
|
||||
def loader_cache_range_is_final(end_date: str) -> bool:
|
||||
"""Return whether ``end_date`` is settled enough to cache.
|
||||
|
||||
The key is content-addressed on ``end_date`` but not on wall-clock fetch
|
||||
time, so caching a range whose last bar is still forming (``end_date`` today
|
||||
or in the future) would pin a provisional bar and serve it on every later
|
||||
run. Only fully-elapsed days (strictly before today) are cacheable.
|
||||
"""
|
||||
try:
|
||||
end = pd.Timestamp(end_date).normalize().date()
|
||||
except Exception: # noqa: BLE001 - an unparseable date is treated as not cacheable
|
||||
return False
|
||||
return end < dt.date.today()
|
||||
|
||||
|
||||
def loader_cache_get(
|
||||
*,
|
||||
source: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: list[str] | tuple[str, ...] | None = None,
|
||||
) -> pd.DataFrame | None:
|
||||
"""Return a cached DataFrame for one payload, or ``None`` on any miss.
|
||||
|
||||
Misses include: cache disabled, range not yet settled, entry absent, or a
|
||||
corrupt entry. A corrupt entry is non-fatal — the caller falls back to the
|
||||
live provider.
|
||||
"""
|
||||
if not loader_cache_enabled() or not loader_cache_range_is_final(end_date):
|
||||
return None
|
||||
cache_path = loader_cache_path(
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=fields,
|
||||
)
|
||||
return _read_loader_cache_frame(cache_path)
|
||||
|
||||
|
||||
def loader_cache_put(
|
||||
*,
|
||||
source: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: list[str] | tuple[str, ...] | None,
|
||||
frame: pd.DataFrame | None,
|
||||
) -> None:
|
||||
"""Write one non-empty DataFrame to the cache; a no-op when not cacheable.
|
||||
|
||||
Skips a disabled cache, an unsettled range, and empty/non-DataFrame results.
|
||||
Write failures are swallowed so a fetch never fails because of the cache.
|
||||
"""
|
||||
if not loader_cache_enabled() or not loader_cache_range_is_final(end_date):
|
||||
return
|
||||
if not isinstance(frame, pd.DataFrame) or frame.empty:
|
||||
return
|
||||
cache_path = loader_cache_path(
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=fields,
|
||||
)
|
||||
_write_loader_cache_frame(cache_path, frame)
|
||||
|
||||
|
||||
def cached_loader_fetch(
|
||||
*,
|
||||
source: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: list[str] | tuple[str, ...] | None,
|
||||
fetch: Callable[[], pd.DataFrame | None],
|
||||
) -> pd.DataFrame | None:
|
||||
"""Fetch one DataFrame through the opt-in local cache.
|
||||
|
||||
Convenience wrapper over :func:`loader_cache_get` / :func:`loader_cache_put`
|
||||
for the common per-symbol loader loop: return the cached frame when present,
|
||||
otherwise call ``fetch`` and cache a non-empty result. Cache read/write
|
||||
failures are non-fatal and fall back to ``fetch``.
|
||||
"""
|
||||
cached = loader_cache_get(
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=fields,
|
||||
)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
frame = fetch()
|
||||
loader_cache_put(
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=fields,
|
||||
frame=frame,
|
||||
)
|
||||
return frame
|
||||
|
||||
|
||||
def _loader_cache_payload(
|
||||
*,
|
||||
source: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: list[str] | tuple[str, ...] | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"version": _LOADER_CACHE_VERSION,
|
||||
"source": str(source),
|
||||
"symbol": str(symbol),
|
||||
"timeframe": str(timeframe),
|
||||
"start_date": _normalize_cache_date(start_date),
|
||||
"end_date": _normalize_cache_date(end_date),
|
||||
"fields": [str(field) for field in (fields or ())],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_cache_date(value: str) -> str:
|
||||
return pd.Timestamp(value).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _sanitize_cache_segment(value: str) -> str:
|
||||
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in value.strip().lower())
|
||||
return cleaned or "unknown"
|
||||
|
||||
|
||||
def _loader_cache_metadata_path(cache_path: Path) -> Path:
|
||||
return cache_path.with_suffix(cache_path.suffix + ".json")
|
||||
|
||||
|
||||
def _read_loader_cache_frame(cache_path: Path) -> pd.DataFrame | None:
|
||||
if not cache_path.is_file():
|
||||
return None
|
||||
|
||||
metadata_path = _loader_cache_metadata_path(cache_path)
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # noqa: BLE001 - local cache miss is non-fatal
|
||||
logger.warning("loader cache metadata read failed for %s: %s", cache_path.name, exc)
|
||||
return None
|
||||
|
||||
con = None
|
||||
try:
|
||||
import duckdb
|
||||
|
||||
con = duckdb.connect(database=":memory:")
|
||||
frame = con.execute(
|
||||
f"SELECT * FROM read_parquet({_duckdb_sql_string(cache_path)})"
|
||||
).fetchdf()
|
||||
except Exception as exc: # noqa: BLE001 - corrupt cache falls back to provider
|
||||
logger.warning("loader cache read failed for %s: %s", cache_path.name, exc)
|
||||
return None
|
||||
finally:
|
||||
if con is not None:
|
||||
con.close()
|
||||
|
||||
index_columns = metadata.get("index_columns") or []
|
||||
if index_columns:
|
||||
missing = [column for column in index_columns if column not in frame.columns]
|
||||
if missing:
|
||||
logger.warning("loader cache %s missing index column(s): %s", cache_path.name, missing)
|
||||
return None
|
||||
frame = frame.set_index(index_columns)
|
||||
frame.index.names = metadata.get("index_names") or index_columns
|
||||
frame = _restore_cache_index_dtypes(frame, metadata.get("index_dtypes"))
|
||||
frame.columns.name = metadata.get("columns_name")
|
||||
return frame
|
||||
|
||||
|
||||
def _restore_cache_index_dtypes(frame: pd.DataFrame, index_dtypes: object) -> pd.DataFrame:
|
||||
"""Best-effort restore of the per-level index dtypes recorded at write time.
|
||||
|
||||
Cosmetic and non-fatal: duckdb parquet may rewrite datetime resolution, so
|
||||
we cast each level back to its original dtype. A failed cast leaves the
|
||||
duckdb-provided dtype rather than failing the read.
|
||||
"""
|
||||
if not isinstance(index_dtypes, list) or frame.index.nlevels != len(index_dtypes):
|
||||
return frame
|
||||
try:
|
||||
if frame.index.nlevels == 1:
|
||||
frame.index = frame.index.astype(index_dtypes[0])
|
||||
else:
|
||||
for level, dtype in enumerate(index_dtypes):
|
||||
frame.index = frame.index.set_levels(
|
||||
frame.index.levels[level].astype(dtype), level=level
|
||||
)
|
||||
except Exception: # noqa: BLE001 - index dtype restore is cosmetic
|
||||
logger.debug("loader cache index dtype restore skipped: %s", index_dtypes)
|
||||
return frame
|
||||
|
||||
|
||||
def _write_loader_cache_frame(cache_path: Path, frame: pd.DataFrame) -> None:
|
||||
metadata_path = _loader_cache_metadata_path(cache_path)
|
||||
# pid + uuid so two concurrent writers of the same key never share a tmp
|
||||
# path; os.replace then swaps each file in atomically.
|
||||
unique = f"{os.getpid()}.{uuid.uuid4().hex}"
|
||||
tmp_path = cache_path.with_name(f"{cache_path.name}.{unique}.tmp")
|
||||
tmp_metadata_path = metadata_path.with_name(f"{metadata_path.name}.{unique}.tmp")
|
||||
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_frame, metadata = _frame_for_loader_cache(frame)
|
||||
|
||||
import duckdb
|
||||
|
||||
con = duckdb.connect(database=":memory:")
|
||||
try:
|
||||
con.register("cache_frame", cache_frame)
|
||||
con.execute(f"COPY cache_frame TO {_duckdb_sql_string(tmp_path)} (FORMAT PARQUET)")
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
tmp_metadata_path.write_text(
|
||||
json.dumps(metadata, sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp_path, cache_path)
|
||||
os.replace(tmp_metadata_path, metadata_path)
|
||||
except Exception as exc: # noqa: BLE001 - cache write failures should not fail fetches
|
||||
logger.warning("loader cache write failed for %s: %s", cache_path.name, exc)
|
||||
for path in (tmp_path, tmp_metadata_path):
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _frame_for_loader_cache(frame: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, object]]:
|
||||
cache_frame = frame.copy()
|
||||
original_index_names = list(cache_frame.index.names)
|
||||
columns_name = cache_frame.columns.name
|
||||
index_dtypes = [
|
||||
str(cache_frame.index.get_level_values(level).dtype)
|
||||
for level in range(cache_frame.index.nlevels)
|
||||
]
|
||||
index_columns = _cache_index_columns(cache_frame)
|
||||
cache_frame.index = cache_frame.index.set_names(index_columns)
|
||||
metadata: dict[str, object] = {
|
||||
"version": _LOADER_CACHE_VERSION,
|
||||
"index_columns": index_columns,
|
||||
"index_names": original_index_names,
|
||||
# Preserve the columns-axis name (e.g. yfinance leaves "Price") and the
|
||||
# per-level index dtypes so a cached frame round-trips byte-identical to
|
||||
# a freshly fetched one (duckdb parquet otherwise rewrites datetime
|
||||
# resolution, e.g. [s] -> [us]).
|
||||
"columns_name": None if columns_name is None else str(columns_name),
|
||||
"index_dtypes": index_dtypes,
|
||||
}
|
||||
return cache_frame.reset_index(), metadata
|
||||
|
||||
|
||||
def _cache_index_columns(frame: pd.DataFrame) -> list[str]:
|
||||
columns = {str(column) for column in frame.columns}
|
||||
used: set[str] = set()
|
||||
index_columns: list[str] = []
|
||||
for pos, name in enumerate(frame.index.names):
|
||||
base = str(name) if name is not None else f"__vibe_loader_index_{pos}__"
|
||||
candidate = base
|
||||
suffix = 1
|
||||
while candidate in columns or candidate in used:
|
||||
candidate = f"{base}_{suffix}"
|
||||
suffix += 1
|
||||
index_columns.append(candidate)
|
||||
used.add(candidate)
|
||||
return index_columns
|
||||
|
||||
|
||||
def _duckdb_sql_string(path: Path) -> str:
|
||||
return "'" + str(path).replace("'", "''") + "'"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataLoaderProtocol(Protocol):
|
||||
"""Interface that every data source loader must satisfy."""
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Binance spot / USD-M perpetual OHLCV loader (via CCXT).
|
||||
|
||||
Dedicated source name ``binance`` so it can sit **alongside** ``okx`` in the
|
||||
crypto auto-fallback chain — not as a replacement. Public market data only;
|
||||
no API key required.
|
||||
|
||||
Use explicitly with ``source="binance"``, or let ``source="auto"`` fall through
|
||||
to Binance when OKX is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backtest.loaders.ccxt_loader import (
|
||||
_CCXT_TIMEOUT_MS,
|
||||
_ccxt_proxy_config,
|
||||
)
|
||||
from backtest.loaders.ccxt_loader import DataLoader as CcxtDataLoader
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader(CcxtDataLoader):
|
||||
"""Binance-only crypto OHLCV loader (public REST via CCXT)."""
|
||||
|
||||
name = "binance"
|
||||
markets = {"crypto"}
|
||||
requires_auth = False
|
||||
|
||||
def _get_exchange(self, instrument_type: str = "spot") -> Any:
|
||||
"""Always use Binance spot or Binance USD-M (swap), ignore CCXT_EXCHANGE."""
|
||||
import ccxt
|
||||
|
||||
if instrument_type == "swap":
|
||||
exchange_cls = ccxt.binanceusdm
|
||||
else:
|
||||
exchange_cls = ccxt.binance
|
||||
|
||||
config: dict[str, Any] = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS}
|
||||
proxies = _ccxt_proxy_config()
|
||||
if proxies:
|
||||
config["proxies"] = proxies
|
||||
return exchange_cls(config)
|
||||
@@ -7,15 +7,21 @@ No API key required for public market data.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import (
|
||||
cached_loader_fetch,
|
||||
check_budget,
|
||||
positive_env_float,
|
||||
positive_env_int,
|
||||
retry_with_budget,
|
||||
validate_date_range,
|
||||
)
|
||||
@@ -25,7 +31,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_INTERVAL_MAP = {
|
||||
"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m",
|
||||
"1H": "1h", "4H": "4h", "1D": "1d",
|
||||
"1H": "1h", "1h": "1h", "4H": "4h", "4h": "4h",
|
||||
"1D": "1d", "1d": "1d", "1W": "1w", "1w": "1w", "1M": "1M",
|
||||
}
|
||||
|
||||
_TIMEFRAME_DELTA = {
|
||||
"1m": pd.Timedelta(minutes=1),
|
||||
"5m": pd.Timedelta(minutes=5),
|
||||
"15m": pd.Timedelta(minutes=15),
|
||||
"30m": pd.Timedelta(minutes=30),
|
||||
"1h": pd.Timedelta(hours=1),
|
||||
"4h": pd.Timedelta(hours=4),
|
||||
"1d": pd.Timedelta(days=1),
|
||||
"1w": pd.Timedelta(weeks=1),
|
||||
"1M": pd.Timedelta(days=31),
|
||||
}
|
||||
|
||||
# P12-b: ccxt had no request timeout and an unbounded paginated fetch with
|
||||
@@ -33,13 +52,27 @@ _INTERVAL_MAP = {
|
||||
# minutes. Cap each HTTP call, bound transient retries, and enforce a hard
|
||||
# wall-clock budget so the fetch fails fast instead of hanging. Retry
|
||||
# scheduling is delegated to :mod:`backtest.loaders.base`.
|
||||
_CCXT_TIMEOUT_MS = int(os.getenv("CCXT_TIMEOUT_MS", "15000"))
|
||||
_CCXT_FETCH_BUDGET_S = float(os.getenv("CCXT_FETCH_BUDGET_S", "60"))
|
||||
_CCXT_TIMEOUT_MS = positive_env_int("CCXT_TIMEOUT_MS", 15_000)
|
||||
_CCXT_FETCH_BUDGET_S = positive_env_float("CCXT_FETCH_BUDGET_S", 60.0)
|
||||
_FUNDING_HOURS = {0, 8, 16}
|
||||
|
||||
|
||||
def _parse_ccxt_symbol(code: str) -> tuple[str, str]:
|
||||
"""Return the canonical CCXT symbol and instrument type for ``code``."""
|
||||
normalized = code.strip().upper()
|
||||
if normalized.endswith("-PERP"):
|
||||
match = re.fullmatch(r"([A-Z0-9]+)-USDT-PERP", normalized)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
"USD-M perpetual symbol must use BASE-USDT-PERP, e.g. BTC-USDT-PERP"
|
||||
)
|
||||
return f"{match.group(1)}/USDT:USDT", "swap"
|
||||
return normalized.replace("-", "/"), "spot"
|
||||
|
||||
|
||||
def _first_proxy_env(*names: str) -> str:
|
||||
for name in names:
|
||||
value = os.getenv(name, "").strip()
|
||||
value = os.getenv(name, "").strip() # noqa: env-gate — system proxy vars
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
@@ -59,6 +92,95 @@ def _ccxt_proxy_config() -> dict[str, str]:
|
||||
return proxies
|
||||
|
||||
|
||||
_BRACKET_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _validate_bracket_artifact(artifact: dict, *, expected_symbol: str) -> tuple[list[dict], str]:
|
||||
"""Validate a caller-supplied maintenance-bracket artifact.
|
||||
|
||||
``binanceusdm.fetch_leverage_tiers()`` routes to Binance's signed
|
||||
``GET /fapi/v1/leverageBracket`` USER_DATA endpoint — it requires an API
|
||||
key even though the response carries no account-specific data. This
|
||||
loader no longer calls it: maintenance brackets are supplied out of band
|
||||
as a versioned artifact and validated here, never fetched live. Fails
|
||||
closed (raises ``ValueError``) on any schema, symbol, provenance,
|
||||
ordering, or content-hash problem.
|
||||
"""
|
||||
if not isinstance(artifact, dict):
|
||||
raise ValueError("bracket artifact must be a dict")
|
||||
|
||||
schema_version = artifact.get("schema_version")
|
||||
if schema_version != _BRACKET_SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"bracket artifact schema_version must be {_BRACKET_SCHEMA_VERSION}, "
|
||||
f"got {schema_version!r}"
|
||||
)
|
||||
|
||||
symbol = artifact.get("symbol")
|
||||
if symbol != expected_symbol:
|
||||
raise ValueError(
|
||||
f"bracket artifact symbol mismatch: expected {expected_symbol!r}, got {symbol!r}"
|
||||
)
|
||||
|
||||
provenance_timestamp = artifact.get("provenance_timestamp")
|
||||
if not provenance_timestamp:
|
||||
raise ValueError("bracket artifact is missing provenance_timestamp")
|
||||
try:
|
||||
pd.Timestamp(provenance_timestamp)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f"bracket artifact provenance_timestamp is not a valid timestamp: "
|
||||
f"{provenance_timestamp!r}"
|
||||
) from exc
|
||||
|
||||
brackets = artifact.get("brackets")
|
||||
if not brackets:
|
||||
raise ValueError("bracket artifact has no brackets")
|
||||
|
||||
normalized: list[dict] = []
|
||||
for tier in brackets:
|
||||
try:
|
||||
record = {
|
||||
"bracket_tier": int(tier["bracket_tier"]),
|
||||
"notional_cap": float(tier["notional_cap"]),
|
||||
"maintenance_rate": float(tier["maintenance_rate"]),
|
||||
"cumulative_maintenance_amount": float(tier["cumulative_maintenance_amount"]),
|
||||
}
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"bracket artifact tier for {expected_symbol} is missing a required field: {exc}"
|
||||
) from exc
|
||||
# Optional: reserved for future risk-model calibration, not part of
|
||||
# Binance's own bracket schema. Validated only if present.
|
||||
coefficient = tier.get("notional_coefficient")
|
||||
if coefficient is not None:
|
||||
try:
|
||||
record["notional_coefficient"] = float(coefficient)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"bracket artifact notional_coefficient must be numeric: {coefficient!r}"
|
||||
) from exc
|
||||
normalized.append(record)
|
||||
|
||||
normalized.sort(key=lambda row: row["bracket_tier"])
|
||||
caps = [row["notional_cap"] for row in normalized]
|
||||
if caps != sorted(caps) or len(caps) != len(set(caps)):
|
||||
raise ValueError(
|
||||
f"bracket artifact notional caps for {expected_symbol} are not strictly increasing"
|
||||
)
|
||||
|
||||
blob = json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
computed_hash = hashlib.sha256(blob).hexdigest()[:16]
|
||||
content_hash = artifact.get("content_hash")
|
||||
if content_hash != computed_hash:
|
||||
raise ValueError(
|
||||
f"bracket artifact content_hash mismatch for {expected_symbol}: "
|
||||
f"expected {computed_hash}, artifact declares {content_hash!r}"
|
||||
)
|
||||
|
||||
return normalized, computed_hash
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""CCXT-backed crypto OHLCV loader (100+ exchanges)."""
|
||||
@@ -78,10 +200,18 @@ class DataLoader:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def _get_exchange(self):
|
||||
"""Create exchange instance."""
|
||||
def _get_exchange(self, instrument_type: str = "spot"):
|
||||
"""Create an exchange instance for spot or Binance USD-M swaps."""
|
||||
import ccxt
|
||||
exchange_id = os.getenv("CCXT_EXCHANGE", "binance").lower()
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
exchange_id = get_env_config().data.ccxt_exchange.lower()
|
||||
if instrument_type == "swap":
|
||||
if exchange_id not in {"binance", "binanceusdm"}:
|
||||
raise ValueError(
|
||||
"BASE-USDT-PERP currently requires CCXT_EXCHANGE=binance"
|
||||
)
|
||||
exchange_id = "binanceusdm"
|
||||
exchange_cls = getattr(ccxt, exchange_id, None)
|
||||
if exchange_cls is None:
|
||||
logger.warning("Unknown CCXT exchange %s, falling back to binance", exchange_id)
|
||||
@@ -101,6 +231,8 @@ class DataLoader:
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
bracket_artifacts: Optional[Dict[str, dict]] = None,
|
||||
require_brackets: bool = False,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch crypto OHLCV via CCXT.
|
||||
|
||||
@@ -110,31 +242,196 @@ class DataLoader:
|
||||
end_date: End date (YYYY-MM-DD).
|
||||
interval: Bar size.
|
||||
fields: Ignored.
|
||||
bracket_artifacts: Optional ``{code: artifact}`` map of caller-supplied,
|
||||
versioned maintenance-bracket artifacts (see
|
||||
``_validate_bracket_artifact``) for ``-PERP`` codes. Normal
|
||||
execution/mark/funding data never needs this — it stays
|
||||
zero-credential regardless. This loader does not fetch brackets
|
||||
live: ``binanceusdm.fetch_leverage_tiers()`` requires a Binance
|
||||
API key, which this historical/backtest path never carries.
|
||||
require_brackets: When True, a ``-PERP`` code without a matching,
|
||||
valid artifact in ``bracket_artifacts`` fails closed instead of
|
||||
silently returning data without bracket columns. Intended for
|
||||
strict margin-risk consumers.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
exchange = self._get_exchange()
|
||||
timeframe = _INTERVAL_MAP.get(interval, "1d")
|
||||
since_ms = int(pd.Timestamp(start_date).timestamp() * 1000)
|
||||
end_ms = int((pd.Timestamp(end_date) + pd.Timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
# Build the exchange lazily so a full cache hit never imports ccxt or
|
||||
# opens an exchange object.
|
||||
exchange_holder: Dict[str, object] = {}
|
||||
|
||||
def get_exchange(instrument_type: str):
|
||||
if instrument_type not in exchange_holder:
|
||||
exchange_holder[instrument_type] = self._get_exchange(instrument_type)
|
||||
return exchange_holder[instrument_type]
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
instrument_type = "spot"
|
||||
try:
|
||||
ccxt_symbol = code.replace("-", "/").upper()
|
||||
df = self._fetch_one(exchange, ccxt_symbol, timeframe, since_ms, end_ms)
|
||||
ccxt_symbol, instrument_type = _parse_ccxt_symbol(code)
|
||||
exchange = get_exchange(instrument_type)
|
||||
artifact = (bracket_artifacts or {}).get(code)
|
||||
|
||||
def fetch_frame():
|
||||
if instrument_type == "swap":
|
||||
return self._fetch_perpetual(
|
||||
exchange, ccxt_symbol, timeframe, since_ms, end_ms,
|
||||
bracket_artifact=artifact, require_brackets=require_brackets,
|
||||
)
|
||||
return self._fetch_one(
|
||||
exchange, ccxt_symbol, timeframe, since_ms, end_ms
|
||||
)
|
||||
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=fetch_frame,
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
if instrument_type == "swap" or code.strip().upper().endswith("-PERP"):
|
||||
raise
|
||||
logger.warning("CCXT failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _fetch_perpetual(
|
||||
cls, exchange, symbol: str, timeframe: str, since_ms: int, end_ms: int,
|
||||
*, bracket_artifact: dict | None = None, require_brackets: bool = False,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch aligned trade-price and mark-price candles for one USD-M swap.
|
||||
|
||||
Maintenance brackets are never fetched live (see
|
||||
``_validate_bracket_artifact``): they're attached only when the caller
|
||||
supplies a validated artifact. A strict caller (``require_brackets``)
|
||||
fails closed before any network call when no artifact is supplied.
|
||||
"""
|
||||
if require_brackets and bracket_artifact is None:
|
||||
raise ValueError(
|
||||
f"strict margin-risk fetch for {symbol} requires a maintenance-bracket "
|
||||
"artifact, but none was supplied. This loader does not perform a live "
|
||||
"authenticated bracket fetch (binanceusdm.fetch_leverage_tiers requires "
|
||||
"a Binance API key this backtest path does not carry) — pass a validated "
|
||||
"artifact via DataLoader.fetch(bracket_artifacts={code: artifact})."
|
||||
)
|
||||
|
||||
trade = cls._fetch_one(exchange, symbol, timeframe, since_ms, end_ms)
|
||||
mark = cls._fetch_one(
|
||||
exchange,
|
||||
symbol,
|
||||
timeframe,
|
||||
since_ms,
|
||||
end_ms,
|
||||
params={"price": "mark"},
|
||||
)
|
||||
if trade is None or mark is None or not trade.index.equals(mark.index):
|
||||
raise ValueError(f"mark-price timestamps are incomplete or unsynchronized for {symbol}")
|
||||
|
||||
result = trade.copy()
|
||||
result["execution_open"] = trade["open"]
|
||||
for column in ("open", "high", "low", "close"):
|
||||
result[f"mark_{column}"] = mark[column]
|
||||
|
||||
funding = cls._fetch_funding_history(exchange, symbol, since_ms, end_ms)
|
||||
if funding.index.has_duplicates:
|
||||
raise ValueError(f"duplicate funding settlement for {symbol}")
|
||||
required = result.index[result.index.hour.isin(_FUNDING_HOURS)]
|
||||
missing = required.difference(funding.index)
|
||||
if not missing.empty:
|
||||
raise ValueError(
|
||||
f"funding settlement data is missing for {symbol}: "
|
||||
f"{', '.join(str(ts) for ts in missing)}"
|
||||
)
|
||||
|
||||
result["funding_rate"] = 0.0
|
||||
result["funding_settlement_time"] = pd.NaT
|
||||
aligned = funding.index.intersection(result.index)
|
||||
if not aligned.empty:
|
||||
result.loc[aligned, "funding_rate"] = funding.loc[aligned, "funding_rate"]
|
||||
result.loc[aligned, "funding_settlement_time"] = aligned
|
||||
|
||||
if bracket_artifact is not None:
|
||||
brackets, version = _validate_bracket_artifact(
|
||||
bracket_artifact, expected_symbol=symbol
|
||||
)
|
||||
result["maintenance_brackets"] = json.dumps(brackets)
|
||||
result["maintenance_bracket_version"] = version
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fetch_funding_history(
|
||||
exchange, symbol: str, since_ms: int, end_ms: int,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch bounded historical funding settlements for one USD-M swap."""
|
||||
import ccxt
|
||||
|
||||
rows: list[dict] = []
|
||||
cursor = since_ms
|
||||
limit = 1000
|
||||
deadline = time.monotonic() + _CCXT_FETCH_BUDGET_S
|
||||
label = f"ccxt funding fetch for {symbol}"
|
||||
|
||||
for _ in range(200):
|
||||
check_budget(deadline, label, budget_s=_CCXT_FETCH_BUDGET_S)
|
||||
page = retry_with_budget(
|
||||
lambda: exchange.fetch_funding_rate_history(
|
||||
symbol, since=cursor, limit=limit
|
||||
),
|
||||
transient=ccxt.NetworkError,
|
||||
deadline=deadline,
|
||||
label=label,
|
||||
)
|
||||
if not page:
|
||||
break
|
||||
rows.extend(page)
|
||||
last_ts = int(page[-1]["timestamp"])
|
||||
if last_ts >= end_ms or len(page) < limit:
|
||||
break
|
||||
cursor = last_ts + 1
|
||||
|
||||
if not rows:
|
||||
return pd.DataFrame(
|
||||
{"funding_rate": pd.Series(dtype=float)},
|
||||
index=pd.DatetimeIndex([], name="trade_date"),
|
||||
)
|
||||
|
||||
frame = pd.DataFrame({
|
||||
# Binance settlement timestamps carry millisecond jitter
|
||||
# (e.g. 08:00:00.011); round to the second so they align with
|
||||
# bar timestamps instead of failing the missing-settlement check.
|
||||
"trade_date": pd.to_datetime(
|
||||
[row["timestamp"] for row in rows], unit="ms"
|
||||
).round("s"),
|
||||
"funding_rate": pd.to_numeric(
|
||||
[row["fundingRate"] for row in rows], errors="raise"
|
||||
),
|
||||
}).set_index("trade_date").sort_index()
|
||||
start_dt = pd.Timestamp(since_ms, unit="ms")
|
||||
end_dt = pd.Timestamp(end_ms, unit="ms")
|
||||
return frame[(frame.index >= start_dt) & (frame.index < end_dt)]
|
||||
|
||||
@staticmethod
|
||||
def _fetch_one(
|
||||
exchange, symbol: str, timeframe: str, since_ms: int, end_ms: int,
|
||||
exchange,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
since_ms: int,
|
||||
end_ms: int,
|
||||
*,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Paginated OHLCV fetch for one symbol."""
|
||||
import ccxt
|
||||
@@ -144,23 +441,32 @@ class DataLoader:
|
||||
limit = 1000
|
||||
deadline = time.monotonic() + _CCXT_FETCH_BUDGET_S
|
||||
label = f"ccxt fetch for {symbol}"
|
||||
hit_page_cap = True
|
||||
|
||||
for _ in range(200):
|
||||
check_budget(deadline, label, budget_s=_CCXT_FETCH_BUDGET_S)
|
||||
# ``ccxt.NetworkError`` covers RequestTimeout / DDoSProtection /
|
||||
# ExchangeNotAvailable — the transient family. Anything else
|
||||
# (e.g. ``ExchangeError`` for a bad symbol) is not retried.
|
||||
def fetch_page():
|
||||
kwargs = {"since": cursor, "limit": limit}
|
||||
if params is not None:
|
||||
kwargs["params"] = params
|
||||
return exchange.fetch_ohlcv(symbol, timeframe, **kwargs)
|
||||
|
||||
ohlcv = retry_with_budget(
|
||||
lambda: exchange.fetch_ohlcv(symbol, timeframe, since=cursor, limit=limit),
|
||||
fetch_page,
|
||||
transient=ccxt.NetworkError,
|
||||
deadline=deadline,
|
||||
label=label,
|
||||
)
|
||||
if not ohlcv:
|
||||
hit_page_cap = False
|
||||
break
|
||||
all_rows.extend(ohlcv)
|
||||
last_ts = ohlcv[-1][0]
|
||||
if last_ts >= end_ms or len(ohlcv) < limit:
|
||||
hit_page_cap = False
|
||||
break
|
||||
cursor = last_ts + 1
|
||||
|
||||
@@ -181,4 +487,15 @@ class DataLoader:
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df if not df.empty else None
|
||||
if df.empty:
|
||||
return None
|
||||
|
||||
tolerance = _TIMEFRAME_DELTA.get(timeframe)
|
||||
if tolerance is None:
|
||||
raise ValueError(f"unsupported CCXT timeframe: {timeframe}")
|
||||
if hit_page_cap and df.index[-1] < end_dt - tolerance:
|
||||
raise ValueError(
|
||||
f"incomplete CCXT history for {symbol}: requested "
|
||||
f"[{start_dt}, {end_dt}), received [{df.index[0]}, {df.index[-1]}]"
|
||||
)
|
||||
return df
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Corporate-action adjustment for Tushare A-share and fund bars.
|
||||
|
||||
Tushare's ``daily`` and ``fund_daily`` endpoints return *unadjusted* prices, so
|
||||
a close-to-close return taken across an ex-date spans the mechanical price drop
|
||||
of a split, bonus issue or dividend rather than the instrument's actual move.
|
||||
Measured against Tushare's own ``pct_chg`` over 2020-2024: 300750.SZ on
|
||||
2023-04-26 reads -41.82% raw against a true +5.40%, 300124.SZ -34.26% against
|
||||
-1.01%, 601012.SH -24.21% against +6.46%. The error is always negative, because
|
||||
prices only ever gap down on an ex-date, so it is a systematic contaminant of
|
||||
returns rather than noise.
|
||||
|
||||
The matching factor series comes from ``pro.adj_factor`` for equities and
|
||||
``pro.fund_adj`` for funds; both carry the same ``(trade_date, adj_factor)``
|
||||
schema. Factors are cumulative and rise across a corporate action — verified on
|
||||
600519.SH 2022-06-30, where the factor steps 7.4740 -> 7.5546 as the stock goes
|
||||
ex-dividend.
|
||||
|
||||
This module is the single implementation; the backtest loader and the alpha
|
||||
bench both route through it rather than re-deriving the convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def apply_qfq(df: pd.DataFrame, factor: pd.DataFrame | None) -> pd.DataFrame | None:
|
||||
"""Forward-adjust raw Tushare daily bars for corporate actions.
|
||||
|
||||
``pro.daily`` returns *unadjusted* prices, so a close-to-close return taken
|
||||
across an ex-date spans the mechanical price drop of a split, bonus issue or
|
||||
dividend rather than the stock's actual move. Measured against Tushare's own
|
||||
``pct_chg`` on CSI300 members: 300750.SZ on 2023-04-26 reads -41.82% raw
|
||||
against a true +5.40%, 300124.SZ on 2021-06-04 reads -34.26% against -1.01%,
|
||||
601012.SH on 2022-06-06 reads -24.21% against +6.46%. The error is always
|
||||
negative, so it is a systematic contaminant of every cross-sectional IC the
|
||||
bench reports, not noise.
|
||||
|
||||
Prices are scaled to the last bar in the window (前复权), matching the
|
||||
``adjust="qfq"`` convention the akshare and tencent loaders already use.
|
||||
``volume`` is divided by the same ratio so share counts stay on one basis
|
||||
and the ``amount``-derived VWAP keeps the same relationship to ``close``;
|
||||
``amount`` is a cash figure and is left untouched.
|
||||
|
||||
Args:
|
||||
df: Raw daily bars indexed by trade date.
|
||||
factor: ``pro.adj_factor`` rows for the same symbol and window.
|
||||
|
||||
Returns:
|
||||
The adjusted frame, or ``None`` when the adjustment factors are missing
|
||||
or unusable — the caller drops the symbol rather than benching it on
|
||||
contaminated prices.
|
||||
"""
|
||||
if factor is None or getattr(factor, "empty", True):
|
||||
return None
|
||||
if "adj_factor" not in factor.columns or "trade_date" not in factor.columns:
|
||||
return None
|
||||
series = factor.copy()
|
||||
series["trade_date"] = pd.to_datetime(series["trade_date"], errors="coerce")
|
||||
series = (
|
||||
series.dropna(subset=["trade_date"])
|
||||
.set_index("trade_date")["adj_factor"]
|
||||
.astype(float)
|
||||
.sort_index()
|
||||
)
|
||||
series = series.reindex(df.index).ffill().bfill()
|
||||
if series.isna().any() or (series <= 0).any():
|
||||
return None
|
||||
|
||||
ratio = series / series.iloc[-1]
|
||||
out = df.copy()
|
||||
for col in ("open", "high", "low", "close"):
|
||||
if col in out.columns:
|
||||
out[col] = out[col] * ratio
|
||||
if "volume" in out.columns:
|
||||
out["volume"] = out["volume"] / ratio
|
||||
return out
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Shared Eastmoney HTTP client: secid resolution + push2his kline fetch.
|
||||
|
||||
Eastmoney exposes free, no-auth quote endpoints but rate-limits aggressively by
|
||||
source IP and will temporarily ban a bursting client, so every request here
|
||||
routes through :mod:`backtest.loaders._http` for per-host throttling and session
|
||||
reuse. This module is provider-internal plumbing shared by the Eastmoney-backed
|
||||
loaders; it only knows Eastmoney's ``secid`` addressing scheme and the
|
||||
``push2his`` kline JSON layout, not any loader's DataFrame conventions.
|
||||
|
||||
Eastmoney addresses every instrument by a ``secid`` of the form ``<market>.<code>``:
|
||||
|
||||
* A-shares — Shanghai (``.SH``, plus ``.BJ`` Beijing exchange) use market ``1``
|
||||
for SH and ``0`` for SZ/BJ.
|
||||
* Hong Kong (``.HK``) uses market ``116`` with the numeric code zero-padded to
|
||||
five digits.
|
||||
* US (``.US``) markets (NASDAQ ``105`` / NYSE ``106`` / AMEX ``107``) are not
|
||||
derivable from the ticker alone, so the market prefix is discovered once via
|
||||
Eastmoney's search/suggest endpoint and cached for the life of the process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Eastmoney kline endpoints. push2his serves historical bars; searchapi resolves
|
||||
# a free-text ticker to its fully-qualified secid (needed for US tickers).
|
||||
_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
_SEARCH_URL = "https://searchapi.eastmoney.com/api/suggest/get"
|
||||
|
||||
# Throttle/session bucket shared by all Eastmoney calls.
|
||||
_HOST_KEY = "eastmoney"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_EASTMONEY_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL = 1.0
|
||||
|
||||
# Eastmoney kline period codes (``klt``) keyed by our interval labels.
|
||||
KLT_BY_INTERVAL: dict[str, int] = {
|
||||
"1D": 101,
|
||||
"1d": 101,
|
||||
"1W": 102,
|
||||
"1w": 102,
|
||||
"1M": 103,
|
||||
"1m": 1,
|
||||
"5m": 5,
|
||||
"15m": 15,
|
||||
"30m": 30,
|
||||
"1H": 60,
|
||||
"60m": 60,
|
||||
}
|
||||
|
||||
# push2his column selectors: fields1 picks the per-request meta, fields2 picks
|
||||
# the per-bar columns in order: date, open, close, high, low, volume, amount.
|
||||
_FIELDS1 = "f1,f2,f3,f4,f5,f6"
|
||||
_FIELDS2 = "f51,f52,f53,f54,f55,f56,f57"
|
||||
|
||||
# Process-level cache of resolved US secids so a repeated ticker never re-hits
|
||||
# the search endpoint. Keyed by the upper-cased bare ticker (e.g. "AAPL").
|
||||
_US_SECID_CACHE: dict[str, str | None] = {}
|
||||
|
||||
# A JSONP envelope is a JS callback identifier followed by a parenthesized body,
|
||||
# optionally terminated by ';'. The identifier is restricted to legal JS names
|
||||
# (incl. dotted namespaces like "jQuery.cb") so a plain JSON array/object body —
|
||||
# which never starts this way — is left untouched.
|
||||
_JSONP_WRAPPER = re.compile(
|
||||
r"^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\((.*)\)\s*;?\s*$",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum Eastmoney request spacing in seconds."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL)
|
||||
|
||||
|
||||
def get_json(url: str, *, params: dict[str, Any]) -> Any:
|
||||
"""Issue a throttled Eastmoney GET and decode the body as JSON.
|
||||
|
||||
Args:
|
||||
url: Fully-qualified Eastmoney endpoint URL.
|
||||
params: Query parameters for the request.
|
||||
|
||||
Returns:
|
||||
The decoded JSON payload (typically a ``dict``).
|
||||
|
||||
Raises:
|
||||
requests.RequestException: Network failure, propagated for the caller's
|
||||
retry policy.
|
||||
requests.HTTPError: Non-2xx response status.
|
||||
ValueError: Body is not valid JSON.
|
||||
"""
|
||||
return throttled_get_json(
|
||||
url,
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_a_share_secid(code: str, suffix: str) -> str | None:
|
||||
"""Map an A-share ``code`` + exchange ``suffix`` to its Eastmoney secid.
|
||||
|
||||
SH instruments live on market ``1``; SZ and BJ (Beijing exchange) on ``0``.
|
||||
"""
|
||||
if suffix == "SH":
|
||||
return f"1.{code}"
|
||||
if suffix in ("SZ", "BJ"):
|
||||
return f"0.{code}"
|
||||
return None
|
||||
|
||||
|
||||
def _parse_us_secid(payload: Any) -> str | None:
|
||||
"""Extract a US ``<market>.<code>`` secid from a search/suggest payload.
|
||||
|
||||
Eastmoney's suggest endpoint returns candidates under
|
||||
``QuotationCodeTable.Data``, each carrying a ``QuoteID`` already in
|
||||
``<market>.<code>`` form. The first US-market (105/106/107) candidate wins.
|
||||
|
||||
Args:
|
||||
payload: Decoded JSON from the search endpoint.
|
||||
|
||||
Returns:
|
||||
The first matching US secid, or ``None`` when no US candidate is found.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
table = payload.get("QuotationCodeTable")
|
||||
if not isinstance(table, dict):
|
||||
return None
|
||||
candidates = table.get("Data")
|
||||
if not isinstance(candidates, list):
|
||||
return None
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
quote_id = candidate.get("QuoteID")
|
||||
if not isinstance(quote_id, str) or "." not in quote_id:
|
||||
continue
|
||||
market = quote_id.split(".", 1)[0]
|
||||
if market in ("105", "106", "107"):
|
||||
return quote_id
|
||||
return None
|
||||
|
||||
|
||||
def _strip_jsonp(text: str) -> Any:
|
||||
"""Decode a possibly JSONP-wrapped body to a Python object.
|
||||
|
||||
The suggest endpoint sometimes wraps its JSON in a ``callback(...)`` envelope.
|
||||
A plain JSON body is parsed as-is first; only a body that actually matches a
|
||||
leading callback identifier is unwrapped. This avoids corrupting plain JSON
|
||||
whose string values contain parentheses (an earlier first-``(`` / last-``)``
|
||||
slice mangled such bodies and returned ``None``).
|
||||
|
||||
Args:
|
||||
text: Raw response body.
|
||||
|
||||
Returns:
|
||||
The decoded object, or ``None`` when nothing parseable is found.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
match = _JSONP_WRAPPER.match(stripped)
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_us_secid(code: str) -> str | None:
|
||||
"""Resolve a US ticker to its Eastmoney secid via search, with caching.
|
||||
|
||||
Args:
|
||||
code: Bare upper-cased US ticker (e.g. ``"AAPL"``).
|
||||
|
||||
Returns:
|
||||
The resolved ``<market>.<code>`` secid, or ``None`` when unresolvable.
|
||||
"""
|
||||
if code in _US_SECID_CACHE:
|
||||
return _US_SECID_CACHE[code]
|
||||
|
||||
secid: str | None = None
|
||||
try:
|
||||
payload = get_json(
|
||||
_SEARCH_URL,
|
||||
params={"input": code, "type": "14", "count": "10"},
|
||||
)
|
||||
if isinstance(payload, str):
|
||||
payload = _strip_jsonp(payload)
|
||||
secid = _parse_us_secid(payload)
|
||||
except Exception as exc: # noqa: BLE001 - an unresolved ticker is non-fatal
|
||||
logger.warning("eastmoney secid search failed for %s: %s", code, exc)
|
||||
secid = None
|
||||
|
||||
_US_SECID_CACHE[code] = secid
|
||||
return secid
|
||||
|
||||
|
||||
def resolve_secid(symbol: str) -> str | None:
|
||||
"""Map a Vibe-Trading symbol to its Eastmoney secid.
|
||||
|
||||
Supported suffixes: ``.SH`` / ``.SZ`` / ``.BJ`` (A-share), ``.HK`` (Hong
|
||||
Kong, code zero-padded to five digits), ``.US`` (resolved via search and
|
||||
cached). A symbol with no recognized suffix, or a US ticker the search
|
||||
cannot place, returns ``None``.
|
||||
|
||||
Args:
|
||||
symbol: Symbol such as ``"600519.SH"``, ``"00700.HK"`` or ``"AAPL.US"``.
|
||||
|
||||
Returns:
|
||||
The Eastmoney secid (e.g. ``"1.600519"``), or ``None`` if unresolvable.
|
||||
"""
|
||||
if not symbol or "." not in symbol:
|
||||
return None
|
||||
code, _, suffix = symbol.rpartition(".")
|
||||
code = code.strip().upper()
|
||||
suffix = suffix.strip().upper()
|
||||
if not code:
|
||||
return None
|
||||
|
||||
if suffix in ("SH", "SZ", "BJ"):
|
||||
return _resolve_a_share_secid(code, suffix)
|
||||
if suffix == "HK":
|
||||
return f"116.{code.zfill(5)}"
|
||||
if suffix == "US":
|
||||
return _resolve_us_secid(code)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_kline_row(raw: str) -> dict[str, Any] | None:
|
||||
"""Parse one comma-joined push2his kline row into an OHLCV dict.
|
||||
|
||||
Column order follows ``fields2``: date, open, close, high, low, volume,
|
||||
amount.
|
||||
|
||||
Args:
|
||||
raw: One row string from ``data.klines``.
|
||||
|
||||
Returns:
|
||||
A dict ``{trade_date, open, high, low, close, volume, amount}``, or
|
||||
``None`` when the row is malformed.
|
||||
"""
|
||||
parts = raw.split(",")
|
||||
if len(parts) < 7:
|
||||
return None
|
||||
try:
|
||||
return {
|
||||
"trade_date": parts[0],
|
||||
"open": float(parts[1]),
|
||||
"close": float(parts[2]),
|
||||
"high": float(parts[3]),
|
||||
"low": float(parts[4]),
|
||||
"volume": float(parts[5]),
|
||||
"amount": float(parts[6]),
|
||||
}
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_kline(
|
||||
secid: str,
|
||||
*,
|
||||
klt: int,
|
||||
fqt: int = 1,
|
||||
beg: str = "0",
|
||||
end: str = "20500101",
|
||||
) -> list[dict]:
|
||||
"""Fetch ascending OHLCV bars for one ``secid`` from push2his.
|
||||
|
||||
Args:
|
||||
secid: Eastmoney secid (e.g. ``"1.600519"``).
|
||||
klt: Period code from :data:`KLT_BY_INTERVAL`.
|
||||
fqt: Adjustment mode (0 raw, 1 forward-adjusted, 2 back-adjusted).
|
||||
beg: Inclusive start date ``YYYYMMDD`` or ``"0"`` for earliest.
|
||||
end: Inclusive end date ``YYYYMMDD``.
|
||||
|
||||
Returns:
|
||||
Ascending list of ``{trade_date, open, high, low, close, volume,
|
||||
amount}`` dicts. Empty when the payload carries no bars.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: Network failure.
|
||||
requests.HTTPError: Non-2xx response status.
|
||||
ValueError: Body is not valid JSON.
|
||||
"""
|
||||
payload = get_json(
|
||||
_KLINE_URL,
|
||||
params={
|
||||
"secid": secid,
|
||||
"klt": str(klt),
|
||||
"fqt": str(fqt),
|
||||
"beg": beg,
|
||||
"end": end,
|
||||
"fields1": _FIELDS1,
|
||||
"fields2": _FIELDS2,
|
||||
"rev": "1",
|
||||
"lmt": "1000000",
|
||||
},
|
||||
)
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
klines = data.get("klines")
|
||||
if not isinstance(klines, list):
|
||||
return []
|
||||
|
||||
rows: list[dict] = []
|
||||
for raw in klines:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
parsed = _parse_kline_row(raw)
|
||||
if parsed is not None:
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Eastmoney loader: free, no-auth OHLCV across A-share, HK and US equities.
|
||||
|
||||
Eastmoney's ``push2his`` quote endpoints are free and require no token, but the
|
||||
service rate-limits aggressively by source IP. All HTTP goes through the shared
|
||||
:mod:`backtest.loaders.eastmoney_client`, which routes every call through the
|
||||
per-host throttle in :mod:`backtest.loaders._http`. This loader only maps our
|
||||
symbol/interval/DataFrame conventions onto that client; it owns no HTTP itself.
|
||||
|
||||
Symbol routing is delegated to :func:`eastmoney_client.resolve_secid`:
|
||||
|
||||
* A-share — ``600519.SH`` / ``000001.SZ`` / ``430139.BJ``
|
||||
* Hong Kong — ``00700.HK`` (numeric code zero-padded to five digits)
|
||||
* US — ``AAPL.US`` (market prefix discovered via Eastmoney search, cached)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders import eastmoney_client
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OHLCV columns the engine consumes, in canonical order.
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
|
||||
def _to_compact_date(value: str) -> str:
|
||||
"""Convert a ``YYYY-MM-DD`` date into Eastmoney's ``YYYYMMDD`` form.
|
||||
|
||||
Args:
|
||||
value: Date string in any pandas-parseable form.
|
||||
|
||||
Returns:
|
||||
The date rendered as ``YYYYMMDD``.
|
||||
|
||||
Raises:
|
||||
ValueError: ``value`` is not a parseable date.
|
||||
"""
|
||||
try:
|
||||
return pd.Timestamp(value).strftime("%Y%m%d")
|
||||
except Exception as exc: # noqa: BLE001 - surfaced as a clear ValueError
|
||||
raise ValueError(f"Invalid date for eastmoney: {value!r}") from exc
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Eastmoney OHLCV loader (free, throttled HTTP, no auth)."""
|
||||
|
||||
name = "eastmoney"
|
||||
markets = {"a_share", "hk_equity", "us_equity"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available — uses unauthenticated throttled HTTP."""
|
||||
return True
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV for each symbol; a single failure never aborts the batch.
|
||||
|
||||
Args:
|
||||
codes: Symbols such as ``"600519.SH"``, ``"00700.HK"``, ``"AAPL.US"``.
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
interval: Bar interval label (e.g. ``"1D"``, ``"1H"``, ``"5m"``).
|
||||
fields: Accepted for protocol parity; the OHLCV columns are fixed.
|
||||
|
||||
Returns:
|
||||
Mapping ``{symbol: DataFrame}`` for every symbol that yielded bars.
|
||||
Each DataFrame has a ``trade_date`` ``DatetimeIndex`` and float
|
||||
columns ``open/high/low/close/volume``. Symbols that resolve to no
|
||||
data are omitted.
|
||||
|
||||
Raises:
|
||||
ValueError: ``start_date``/``end_date`` are malformed or inverted.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(
|
||||
code, start_date, end_date, interval
|
||||
),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort
|
||||
logger.warning("eastmoney failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, interval: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Resolve one symbol and build its OHLCV frame, or ``None`` on a miss.
|
||||
|
||||
Args:
|
||||
code: A single Vibe-Trading symbol.
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
interval: Bar interval label.
|
||||
|
||||
Returns:
|
||||
An OHLCV DataFrame, or ``None`` when the symbol/interval is
|
||||
unsupported or Eastmoney returns no bars.
|
||||
"""
|
||||
klt = eastmoney_client.KLT_BY_INTERVAL.get(interval)
|
||||
if klt is None:
|
||||
logger.warning("eastmoney unsupported interval %r for %s", interval, code)
|
||||
return None
|
||||
|
||||
secid = eastmoney_client.resolve_secid(code)
|
||||
if not secid:
|
||||
return None
|
||||
|
||||
rows = eastmoney_client.fetch_kline(
|
||||
secid,
|
||||
klt=klt,
|
||||
fqt=1,
|
||||
beg=_to_compact_date(start_date),
|
||||
end=_to_compact_date(end_date),
|
||||
)
|
||||
return self._frame_from_rows(rows)
|
||||
|
||||
@staticmethod
|
||||
def _frame_from_rows(rows: List[dict]) -> Optional[pd.DataFrame]:
|
||||
"""Assemble the canonical OHLCV DataFrame from client kline rows.
|
||||
|
||||
Args:
|
||||
rows: Ascending ``{trade_date, open, high, low, close, volume, ...}``
|
||||
dicts from :func:`eastmoney_client.fetch_kline`.
|
||||
|
||||
Returns:
|
||||
A DataFrame indexed by ``trade_date`` with float OHLCV columns, or
|
||||
``None`` when ``rows`` is empty or fully unparseable.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
if df.empty or "trade_date" not in df.columns:
|
||||
return None
|
||||
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df.index.name = "trade_date"
|
||||
|
||||
for column in _OHLCV_COLUMNS:
|
||||
if column not in df.columns:
|
||||
return None
|
||||
df[column] = pd.to_numeric(df[column], errors="coerce").astype(float)
|
||||
|
||||
df = df[_OHLCV_COLUMNS].dropna(subset=["open", "high", "low", "close"])
|
||||
if df.empty:
|
||||
return None
|
||||
return df
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Finnhub loader: key-gated US-equity OHLCV via the public stock-candle API.
|
||||
|
||||
Finnhub (https://finnhub.io) serves daily stock candles from a single REST
|
||||
endpoint, gated by a free API key passed as the ``token`` query parameter:
|
||||
|
||||
https://finnhub.io/api/v1/stock/candle?symbol=AAPL&resolution=D&from=...&to=...&token=...
|
||||
|
||||
Like other free quote providers it rate-limits by source IP, so every request
|
||||
routes through :func:`backtest.loaders._http.throttled_get_json` for per-host
|
||||
spacing plus session reuse. The endpoint answers with parallel arrays
|
||||
(``o``/``h``/``l``/``c``/``v`` plus epoch ``t``) and a status field ``s`` that
|
||||
reads ``"ok"`` on a hit or ``"no_data"`` for an empty window.
|
||||
|
||||
Symbol convention (Vibe-Trading -> Finnhub):
|
||||
* US ``AAPL.US`` -> ``AAPL`` (Finnhub carries US tickers bare)
|
||||
* Anything else is passed through uppercased (e.g. a bare ``MSFT``).
|
||||
|
||||
No market data is ever persisted in the repo; settled ranges may be cached to
|
||||
the user-home loader cache via :func:`cached_loader_fetch`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get_json
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_KEY_ENV = "FINNHUB_API_KEY"
|
||||
_CANDLE_URL = "https://finnhub.io/api/v1/stock/candle"
|
||||
|
||||
# Throttle bucket + default minimum spacing (free tier is ~30 req/s but bursts
|
||||
# get a 429; one shared bucket keeps the whole process polite).
|
||||
_HOST_KEY = "finnhub"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_FINNHUB_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL_S = 0.4
|
||||
|
||||
# Only daily candles are wired here; Finnhub also exposes intraday resolutions
|
||||
# but the project's us_equity fallback chain is daily-only.
|
||||
_RESOLUTION = "D"
|
||||
|
||||
# Finnhub packs parallel arrays under these keys; ``t`` carries epoch seconds.
|
||||
_FINNHUB_TO_OHLCV = {"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"}
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum spacing, honoring the env override."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
|
||||
|
||||
|
||||
def _to_finnhub_symbol(code: str) -> str:
|
||||
"""Translate a project symbol into Finnhub's ticker convention.
|
||||
|
||||
Args:
|
||||
code: Project-side symbol, e.g. ``AAPL.US`` or a bare ``MSFT``.
|
||||
|
||||
Returns:
|
||||
The Finnhub ticker: the ``.US`` suffix stripped, otherwise the
|
||||
uppercased symbol unchanged.
|
||||
"""
|
||||
upper = code.strip().upper()
|
||||
if upper.endswith(".US"):
|
||||
return upper[: -len(".US")]
|
||||
return upper
|
||||
|
||||
|
||||
def _to_epoch_seconds(date_str: str, *, end_of_day: bool) -> int:
|
||||
"""Convert a ``YYYY-MM-DD`` date to a UTC epoch-second bound.
|
||||
|
||||
Args:
|
||||
date_str: Inclusive date string.
|
||||
end_of_day: When ``True`` push the instant to 23:59:59 so the day's
|
||||
bar falls inside Finnhub's inclusive ``[from, to]`` window.
|
||||
|
||||
Returns:
|
||||
Epoch seconds (UTC) for the requested bound.
|
||||
"""
|
||||
ts = pd.Timestamp(date_str).normalize()
|
||||
if end_of_day:
|
||||
ts = ts + pd.Timedelta(hours=23, minutes=59, seconds=59)
|
||||
return int(ts.value // 1_000_000_000)
|
||||
|
||||
|
||||
def _rows_from_payload(payload: Any) -> List[Dict[str, Any]]:
|
||||
"""Zip Finnhub's parallel candle arrays into ascending row dicts.
|
||||
|
||||
Args:
|
||||
payload: Decoded JSON body from the stock-candle endpoint.
|
||||
|
||||
Returns:
|
||||
Ascending list of ``{trade_date, open, high, low, close, volume}`` dicts
|
||||
where ``trade_date`` is the raw epoch-second ``int`` (normalized to a
|
||||
``datetime64[ns]`` index later in :meth:`DataLoader._fetch_one`).
|
||||
Empty when the status is not ``"ok"`` or the timestamp array is missing,
|
||||
and per-bar gaps (a ``None`` in any OHLC slot) are skipped.
|
||||
"""
|
||||
if not isinstance(payload, dict) or payload.get("s") != "ok":
|
||||
return []
|
||||
|
||||
timestamps = payload.get("t") or []
|
||||
if not isinstance(timestamps, list):
|
||||
return []
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for index, epoch in enumerate(timestamps):
|
||||
values = {
|
||||
field: _at(payload.get(key), index)
|
||||
for key, field in _FINNHUB_TO_OHLCV.items()
|
||||
}
|
||||
if any(values[field] is None for field in ("open", "high", "low", "close")):
|
||||
continue
|
||||
# Keep the raw epoch here; the resolution is normalized in one place
|
||||
# (``_fetch_one``) so the index dtype matches the other loaders.
|
||||
row: Dict[str, Any] = {"trade_date": int(epoch)}
|
||||
row.update({field: _to_float(values[field]) for field in _OHLCV_COLUMNS})
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _at(series: Any, index: int) -> Any:
|
||||
"""Return ``series[index]`` when in range, else ``None``."""
|
||||
if isinstance(series, list) and 0 <= index < len(series):
|
||||
return series[index]
|
||||
return None
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
"""Coerce a Finnhub numeric (or ``None``) to ``float``; ``None`` on failure."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Finnhub US-equity daily OHLCV loader (key-gated, throttled HTTP)."""
|
||||
|
||||
name = "finnhub"
|
||||
markets = {"us_equity"}
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the loader without touching the network or credentials.
|
||||
|
||||
Construction never raises on a missing key; availability is reported
|
||||
separately via :meth:`is_available` so the fallback chain can keep
|
||||
walking when the key is absent.
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return whether a Finnhub API key is present in the environment."""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
return bool(get_env_config().data.finnhub_api_key)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily OHLCV history keyed by the original project symbols.
|
||||
|
||||
Each symbol is fetched independently through the opt-in loader cache;
|
||||
a single failing symbol is logged and skipped so it never aborts the
|
||||
batch.
|
||||
|
||||
Args:
|
||||
codes: Project symbols such as ``AAPL.US``.
|
||||
start_date: Inclusive start date in ``YYYY-MM-DD`` format.
|
||||
end_date: Inclusive end date in ``YYYY-MM-DD`` format.
|
||||
interval: Backtest interval; only daily (``1D``) is supported.
|
||||
fields: Ignored; included for interface compatibility.
|
||||
|
||||
Returns:
|
||||
Mapping of input symbol to a DataFrame indexed by a ``trade_date``
|
||||
:class:`~pandas.DatetimeIndex` with float ``open``/``high``/``low``/
|
||||
``close``/``volume`` columns. Symbols without data are omitted.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``start_date`` > ``end_date``.
|
||||
"""
|
||||
del fields
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
# Daily-only candle resolution; do not silently return day bars for ``1H``.
|
||||
if str(interval).strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning(
|
||||
"finnhub supports daily bars only; rejecting interval=%r",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
api_key = get_env_config().data.finnhub_api_key
|
||||
if not api_key:
|
||||
logger.warning("finnhub fetch skipped: %s not set", _API_KEY_ENV)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(
|
||||
code, start_date, end_date, api_key
|
||||
),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc: # noqa: BLE001 - one symbol must not abort the batch
|
||||
logger.warning("finnhub failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, api_key: str
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and normalize one symbol's daily candles.
|
||||
|
||||
Args:
|
||||
code: Project-side symbol.
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
api_key: Finnhub API token for the ``token`` query parameter.
|
||||
|
||||
Returns:
|
||||
A normalized OHLCV DataFrame, or ``None`` when Finnhub reports no
|
||||
usable data for the window.
|
||||
"""
|
||||
payload = throttled_get_json(
|
||||
_CANDLE_URL,
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params={
|
||||
"symbol": _to_finnhub_symbol(code),
|
||||
"resolution": _RESOLUTION,
|
||||
"from": _to_epoch_seconds(start_date, end_of_day=False),
|
||||
"to": _to_epoch_seconds(end_date, end_of_day=True),
|
||||
"token": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
rows = _rows_from_payload(payload)
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
# Epoch seconds -> nanosecond-resolution DatetimeIndex so the index
|
||||
# dtype (``datetime64[ns]``) stays consistent with the other loaders
|
||||
# rather than collapsing to the second resolution pandas infers from a
|
||||
# ``unit="s"`` conversion.
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"], unit="s").astype(
|
||||
"datetime64[ns]"
|
||||
)
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df[_OHLCV_COLUMNS].astype(float).dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Financial Modeling Prep (FMP) loader: key-gated US-equity OHLCV via HTTP.
|
||||
|
||||
FMP exposes a daily historical-price endpoint that, like other free quote
|
||||
providers, rate-limits by source IP and must be throttled. Every request here
|
||||
routes through :mod:`backtest.loaders._http` so calls share one process-wide
|
||||
minimum-spacing gate and a reused session.
|
||||
|
||||
API format (public, documented):
|
||||
https://financialmodelingprep.com/api/v3/historical-price-full/{SYMBOL}
|
||||
?from=YYYY-MM-DD&to=YYYY-MM-DD&apikey=KEY
|
||||
|
||||
The JSON body is ``{"symbol": "AAPL", "historical": [{date, open, high, low,
|
||||
close, volume}, ...]}`` in descending date order; an unknown symbol or empty
|
||||
window yields an empty/absent ``historical`` array.
|
||||
|
||||
Auth: set ``FMP_API_KEY`` in the environment. Covers US equities only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get_json
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_KEY_ENV = "FMP_API_KEY"
|
||||
_BASE_URL = "https://financialmodelingprep.com/api/v3/historical-price-full"
|
||||
|
||||
# Shared throttle/session bucket for every FMP request in this process.
|
||||
_HOST_KEY = "fmp"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_FMP_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL_S = 0.3
|
||||
|
||||
# FMP daily bars carry these numeric fields; emitted in this column order.
|
||||
_OHLCV_FIELDS = ("open", "high", "low", "close", "volume")
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
"""Return the FMP API key from the environment, stripped (``""`` if unset)."""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
return get_env_config().data.fmp_api_key.strip()
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum spacing, honoring the env override."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
|
||||
|
||||
|
||||
def _fmp_symbol(code: str) -> str:
|
||||
"""Translate a project symbol into FMP's bare-ticker convention.
|
||||
|
||||
FMP carries US tickers bare, so a trailing ``.US`` suffix (the project's
|
||||
US-equity marker) is dropped; everything else is upper-cased and passed
|
||||
through unchanged.
|
||||
|
||||
Args:
|
||||
code: Project-side symbol, e.g. ``AAPL`` or ``AAPL.US``.
|
||||
|
||||
Returns:
|
||||
The FMP ticker (suffix stripped, upper-cased).
|
||||
"""
|
||||
cleaned = code.strip().upper()
|
||||
if cleaned.endswith(".US"):
|
||||
cleaned = cleaned[: -len(".US")]
|
||||
return cleaned
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Financial Modeling Prep US-equity OHLCV loader (key-gated, HTTP)."""
|
||||
|
||||
name = "fmp"
|
||||
markets = {"us_equity"}
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when ``FMP_API_KEY`` is set to a non-empty value."""
|
||||
return bool(_api_key())
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily OHLCV bars from FMP, one symbol at a time.
|
||||
|
||||
A single failing symbol is logged and skipped so it never aborts the
|
||||
rest of the batch.
|
||||
|
||||
Args:
|
||||
codes: Project symbols (e.g. ``["AAPL", "MSFT.US"]``).
|
||||
start_date: Inclusive start date, ``YYYY-MM-DD``.
|
||||
end_date: Inclusive end date, ``YYYY-MM-DD``.
|
||||
interval: Bar size; only ``"1D"`` is supported (others skipped).
|
||||
fields: Ignored — FMP returns a fixed OHLCV schema.
|
||||
|
||||
Returns:
|
||||
Mapping ``{symbol: DataFrame(trade_date, open, high, low, close,
|
||||
volume)}`` for every symbol that returned non-empty data.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``start_date`` > ``end_date`` (via
|
||||
:func:`validate_date_range`).
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if str(interval).strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning("fmp only supports 1D bars; got interval=%r", interval)
|
||||
return {}
|
||||
|
||||
if not self.is_available():
|
||||
logger.warning("fmp fetch skipped: %s not set", _API_KEY_ENV)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("fmp failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and parse one symbol's daily bars; ``None`` on no data.
|
||||
|
||||
Args:
|
||||
code: Project symbol to fetch.
|
||||
start_date: Inclusive start date, ``YYYY-MM-DD``.
|
||||
end_date: Inclusive end date, ``YYYY-MM-DD``.
|
||||
|
||||
Returns:
|
||||
An ascending OHLCV DataFrame indexed by ``trade_date``, or ``None``
|
||||
when FMP reports no bars for the symbol/window.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the API key is missing at call time.
|
||||
requests.RequestException: Propagated from the HTTP layer.
|
||||
"""
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
raise RuntimeError(f"{_API_KEY_ENV} is not set")
|
||||
|
||||
symbol = _fmp_symbol(code)
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
payload = throttled_get_json(
|
||||
f"{_BASE_URL}/{symbol}",
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params={"from": start_date, "to": end_date, "apikey": api_key},
|
||||
)
|
||||
return _parse_historical(payload)
|
||||
|
||||
|
||||
def _parse_historical(payload: Any) -> Optional[pd.DataFrame]:
|
||||
"""Convert an FMP historical-price body into an ascending OHLCV frame.
|
||||
|
||||
Args:
|
||||
payload: Decoded JSON body from the historical-price endpoint.
|
||||
|
||||
Returns:
|
||||
DataFrame indexed by ``trade_date`` with float ``open/high/low/close/
|
||||
volume`` columns, or ``None`` when no usable rows are present.
|
||||
"""
|
||||
historical = (payload or {}).get("historical") if isinstance(payload, dict) else None
|
||||
if not historical:
|
||||
return None
|
||||
|
||||
rows = []
|
||||
for bar in historical:
|
||||
if not isinstance(bar, dict) or "date" not in bar:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"trade_date": bar["date"],
|
||||
**{field: bar.get(field) for field in _OHLCV_FIELDS},
|
||||
}
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
for field in _OHLCV_FIELDS:
|
||||
# Cast to float (not just to_numeric) so integer volume from the API
|
||||
# does not leave the column int64 and break the float-OHLCV contract.
|
||||
df[field] = pd.to_numeric(df[field], errors="coerce").astype(float)
|
||||
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df[list(_OHLCV_FIELDS)].dropna(subset=["open", "high", "low", "close"])
|
||||
if df.empty:
|
||||
return None
|
||||
return df
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Translate sparse SEC facts into PIT-safe daily fundamental panels.
|
||||
|
||||
The loader's core invariant is point-in-time safety: values become visible on
|
||||
their SEC ``filed`` date, never on ``period_end``. Phase 1 implements the SEC
|
||||
branch only. ``freq="ttm"`` uses a pragmatic Phase-1 approximation: known
|
||||
income-statement and cash-flow concepts are rolling four-quarter sums keyed by
|
||||
``period_end`` and anchored by the latest ``filed`` date in the rolling window;
|
||||
balance-sheet and share-count concepts use the latest quarterly value. SEC
|
||||
companyfacts can mix true-quarter, year-to-date, and annual facts, so upstream
|
||||
schema normalization may still be needed for issuer-specific precision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders import sec_edgar_client
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.sec_frames import ANNUAL_SPAN_DAYS, QUARTER_SPAN_DAYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_FREQS = {"annual", "quarterly", "ttm"}
|
||||
_ANNUAL_FORMS = {"10-K"}
|
||||
_QUARTERLY_FORMS = {"10-Q", "10-K"}
|
||||
|
||||
_FLOW_CONCEPTS = {
|
||||
"RevenueFromContractWithCustomerExcludingAssessedTax",
|
||||
"RevenueFromContractWithCustomerIncludingAssessedTax",
|
||||
"Revenues",
|
||||
"SalesRevenueNet",
|
||||
"CostOfGoodsAndServicesSold",
|
||||
"CostOfRevenue",
|
||||
"OperatingIncomeLoss",
|
||||
"NetIncomeLoss",
|
||||
"ProfitLoss",
|
||||
"NetCashProvidedByUsedInOperatingActivities",
|
||||
"PaymentsToAcquirePropertyPlantAndEquipment",
|
||||
}
|
||||
|
||||
|
||||
def _extract_concept_series(
|
||||
facts: dict[str, Any],
|
||||
concepts: list[str],
|
||||
freq: str,
|
||||
*,
|
||||
pit: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""Extract a sparse ``period_end``/``filed``/``value`` series.
|
||||
|
||||
Args:
|
||||
facts: SEC companyfacts payload.
|
||||
concepts: Ordered us-gaap concept aliases to union.
|
||||
freq: ``"annual"``, ``"quarterly"``, or ``"ttm"``.
|
||||
pit: When true, duplicate ``period_end`` rows keep the earliest filing;
|
||||
when false, they keep the latest filing for research mode.
|
||||
|
||||
Returns:
|
||||
DataFrame with ``period_end``, ``filed``, and ``value`` columns.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``freq`` is unsupported.
|
||||
"""
|
||||
if freq not in _VALID_FREQS:
|
||||
raise ValueError(f"unsupported fundamental freq: {freq}")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
us_gaap = facts.get("facts", {}).get("us-gaap", {})
|
||||
for concept_order, concept in enumerate(concepts):
|
||||
concept_node = us_gaap.get(concept, {})
|
||||
units = concept_node.get("units", {}) if isinstance(concept_node, dict) else {}
|
||||
if not isinstance(units, dict):
|
||||
continue
|
||||
for unit_items in units.values():
|
||||
if not isinstance(unit_items, list):
|
||||
continue
|
||||
for item in unit_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if not _form_allowed(str(item.get("form", "")), freq):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"period_start": item.get("start"),
|
||||
"period_end": item.get("end"),
|
||||
"filed": item.get("filed"),
|
||||
"value": item.get("val"),
|
||||
"_concept_order": concept_order,
|
||||
}
|
||||
)
|
||||
|
||||
columns = ["period_end", "filed", "value"]
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=columns)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df["period_start"] = pd.to_datetime(df["period_start"], errors="coerce").dt.normalize()
|
||||
df["period_end"] = pd.to_datetime(df["period_end"], errors="coerce").dt.normalize()
|
||||
df["filed"] = pd.to_datetime(df["filed"], errors="coerce").dt.normalize()
|
||||
df["value"] = pd.to_numeric(df["value"], errors="coerce")
|
||||
df = df.dropna(subset=["period_end", "filed", "value"])
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=columns)
|
||||
|
||||
keep = "first" if pit else "last"
|
||||
if _is_flow_concept_series(concepts):
|
||||
# Duration entries mix true quarters, year-to-date frames sharing the
|
||||
# same end date, and full-year frames; summing them blindly double
|
||||
# counts. Select by (start, end) span before any dedupe or rolling.
|
||||
df = df.dropna(subset=["period_start"])
|
||||
df = df.sort_values(["period_start", "period_end", "filed", "_concept_order"])
|
||||
df = df.drop_duplicates(subset=["period_start", "period_end"], keep=keep)
|
||||
duration = (df["period_end"] - df["period_start"]).dt.days
|
||||
if freq == "annual":
|
||||
df = df[duration.between(*ANNUAL_SPAN_DAYS)]
|
||||
else:
|
||||
df = _quarterly_flow_frames(df, duration)
|
||||
else:
|
||||
df = df.sort_values(["period_end", "filed", "_concept_order"])
|
||||
|
||||
df = df.drop_duplicates(subset=["period_end"], keep=keep).sort_values("period_end")
|
||||
|
||||
if freq == "ttm" and _is_flow_concept_series(concepts):
|
||||
df = _to_ttm_flow_series(df)
|
||||
|
||||
return df.loc[:, columns].sort_values("period_end").reset_index(drop=True)
|
||||
|
||||
|
||||
def _quarterly_flow_frames(df: pd.DataFrame, duration: pd.Series) -> pd.DataFrame:
|
||||
"""Return true-quarter flow frames, deriving fiscal Q4 from 10-K full years.
|
||||
|
||||
Filers like AAPL report Q4 only inside the 10-K full-year frame, so a
|
||||
missing quarter ending at the fiscal year end is synthesized as
|
||||
``FY - (Q1 + Q2 + Q3)``. The synthesized row is anchored on the 10-K
|
||||
``filed`` date, which keeps it PIT-safe.
|
||||
"""
|
||||
quarters = df[duration.between(*QUARTER_SPAN_DAYS)].copy()
|
||||
annuals = df[duration.between(*ANNUAL_SPAN_DAYS)]
|
||||
quarter_ends = set(quarters["period_end"])
|
||||
synthesized: list[dict[str, Any]] = []
|
||||
for annual in annuals.to_dict("records"):
|
||||
if annual["period_end"] in quarter_ends:
|
||||
continue
|
||||
inside = quarters[
|
||||
(quarters["period_start"] >= annual["period_start"])
|
||||
& (quarters["period_end"] < annual["period_end"])
|
||||
]
|
||||
if len(inside) != 3:
|
||||
continue
|
||||
synthesized.append(
|
||||
{
|
||||
"period_start": inside["period_end"].max(),
|
||||
"period_end": annual["period_end"],
|
||||
"filed": max(annual["filed"], inside["filed"].max()),
|
||||
"value": annual["value"] - inside["value"].sum(),
|
||||
"_concept_order": annual["_concept_order"],
|
||||
}
|
||||
)
|
||||
if synthesized:
|
||||
quarters = pd.concat([quarters, pd.DataFrame(synthesized)], ignore_index=True)
|
||||
return quarters
|
||||
|
||||
|
||||
def _ffill_pit(series: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series:
|
||||
"""Forward-fill sparse values onto ``index`` using ``filed`` as the anchor.
|
||||
|
||||
Args:
|
||||
series: DataFrame containing ``filed`` and ``value`` columns.
|
||||
index: Target dates.
|
||||
|
||||
Returns:
|
||||
Dense Series indexed like ``index``.
|
||||
"""
|
||||
target_index = pd.DatetimeIndex(index)
|
||||
if series.empty:
|
||||
return pd.Series(index=target_index, dtype="float64")
|
||||
|
||||
sparse = series.copy()
|
||||
sparse["filed"] = pd.to_datetime(sparse["filed"], errors="coerce").dt.normalize()
|
||||
sparse["value"] = pd.to_numeric(sparse["value"], errors="coerce")
|
||||
sparse = sparse.dropna(subset=["filed", "value"]).sort_values("filed")
|
||||
if sparse.empty:
|
||||
return pd.Series(index=target_index, dtype="float64")
|
||||
|
||||
values = sparse.set_index("filed")["value"].sort_index()
|
||||
values = values[~values.index.duplicated(keep="last")]
|
||||
aligned_index = values.index.union(target_index)
|
||||
return values.reindex(aligned_index).ffill().reindex(target_index)
|
||||
|
||||
|
||||
def load_fundamental_panel(
|
||||
symbols: list[str],
|
||||
fields: list[str],
|
||||
start: str,
|
||||
end: str,
|
||||
freq: str = "ttm",
|
||||
pit: bool = True,
|
||||
source: str = "auto",
|
||||
index: pd.DatetimeIndex | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Load SEC fundamental fields as dense date-by-symbol panels.
|
||||
|
||||
Args:
|
||||
symbols: Tickers to load.
|
||||
fields: Unified schema field names, raw or derived.
|
||||
start: Inclusive start date.
|
||||
end: Inclusive end date.
|
||||
freq: ``"annual"``, ``"quarterly"``, or ``"ttm"``.
|
||||
pit: True keeps first filed facts per period; false keeps latest filed.
|
||||
source: ``"auto"`` or ``"sec"``. Phase 1 routes both to SEC.
|
||||
index: Optional target date index. When omitted, calendar days from
|
||||
``start`` through ``end`` are used.
|
||||
|
||||
Returns:
|
||||
Mapping ``field -> DataFrame(index=dates, columns=symbols)``.
|
||||
|
||||
Raises:
|
||||
ValueError: If source or frequency is unsupported.
|
||||
RuntimeError: If the fundamental schema module is unavailable.
|
||||
"""
|
||||
validate_date_range(start, end)
|
||||
if freq not in _VALID_FREQS:
|
||||
raise ValueError(f"unsupported fundamental freq: {freq}")
|
||||
if source not in {"auto", "sec"}:
|
||||
raise ValueError("Phase 1 fundamentals loader supports source='auto' or 'sec' only")
|
||||
|
||||
target_index = _target_index(start, end, index)
|
||||
schema = _load_schema()
|
||||
requested_fields = [_resolve_field_name(schema, field) for field in fields]
|
||||
raw_fields = sorted(_collect_raw_fields(schema, requested_fields))
|
||||
symbol_list = list(symbols)
|
||||
|
||||
ciks = _resolve_ciks(symbol_list)
|
||||
symbol_raw_frames = {
|
||||
symbol: _load_symbol_raw_frame(
|
||||
symbol=symbol,
|
||||
cik=ciks.get(symbol),
|
||||
raw_fields=raw_fields,
|
||||
schema=schema,
|
||||
start=start,
|
||||
end=end,
|
||||
freq=freq,
|
||||
pit=pit,
|
||||
index=target_index,
|
||||
)
|
||||
for symbol in symbol_list
|
||||
}
|
||||
|
||||
panels: dict[str, pd.DataFrame] = {}
|
||||
for field in raw_fields:
|
||||
panels[field] = pd.DataFrame(
|
||||
{symbol: symbol_raw_frames[symbol][field] for symbol in symbol_list},
|
||||
index=target_index,
|
||||
columns=symbol_list,
|
||||
)
|
||||
|
||||
def panel_for(field: str) -> pd.DataFrame:
|
||||
if field in panels:
|
||||
return panels[field]
|
||||
derived = _derived_field(schema, field)
|
||||
if derived is None:
|
||||
panels[field] = _empty_panel(target_index, symbol_list)
|
||||
return panels[field]
|
||||
deps = {
|
||||
_resolve_field_name(schema, dep): panel_for(_resolve_field_name(schema, dep))
|
||||
for dep in _derived_dependencies(derived)
|
||||
}
|
||||
computed = _compute_derived(derived, deps)
|
||||
panels[field] = computed.reindex(index=target_index, columns=symbol_list)
|
||||
return panels[field]
|
||||
|
||||
return {field: panel_for(field) for field in requested_fields}
|
||||
|
||||
|
||||
def _form_allowed(form: str, freq: str) -> bool:
|
||||
if freq == "annual":
|
||||
return form in _ANNUAL_FORMS
|
||||
if freq in {"quarterly", "ttm"}:
|
||||
return form in _QUARTERLY_FORMS
|
||||
return False
|
||||
|
||||
|
||||
def _is_flow_concept_series(concepts: Iterable[str]) -> bool:
|
||||
return any(concept in _FLOW_CONCEPTS for concept in concepts)
|
||||
|
||||
|
||||
def _to_ttm_flow_series(df: pd.DataFrame) -> pd.DataFrame:
|
||||
ttm = df.sort_values("period_end").copy()
|
||||
ttm["value"] = ttm["value"].rolling(window=4, min_periods=4).sum()
|
||||
filed_values = list(ttm["filed"])
|
||||
ttm["filed"] = [
|
||||
pd.NaT if pos < 3 else max(filed_values[pos - 3 : pos + 1])
|
||||
for pos in range(len(filed_values))
|
||||
]
|
||||
return ttm.dropna(subset=["filed", "value"])
|
||||
|
||||
|
||||
def _target_index(
|
||||
start: str,
|
||||
end: str,
|
||||
index: pd.DatetimeIndex | None,
|
||||
) -> pd.DatetimeIndex:
|
||||
if index is not None:
|
||||
return pd.DatetimeIndex(index)
|
||||
return pd.date_range(pd.Timestamp(start), pd.Timestamp(end), freq="D")
|
||||
|
||||
|
||||
def _load_schema() -> Any:
|
||||
try:
|
||||
return importlib.import_module("backtest.loaders._fundamental_schema")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"backtest.loaders._fundamental_schema is required for fundamentals loading"
|
||||
) from exc
|
||||
|
||||
|
||||
def _resolve_field_name(schema: Any, field: str) -> str:
|
||||
normalized = str(field).removeprefix("fund:")
|
||||
resolver = getattr(schema, "resolve_field", None)
|
||||
if not callable(resolver):
|
||||
return normalized
|
||||
resolved = resolver(normalized)
|
||||
if isinstance(resolved, str):
|
||||
return resolved.removeprefix("fund:")
|
||||
name = getattr(resolved, "name", None)
|
||||
if isinstance(name, str):
|
||||
return name.removeprefix("fund:")
|
||||
return normalized
|
||||
|
||||
|
||||
def _collect_raw_fields(schema: Any, fields: Iterable[str]) -> set[str]:
|
||||
raw: set[str] = set()
|
||||
visiting: set[str] = set()
|
||||
|
||||
def visit(field: str) -> None:
|
||||
if field in visiting:
|
||||
raise ValueError(f"cyclic derived fundamental field: {field}")
|
||||
derived = _derived_field(schema, field)
|
||||
if derived is None:
|
||||
raw.add(field)
|
||||
return
|
||||
visiting.add(field)
|
||||
for dep in _derived_dependencies(derived):
|
||||
visit(_resolve_field_name(schema, dep))
|
||||
visiting.remove(field)
|
||||
|
||||
for field in fields:
|
||||
visit(field)
|
||||
return raw
|
||||
|
||||
|
||||
def _derived_field(schema: Any, field: str) -> Any | None:
|
||||
return getattr(schema, "DERIVED_FIELDS", {}).get(field)
|
||||
|
||||
|
||||
def _spec_get(derived: Any, key: str) -> Any:
|
||||
"""Read a derived-field spec entry from either a mapping or an object."""
|
||||
if isinstance(derived, dict):
|
||||
return derived.get(key)
|
||||
return getattr(derived, key, None)
|
||||
|
||||
|
||||
def _derived_dependencies(derived: Any) -> list[str]:
|
||||
deps = _spec_get(derived, "dependencies") or ()
|
||||
return [str(dep) for dep in deps]
|
||||
|
||||
|
||||
def _compute_derived(derived: Any, dependencies: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
compute = _spec_get(derived, "compute")
|
||||
if not callable(compute):
|
||||
raise ValueError(f"derived field has no callable compute: {derived!r}")
|
||||
try:
|
||||
result = compute(dependencies)
|
||||
except TypeError:
|
||||
result = compute(**dependencies)
|
||||
if not isinstance(result, pd.DataFrame):
|
||||
result = pd.DataFrame(result)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_ciks(symbols: list[str]) -> dict[str, str | None]:
|
||||
"""Map each symbol to its SEC CIK.
|
||||
|
||||
Args:
|
||||
symbols: Requested symbols.
|
||||
|
||||
Returns:
|
||||
A symbol -> CIK mapping; the value is ``None`` for a symbol the SEC
|
||||
ticker table does not carry.
|
||||
|
||||
Raises:
|
||||
ValueError: If no symbol resolves. Returning an all-null panel here
|
||||
reads as "this issuer reports nothing" rather than "this loader
|
||||
cannot serve this market", which is the more damaging of the two.
|
||||
"""
|
||||
ciks: dict[str, str | None] = {}
|
||||
missing: list[str] = []
|
||||
for symbol in symbols:
|
||||
cik = sec_edgar_client.cik_for(symbol)
|
||||
ciks[symbol] = cik
|
||||
if cik is None:
|
||||
missing.append(symbol)
|
||||
if missing:
|
||||
logger.warning("No SEC CIK for symbols: %s", ", ".join(missing))
|
||||
if symbols and len(missing) == len(symbols):
|
||||
raise ValueError(
|
||||
f"no SEC CIK resolved for any of: {', '.join(missing)}. The "
|
||||
"fundamentals loader is US-only (SEC XBRL); pass a US ticker with "
|
||||
"or without a .US suffix, e.g. 'AAPL' or 'AAPL.US'."
|
||||
)
|
||||
return ciks
|
||||
|
||||
|
||||
def _load_symbol_raw_frame(
|
||||
*,
|
||||
symbol: str,
|
||||
cik: str | None,
|
||||
raw_fields: list[str],
|
||||
schema: Any,
|
||||
start: str,
|
||||
end: str,
|
||||
freq: str,
|
||||
pit: bool,
|
||||
index: pd.DatetimeIndex,
|
||||
) -> pd.DataFrame:
|
||||
if cik is None:
|
||||
return _empty_field_frame(index, raw_fields)
|
||||
|
||||
def fetch() -> pd.DataFrame:
|
||||
return _fetch_symbol_raw_frame(
|
||||
symbol=symbol,
|
||||
cik=cik,
|
||||
raw_fields=raw_fields,
|
||||
schema=schema,
|
||||
freq=freq,
|
||||
pit=pit,
|
||||
index=index,
|
||||
)
|
||||
|
||||
cached = cached_loader_fetch(
|
||||
source="sec_fundamentals",
|
||||
symbol=symbol,
|
||||
timeframe=f"{freq}:pit={int(pit)}",
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
fields=raw_fields,
|
||||
fetch=fetch,
|
||||
)
|
||||
if cached is None:
|
||||
return _empty_field_frame(index, raw_fields)
|
||||
return cached.reindex(index=index, columns=raw_fields)
|
||||
|
||||
|
||||
def _fetch_symbol_raw_frame(
|
||||
*,
|
||||
symbol: str,
|
||||
cik: str,
|
||||
raw_fields: list[str],
|
||||
schema: Any,
|
||||
freq: str,
|
||||
pit: bool,
|
||||
index: pd.DatetimeIndex,
|
||||
) -> pd.DataFrame:
|
||||
try:
|
||||
facts = sec_edgar_client.get_company_facts(cik)
|
||||
except Exception as exc: # noqa: BLE001 - one bad symbol should not abort the panel
|
||||
logger.warning("SEC companyfacts failed for %s: %s", symbol, exc)
|
||||
return _empty_field_frame(index, raw_fields)
|
||||
|
||||
frame = _empty_field_frame(index, raw_fields)
|
||||
concept_map = getattr(schema, "SEC_CONCEPT_MAP", {})
|
||||
for field in raw_fields:
|
||||
concepts = list(concept_map.get(field, ()))
|
||||
if not concepts:
|
||||
logger.warning("SEC concept miss for %s field %s: no aliases configured", symbol, field)
|
||||
continue
|
||||
sparse = _extract_concept_series(facts, concepts, freq, pit=pit)
|
||||
if sparse.empty:
|
||||
logger.warning(
|
||||
"SEC concept miss for %s field %s: aliases=%s",
|
||||
symbol,
|
||||
field,
|
||||
",".join(concepts),
|
||||
)
|
||||
continue
|
||||
frame[field] = _ffill_pit(sparse, index)
|
||||
return frame
|
||||
|
||||
|
||||
def _empty_field_frame(index: pd.DatetimeIndex, fields: list[str]) -> pd.DataFrame:
|
||||
return pd.DataFrame(index=index, columns=fields, dtype="float64")
|
||||
|
||||
|
||||
def _empty_panel(index: pd.DatetimeIndex, symbols: list[str]) -> pd.DataFrame:
|
||||
return pd.DataFrame(index=index, columns=symbols, dtype="float64")
|
||||
+111
-22
@@ -3,12 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import NoAvailableSourceError, validate_date_range
|
||||
from backtest.loaders.base import (
|
||||
NoAvailableSourceError,
|
||||
loader_cache_get,
|
||||
loader_cache_put,
|
||||
validate_date_range,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,10 +20,18 @@ logger = logging.getLogger(__name__)
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
_INTERVAL_MAP: dict[str, str] = {
|
||||
"1m": "K_1M",
|
||||
"5m": "K_5M",
|
||||
"15m": "K_15M",
|
||||
"30m": "K_30M",
|
||||
"1D": "K_DAY",
|
||||
"1d": "K_DAY",
|
||||
"1H": "K_60M",
|
||||
"1h": "K_60M",
|
||||
"4H": "K_240M",
|
||||
"4h": "K_240M",
|
||||
"1W": "K_WEEK",
|
||||
"1w": "K_WEEK",
|
||||
"1M": "K_MON",
|
||||
}
|
||||
|
||||
@@ -47,10 +59,24 @@ def _to_futu_ktype(interval: str):
|
||||
"""Map project interval string to a futu KLType enum value.
|
||||
|
||||
Lazy-imports futu so the module can be imported without futu installed.
|
||||
Unknown intervals fall back to K_DAY.
|
||||
Unsupported intervals fail explicitly so requested bar fidelity is never
|
||||
changed silently.
|
||||
"""
|
||||
from futu import KLType # noqa: PLC0415
|
||||
return getattr(KLType, _INTERVAL_MAP.get(interval.strip(), "K_DAY"))
|
||||
|
||||
token = interval.strip()
|
||||
attr = _INTERVAL_MAP.get(token)
|
||||
if attr is None:
|
||||
raise NoAvailableSourceError(
|
||||
f"unsupported Futu interval: {interval!r}; "
|
||||
f"supported intervals: {sorted(_INTERVAL_MAP)}"
|
||||
)
|
||||
try:
|
||||
return getattr(KLType, attr)
|
||||
except AttributeError as exc:
|
||||
raise NoAvailableSourceError(
|
||||
f"installed Futu SDK does not expose KLType.{attr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _normalize_frame(df: pd.DataFrame) -> pd.DataFrame:
|
||||
@@ -73,7 +99,8 @@ def _normalize_frame(df: pd.DataFrame) -> pd.DataFrame:
|
||||
result = result.apply(pd.to_numeric, errors="coerce")
|
||||
result["volume"] = result["volume"].fillna(0.0)
|
||||
result = result.dropna(subset=["open", "high", "low", "close"])
|
||||
return result.sort_index()
|
||||
result = result.sort_index()
|
||||
return result[~result.index.duplicated(keep="last")]
|
||||
|
||||
|
||||
@register
|
||||
@@ -89,12 +116,18 @@ class FutuLoader:
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._host = os.environ.get("FUTU_HOST", "127.0.0.1")
|
||||
self._port = int(os.environ.get("FUTU_PORT", "11111"))
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
cfg = get_env_config().data
|
||||
self._host = cfg.futu_host
|
||||
self._port = cfg.futu_port
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True if env vars are set and FutuOpenD is reachable."""
|
||||
if not os.environ.get("FUTU_HOST") or not os.environ.get("FUTU_PORT"):
|
||||
"""Return True if FutuOpenD is reachable."""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
cfg = get_env_config().data
|
||||
if not cfg.futu_host or not cfg.futu_port:
|
||||
return False
|
||||
try:
|
||||
import futu # noqa: PLC0415
|
||||
@@ -119,7 +152,7 @@ class FutuLoader:
|
||||
codes: Project symbols such as ``700.HK`` or ``000001.SZ``.
|
||||
start_date: Start date in ``YYYY-MM-DD`` format.
|
||||
end_date: End date in ``YYYY-MM-DD`` format.
|
||||
interval: Backtest interval — ``1D``, ``1H``, or ``4H``.
|
||||
interval: Backtest interval supported by :data:`_INTERVAL_MAP`.
|
||||
fields: Ignored; included for interface compatibility.
|
||||
|
||||
Returns:
|
||||
@@ -132,9 +165,38 @@ class FutuLoader:
|
||||
if not codes:
|
||||
return {}
|
||||
validate_date_range(start_date, end_date)
|
||||
if interval.strip() not in _INTERVAL_MAP:
|
||||
raise NoAvailableSourceError(
|
||||
f"unsupported Futu interval: {interval!r}; "
|
||||
f"supported intervals: {sorted(_INTERVAL_MAP)}"
|
||||
)
|
||||
|
||||
results: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
# Serve cached symbols first; only open a FutuOpenD connection when at
|
||||
# least one symbol is uncached, so a fully-cached request needs no
|
||||
# running gateway.
|
||||
pending: List[str] = []
|
||||
for code in codes:
|
||||
cached = loader_cache_get(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
)
|
||||
if cached is not None and not cached.empty:
|
||||
results[code] = cached.copy()
|
||||
else:
|
||||
pending.append(code)
|
||||
|
||||
if not pending:
|
||||
return results
|
||||
|
||||
try:
|
||||
import futu # noqa: PLC0415
|
||||
|
||||
ktype = _to_futu_ktype(interval)
|
||||
ctx = futu.OpenQuoteContext(host=self._host, port=self._port)
|
||||
except Exception as exc:
|
||||
@@ -142,21 +204,48 @@ class FutuLoader:
|
||||
f"Cannot connect to FutuOpenD at {self._host}:{self._port}: {exc}"
|
||||
) from exc
|
||||
|
||||
results: Dict[str, pd.DataFrame] = {}
|
||||
try:
|
||||
for code in codes:
|
||||
for code in pending:
|
||||
futu_code = _to_futu_symbol(code)
|
||||
ret, data = ctx.request_history_kline(
|
||||
futu_code,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
ktype=ktype,
|
||||
max_count=10_000,
|
||||
)
|
||||
if ret != futu.RET_OK:
|
||||
logger.warning("Futu returned error for %s: %s", futu_code, data)
|
||||
page_key = None
|
||||
pages: List[pd.DataFrame] = []
|
||||
failed = False
|
||||
while True:
|
||||
ret, data, next_page_key = ctx.request_history_kline(
|
||||
futu_code,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
ktype=ktype,
|
||||
max_count=10_000,
|
||||
page_req_key=page_key,
|
||||
)
|
||||
if ret != futu.RET_OK:
|
||||
logger.warning(
|
||||
"Futu returned error for %s: %s", futu_code, data
|
||||
)
|
||||
failed = True
|
||||
break
|
||||
if isinstance(data, pd.DataFrame) and not data.empty:
|
||||
pages.append(data)
|
||||
if not next_page_key:
|
||||
break
|
||||
page_key = next_page_key
|
||||
|
||||
if failed or not pages:
|
||||
continue
|
||||
results[code] = _normalize_frame(data)
|
||||
normalized = _normalize_frame(pd.concat(pages, ignore_index=True))
|
||||
if normalized.empty:
|
||||
continue
|
||||
loader_cache_put(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
frame=normalized,
|
||||
)
|
||||
results[code] = normalized
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""India broker data bridge: feed Shoonya / Dhan history into the backtest layer.
|
||||
|
||||
The Shoonya (Finvasia) and Dhan connectors already expose live-account market
|
||||
data via ``get_historical_bars`` (read path). This loader adapts that envelope
|
||||
into the standard OHLCV frame so a user's *broker* history can back the same
|
||||
backtests as the public Yahoo feed — useful when matching a live account exactly
|
||||
or pulling symbols Yahoo lacks.
|
||||
|
||||
It is an OPT-IN source (``requires_auth``): ``is_available`` is True only when a
|
||||
broker SDK is importable AND a broker is configured, so it trails Yahoo/yfinance
|
||||
in the ``india_equity`` fallback chain and never fires in CI / unconfigured runs.
|
||||
|
||||
Symbol convention: project ``RELIANCE.NS`` / ``500325.BO`` → broker ``RELIANCE``
|
||||
on exchange ``NSE`` / ``BSE`` (the suffix selects the exchange; the base symbol
|
||||
is passed bare).
|
||||
|
||||
Limitation: broker endpoints return a bounded window of *recent* bars (period +
|
||||
limit), not an arbitrary historical start. This loader requests enough bars to
|
||||
cover the window and clips to ``[start_date, end_date]``; very old ranges may
|
||||
come back short. For deep history prefer Yahoo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OUTPUT_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
# Project interval -> broker ``period`` token (both connectors share this set).
|
||||
# ``1h``/``1d`` alias the project-style tokens; ``1m`` vs ``1M`` stays case-sensitive.
|
||||
_PERIOD_MAP = {
|
||||
"1D": "1d",
|
||||
"1d": "1d",
|
||||
"1H": "1h",
|
||||
"1h": "1h",
|
||||
"5m": "5m",
|
||||
"15m": "15m",
|
||||
"30m": "30m",
|
||||
"1m": "1m",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_broker():
|
||||
"""Return ``(broker_key, sdk_module)`` for the first available India broker.
|
||||
|
||||
Prefers Shoonya, then Dhan. Returns ``(None, None)`` when neither the SDK
|
||||
nor a config is present. Import is deferred and defensive so a missing
|
||||
``src.trading`` package or broker SDK simply means "unavailable", never a
|
||||
crash in the loader registry.
|
||||
"""
|
||||
try:
|
||||
from src.trading.connectors.shoonya import sdk as shoonya_sdk
|
||||
|
||||
if shoonya_sdk.shoonya_available():
|
||||
return "shoonya", shoonya_sdk
|
||||
except Exception as exc: # noqa: BLE001 — optional dependency / config
|
||||
logger.debug("shoonya bridge unavailable: %s", exc)
|
||||
try:
|
||||
from src.trading.connectors.dhan import sdk as dhan_sdk
|
||||
|
||||
if dhan_sdk.dhan_available():
|
||||
return "dhan", dhan_sdk
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("dhan bridge unavailable: %s", exc)
|
||||
return None, None
|
||||
|
||||
|
||||
def _exchange_for(code: str) -> str:
|
||||
"""Map the project suffix to the broker exchange code (NSE / BSE)."""
|
||||
return "BSE" if code.strip().upper().endswith(".BO") else "NSE"
|
||||
|
||||
|
||||
def _base_symbol(code: str) -> str:
|
||||
"""Strip the ``.NS`` / ``.BO`` suffix, leaving the broker's bare symbol."""
|
||||
cleaned = code.strip()
|
||||
upper = cleaned.upper()
|
||||
if upper.endswith((".NS", ".BO")):
|
||||
return cleaned[:-3]
|
||||
return cleaned
|
||||
|
||||
|
||||
def _bars_to_frame(bars: list[dict], start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Convert the broker ``bars`` list into a clipped OHLCV frame, or ``None``."""
|
||||
if not bars:
|
||||
return None
|
||||
frame = pd.DataFrame(bars)
|
||||
if "time" not in frame.columns:
|
||||
return None
|
||||
# ``time`` is epoch seconds (Shoonya ssboe / Dhan candle[0]) or an ISO string.
|
||||
ts = pd.to_numeric(frame["time"], errors="coerce")
|
||||
if ts.notna().any():
|
||||
index = pd.to_datetime(ts, unit="s", errors="coerce")
|
||||
else:
|
||||
index = pd.to_datetime(frame["time"], errors="coerce")
|
||||
frame = frame.drop(columns=["time"])
|
||||
frame.index = pd.DatetimeIndex(index).tz_localize(None)
|
||||
frame.index.name = "trade_date"
|
||||
|
||||
for col in _OUTPUT_COLUMNS:
|
||||
if col not in frame.columns:
|
||||
frame[col] = 0.0 if col == "volume" else pd.NA
|
||||
frame = frame[_OUTPUT_COLUMNS].apply(pd.to_numeric, errors="coerce")
|
||||
frame = frame.dropna(subset=["open", "high", "low", "close"]).sort_index()
|
||||
|
||||
lower = pd.Timestamp(start_date).normalize()
|
||||
upper = pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)
|
||||
frame = frame[(frame.index >= lower) & (frame.index < upper)]
|
||||
return frame.astype(float) if not frame.empty else None
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Shoonya / Dhan history adapter for the ``india_equity`` market."""
|
||||
|
||||
name = "india_broker"
|
||||
markets = {"india_equity"}
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""True only when an India broker SDK is importable and configured."""
|
||||
broker, _ = _resolve_broker()
|
||||
return broker is not None
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV history for ``codes`` from the configured India broker."""
|
||||
del fields
|
||||
if not codes:
|
||||
return {}
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
broker, sdk = _resolve_broker()
|
||||
if sdk is None:
|
||||
return {}
|
||||
|
||||
period = _PERIOD_MAP.get(str(interval).strip())
|
||||
if period is None:
|
||||
# Do not silently substitute daily bars for an unsupported interval
|
||||
# (e.g. runner ``4H`` used to fall through to ``1d``).
|
||||
logger.warning(
|
||||
"india broker only supports %s; rejecting interval=%r",
|
||||
sorted(_PERIOD_MAP),
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
# Request enough bars to cover the window (business days + headroom).
|
||||
span_days = max((pd.Timestamp(end_date) - pd.Timestamp(start_date)).days, 1)
|
||||
limit = min(max(span_days, 30), 2000)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
envelope = sdk.get_historical_bars(
|
||||
_base_symbol(code),
|
||||
exchange=_exchange_for(code),
|
||||
period=period,
|
||||
limit=limit,
|
||||
)
|
||||
except TypeError:
|
||||
# Dhan uses ``exchange_segment``; retry without the ``exchange`` kw.
|
||||
try:
|
||||
envelope = sdk.get_historical_bars(
|
||||
_base_symbol(code), period=period, limit=limit
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — one bad symbol never aborts
|
||||
logger.warning("%s bridge failed for %s: %s", broker, code, exc)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("%s bridge failed for %s: %s", broker, code, exc)
|
||||
continue
|
||||
|
||||
if not isinstance(envelope, dict) or str(envelope.get("status", "")).lower() != "ok":
|
||||
continue
|
||||
frame = _bars_to_frame(envelope.get("bars", []), start_date, end_date)
|
||||
if frame is not None and not frame.empty:
|
||||
result[code] = frame
|
||||
return result
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Local data loader: reads CSV, Parquet, and DuckDB files from user config.
|
||||
|
||||
Configuration lives at ``~/.vibe-trading/data-bridge/config.yaml``.
|
||||
Each entry maps a symbol to a data file with optional column-name overrides,
|
||||
date format, and (for DuckDB) an SQL query.
|
||||
|
||||
Example config::
|
||||
|
||||
sources:
|
||||
- symbol: "AAPL.US"
|
||||
type: csv
|
||||
path: "~/data/aapl_2024.csv"
|
||||
columns:
|
||||
date: "Date"
|
||||
open: "Open"
|
||||
high: "High"
|
||||
low: "Low"
|
||||
close: "Close"
|
||||
volume: "Volume"
|
||||
date_format: "%Y-%m-%d"
|
||||
|
||||
- symbol: "BTC-USDT"
|
||||
type: parquet
|
||||
path: "~/data/btc.parquet"
|
||||
|
||||
- symbol: "MYINDEX"
|
||||
type: duckdb
|
||||
db_path: "~/data/market.duckdb"
|
||||
query: "SELECT * FROM prices WHERE ticker = 'MYINDEX'"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range, validate_ohlc
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONFIG_DIR = Path.home() / ".vibe-trading" / "data-bridge"
|
||||
_CONFIG_PATH = _CONFIG_DIR / "config.yaml"
|
||||
|
||||
_DEFAULT_COLUMNS = {
|
||||
"date": "date",
|
||||
"open": "open",
|
||||
"high": "high",
|
||||
"low": "low",
|
||||
"close": "close",
|
||||
"volume": "volume",
|
||||
}
|
||||
|
||||
# Map the project's bar intervals (see ``backtest.runner._VALID_INTERVALS``) to
|
||||
# pandas offset aliases used for resampling. All entries are fixed-duration so
|
||||
# ``pd.Timedelta`` of the alias is well defined.
|
||||
_RESAMPLE_RULES = {
|
||||
"1m": "1min",
|
||||
"5m": "5min",
|
||||
"15m": "15min",
|
||||
"30m": "30min",
|
||||
"1H": "1h",
|
||||
"1h": "1h",
|
||||
"4H": "4h",
|
||||
"4h": "4h",
|
||||
"1D": "1D",
|
||||
"1d": "1D",
|
||||
}
|
||||
|
||||
_OHLCV_AGG = {
|
||||
"open": "first",
|
||||
"high": "max",
|
||||
"low": "min",
|
||||
"close": "last",
|
||||
"volume": "sum",
|
||||
}
|
||||
|
||||
|
||||
def _resample_to_interval(df: pd.DataFrame, interval: str, symbol: str) -> pd.DataFrame:
|
||||
"""Resample an OHLCV frame to the requested bar ``interval``.
|
||||
|
||||
Local files can hold any native granularity, so a requested interval the
|
||||
file does not already match must be honored explicitly. Previously
|
||||
``interval`` was passed only to the cache key, so requesting e.g. ``4H``
|
||||
against hourly data silently returned the file's native bars.
|
||||
|
||||
Coarser-than-source requests are downsampled with the standard OHLCV
|
||||
aggregation. A finer-than-source request cannot be fabricated from a file,
|
||||
so the source bars are returned unchanged with a warning. Requests that
|
||||
already match the source granularity are returned unchanged.
|
||||
"""
|
||||
rule = _RESAMPLE_RULES.get(interval)
|
||||
if df.empty:
|
||||
return df
|
||||
if rule is None:
|
||||
logger.warning(
|
||||
"local loader: unsupported interval %r for %s; returning source bars",
|
||||
interval,
|
||||
symbol,
|
||||
)
|
||||
return df
|
||||
|
||||
target = pd.Timedelta(rule)
|
||||
if len(df.index) >= 2:
|
||||
source = df.index.to_series().diff().dropna().median()
|
||||
if pd.notna(source):
|
||||
if target < source:
|
||||
logger.warning(
|
||||
"local loader: cannot upsample %s source bars to %s for %s; "
|
||||
"returning source bars",
|
||||
source,
|
||||
interval,
|
||||
symbol,
|
||||
)
|
||||
return df
|
||||
if target == source:
|
||||
return df
|
||||
|
||||
resampled = df.resample(rule).agg(_OHLCV_AGG)
|
||||
resampled = resampled.dropna(subset=["open", "high", "low", "close"])
|
||||
resampled.index.name = df.index.name
|
||||
return resampled
|
||||
|
||||
|
||||
def _load_config() -> dict[str, Any] | None:
|
||||
if not _CONFIG_PATH.exists():
|
||||
return None
|
||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _normalize_columns(
|
||||
df: pd.DataFrame,
|
||||
col_map: dict[str, str],
|
||||
date_fmt: str | None,
|
||||
) -> pd.DataFrame | None:
|
||||
rename: dict[str, str] = {}
|
||||
for std_name, src_name in col_map.items():
|
||||
if std_name == "date":
|
||||
continue
|
||||
if src_name in df.columns:
|
||||
rename[src_name] = std_name
|
||||
df = df.rename(columns=rename)
|
||||
|
||||
required = {"open", "high", "low", "close"}
|
||||
if not required.issubset(df.columns):
|
||||
return None
|
||||
|
||||
date_col = col_map.get("date", "date")
|
||||
if date_col not in df.columns:
|
||||
return None
|
||||
|
||||
if date_fmt:
|
||||
parsed_dates = pd.to_datetime(
|
||||
df[date_col], format=date_fmt, errors="coerce", utc=True
|
||||
)
|
||||
else:
|
||||
parsed_dates = pd.to_datetime(df[date_col], errors="coerce", utc=True)
|
||||
# Normalize every input to the loader's UTC-naive index contract. Parsing
|
||||
# as UTC first also handles files that span daylight-saving offset changes.
|
||||
df["trade_date"] = parsed_dates.dt.tz_convert(None)
|
||||
df = df.dropna(subset=["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
|
||||
ohlcv_cols: list[str] = ["open", "high", "low", "close"]
|
||||
if "volume" in df.columns:
|
||||
ohlcv_cols.append("volume")
|
||||
|
||||
df = df[ohlcv_cols]
|
||||
for col in ohlcv_cols:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
df = df.dropna(subset=["open", "high", "low", "close"])
|
||||
df = validate_ohlc(df)
|
||||
for col in ohlcv_cols:
|
||||
df[col] = df[col].astype("float64")
|
||||
|
||||
if "volume" not in df.columns:
|
||||
df["volume"] = 0.0
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def _read_csv(path: str, col_map: dict[str, str], date_fmt: str | None) -> pd.DataFrame | None:
|
||||
df = pd.read_csv(path)
|
||||
return _normalize_columns(df, col_map, date_fmt)
|
||||
|
||||
|
||||
def _read_parquet(path: str, col_map: dict[str, str], date_fmt: str | None) -> pd.DataFrame | None:
|
||||
df = pd.read_parquet(path)
|
||||
if isinstance(df.index, pd.DatetimeIndex):
|
||||
df = df.reset_index()
|
||||
if date_fmt is None and col_map.get("date", "date") not in df.columns:
|
||||
col_map = dict(col_map)
|
||||
col_map["date"] = df.columns[0]
|
||||
return _normalize_columns(df, col_map, date_fmt)
|
||||
|
||||
|
||||
def _read_duckdb(
|
||||
db_path: str, query: str, col_map: dict[str, str], date_fmt: str | None
|
||||
) -> pd.DataFrame | None:
|
||||
import duckdb
|
||||
|
||||
with duckdb.connect(db_path, read_only=True) as conn:
|
||||
df = conn.execute(query).df()
|
||||
return _normalize_columns(df, col_map, date_fmt)
|
||||
|
||||
|
||||
_READERS = {
|
||||
"csv": _read_csv,
|
||||
"parquet": _read_parquet,
|
||||
"duckdb": _read_duckdb,
|
||||
}
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Config-driven local data loader for CSV, Parquet, and DuckDB."""
|
||||
|
||||
name = "local"
|
||||
markets = {"us_equity", "a_share", "hk_equity", "crypto", "futures", "forex", "macro", "fund"}
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._config: dict[str, Any] | None = None
|
||||
self._source_by_symbol: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when the YAML config file exists and has sources."""
|
||||
config = _load_config()
|
||||
if config is None:
|
||||
return False
|
||||
sources = config.get("sources")
|
||||
return isinstance(sources, list) and len(sources) > 0
|
||||
|
||||
def _ensure_config(self) -> None:
|
||||
if self._config is not None:
|
||||
return
|
||||
self._config = _load_config() or {}
|
||||
self._source_by_symbol = {}
|
||||
for entry in self._config.get("sources", []):
|
||||
symbol = entry.get("symbol", "").strip()
|
||||
if symbol:
|
||||
self._source_by_symbol[symbol] = entry
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV data for each code from configured local sources.
|
||||
|
||||
Args:
|
||||
codes: Symbol list, optionally prefixed with ``local:``.
|
||||
start_date: YYYY-MM-DD.
|
||||
end_date: YYYY-MM-DD.
|
||||
interval: Bar size (all intervals supported if data contains them).
|
||||
fields: Ignored.
|
||||
|
||||
Returns:
|
||||
Mapping clean_symbol -> OHLCV DataFrame.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
self._ensure_config()
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
clean = code.split(":", 1)[1] if code.startswith("local:") else code
|
||||
entry = self._source_by_symbol.get(clean)
|
||||
if entry is None:
|
||||
logger.warning("local loader: no config entry for symbol %s", clean)
|
||||
continue
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=clean,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda c=clean: self._fetch_one(c, start_date, end_date, interval),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[clean] = df
|
||||
except Exception as exc:
|
||||
logger.warning("local loader failed for %s: %s", clean, exc)
|
||||
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, symbol: str, start_date: str, end_date: str, interval: str = "1D",
|
||||
) -> pd.DataFrame | None:
|
||||
entry = self._source_by_symbol.get(symbol)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
src_type: str = entry.get("type", "csv").strip().lower()
|
||||
reader = _READERS.get(src_type)
|
||||
if reader is None:
|
||||
logger.warning("local loader: unsupported type %r for symbol %s", src_type, symbol)
|
||||
return None
|
||||
|
||||
col_map: dict[str, str] = dict(_DEFAULT_COLUMNS)
|
||||
user_cols = entry.get("columns")
|
||||
if isinstance(user_cols, dict):
|
||||
for k, v in user_cols.items():
|
||||
if isinstance(v, str):
|
||||
col_map[k] = v
|
||||
|
||||
date_fmt: str | None = None
|
||||
user_fmt = entry.get("date_format")
|
||||
if isinstance(user_fmt, str) and user_fmt.strip():
|
||||
date_fmt = user_fmt.strip()
|
||||
|
||||
if src_type == "duckdb":
|
||||
db_path = str(Path(entry.get("db_path", "")).expanduser())
|
||||
query = entry.get("query", "").strip()
|
||||
if not db_path or not query:
|
||||
logger.warning(
|
||||
"local loader: missing db_path or query for duckdb symbol %s", symbol
|
||||
)
|
||||
return None
|
||||
df = _read_duckdb(db_path, query, col_map, date_fmt)
|
||||
else:
|
||||
path = entry.get("path", "").strip()
|
||||
if not path:
|
||||
logger.warning("local loader: missing path for symbol %s", symbol)
|
||||
return None
|
||||
expanded = str(Path(path).expanduser())
|
||||
df = reader(expanded, col_map, date_fmt)
|
||||
|
||||
if df is None:
|
||||
return None
|
||||
|
||||
start = pd.Timestamp(start_date)
|
||||
# Treat ``end_date`` as inclusive of the whole day so intraday bars on
|
||||
# the end day survive the filter (a bare midnight bound dropped them,
|
||||
# which would defeat any sub-daily ``interval``).
|
||||
end = pd.Timestamp(end_date) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
|
||||
df = df[(df.index >= start) & (df.index <= end)]
|
||||
if df.empty:
|
||||
return None
|
||||
df = _resample_to_interval(df, interval, symbol)
|
||||
if df.empty:
|
||||
return None
|
||||
return df
|
||||
@@ -0,0 +1,411 @@
|
||||
"""LongPort (Longbridge) OpenAPI-backed loader for US and HK equity OHLCV data.
|
||||
|
||||
Wraps the ``longbridge`` SDK :class:`~longbridge.openapi.QuoteContext` to fetch
|
||||
historical candlesticks for backtesting. Supports US and HK equities.
|
||||
|
||||
Auth requires the three LongPort credentials declared in
|
||||
``src.config.env_schema.DataConfig``:
|
||||
``LONGBRIDGE_APP_KEY``, ``LONGBRIDGE_APP_SECRET``, ``LONGBRIDGE_ACCESS_TOKEN``.
|
||||
|
||||
Paper-vs-live identity guard: this loader does **not** discriminate between
|
||||
paper and live environments (LongPort exposes no API field for it). The loaded
|
||||
Access Token implicitly selects the environment. For backtest purposes the
|
||||
historical bars are identical regardless of source account.
|
||||
|
||||
The LongPort ``history_candlesticks_by_date`` endpoint caps responses at
|
||||
~1000 bars per call. Date ranges longer than ~4 years of daily bars are
|
||||
automatically split into sequential 180-day windows. Requests wider than the
|
||||
bounded window budget fail explicitly instead of returning truncated history.
|
||||
|
||||
This module is for backtest data only; live trading uses the separate
|
||||
``src.trading.connectors.longbridge.sdk`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import (
|
||||
NoAvailableSourceError,
|
||||
loader_cache_get,
|
||||
loader_cache_put,
|
||||
validate_date_range,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
from src.trading.connectors.longbridge.credentials import (
|
||||
LongbridgeCredentialError,
|
||||
resolve_longbridge_credentials,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
_INTERVAL_MAP: dict[str, str] = {
|
||||
"1D": "Day",
|
||||
"1d": "Day",
|
||||
"1W": "Week",
|
||||
"1w": "Week",
|
||||
"1M": "Month",
|
||||
"1H": "Min_60",
|
||||
"1h": "Min_60",
|
||||
"1m": "Min_1",
|
||||
"5m": "Min_5",
|
||||
"15m": "Min_15",
|
||||
"30m": "Min_30",
|
||||
}
|
||||
|
||||
# LongPort returns at most ~1000 bars per call. For wide ranges we split into
|
||||
# sequential windows of this many days to avoid silent truncation.
|
||||
_MAX_WINDOW_DAYS = 180
|
||||
# Cap on the number of windows so a pathological request can't loop forever.
|
||||
_MAX_WINDOWS = 20
|
||||
|
||||
|
||||
class LongbridgeDependencyError(RuntimeError):
|
||||
"""Raised when the ``longbridge`` SDK is not installed."""
|
||||
|
||||
|
||||
def _require_longbridge():
|
||||
"""Import and return the ``longbridge.openapi`` module.
|
||||
|
||||
Raises:
|
||||
LongbridgeDependencyError: If the SDK is not installed.
|
||||
"""
|
||||
try:
|
||||
from longbridge import openapi # noqa: PLC0415
|
||||
except ImportError as exc:
|
||||
raise LongbridgeDependencyError(
|
||||
"The 'longbridge' SDK is not installed. "
|
||||
"Run: pip install 'vibe-trading-ai[longbridge]'"
|
||||
) from exc
|
||||
return openapi
|
||||
|
||||
|
||||
def _to_longport_symbol(code: str) -> str:
|
||||
"""Convert a project symbol to LongPort format.
|
||||
|
||||
LongPort accepts ``AAPL.US``, ``700.HK``, ``000001.SZ``, ``600519.SH``.
|
||||
Bare codes without a ``.`` suffix get ``.US`` appended so the resolver
|
||||
treats them as US equities (loader ``markets`` is us_equity + hk_equity;
|
||||
ambiguous codes lean US).
|
||||
|
||||
Examples:
|
||||
AAPL -> AAPL.US
|
||||
AAPL.US -> AAPL.US
|
||||
700.HK -> 700.HK
|
||||
0700.HK -> 0700.HK
|
||||
000001.SZ -> 000001.SZ
|
||||
600519.SH -> 600519.SH
|
||||
"""
|
||||
upper = code.strip().upper()
|
||||
if "." in upper:
|
||||
return upper
|
||||
return f"{upper}.US"
|
||||
|
||||
|
||||
def _to_longport_period(interval: str):
|
||||
"""Map a project interval string to a LongPort ``Period`` enum value.
|
||||
|
||||
Lazy-imports the SDK so this module can be imported without it installed.
|
||||
Unsupported intervals fail explicitly so requested bar fidelity is never
|
||||
changed silently.
|
||||
"""
|
||||
openapi = _require_longbridge()
|
||||
period_cls = getattr(openapi, "Period")
|
||||
token = interval.strip()
|
||||
attr = _INTERVAL_MAP.get(token)
|
||||
if attr is None:
|
||||
raise NoAvailableSourceError(
|
||||
f"unsupported Longbridge interval: {interval!r}; "
|
||||
f"supported intervals: {sorted(_INTERVAL_MAP)}"
|
||||
)
|
||||
try:
|
||||
return getattr(period_cls, attr)
|
||||
except AttributeError as exc:
|
||||
raise NoAvailableSourceError(
|
||||
f"installed Longbridge SDK does not expose Period.{attr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _date_windows(start: dt.date, end: dt.date) -> list[tuple[dt.date, dt.date]]:
|
||||
"""Split a wide date range into sequential windows to respect the
|
||||
LongPort ~1000-bar per-call cap.
|
||||
|
||||
Each window spans at most ``_MAX_WINDOW_DAYS`` days. Windows are capped
|
||||
at ``_MAX_WINDOWS`` so a pathological request cannot loop forever.
|
||||
"""
|
||||
requested_days = (end - start).days + 1
|
||||
maximum_days = _MAX_WINDOW_DAYS * _MAX_WINDOWS
|
||||
if requested_days > maximum_days:
|
||||
raise NoAvailableSourceError(
|
||||
f"Longbridge date range spans {requested_days} days and exceeds "
|
||||
f"the {maximum_days}-day window limit"
|
||||
)
|
||||
|
||||
windows: list[tuple[dt.date, dt.date]] = []
|
||||
cursor = start
|
||||
while cursor <= end and len(windows) < _MAX_WINDOWS:
|
||||
window_end = min(cursor + dt.timedelta(days=_MAX_WINDOW_DAYS - 1), end)
|
||||
windows.append((cursor, window_end))
|
||||
cursor = window_end + dt.timedelta(days=1)
|
||||
return windows
|
||||
|
||||
|
||||
def _normalize_frame(bars: list[Any]) -> pd.DataFrame:
|
||||
"""Normalise a list of LongPort Candlestick objects to OHLCV schema.
|
||||
|
||||
Args:
|
||||
bars: List of candlestick objects returned by the SDK.
|
||||
|
||||
Returns:
|
||||
DataFrame with columns [open, high, low, close, volume] indexed
|
||||
by ``trade_date`` (timezone-naive DatetimeIndex), sorted ascending.
|
||||
"""
|
||||
if not bars:
|
||||
return pd.DataFrame(columns=_OHLCV_COLUMNS)
|
||||
|
||||
rows = []
|
||||
for bar in bars:
|
||||
ts = getattr(bar, "timestamp", None)
|
||||
rows.append({
|
||||
"open": float(getattr(bar, "open", 0) or 0),
|
||||
"high": float(getattr(bar, "high", 0) or 0),
|
||||
"low": float(getattr(bar, "low", 0) or 0),
|
||||
"close": float(getattr(bar, "close", 0) or 0),
|
||||
"volume": float(getattr(bar, "volume", 0) or 0),
|
||||
"trade_date": pd.to_datetime(ts) if ts is not None else pd.NaT,
|
||||
})
|
||||
|
||||
result = pd.DataFrame(rows)
|
||||
result.index = result["trade_date"]
|
||||
result.index.name = "trade_date"
|
||||
result = result[_OHLCV_COLUMNS].copy()
|
||||
|
||||
# Standardise to timezone-naive UTC. Aware timestamps may be represented
|
||||
# in an exchange timezone by SDK versions even when the instant is UTC.
|
||||
if isinstance(result.index, pd.DatetimeIndex) and result.index.tz is not None:
|
||||
result.index = result.index.tz_convert("UTC").tz_localize(None)
|
||||
|
||||
result = result.dropna(subset=["open", "high", "low", "close"])
|
||||
result["volume"] = result["volume"].fillna(0.0)
|
||||
return result.sort_index()
|
||||
|
||||
|
||||
@register
|
||||
class LongbridgeLoader:
|
||||
"""Fetch US and HK equity bars from LongPort OpenAPI.
|
||||
|
||||
Resolves one atomic credential set from the ``LONGBRIDGE_*`` environment
|
||||
variables or the backward-compatible runtime file.
|
||||
"""
|
||||
|
||||
name = "longbridge"
|
||||
markets = {"us_equity", "hk_equity"}
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
resolution = resolve_longbridge_credentials()
|
||||
except LongbridgeCredentialError as exc:
|
||||
self._credential_source = None
|
||||
self._credential_error = exc
|
||||
self._app_key = self._app_secret = self._access_token = ""
|
||||
return
|
||||
self._credential_source = resolution.source
|
||||
self._credential_error: LongbridgeCredentialError | None = None
|
||||
if resolution.credentials is None:
|
||||
if resolution.conflict_fields:
|
||||
code = "credentials_conflict"
|
||||
fields = resolution.conflict_fields
|
||||
else:
|
||||
code = (
|
||||
"credentials_missing"
|
||||
if resolution.source is None
|
||||
else "credentials_partial"
|
||||
)
|
||||
fields = resolution.missing_fields
|
||||
self._credential_error = LongbridgeCredentialError(code, fields)
|
||||
self._app_key = self._app_secret = self._access_token = ""
|
||||
return
|
||||
|
||||
credentials = resolution.credentials
|
||||
self._app_key = credentials.app_key
|
||||
self._app_secret = credentials.app_secret
|
||||
self._access_token = credentials.access_token
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True if the LongPort SDK is installed and credentials exist.
|
||||
|
||||
Availability checks are side-effect free: they validate configured
|
||||
credentials and SDK importability without consuming quote API quota.
|
||||
"""
|
||||
if not (self._app_key and self._app_secret and self._access_token):
|
||||
return False
|
||||
try:
|
||||
_require_longbridge()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV history from LongPort OpenAPI.
|
||||
|
||||
Args:
|
||||
codes: Project symbols such as ``AAPL``, ``AAPL.US``, ``700.HK``,
|
||||
``000001.SZ`` or ``600519.SH``.
|
||||
start_date: Start date in ``YYYY-MM-DD`` format.
|
||||
end_date: End date in ``YYYY-MM-DD`` format.
|
||||
interval: Backtest interval — ``1D``, ``1W``, ``1M``, ``1H``.
|
||||
fields: Ignored; included for interface compatibility.
|
||||
|
||||
Returns:
|
||||
Mapping of input symbol to normalised OHLCV dataframe.
|
||||
|
||||
Raises:
|
||||
NoAvailableSourceError: If the SDK connection fails or
|
||||
credentials are missing.
|
||||
"""
|
||||
del fields
|
||||
if not codes:
|
||||
return {}
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
results: dict[str, pd.DataFrame] = {}
|
||||
|
||||
# Serve cached symbols first; only open an SDK connection when at
|
||||
# least one symbol is uncached, so a fully-cached request needs no
|
||||
# network call.
|
||||
pending: List[str] = []
|
||||
for code in codes:
|
||||
cached = loader_cache_get(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
)
|
||||
if cached is not None:
|
||||
results[code] = cached.copy()
|
||||
else:
|
||||
pending.append(code)
|
||||
|
||||
if not pending:
|
||||
return results
|
||||
|
||||
credential_error = getattr(self, "_credential_error", None)
|
||||
if credential_error is not None:
|
||||
if credential_error.code == "credentials_missing":
|
||||
message = (
|
||||
"Longbridge credentials are not configured; missing fields: "
|
||||
+ ", ".join(credential_error.fields)
|
||||
)
|
||||
elif credential_error.code == "credentials_partial":
|
||||
message = (
|
||||
"Longbridge credentials_partial; missing fields: "
|
||||
+ ", ".join(credential_error.fields)
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"Longbridge credentials_conflict; differing fields: "
|
||||
+ ", ".join(credential_error.fields)
|
||||
)
|
||||
raise NoAvailableSourceError(message) from None
|
||||
if not (self._app_key and self._app_secret and self._access_token):
|
||||
raise NoAvailableSourceError(
|
||||
"Longbridge credentials are not configured; set "
|
||||
"LONGBRIDGE_APP_KEY, LONGBRIDGE_APP_SECRET, and "
|
||||
"LONGBRIDGE_ACCESS_TOKEN"
|
||||
)
|
||||
|
||||
openapi = _require_longbridge()
|
||||
_init_error: str | None = None
|
||||
try:
|
||||
cfg = openapi.Config(
|
||||
self._app_key, self._app_secret, self._access_token,
|
||||
)
|
||||
ctx = openapi.QuoteContext(cfg)
|
||||
except LongbridgeDependencyError:
|
||||
raise
|
||||
except Exception:
|
||||
# Capture only stable text; do NOT raise inside the handler so
|
||||
# the original exception (which may contain secrets) cannot
|
||||
# leak into __context__.
|
||||
_init_error = "Longbridge SDK initialization failed."
|
||||
|
||||
if _init_error is not None:
|
||||
raise NoAvailableSourceError(_init_error)
|
||||
|
||||
period = _to_longport_period(interval)
|
||||
adjust_type = getattr(openapi, "AdjustType").NoAdjust
|
||||
try:
|
||||
start = dt.date.fromisoformat(start_date)
|
||||
end = dt.date.fromisoformat(end_date)
|
||||
except (TypeError, ValueError):
|
||||
raise NoAvailableSourceError(
|
||||
"Invalid Longbridge date range."
|
||||
) from None
|
||||
|
||||
windows = _date_windows(start, end)
|
||||
|
||||
try:
|
||||
for code in pending:
|
||||
lp_symbol = _to_longport_symbol(code)
|
||||
all_bars: list[Any] = []
|
||||
_window_error: str | None = None
|
||||
for w_start, w_end in windows:
|
||||
try:
|
||||
bars = ctx.history_candlesticks_by_date(
|
||||
lp_symbol, period, adjust_type,
|
||||
start=w_start, end=w_end,
|
||||
)
|
||||
if isinstance(bars, (list, tuple)):
|
||||
all_bars.extend(bars)
|
||||
elif bars is not None:
|
||||
all_bars.append(bars)
|
||||
except Exception:
|
||||
_window_error = "Longbridge history request failed."
|
||||
break
|
||||
else:
|
||||
_window_error = None
|
||||
|
||||
if _window_error is not None:
|
||||
raise NoAvailableSourceError(_window_error)
|
||||
|
||||
if not all_bars:
|
||||
logger.warning(
|
||||
"LongPort returned no data for %s in [%s, %s]",
|
||||
lp_symbol, start_date, end_date,
|
||||
)
|
||||
continue
|
||||
|
||||
normalized = _normalize_frame(all_bars)
|
||||
loader_cache_put(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
frame=normalized,
|
||||
)
|
||||
results[code] = normalized
|
||||
finally:
|
||||
# LongPort QuoteContext has no explicit close(); the SDK manages
|
||||
# its own connection pool. No cleanup needed.
|
||||
pass
|
||||
|
||||
return results
|
||||
@@ -17,7 +17,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -132,7 +132,15 @@ class DataLoader:
|
||||
)
|
||||
continue
|
||||
try:
|
||||
df = self._fetch_one(code, start_date, end_date, interval)
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date, interval),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
@@ -183,9 +191,10 @@ class DataLoader:
|
||||
if first_dt <= start_ts:
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
"mootdx: %s %s pagination hit cap (%d pages) without reaching %s",
|
||||
symbol, freq, _MAX_PAGES, start_date,
|
||||
raise ValueError(
|
||||
"incomplete mootdx history: "
|
||||
f"{symbol} frequency={freq} hit {_MAX_PAGES} pages "
|
||||
f"without reaching {start_date}"
|
||||
)
|
||||
if not chunks:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""MetaTrader 5 data loader — forex/metals history from a local MT5 terminal.
|
||||
|
||||
Feeds backtests and ``get_market_data`` from the user's own broker feed
|
||||
(e.g. Exness), with the broker's exact symbols and session times. Requires
|
||||
Windows, the optional ``MetaTrader5`` package (``pip install
|
||||
"vibe-trading-ai[mt5]"``), and a running, logged-in MT5 terminal — when any of
|
||||
those are missing :meth:`DataLoader.is_available` is ``False`` and the forex
|
||||
fallback chain degrades to akshare/yfinance/local.
|
||||
|
||||
Config is shared with the MT5 trading connector via ``~/.vibe-trading/mt5.json``
|
||||
(read directly — this module deliberately does not import the connector).
|
||||
With no config file, ``mt5.initialize()`` attaches to the last-used, already
|
||||
logged-in terminal, which is the primary read-only path.
|
||||
|
||||
History depth is bounded by the terminal's "Max bars in chart" setting.
|
||||
Broker account-type suffixes (Exness ``EURUSDm``) are discovered via
|
||||
``symbols_get`` and memoized; results are keyed by the caller's ORIGINAL
|
||||
code so the runner's coverage check (``set(codes) - set(data_map)``) holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range, validate_ohlc
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Shared file contract with the MT5 trading connector (extra keys tolerated).
|
||||
_MT5_CONFIG_PATH = Path.home() / ".vibe-trading" / "mt5.json"
|
||||
|
||||
#: Canonical interval token → MetaTrader5 timeframe constant name.
|
||||
#: Lowercase ``1h``/``4h``/``1d``/``1w`` alias project-style tokens (connector parity).
|
||||
#: ``1m`` (minute) and ``1M`` (month) differ by case.
|
||||
_INTERVAL_MAP = {
|
||||
"1m": "TIMEFRAME_M1", "5m": "TIMEFRAME_M5", "15m": "TIMEFRAME_M15",
|
||||
"30m": "TIMEFRAME_M30",
|
||||
"1H": "TIMEFRAME_H1", "1h": "TIMEFRAME_H1",
|
||||
"4H": "TIMEFRAME_H4", "4h": "TIMEFRAME_H4",
|
||||
"1D": "TIMEFRAME_D1", "1d": "TIMEFRAME_D1",
|
||||
"1W": "TIMEFRAME_W1", "1w": "TIMEFRAME_W1",
|
||||
"1M": "TIMEFRAME_MN1",
|
||||
}
|
||||
|
||||
#: Process-lifetime attach cache: ``initialize`` can take seconds (it may even
|
||||
#: launch the terminal), and chain resolution probes ``is_available`` often.
|
||||
_init_state: bool | None = None
|
||||
|
||||
#: base symbol → resolved broker symbol memo.
|
||||
_symbol_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def _import_mt5() -> ModuleType | None:
|
||||
"""Lazy-import the Windows-only SDK; ``None`` when absent."""
|
||||
try:
|
||||
import MetaTrader5 # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
return None
|
||||
return MetaTrader5
|
||||
|
||||
|
||||
def _read_mt5_config() -> dict[str, Any]:
|
||||
"""Best-effort read of the shared mt5.json ({} on missing/corrupt)."""
|
||||
try:
|
||||
raw = json.loads(_MT5_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _ensure_initialized() -> bool:
|
||||
"""Attach to the terminal once per process (result cached, even negative)."""
|
||||
global _init_state
|
||||
if _init_state is not None:
|
||||
return _init_state
|
||||
mt5 = _import_mt5()
|
||||
if mt5 is None:
|
||||
_init_state = False
|
||||
return False
|
||||
config = _read_mt5_config()
|
||||
kwargs: dict[str, Any] = {}
|
||||
if config.get("login"):
|
||||
kwargs["login"] = int(config["login"])
|
||||
kwargs["password"] = str(config.get("password") or "")
|
||||
kwargs["server"] = str(config.get("server") or "")
|
||||
if config.get("timeout"):
|
||||
kwargs["timeout"] = int(float(config["timeout"]) * 1000)
|
||||
args = (str(config["terminal_path"]),) if config.get("terminal_path") else ()
|
||||
try:
|
||||
ok = bool(mt5.initialize(*args, **kwargs))
|
||||
except Exception as exc: # noqa: BLE001 - availability probe must not raise
|
||||
logger.warning("mt5: initialize raised: %s", exc)
|
||||
ok = False
|
||||
if not ok:
|
||||
try:
|
||||
logger.warning("mt5: terminal attach failed: %s", mt5.last_error())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_init_state = ok
|
||||
return ok
|
||||
|
||||
|
||||
def _to_query_base(code: str) -> str:
|
||||
"""``EUR/USD`` / ``EURUSD.FX`` → ``EURUSD`` (upper, separators stripped)."""
|
||||
token = code.strip().upper()
|
||||
if token.endswith(".FX"):
|
||||
token = token[: -len(".FX")]
|
||||
for separator in ("/", "-", "_", " "):
|
||||
token = token.replace(separator, "")
|
||||
return token
|
||||
|
||||
|
||||
def _resolve_broker_symbol(mt5: ModuleType, code: str) -> str | None:
|
||||
"""Resolve a project code to the broker's symbol name (memoized).
|
||||
|
||||
Order: the raw token exactly (case-sensitive broker names pass through),
|
||||
the normalized base, then suffix discovery via ``symbols_get(f"{base}*")``
|
||||
— shortest name first, then alphabetical, which deterministically picks
|
||||
``EURUSDm`` over ``EURUSDz`` on Exness-style brokers.
|
||||
"""
|
||||
base = _to_query_base(code)
|
||||
cached = _symbol_cache.get(base)
|
||||
if cached is not None:
|
||||
return cached
|
||||
raw = code.strip()
|
||||
for candidate in dict.fromkeys((raw, base)): # ordered, deduped
|
||||
if candidate and mt5.symbol_info(candidate) is not None:
|
||||
_symbol_cache[base] = candidate
|
||||
return candidate
|
||||
matches = sorted(
|
||||
(getattr(info, "name", "") for info in (mt5.symbols_get(group=f"{base}*") or ())),
|
||||
key=lambda name: (len(name), name),
|
||||
)
|
||||
matches = [m for m in matches if m]
|
||||
if not matches:
|
||||
logger.warning("mt5: symbol %r not offered by this broker (no match for %s*)", code, base)
|
||||
return None
|
||||
_symbol_cache[base] = matches[0]
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _rates_to_frame(rates: Any, start_date: str, end_date: str) -> pd.DataFrame | None:
|
||||
"""Map a ``copy_rates_range`` structured array to the loader frame contract."""
|
||||
if rates is None or len(rates) == 0:
|
||||
return None
|
||||
frame = pd.DataFrame(rates)
|
||||
if "time" not in frame.columns or "close" not in frame.columns:
|
||||
return None
|
||||
frame["trade_date"] = pd.to_datetime(frame["time"], unit="s")
|
||||
frame = frame.set_index("trade_date").sort_index()
|
||||
# Forex real_volume is zero on most brokers; tick_volume is the standard proxy.
|
||||
frame["volume"] = frame.get("tick_volume", 0)
|
||||
# Bars carry intraday timestamps; the end date is inclusive of its whole day.
|
||||
start = pd.Timestamp(start_date)
|
||||
end = pd.Timestamp(end_date) + pd.Timedelta(days=1)
|
||||
frame = frame[(frame.index >= start) & (frame.index < end)]
|
||||
frame = frame[["open", "high", "low", "close", "volume"]].dropna()
|
||||
frame = validate_ohlc(frame)
|
||||
return frame if not frame.empty else None
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Forex/metals OHLCV from the local MT5 terminal (Windows, opt-in extra)."""
|
||||
|
||||
name = "mt5"
|
||||
markets = {"forex"}
|
||||
#: A running, logged-in terminal is the auth surface (futu precedent).
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None: # never raises — registry availability contract
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""SDK importable AND the terminal attach succeeded (cached)."""
|
||||
return _import_mt5() is not None and _ensure_initialized()
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: list[str] | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV frames keyed by the original input codes.
|
||||
|
||||
Per-symbol failures log and skip (never raise) so the runner's
|
||||
runtime fallback can engage for the missing symbols.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
timeframe_name = _INTERVAL_MAP.get(str(interval).strip())
|
||||
if timeframe_name is None:
|
||||
# Reject unknown tokens; do not fetch TIMEFRAME_D1 under the caller's key.
|
||||
logger.warning("mt5 unsupported interval %r; rejecting", interval)
|
||||
return {}
|
||||
|
||||
result: dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
clean = code.strip()
|
||||
if not clean:
|
||||
continue
|
||||
try:
|
||||
frame = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=_to_query_base(clean),
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda c=clean: self._fetch_one(c, start_date, end_date, timeframe_name),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - one symbol never poisons the batch
|
||||
logger.warning("mt5: fetch failed for %s: %s", clean, exc)
|
||||
continue
|
||||
if frame is not None and not frame.empty:
|
||||
result[code] = frame
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, timeframe_name: str
|
||||
) -> pd.DataFrame | None:
|
||||
"""Fetch one symbol from the terminal (``None`` on any failure)."""
|
||||
mt5 = _import_mt5()
|
||||
if mt5 is None or not _ensure_initialized():
|
||||
return None
|
||||
name = _resolve_broker_symbol(mt5, code)
|
||||
if name is None:
|
||||
return None
|
||||
try:
|
||||
mt5.symbol_select(name, True)
|
||||
timeframe = getattr(mt5, timeframe_name)
|
||||
# tz-aware UTC is mandatory: the MT5 API shifts naive datetimes by
|
||||
# the local timezone, silently offsetting the requested range.
|
||||
date_from = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
|
||||
date_to = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||
rates = mt5.copy_rates_range(name, timeframe, date_from, date_to)
|
||||
except Exception as exc: # noqa: BLE001 - terminal hiccups degrade, not raise
|
||||
logger.warning("mt5: rates fetch failed for %s (%s): %s", code, name, exc)
|
||||
return None
|
||||
return _rates_to_frame(rates, start_date, end_date)
|
||||
+253
-49
@@ -1,10 +1,27 @@
|
||||
"""OKX spot candle loader (crypto).
|
||||
|
||||
Uses OKX V5 public REST API (no auth).
|
||||
Supports 1m/5m/15m/30m/1H/4H/1D.
|
||||
Up to 300 bars per request; paginates with ``after`` for longer history.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
- ``/market/candles`` — recent bars only (limited depth; not enough for multi-year
|
||||
backtests).
|
||||
- ``/market/history-candles`` — multi-year history; used whenever the requested
|
||||
range is older than a few months or recent endpoint returns empty.
|
||||
|
||||
Hardening (2026-07 local audit)
|
||||
--------------------------------
|
||||
- Explicit proxy support (same env vars as CCXT loader); required on networks
|
||||
that time out direct HTTPS to www.okx.com.
|
||||
- Raise / retry on HTTP 429/5xx and OKX business ``code != "0"``.
|
||||
- Prefer ``history-candles`` for deep ranges so 2020-era backtests no longer
|
||||
return empty frames.
|
||||
- ``is_available()`` does a short probe instead of always returning True.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
@@ -12,21 +29,73 @@ from typing import Dict, List, Optional
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Project / connector period tokens -> OKX candle ``bar`` strings.
|
||||
# ``1m`` vs ``1M`` stays case-sensitive; hour/day accept either case.
|
||||
_INTERVAL_MAP = {
|
||||
"1m": "1m",
|
||||
"5m": "5m",
|
||||
"15m": "15m",
|
||||
"30m": "30m",
|
||||
"1h": "1H",
|
||||
"1H": "1H",
|
||||
"4h": "4H",
|
||||
"4H": "4H",
|
||||
"1d": "1D",
|
||||
"1D": "1D",
|
||||
}
|
||||
|
||||
from backtest.loaders.base import (
|
||||
cached_loader_fetch,
|
||||
check_budget,
|
||||
positive_env_float,
|
||||
positive_env_int,
|
||||
retry_with_budget,
|
||||
validate_date_range,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
BASE_URL = "https://www.okx.com/api/v5"
|
||||
CANDLES_PATH = f"{BASE_URL}/market/candles"
|
||||
HISTORY_CANDLES_PATH = f"{BASE_URL}/market/history-candles"
|
||||
_MAX_PER_PAGE = 300
|
||||
# P12-b parity: OKX already sets a per-request timeout but had no retry
|
||||
# budget, so a transient blip dropped the whole symbol and a slow tier
|
||||
# could stall ~max_pages*timeout. Bound it like the ccxt loader; retry
|
||||
# scheduling is delegated to :mod:`backtest.loaders.base`.
|
||||
_OKX_TIMEOUT = int(os.getenv("OKX_TIMEOUT_S", "15"))
|
||||
_OKX_FETCH_BUDGET_S = float(os.getenv("OKX_FETCH_BUDGET_S", "60"))
|
||||
# Recent endpoint typically only covers ~months of 1D bars; beyond this age
|
||||
# always hit history-candles first.
|
||||
_RECENT_ONLY_DAYS = 400
|
||||
|
||||
_OKX_TIMEOUT = positive_env_int("OKX_TIMEOUT_S", 20)
|
||||
_OKX_FETCH_BUDGET_S = positive_env_float("OKX_FETCH_BUDGET_S", 90.0)
|
||||
_OKX_PROBE_TIMEOUT = positive_env_int("OKX_PROBE_TIMEOUT_S", 8)
|
||||
|
||||
|
||||
def _first_proxy_env(*names: str) -> str:
|
||||
for name in names:
|
||||
value = os.getenv(name, "").strip() # noqa: env-gate — system proxy vars
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _okx_proxy_config() -> dict[str, str]:
|
||||
"""Build requests proxies from conventional env vars (parity with CCXT)."""
|
||||
all_proxy = _first_proxy_env("ALL_PROXY", "all_proxy")
|
||||
http_proxy = _first_proxy_env("HTTP_PROXY", "http_proxy") or all_proxy
|
||||
https_proxy = _first_proxy_env("HTTPS_PROXY", "https_proxy") or all_proxy or http_proxy
|
||||
proxies: dict[str, str] = {}
|
||||
if http_proxy:
|
||||
proxies["http"] = http_proxy
|
||||
if https_proxy:
|
||||
proxies["https"] = https_proxy
|
||||
return proxies
|
||||
|
||||
|
||||
def _okx_session() -> requests.Session:
|
||||
session = requests.Session()
|
||||
proxies = _okx_proxy_config()
|
||||
if proxies:
|
||||
session.proxies.update(proxies)
|
||||
return session
|
||||
|
||||
|
||||
@register
|
||||
@@ -38,8 +107,22 @@ class DataLoader:
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available (public API, no auth)."""
|
||||
return True
|
||||
"""Probe public candles with a short timeout (honours proxy env)."""
|
||||
try:
|
||||
session = _okx_session()
|
||||
resp = session.get(
|
||||
CANDLES_PATH,
|
||||
params={"instId": "BTC-USDT", "bar": "1D", "limit": "1"},
|
||||
timeout=_OKX_PROBE_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("OKX probe HTTP %s", resp.status_code)
|
||||
return False
|
||||
data = resp.json()
|
||||
return data.get("code") == "0" and bool(data.get("data"))
|
||||
except Exception as exc: # noqa: BLE001 — availability probe
|
||||
logger.warning("OKX probe failed: %s", exc)
|
||||
return False
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""No credentials required for public candles."""
|
||||
@@ -50,8 +133,9 @@ class DataLoader:
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: Optional[List[str]] = None,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch crypto OHLCV via OKX public API.
|
||||
|
||||
@@ -60,7 +144,7 @@ class DataLoader:
|
||||
start_date: Start date (YYYY-MM-DD).
|
||||
end_date: End date (YYYY-MM-DD).
|
||||
fields: Ignored (OKX has no extra fields).
|
||||
interval: Bar size (1m/5m/15m/30m/1H/4H/1D), default ``1D``.
|
||||
interval: Bar size (1m/5m/15m/30m/1h/1H/4h/4H/1d/1D), default ``1D``.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> DataFrame.
|
||||
@@ -68,50 +152,132 @@ class DataLoader:
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if fields:
|
||||
print(f"[WARN] OKX ignores extra fields: {fields}")
|
||||
logger.warning("OKX ignores extra fields: %s", fields)
|
||||
|
||||
valid_intervals = {"1m", "5m", "15m", "30m", "1H", "4H", "1D"}
|
||||
if interval not in valid_intervals:
|
||||
print(f"[WARN] unsupported OKX interval {interval}, using 1D")
|
||||
interval = "1D"
|
||||
# Case aliases: connector-style ``1h``/``4h`` must not fall through to daily.
|
||||
mapped = _INTERVAL_MAP.get(interval.strip())
|
||||
if mapped is None:
|
||||
logger.warning(
|
||||
"unsupported OKX interval %r; rejecting (supported: %s)",
|
||||
interval,
|
||||
sorted(set(_INTERVAL_MAP.values())),
|
||||
)
|
||||
return {}
|
||||
interval = mapped
|
||||
|
||||
codes = [c.replace("/", "-").upper() for c in codes]
|
||||
|
||||
start_ts = int(pd.Timestamp(start_date).timestamp() * 1000)
|
||||
end_ts = int((pd.Timestamp(end_date) + pd.Timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
max_pages = 200 if interval in ("1m", "5m") else 50 if interval in ("15m", "30m") else 20
|
||||
# More pages for minute bars; history endpoint still needs walk-back.
|
||||
if interval in ("1m", "5m"):
|
||||
max_pages = 200
|
||||
elif interval in ("15m", "30m"):
|
||||
max_pages = 80
|
||||
else:
|
||||
max_pages = 40
|
||||
|
||||
use_history = self._should_use_history(start_date)
|
||||
session = _okx_session()
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for symbol in codes:
|
||||
try:
|
||||
df = self._fetch_candles(symbol, start_ts, end_ts, interval, max_pages)
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=symbol,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda symbol=symbol, use_history=use_history: self._fetch_candles(
|
||||
session,
|
||||
symbol,
|
||||
start_ts,
|
||||
end_ts,
|
||||
interval,
|
||||
max_pages,
|
||||
prefer_history=use_history,
|
||||
),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[symbol] = df
|
||||
except Exception as exc:
|
||||
print(f"[WARN] failed to fetch {symbol}: {exc}")
|
||||
logger.warning("failed to fetch %s: %s", symbol, exc)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _should_use_history(start_date: str) -> bool:
|
||||
"""True when the range starts older than recent-only window."""
|
||||
try:
|
||||
start = pd.Timestamp(start_date)
|
||||
age_days = (pd.Timestamp.utcnow().tz_localize(None) - start).days
|
||||
return age_days > _RECENT_ONLY_DAYS
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _fetch_candles(
|
||||
self, inst_id: str, start_ts: int, end_ts: int,
|
||||
bar: str = "1D", max_pages: int = 20,
|
||||
self,
|
||||
session: requests.Session,
|
||||
inst_id: str,
|
||||
start_ts: int,
|
||||
end_ts: int,
|
||||
bar: str = "1D",
|
||||
max_pages: int = 20,
|
||||
*,
|
||||
prefer_history: bool = True,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Paginated candle download.
|
||||
"""Paginated candle download (history endpoint for deep ranges)."""
|
||||
endpoints: list[str] = []
|
||||
if prefer_history:
|
||||
endpoints = [HISTORY_CANDLES_PATH, CANDLES_PATH]
|
||||
else:
|
||||
endpoints = [CANDLES_PATH, HISTORY_CANDLES_PATH]
|
||||
|
||||
Args:
|
||||
inst_id: OKX instrument id.
|
||||
start_ts: Start time (ms).
|
||||
end_ts: End time (ms).
|
||||
bar: Bar size.
|
||||
max_pages: Max pagination rounds.
|
||||
last_error: Exception | None = None
|
||||
for endpoint in endpoints:
|
||||
try:
|
||||
df = self._paginate(
|
||||
session,
|
||||
endpoint,
|
||||
inst_id,
|
||||
start_ts,
|
||||
end_ts,
|
||||
bar,
|
||||
max_pages,
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
return df
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
logger.warning(
|
||||
"OKX %s failed for %s: %s — trying next endpoint",
|
||||
endpoint.rsplit("/", 1)[-1],
|
||||
inst_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
Returns:
|
||||
OHLCV DataFrame or None.
|
||||
"""
|
||||
if last_error is not None:
|
||||
logger.warning("OKX empty/failed for %s: %s", inst_id, last_error)
|
||||
else:
|
||||
logger.warning("OKX empty response: %s", inst_id)
|
||||
return None
|
||||
|
||||
def _paginate(
|
||||
self,
|
||||
session: requests.Session,
|
||||
endpoint: str,
|
||||
inst_id: str,
|
||||
start_ts: int,
|
||||
end_ts: int,
|
||||
bar: str,
|
||||
max_pages: int,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
all_rows: list = []
|
||||
after = str(end_ts)
|
||||
deadline = time.monotonic() + _OKX_FETCH_BUDGET_S
|
||||
label = f"OKX fetch for {inst_id}"
|
||||
label = f"OKX fetch for {inst_id} via {endpoint.rsplit('/', 1)[-1]}"
|
||||
|
||||
for _ in range(max_pages):
|
||||
check_budget(deadline, label, budget_s=_OKX_FETCH_BUDGET_S)
|
||||
@@ -122,47 +288,85 @@ class DataLoader:
|
||||
"after": after,
|
||||
}
|
||||
|
||||
def _do_request() -> dict:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/market/candles",
|
||||
def _do_request(params=params) -> dict:
|
||||
resp = session.get(
|
||||
endpoint,
|
||||
params=params,
|
||||
timeout=_OKX_TIMEOUT,
|
||||
)
|
||||
return resp.json()
|
||||
# Transient gateway / rate-limit → raise for retry_with_budget
|
||||
if resp.status_code in {429, 500, 502, 503, 504}:
|
||||
raise requests.HTTPError(
|
||||
f"OKX HTTP {resp.status_code}",
|
||||
response=resp,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as exc:
|
||||
raise requests.RequestException(
|
||||
f"OKX non-JSON response HTTP {resp.status_code}"
|
||||
) from exc
|
||||
code = str(data.get("code", ""))
|
||||
if code != "0":
|
||||
# Business errors are not always transient; still surface.
|
||||
msg = data.get("msg") or data.get("error_message") or code
|
||||
raise requests.RequestException(f"OKX API code={code} msg={msg}")
|
||||
return data
|
||||
|
||||
data = retry_with_budget(
|
||||
_do_request,
|
||||
transient=requests.RequestException,
|
||||
transient=(requests.RequestException, TimeoutError),
|
||||
deadline=deadline,
|
||||
label=label,
|
||||
)
|
||||
if data.get("code") != "0" or not data.get("data"):
|
||||
raw_rows = data.get("data") or []
|
||||
if not raw_rows:
|
||||
break
|
||||
|
||||
rows = data["data"]
|
||||
rows = [r for r in rows if r[8] == "1"]
|
||||
# Keep confirmed bars (confirm=="1"); also keep unconfirmed when
|
||||
# it is the only data returned so live partial days are not empty.
|
||||
confirmed = [r for r in raw_rows if len(r) > 8 and str(r[8]) == "1"]
|
||||
rows = confirmed if confirmed else list(raw_rows)
|
||||
all_rows.extend(rows)
|
||||
|
||||
oldest_ts = int(rows[-1][0]) if rows else start_ts
|
||||
if oldest_ts <= start_ts or len(data["data"]) < _MAX_PER_PAGE:
|
||||
oldest_ts = int(raw_rows[-1][0])
|
||||
if oldest_ts <= start_ts or len(raw_rows) < _MAX_PER_PAGE:
|
||||
break
|
||||
after = str(oldest_ts)
|
||||
|
||||
if not all_rows:
|
||||
print(f"[WARN] OKX empty response: {inst_id}")
|
||||
return None
|
||||
|
||||
columns = ["ts", "open", "high", "low", "close", "vol", "volCcy", "volCcyQuote", "confirm"]
|
||||
df = pd.DataFrame(all_rows, columns=columns)
|
||||
df["trade_date"] = pd.to_datetime(df["ts"].astype("int64"), unit="ms")
|
||||
columns = [
|
||||
"ts", "open", "high", "low", "close",
|
||||
"vol", "volCcy", "volCcyQuote", "confirm",
|
||||
]
|
||||
# Rows may be shorter if API schema changes — pad safely
|
||||
normalized = []
|
||||
for r in all_rows:
|
||||
row = list(r) + [""] * (len(columns) - len(r))
|
||||
normalized.append(row[: len(columns)])
|
||||
|
||||
df = pd.DataFrame(normalized, columns=columns)
|
||||
# OKX daily open is UTC+8 midnight (= 16:00 UTC). Keep absolute UTC
|
||||
# timestamps so multi-source merges stay consistent; floor to second.
|
||||
df["trade_date"] = pd.to_datetime(
|
||||
pd.to_numeric(df["ts"], errors="coerce"),
|
||||
unit="ms",
|
||||
utc=True,
|
||||
).dt.tz_convert(None)
|
||||
for col in ["open", "high", "low", "close"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
df["volume"] = pd.to_numeric(df["vol"], errors="coerce").fillna(0)
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df.dropna(subset=["trade_date"]).set_index("trade_date").sort_index()
|
||||
df = df[~df.index.duplicated(keep="last")]
|
||||
|
||||
start_dt = pd.Timestamp(start_ts, unit="ms")
|
||||
end_dt = pd.Timestamp(end_ts, unit="ms")
|
||||
df = df[(df.index >= start_dt) & (df.index < end_dt)]
|
||||
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(subset=["open", "high", "low", "close"])
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df if not df.empty else None
|
||||
@@ -0,0 +1,193 @@
|
||||
"""pykrx loader: free, no-auth KRX (KOSPI/KOSDAQ) EOD OHLCV.
|
||||
|
||||
Fetches Korean equity **daily** bars through the `pykrx
|
||||
<https://github.com/sharebook-kr/pykrx>`_ package. No API key.
|
||||
|
||||
Provenance caveat (be precise about what this data is): ``pykrx`` exposes both
|
||||
a raw KRX path and an adjusted-price path, and
|
||||
``stock.get_market_ohlcv_by_date`` defaults to ``adjusted=True``. The adjusted
|
||||
path is served by Naver rather than by KRX's own endpoint (upstream discussion:
|
||||
https://github.com/sharebook-kr/pykrx/issues/270). This loader passes
|
||||
``adjusted=True`` **explicitly** — split/consolidation-adjusted bars are what a
|
||||
backtest needs — so be clear that the series is *Naver-backed adjusted* data,
|
||||
not a verbatim KRX print. Nothing here silently inherits that choice.
|
||||
|
||||
Symbol convention (Vibe-Trading -> pykrx):
|
||||
* ``005930.KS`` (KOSPI) / ``247540.KQ`` (KOSDAQ) -> bare 6-digit ticker
|
||||
``005930``. The ``.KS``/``.KQ`` suffix follows the Yahoo convention already
|
||||
used for market inference elsewhere; pykrx itself takes the bare code and
|
||||
does not care which board it trades on.
|
||||
|
||||
pykrx returns a DataFrame indexed by date with Korean column names
|
||||
(시가/고가/저가/종가/거래량), renamed here to the project's canonical
|
||||
``open/high/low/close/volume``.
|
||||
|
||||
Politeness: pykrx's README asks callers to keep at least ~1 second between
|
||||
bulk requests, so spacing defaults to 1.0s and runs through the shared,
|
||||
lock-protected :class:`~backtest.loaders._http.HostThrottle` rather than a
|
||||
module-global timestamp (which raced under concurrent fetches). Override with
|
||||
``VIBE_TRADING_PYKRX_MIN_INTERVAL``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import HostThrottle, resolve_min_interval
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_COLUMN_MAP = {
|
||||
"시가": "open",
|
||||
"고가": "high",
|
||||
"저가": "low",
|
||||
"종가": "close",
|
||||
"거래량": "volume",
|
||||
}
|
||||
_OUTPUT_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
# Daily-only upstream API. Accepting anything else would cache day bars under an
|
||||
# intraday key and run a 1m/1H/4H backtest on daily data.
|
||||
_DAILY_INTERVALS = frozenset({"1d", "d", "day", "daily"})
|
||||
|
||||
# pykrx owns its own HTTP session, so requests cannot route through
|
||||
# ``throttled_get``; the spacing gate is reused on its own.
|
||||
_HOST_KEY = "pykrx"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_PYKRX_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL_S = 1.0
|
||||
_THROTTLE = HostThrottle()
|
||||
|
||||
|
||||
def map_symbol(symbol: str) -> str:
|
||||
"""``005930.KS`` / ``247540.KQ`` -> pykrx's bare 6-digit ticker."""
|
||||
return symbol.strip().upper().removesuffix(".KS").removesuffix(".KQ")
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum spacing, honoring the env override."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""KRX (KOSPI/KOSDAQ) EOD OHLCV loader via pykrx (free, no auth)."""
|
||||
|
||||
name = "pykrx"
|
||||
markets = {"kr_equity"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when the optional ``pykrx`` package is importable."""
|
||||
try:
|
||||
import pykrx # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily OHLCV bars for ``codes`` over ``[start_date, end_date]``.
|
||||
|
||||
Args:
|
||||
codes: Project-side symbols (e.g. ``["005930.KS", "247540.KQ"]``).
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
interval: Only daily (``"1D"``) is supported. Any other value is
|
||||
rejected with an empty result so the runner's fallback chain can
|
||||
reach a source that actually serves that bar size.
|
||||
fields: Unused — pykrx always returns the full OHLCV set.
|
||||
|
||||
Returns:
|
||||
Mapping ``{symbol: DataFrame}`` for symbols that returned data,
|
||||
each indexed by ``trade_date`` with float OHLCV columns ascending.
|
||||
A failing or empty symbol is omitted, never aborting the batch.
|
||||
Empty when ``interval`` is not daily.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
# Daily-only API; do not silently return day bars for runner ``1H``/``4H``.
|
||||
if str(interval).strip().lower() not in _DAILY_INTERVALS:
|
||||
logger.warning(
|
||||
"pykrx supports daily bars only; rejecting interval=%r",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort the batch
|
||||
logger.warning("pykrx failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and normalize one symbol; ``None`` when KRX has no data.
|
||||
|
||||
``adjusted=True`` is passed explicitly: it is pykrx's own default, but
|
||||
stating it keeps the Naver-backed adjusted-price provenance (see the
|
||||
module docstring) visible at the call site instead of implied.
|
||||
"""
|
||||
from pykrx import stock
|
||||
|
||||
_THROTTLE.wait(_HOST_KEY, _min_interval())
|
||||
frame = stock.get_market_ohlcv_by_date(
|
||||
pd.Timestamp(start_date).strftime("%Y%m%d"),
|
||||
pd.Timestamp(end_date).strftime("%Y%m%d"),
|
||||
map_symbol(code),
|
||||
adjusted=True,
|
||||
)
|
||||
return _normalize(frame)
|
||||
|
||||
|
||||
def _normalize(frame: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]:
|
||||
"""Convert a raw pykrx OHLCV frame into the project's canonical shape.
|
||||
|
||||
Args:
|
||||
frame: pykrx output — date-indexed with Korean column names
|
||||
(시가/고가/저가/종가/거래량) — or ``None``/empty when KRX has no
|
||||
data for the symbol/window.
|
||||
|
||||
Returns:
|
||||
A frame indexed by ``trade_date`` with float OHLCV columns sorted
|
||||
ascending, or ``None`` when the input carries no usable rows.
|
||||
"""
|
||||
if frame is None or frame.empty:
|
||||
return None
|
||||
|
||||
frame = frame.rename(columns=_COLUMN_MAP)
|
||||
if not all(col in frame.columns for col in _OUTPUT_COLUMNS):
|
||||
return None
|
||||
frame.index = pd.to_datetime(frame.index)
|
||||
frame.index.name = "trade_date"
|
||||
frame = frame[_OUTPUT_COLUMNS].sort_index()
|
||||
for col in _OUTPUT_COLUMNS:
|
||||
frame[col] = pd.to_numeric(frame[col], errors="coerce")
|
||||
frame = frame.dropna(subset=["open", "high", "low", "close"])
|
||||
if frame.empty:
|
||||
return None
|
||||
return frame.astype(float)
|
||||
@@ -0,0 +1,684 @@
|
||||
"""QVeris loader: explicit, key-gated OHLCV fetches through QVeris tools.
|
||||
|
||||
This loader is intentionally self-contained for the QVeris integration parcel:
|
||||
it reads the shared ``~/.vibe-trading/qveris.json`` config schema, applies the
|
||||
``QVERIS_API_KEY`` / ``QVERIS_BASE_URL`` environment overrides, and embeds the
|
||||
small HTTP client it needs for ``POST /search``, ``POST /tools/execute``, and
|
||||
truncated-result downloads.
|
||||
|
||||
QVeris is a paid-capability router, so it must only run when the user explicitly
|
||||
requests ``source="qveris"``. The registry keeps it out of every auto fallback
|
||||
chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from backtest.loaders.base import (
|
||||
cached_loader_fetch,
|
||||
validate_date_range,
|
||||
validate_ohlc,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONFIG_PATH = Path.home() / ".vibe-trading" / "qveris.json"
|
||||
_DEFAULT_BASE_URL = "https://qveris.ai/api/v1"
|
||||
_API_KEY_ENV = "QVERIS_API_KEY"
|
||||
_BASE_URL_ENV = "QVERIS_BASE_URL"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_QVERIS_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL_S = 0.5
|
||||
_HTTP_TIMEOUT_S = 30.0
|
||||
_MAX_RETRIES = 3
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
_DATE_KEYS = (
|
||||
"trade_date",
|
||||
"date",
|
||||
"datetime",
|
||||
"timestamp",
|
||||
"time",
|
||||
"period",
|
||||
)
|
||||
_FIELD_ALIASES = {
|
||||
"open": ("open", "o", "1. open"),
|
||||
"high": ("high", "h", "2. high"),
|
||||
"low": ("low", "l", "3. low"),
|
||||
"close": ("close", "c", "adj_close", "adjusted_close", "4. close"),
|
||||
"volume": ("volume", "vol", "v", "5. volume", "6. volume"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QVerisConfig:
|
||||
"""Resolved QVeris loader configuration."""
|
||||
|
||||
enabled: bool
|
||||
base_url: str
|
||||
api_key: str
|
||||
mode: str
|
||||
budget_credits_per_session: float
|
||||
|
||||
|
||||
def _load_config() -> QVerisConfig:
|
||||
"""Read QVeris config with environment overrides.
|
||||
|
||||
Returns:
|
||||
Resolved config. Missing or malformed config files fall back to the
|
||||
disabled default; env vars only override matching fields and do not
|
||||
implicitly enable the integration.
|
||||
"""
|
||||
raw: dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"base_url": _DEFAULT_BASE_URL,
|
||||
"api_key": "",
|
||||
"mode": "free",
|
||||
"budget_credits_per_session": 50.0,
|
||||
}
|
||||
try:
|
||||
if _CONFIG_PATH.is_file():
|
||||
loaded = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
raw.update(loaded)
|
||||
except Exception as exc: # noqa: BLE001 - config read failures mean unavailable
|
||||
logger.warning("qveris config ignored: %s", exc)
|
||||
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
api_key = (get_env_config().data.qveris_api_key or str(raw.get("api_key") or "")).strip()
|
||||
base_url = (get_env_config().data.qveris_base_url or str(raw.get("base_url") or _DEFAULT_BASE_URL)).strip()
|
||||
try:
|
||||
budget = float(raw.get("budget_credits_per_session", 50.0))
|
||||
except (TypeError, ValueError):
|
||||
budget = 50.0
|
||||
if not math.isfinite(budget):
|
||||
budget = 50.0
|
||||
return QVerisConfig(
|
||||
enabled=bool(raw.get("enabled")),
|
||||
base_url=(base_url or _DEFAULT_BASE_URL).rstrip("/"),
|
||||
api_key=api_key,
|
||||
mode=_normalize_mode(str(raw.get("mode") or "free")),
|
||||
budget_credits_per_session=max(budget, 0.0),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mode(mode: str) -> str:
|
||||
"""Normalize QVeris paid-route mode."""
|
||||
return {"preview": "free", "allow_paid": "paid", "free": "free", "paid": "paid"}.get(mode.strip(), "free")
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the minimum interval between QVeris requests."""
|
||||
raw = os.getenv(_MIN_INTERVAL_ENV) # noqa: env-gate — loader-specific rate limit
|
||||
if raw is None or not raw.strip():
|
||||
return _DEFAULT_MIN_INTERVAL_S
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"invalid %s=%r, using default %s",
|
||||
_MIN_INTERVAL_ENV,
|
||||
raw,
|
||||
_DEFAULT_MIN_INTERVAL_S,
|
||||
)
|
||||
return _DEFAULT_MIN_INTERVAL_S
|
||||
if value < 0:
|
||||
logger.warning(
|
||||
"negative %s=%r, using default %s",
|
||||
_MIN_INTERVAL_ENV,
|
||||
raw,
|
||||
_DEFAULT_MIN_INTERVAL_S,
|
||||
)
|
||||
return _DEFAULT_MIN_INTERVAL_S
|
||||
return value
|
||||
|
||||
|
||||
class QVerisClient:
|
||||
"""Minimal QVeris HTTP client for search, execute, and full-result GET."""
|
||||
|
||||
def __init__(self, config: QVerisConfig) -> None:
|
||||
"""Initialize a session-bound client.
|
||||
|
||||
Args:
|
||||
config: Resolved QVeris config containing base URL and API key.
|
||||
"""
|
||||
self._config = config
|
||||
self._session = requests.Session()
|
||||
self._last_request_at = 0.0
|
||||
|
||||
def search(self, query: str, *, limit: int = 20) -> dict[str, Any]:
|
||||
"""Call ``POST /search``.
|
||||
|
||||
Args:
|
||||
query: Natural-language capability query.
|
||||
limit: Maximum result count.
|
||||
|
||||
Returns:
|
||||
Decoded response body.
|
||||
"""
|
||||
return self._post_json("/search", {"query": query, "limit": limit})
|
||||
|
||||
def execute(
|
||||
self,
|
||||
tool_id: str,
|
||||
parameters: dict[str, Any],
|
||||
*,
|
||||
search_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Call ``POST /tools/execute`` and hydrate truncated results.
|
||||
|
||||
Args:
|
||||
tool_id: QVeris tool identifier returned by search.
|
||||
parameters: Provider parameters.
|
||||
search_id: Optional search correlation id.
|
||||
|
||||
Returns:
|
||||
Decoded execute response, with ``result`` replaced by downloaded
|
||||
full JSON when QVeris returned ``full_content_file_url``.
|
||||
"""
|
||||
body: dict[str, Any] = {
|
||||
"parameters": parameters,
|
||||
"max_response_size": 20480,
|
||||
}
|
||||
if search_id:
|
||||
body["search_id"] = search_id
|
||||
payload = self._post_json(f"/tools/execute?tool_id={tool_id}", body)
|
||||
if isinstance(payload, dict) and isinstance(payload.get("result"), dict):
|
||||
full_url = payload["result"].get("full_content_file_url")
|
||||
if isinstance(full_url, str) and full_url:
|
||||
payload = dict(payload)
|
||||
payload["result"] = self._get_json(full_url)
|
||||
return payload
|
||||
|
||||
def _post_json(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f"{self._config.base_url}{path}"
|
||||
response = self._request("post", url, json=body, auth=True)
|
||||
decoded = response.json()
|
||||
return decoded if isinstance(decoded, dict) else {}
|
||||
|
||||
def _get_json(self, url: str) -> Any:
|
||||
response = self._request("get", url, auth=False)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return json.loads(response.text)
|
||||
|
||||
def _request(self, method: str, url: str, *, auth: bool, **kwargs: Any) -> requests.Response:
|
||||
headers = {"User-Agent": "Vibe-Trading/1.0"}
|
||||
if auth:
|
||||
headers["Authorization"] = f"Bearer {self._config.api_key}"
|
||||
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
self._wait()
|
||||
response = self._session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
**kwargs,
|
||||
)
|
||||
if response.status_code != 429:
|
||||
response.raise_for_status()
|
||||
return response
|
||||
if attempt == _MAX_RETRIES:
|
||||
response.raise_for_status()
|
||||
time.sleep(_retry_after_seconds(response))
|
||||
raise AssertionError("unreachable: retry loop must return or raise")
|
||||
|
||||
def _wait(self) -> None:
|
||||
interval = _min_interval()
|
||||
if interval <= 0:
|
||||
return
|
||||
now = time.monotonic()
|
||||
sleep_for = self._last_request_at + interval - now
|
||||
if sleep_for > 0:
|
||||
time.sleep(sleep_for)
|
||||
self._last_request_at = time.monotonic()
|
||||
|
||||
|
||||
def _retry_after_seconds(response: requests.Response) -> float:
|
||||
"""Parse a Retry-After header, falling back to the default interval."""
|
||||
raw = response.headers.get("Retry-After", "")
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = _min_interval()
|
||||
return max(value, 0.0)
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""QVeris OHLCV loader, available only when explicitly configured."""
|
||||
|
||||
name = "qveris"
|
||||
markets = {"us_equity", "hk_equity", "a_share", "crypto", "forex", "fund", "macro"}
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize without network access."""
|
||||
self._config = _load_config()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return whether paid QVeris routing is enabled and keyed."""
|
||||
return self._config.enabled and bool(self._config.api_key) and self._config.mode == "paid"
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily OHLCV history through selected QVeris capabilities.
|
||||
|
||||
Args:
|
||||
codes: Symbols to fetch.
|
||||
start_date: Inclusive ``YYYY-MM-DD`` start date.
|
||||
end_date: Inclusive ``YYYY-MM-DD`` end date.
|
||||
interval: Bar interval. QVeris selection is optimized for daily
|
||||
bars; non-daily values are passed through when a tool accepts an
|
||||
interval-like parameter.
|
||||
fields: Ignored; QVeris loader returns the standard OHLCV columns.
|
||||
|
||||
Returns:
|
||||
Mapping of input symbol to normalized OHLCV DataFrames.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``start_date`` > ``end_date``.
|
||||
"""
|
||||
del fields
|
||||
validate_date_range(start_date, end_date)
|
||||
if not self.is_available():
|
||||
logger.warning("qveris fetch skipped: disabled or %s not set", _API_KEY_ENV)
|
||||
return {}
|
||||
|
||||
client = QVerisClient(self._config)
|
||||
budget_state = {"spent": 0.0}
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(
|
||||
client,
|
||||
code,
|
||||
start_date,
|
||||
end_date,
|
||||
interval,
|
||||
budget_state,
|
||||
),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc: # noqa: BLE001 - one symbol must not abort the batch
|
||||
logger.warning("qveris failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self,
|
||||
client: QVerisClient,
|
||||
code: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
interval: str,
|
||||
budget_state: dict[str, float],
|
||||
) -> Optional[pd.DataFrame]:
|
||||
search_payload = client.search(_search_query(code, interval))
|
||||
candidates = _select_capabilities(search_payload.get("results"), interval)
|
||||
search_id = _str_or_none(search_payload.get("search_id"))
|
||||
for capability in candidates:
|
||||
tool_id = str(capability.get("tool_id") or "").strip()
|
||||
if not tool_id:
|
||||
continue
|
||||
quoted_cost = _expected_cost(capability.get("expected_cost"))
|
||||
if (
|
||||
not math.isfinite(quoted_cost)
|
||||
or quoted_cost < 0.0
|
||||
or budget_state["spent"] + quoted_cost
|
||||
> self._config.budget_credits_per_session
|
||||
):
|
||||
logger.warning(
|
||||
"QVeris paid capability skipped for %s: credit budget exceeded",
|
||||
code,
|
||||
)
|
||||
continue
|
||||
parameters = _build_parameters(capability, code, start_date, end_date, interval)
|
||||
# Reserve the quoted cost before the request. If transport fails
|
||||
# after the provider accepted it, later symbols still fail closed.
|
||||
budget_state["spent"] += quoted_cost
|
||||
execute_payload = client.execute(tool_id, parameters, search_id=search_id)
|
||||
try:
|
||||
actual_cost = float(execute_payload.get("cost"))
|
||||
except (TypeError, ValueError):
|
||||
actual_cost = quoted_cost
|
||||
if math.isfinite(actual_cost) and actual_cost > quoted_cost:
|
||||
budget_state["spent"] += actual_cost - quoted_cost
|
||||
if execute_payload.get("success") is False:
|
||||
logger.warning("QVeris execute failed for %s via %s", code, tool_id)
|
||||
continue
|
||||
frame = _result_to_frame(execute_payload.get("result"), start_date, end_date)
|
||||
if frame is not None:
|
||||
return frame
|
||||
logger.warning("QVeris result for %s via %s had no parseable bars", code, tool_id)
|
||||
return None
|
||||
|
||||
|
||||
_MAX_CANDIDATES = 3
|
||||
|
||||
|
||||
def _search_query(symbol: str, interval: str) -> str:
|
||||
"""Build a capability-search query for one symbol."""
|
||||
return f"daily OHLCV historical market data for {symbol.strip().upper()} interval {interval}"
|
||||
|
||||
|
||||
def _select_capabilities(results: Any, interval: str) -> list[dict[str, Any]]:
|
||||
"""Rank OHLCV-like capabilities, excluding wrong-granularity series."""
|
||||
if not isinstance(results, list):
|
||||
return []
|
||||
wanted, unwanted = _granularity_tokens(interval)
|
||||
candidates = []
|
||||
for item in results:
|
||||
if not isinstance(item, dict) or not _looks_ohlcv(item):
|
||||
continue
|
||||
text = _capability_text(item)
|
||||
if any(token in text for token in unwanted):
|
||||
continue
|
||||
priority = 0 if any(token in text for token in wanted) else 1
|
||||
candidates.append((priority, item))
|
||||
ranked = sorted(candidates, key=lambda pair: (pair[0],) + _capability_rank(pair[1]))
|
||||
return [item for _, item in ranked[:_MAX_CANDIDATES]]
|
||||
|
||||
|
||||
def _capability_text(item: dict[str, Any]) -> str:
|
||||
return " ".join(
|
||||
str(item.get(key) or "").lower() for key in ("tool_id", "name", "description")
|
||||
)
|
||||
|
||||
|
||||
def _granularity_tokens(interval: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Return (wanted, unwanted) capability-text tokens for a bar interval.
|
||||
|
||||
``1m``/``5m``/``15m``/``30m`` are case-sensitive minute tokens; ``1M`` is
|
||||
month. Lowercasing first would collapse ``1m`` into an empty match and let
|
||||
daily capabilities outrank minute ones.
|
||||
"""
|
||||
token = interval.strip()
|
||||
norm = token.lower()
|
||||
intraday = ("intraday", "minute", "1min", "5min", "15min", "30min", "60min", "hourly")
|
||||
if norm in ("", "1d", "d", "day", "daily"):
|
||||
return ("daily", "eod", "end-of-day", "end of day"), ("monthly", "weekly") + intraday
|
||||
# Case-sensitive: ``1M`` (month) must not be treated as ``1m`` (minute).
|
||||
if token == "1M" or "month" in norm:
|
||||
return ("monthly",), ("weekly",) + intraday
|
||||
if token in {"1m", "5m", "15m", "30m"} or "min" in norm or norm in ("1h", "4h") or "hour" in norm:
|
||||
return intraday, ("monthly", "weekly")
|
||||
if "w" in norm or "week" in norm:
|
||||
return ("weekly",), ("monthly",) + intraday
|
||||
if "mo" in norm:
|
||||
return ("monthly",), ("weekly",) + intraday
|
||||
return (), ()
|
||||
|
||||
|
||||
def _looks_ohlcv(item: dict[str, Any]) -> bool:
|
||||
text_parts = [
|
||||
item.get("name"),
|
||||
item.get("description"),
|
||||
item.get("provider_name"),
|
||||
json.dumps(item.get("params") or "", default=str),
|
||||
json.dumps(item.get("examples") or "", default=str),
|
||||
]
|
||||
text = " ".join(str(part or "").lower() for part in text_parts)
|
||||
has_price = any(token in text for token in ("ohlcv", "open", "high", "low", "close", "candle", "historical price"))
|
||||
has_symbol = any(token in text for token in ("symbol", "ticker", "instrument", "code"))
|
||||
return has_price and has_symbol
|
||||
|
||||
|
||||
def _capability_rank(item: dict[str, Any]) -> tuple[float, float, str]:
|
||||
success_rate = _success_rate(item.get("stats"))
|
||||
cost = _expected_cost(item.get("expected_cost"))
|
||||
return (-success_rate, cost, str(item.get("tool_id") or item.get("name") or ""))
|
||||
|
||||
|
||||
def _success_rate(stats: Any) -> float:
|
||||
if not isinstance(stats, dict):
|
||||
return 0.0
|
||||
value = stats.get("success_rate", 0.0)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return parsed / 100.0 if parsed > 1.0 else parsed
|
||||
|
||||
|
||||
def _expected_cost(value: Any) -> float:
|
||||
if value is None:
|
||||
return float("inf")
|
||||
text = str(value)
|
||||
number = ""
|
||||
for char in text:
|
||||
if char.isdigit() or char == ".":
|
||||
number += char
|
||||
elif number:
|
||||
break
|
||||
try:
|
||||
return float(number)
|
||||
except ValueError:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def _build_parameters(
|
||||
capability: dict[str, Any],
|
||||
code: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
interval: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an execute parameter object from examples plus known date keys."""
|
||||
parameters = _sample_parameters(capability)
|
||||
for param in capability.get("params") or []:
|
||||
if not isinstance(param, dict):
|
||||
continue
|
||||
name = str(param.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
lower = name.lower()
|
||||
if _is_symbol_param(lower):
|
||||
parameters[name] = _provider_symbol(code)
|
||||
elif _is_start_param(lower):
|
||||
parameters[name] = start_date
|
||||
elif _is_end_param(lower):
|
||||
parameters[name] = end_date
|
||||
elif _is_interval_param(lower):
|
||||
parameters[name] = _interval_value(param, interval)
|
||||
return parameters
|
||||
|
||||
|
||||
def _sample_parameters(capability: dict[str, Any]) -> dict[str, Any]:
|
||||
examples = capability.get("examples")
|
||||
if not isinstance(examples, dict):
|
||||
return {}
|
||||
sample = examples.get("sample_parameters")
|
||||
return dict(sample) if isinstance(sample, dict) else {}
|
||||
|
||||
|
||||
def _provider_symbol(code: str) -> str:
|
||||
"""Normalize common project US suffixes while preserving other markets."""
|
||||
upper = code.strip().upper()
|
||||
if upper.endswith(".US"):
|
||||
return upper[: -len(".US")]
|
||||
return upper
|
||||
|
||||
|
||||
def _is_symbol_param(name: str) -> bool:
|
||||
return any(token in name for token in ("symbol", "ticker", "instrument", "code"))
|
||||
|
||||
|
||||
def _is_start_param(name: str) -> bool:
|
||||
return any(token in name for token in ("start", "from", "begin")) and "end" not in name
|
||||
|
||||
|
||||
def _is_end_param(name: str) -> bool:
|
||||
return any(token in name for token in ("end", "to", "until"))
|
||||
|
||||
|
||||
def _is_interval_param(name: str) -> bool:
|
||||
return any(token in name for token in ("interval", "timeframe", "resolution", "frequency"))
|
||||
|
||||
|
||||
def _interval_value(param: dict[str, Any], interval: str) -> str:
|
||||
enum = param.get("enum")
|
||||
if isinstance(enum, list):
|
||||
lowered = {str(value).lower(): value for value in enum}
|
||||
for candidate in (interval, interval.lower(), "1d", "d", "daily"):
|
||||
if candidate.lower() in lowered:
|
||||
return str(lowered[candidate.lower()])
|
||||
return "daily" if interval.upper() == "1D" else interval
|
||||
|
||||
|
||||
def _result_to_frame(result: Any, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
rows = [_normalize_record(record) for record in _iter_ohlcv_records(result)]
|
||||
cleaned = [row for row in rows if row is not None]
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(cleaned)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce").astype(
|
||||
"datetime64[ns]"
|
||||
)
|
||||
for field in _OHLCV_COLUMNS:
|
||||
if field not in df.columns:
|
||||
df[field] = None
|
||||
df[field] = pd.to_numeric(df[field], errors="coerce").astype(float)
|
||||
df = df.dropna(subset=["trade_date", "open", "high", "low", "close"])
|
||||
if df.empty:
|
||||
return None
|
||||
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df[_OHLCV_COLUMNS]
|
||||
start = pd.Timestamp(start_date)
|
||||
end = pd.Timestamp(end_date)
|
||||
df = df.loc[(df.index >= start) & (df.index <= end)]
|
||||
df = validate_ohlc(df)
|
||||
return None if df.empty else df
|
||||
|
||||
|
||||
def _iter_ohlcv_records(payload: Any) -> Iterable[dict[str, Any]]:
|
||||
"""Yield dict-like OHLCV records from common QVeris/provider shapes."""
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
yield from _iter_ohlcv_records(item)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
if _record_has_ohlc(payload):
|
||||
yield payload
|
||||
|
||||
date_keyed = _date_keyed_records(payload)
|
||||
if date_keyed is not None:
|
||||
yield from date_keyed
|
||||
return
|
||||
|
||||
yielded = False
|
||||
for key in (
|
||||
"data",
|
||||
"results",
|
||||
"result",
|
||||
"historical",
|
||||
"prices",
|
||||
"items",
|
||||
"rows",
|
||||
"candles",
|
||||
"values",
|
||||
"time_series",
|
||||
"Time Series (Daily)",
|
||||
):
|
||||
if key in payload:
|
||||
for record in _iter_ohlcv_records(payload[key]):
|
||||
yielded = True
|
||||
yield record
|
||||
if yielded:
|
||||
return
|
||||
# Provider-specific series containers ("Weekly Adjusted Time Series",
|
||||
# "Time Series (5min)", ...) — any nested dict of date-keyed records.
|
||||
for value in payload.values():
|
||||
if isinstance(value, dict):
|
||||
date_keyed = _date_keyed_records(value)
|
||||
if date_keyed:
|
||||
yield from date_keyed
|
||||
|
||||
|
||||
def _date_keyed_records(payload: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key, value in payload.items():
|
||||
if not isinstance(value, dict) or not _looks_like_date(key):
|
||||
return None
|
||||
row = dict(value)
|
||||
row.setdefault("trade_date", key)
|
||||
rows.append(row)
|
||||
return rows if rows else None
|
||||
|
||||
|
||||
def _looks_like_date(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
pd.Timestamp(value)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _record_has_ohlc(record: dict[str, Any]) -> bool:
|
||||
lowered = {str(key).lower() for key in record}
|
||||
return all(any(alias in lowered for alias in aliases) for aliases in (
|
||||
_FIELD_ALIASES["open"],
|
||||
_FIELD_ALIASES["high"],
|
||||
_FIELD_ALIASES["low"],
|
||||
_FIELD_ALIASES["close"],
|
||||
))
|
||||
|
||||
|
||||
def _normalize_record(record: dict[str, Any]) -> dict[str, Any] | None:
|
||||
lowered = {str(key).lower(): value for key, value in record.items()}
|
||||
date = _first_value(lowered, _DATE_KEYS)
|
||||
if date is None:
|
||||
return None
|
||||
row = {"trade_date": date}
|
||||
for field, aliases in _FIELD_ALIASES.items():
|
||||
row[field] = _first_value(lowered, aliases)
|
||||
if any(row[field] is None for field in ("open", "high", "low", "close")):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _first_value(mapping: dict[str, Any], keys: Iterable[str]) -> Any:
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
return mapping[key]
|
||||
return None
|
||||
|
||||
|
||||
def _str_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@@ -24,6 +24,40 @@ LOADER_REGISTRY: dict[str, Type[Any]] = {}
|
||||
|
||||
_registered = False
|
||||
|
||||
# Canonical set of accepted data-source names: every registered loader plus the
|
||||
# ``"auto"`` cross-market selector. Single source of truth shared by the backtest
|
||||
# config schema (``backtest.runner.BacktestConfigSchema``) and the agent-facing
|
||||
# backtest tool (``src.tools.backtest_tool``) so the two can never drift apart.
|
||||
# Keep in sync with ``_loader_modules`` below — the regression test
|
||||
# ``test_valid_sources_covers_all_registered_loaders`` enforces full coverage.
|
||||
VALID_SOURCES: set[str] = {
|
||||
"tushare",
|
||||
"okx",
|
||||
"binance",
|
||||
"yfinance",
|
||||
"akshare",
|
||||
"baostock",
|
||||
"tencent",
|
||||
"mootdx",
|
||||
"ccxt",
|
||||
"futu",
|
||||
"eastmoney",
|
||||
"sina",
|
||||
"stooq",
|
||||
"yahoo",
|
||||
"finnhub",
|
||||
"alphavantage",
|
||||
"tiingo",
|
||||
"fmp",
|
||||
"qveris", # QVERIS-INTEGRATION
|
||||
"india_broker",
|
||||
"pykrx",
|
||||
"longbridge",
|
||||
"mt5",
|
||||
"local",
|
||||
"auto",
|
||||
}
|
||||
|
||||
|
||||
def register(cls: Type[Any]) -> Type[Any]:
|
||||
"""Class decorator: register a loader into the global registry.
|
||||
@@ -49,11 +83,28 @@ def _ensure_registered() -> None:
|
||||
_loader_modules = [
|
||||
"backtest.loaders.tushare",
|
||||
"backtest.loaders.okx",
|
||||
"backtest.loaders.binance_loader",
|
||||
"backtest.loaders.yfinance_loader",
|
||||
"backtest.loaders.akshare_loader",
|
||||
"backtest.loaders.baostock_loader",
|
||||
"backtest.loaders.tencent_loader",
|
||||
"backtest.loaders.mootdx_loader",
|
||||
"backtest.loaders.ccxt_loader",
|
||||
"backtest.loaders.futu",
|
||||
"backtest.loaders.eastmoney_loader",
|
||||
"backtest.loaders.sina_loader",
|
||||
"backtest.loaders.stooq_loader",
|
||||
"backtest.loaders.yahoo_loader",
|
||||
"backtest.loaders.finnhub_loader",
|
||||
"backtest.loaders.alphavantage_loader",
|
||||
"backtest.loaders.tiingo_loader",
|
||||
"backtest.loaders.fmp_loader",
|
||||
"backtest.loaders.qveris_loader", # QVERIS-INTEGRATION
|
||||
"backtest.loaders.india_broker_loader",
|
||||
"backtest.loaders.pykrx_loader",
|
||||
"backtest.loaders.longbridge",
|
||||
"backtest.loaders.mt5_loader",
|
||||
"backtest.loaders.local_loader",
|
||||
]
|
||||
import importlib
|
||||
for mod in _loader_modules:
|
||||
@@ -63,19 +114,39 @@ def _ensure_registered() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Sources that must NEVER silently fall through to a network loader when the
|
||||
# caller asked for them explicitly. ``local`` reads the user's own configured
|
||||
# files (``~/.vibe-trading/data-bridge/config.yaml``); its ``markets`` set spans
|
||||
# every market only so the cross-market auto-resolver can *reach* it, not so an
|
||||
# unavailable ``local`` request can degrade into an unrelated network source.
|
||||
# An explicit ``local`` request that is unavailable is a config problem the user
|
||||
# must see, not something to paper over with a Yahoo/Tencent fetch.
|
||||
_NO_NETWORK_FALLBACK_SOURCES: frozenset[str] = frozenset({"local", "qveris"}) # QVERIS-INTEGRATION
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback chains: market_type -> ordered list of source names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Chains are ordered by IP-ban risk first (lighter, throttle-tolerant public
|
||||
# endpoints lead; key-gated REST and rate-limit-prone sources trail), then by
|
||||
# data quality. Eastmoney/Sina/Stooq/Yahoo are unauthenticated public sources
|
||||
# that must be politely throttled; Finnhub/AlphaVantage/Tiingo/FMP are key-gated
|
||||
# REST fallbacks placed deeper in the chain.
|
||||
FALLBACK_CHAINS: dict[str, list[str]] = {
|
||||
"a_share": ["tushare", "mootdx", "akshare"],
|
||||
"us_equity": ["yfinance", "akshare"],
|
||||
"hk_equity": ["yfinance", "futu", "akshare"],
|
||||
"crypto": ["okx", "ccxt"],
|
||||
"futures": ["tushare", "akshare"],
|
||||
"fund": ["tushare", "akshare"],
|
||||
"macro": ["akshare", "tushare"],
|
||||
"forex": ["akshare", "yfinance"],
|
||||
"a_share": ["tencent", "mootdx", "eastmoney", "baostock", "akshare", "tushare", "local"],
|
||||
"us_equity": ["yahoo", "stooq", "sina", "eastmoney", "yfinance", "tiingo", "fmp", "finnhub", "alphavantage", "longbridge", "akshare", "local"],
|
||||
"hk_equity": ["eastmoney", "yahoo", "futu", "yfinance", "akshare", "longbridge", "local"],
|
||||
"india_equity": ["yahoo", "yfinance", "india_broker", "local"],
|
||||
"kr_equity": ["pykrx", "yahoo", "yfinance", "local"],
|
||||
# OKX first (native), then dedicated Binance, then generic CCXT / Yahoo.
|
||||
"crypto": ["okx", "binance", "ccxt", "yfinance", "local"],
|
||||
"futures": ["tushare", "akshare", "local"],
|
||||
"fund": ["tushare", "akshare", "local"],
|
||||
"macro": ["akshare", "tushare", "local"],
|
||||
# mt5 leads when a local MetaTrader 5 terminal is attached (Windows-only,
|
||||
# broker feed); otherwise it reports unavailable and the chain proceeds.
|
||||
"forex": ["mt5", "akshare", "yfinance", "local"],
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +213,19 @@ def get_loader_cls_with_fallback(source: str) -> Type[Any]:
|
||||
if instance is not None and instance.is_available():
|
||||
return loader_cls
|
||||
|
||||
# Some sources must never silently degrade to an unrelated network loader
|
||||
# when explicitly requested. ``local`` is the canonical case: its broad
|
||||
# ``markets`` set exists only to make it reachable from the cross-market
|
||||
# auto-resolver, so falling back through it would fetch network data the
|
||||
# user never asked for and mask a Data Bridge config problem. Fail loudly.
|
||||
if source in _NO_NETWORK_FALLBACK_SOURCES:
|
||||
raise NoAvailableSourceError(
|
||||
f"Data source '{source}' is unavailable and does not fall back to a "
|
||||
f"network source. Check your local Data Bridge config "
|
||||
f"(~/.vibe-trading/data-bridge/config.yaml) — it must exist and list "
|
||||
f"at least one source."
|
||||
)
|
||||
|
||||
# Source unavailable — try same-market fallback
|
||||
for market in loader_cls.markets:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
"""RSSHub event/sentiment provider with point-in-time safeguards.
|
||||
|
||||
A news / announcement / sentiment provider that runs parallel to the Tushare
|
||||
fundamental layer (:mod:`backtest.loaders.tushare_fundamentals`). It pulls feeds
|
||||
from a self-hosted `RSSHub <https://docs.rsshub.app>`_ instance, normalises each
|
||||
item into the ``event-driven`` skill schema (``date, event_type, score, source,
|
||||
summary``), and attaches a point-in-time-safe ``event_score`` column to daily
|
||||
price frames.
|
||||
|
||||
Point-in-time discipline (mirrors the fundamentals enricher): every item is
|
||||
stamped with a *knowable date* — the date a backtest could first have acted on
|
||||
it (items published after the close roll to the next session) — and only items
|
||||
with ``knowable_date <= as_of`` are ever returned. No feed item can leak into a
|
||||
bar dated before it became knowable.
|
||||
|
||||
Scoring is pluggable. A deterministic, dependency-free lexicon scorer is used by
|
||||
default; callers may pass any ``scorer(title, summary) -> float`` (e.g. an LLM
|
||||
judge, as the ``event-driven`` skill describes) to override it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from defusedxml import ElementTree as ET
|
||||
from defusedxml.common import DefusedXmlException
|
||||
|
||||
from backtest.loaders.base import positive_env_float, retry_with_budget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RSSHUB_BASE_URL_ENV = "RSSHUB_BASE_URL"
|
||||
RSSHUB_BASE_URL_PLACEHOLDERS = {"", "https://rsshub.example.com"}
|
||||
RSSHUB_TIMEOUT_ENV = "RSSHUB_TIMEOUT_S"
|
||||
RSSHUB_BUDGET_ENV = "RSSHUB_FETCH_BUDGET_S"
|
||||
DEFAULT_TIMEOUT_S = 15.0
|
||||
DEFAULT_BUDGET_S = 60.0
|
||||
|
||||
#: Hour (local to the feed timestamps) at/after which a publication is treated as
|
||||
#: knowable only on the next calendar day, matching the event-driven skill's
|
||||
#: "released after market close -> use the next trading day" rule.
|
||||
DEFAULT_CLOSE_CUTOFF_HOUR = 16
|
||||
|
||||
#: Canonical column order for the tidy event frame.
|
||||
EVENT_COLUMNS: tuple[str, ...] = (
|
||||
"ts_code",
|
||||
"knowable_date",
|
||||
"event_type",
|
||||
"score",
|
||||
"source",
|
||||
"summary",
|
||||
)
|
||||
|
||||
ScorerFn = Callable[[str, str], float]
|
||||
|
||||
|
||||
class EventProviderError(Exception):
|
||||
"""Base error for event-provider failures."""
|
||||
|
||||
|
||||
class UnknownFeedError(EventProviderError):
|
||||
"""Raised when a requested feed name is not registered."""
|
||||
|
||||
|
||||
#: Supported ``code_style`` values for per-symbol routes.
|
||||
CODE_STYLES: frozenset[str] = frozenset({"raw", "exchange_prefix", "bare"})
|
||||
|
||||
|
||||
def format_code_for_route(code: str, style: str) -> str:
|
||||
"""Convert a backtest code (e.g. ``"600519.SH"``) to a route's expected form.
|
||||
|
||||
RSSHub routes disagree on instrument formatting: some take the exchange-
|
||||
prefixed form (``SH600519``, e.g. xueqiu), some the bare number (``600519``),
|
||||
some a vendor-specific string. The backtest engine always passes the dotted
|
||||
``"600519.SH"`` form, so a feed declares the shape its route needs.
|
||||
|
||||
Args:
|
||||
code: Dotted instrument code (``"600519.SH"``); passed through if it has
|
||||
no ``.`` suffix.
|
||||
style: One of :data:`CODE_STYLES` — ``"raw"`` (unchanged), ``"bare"``
|
||||
(symbol only, ``"600519"``), or ``"exchange_prefix"`` (``"SH600519"``).
|
||||
|
||||
Returns:
|
||||
The code formatted for the route.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``style`` is not a recognised code style.
|
||||
"""
|
||||
if style not in CODE_STYLES:
|
||||
raise ValueError(f"Unknown code_style {style!r}; expected one of {sorted(CODE_STYLES)}")
|
||||
if style == "raw":
|
||||
return code
|
||||
symbol, _, suffix = code.partition(".")
|
||||
if style == "bare":
|
||||
return symbol
|
||||
return f"{suffix.upper()}{symbol}" if suffix else symbol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeedSpec:
|
||||
"""Machine-readable metadata for one RSSHub feed route.
|
||||
|
||||
Attributes:
|
||||
name: Stable feed identifier used in configs (e.g. ``"sina_announcements"``).
|
||||
route_template: RSSHub route with a ``{code}`` placeholder, joined to the
|
||||
base URL (e.g. ``"/sina/announcement/{code}"``). Routes without a
|
||||
``{code}`` placeholder are treated as market-wide feeds.
|
||||
event_type: Event class emitted for items from this feed; one of the
|
||||
event-driven taxonomy (``earnings``/``macro``/``policy``/
|
||||
``sentiment``/``insider``/``technical_break``).
|
||||
code_style: How the dotted backtest code is rewritten before it is
|
||||
substituted into a per-symbol ``route_template`` — one of
|
||||
:data:`CODE_STYLES`. Ignored for market-wide routes.
|
||||
"""
|
||||
|
||||
name: str
|
||||
route_template: str
|
||||
event_type: str
|
||||
code_style: str = "raw"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.code_style not in CODE_STYLES:
|
||||
raise ValueError(
|
||||
f"FeedSpec {self.name!r}: unknown code_style {self.code_style!r}; "
|
||||
f"expected one of {sorted(CODE_STYLES)}"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_per_symbol(self) -> bool:
|
||||
"""Whether the route is parameterised by instrument code."""
|
||||
return "{code}" in self.route_template
|
||||
|
||||
|
||||
def feed_specs_from_config(entries: Iterable[Mapping[str, Any]]) -> list[FeedSpec]:
|
||||
"""Build :class:`FeedSpec` objects from backtest-config dicts.
|
||||
|
||||
Each entry must carry ``name``, ``route_template`` and ``event_type``; an
|
||||
optional ``code_style`` defaults to ``"raw"``. There is intentionally no
|
||||
built-in feed catalogue — routes evolve and silently rot, so feeds are always
|
||||
declared explicitly in config (or passed to the provider constructor).
|
||||
|
||||
Args:
|
||||
entries: Iterable of mapping objects, one per feed.
|
||||
|
||||
Returns:
|
||||
Parsed feed specs.
|
||||
|
||||
Raises:
|
||||
ValueError: If an entry is missing a required key or has a duplicate name.
|
||||
"""
|
||||
specs: list[FeedSpec] = []
|
||||
seen: set[str] = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
raise ValueError("each event feed must be a mapping with name/route_template/event_type")
|
||||
missing = [k for k in ("name", "route_template", "event_type") if not str(entry.get(k, "")).strip()]
|
||||
if missing:
|
||||
raise ValueError(f"event feed is missing required field(s): {', '.join(missing)}")
|
||||
name = str(entry["name"]).strip()
|
||||
if name in seen:
|
||||
raise ValueError(f"duplicate event feed name: {name!r}")
|
||||
seen.add(name)
|
||||
specs.append(
|
||||
FeedSpec(
|
||||
name=name,
|
||||
route_template=str(entry["route_template"]).strip(),
|
||||
event_type=str(entry["event_type"]).strip(),
|
||||
code_style=str(entry.get("code_style", "raw")).strip() or "raw",
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
#: No built-in feed catalogue: RSSHub routes change and break, so feeds are always
|
||||
#: supplied explicitly (via config ``event_feeds`` or the provider constructor).
|
||||
DEFAULT_FEEDS: tuple[FeedSpec, ...] = ()
|
||||
|
||||
# ── Default dependency-free lexicon scorer ───────────────────────────────────
|
||||
|
||||
_POSITIVE_TERMS: frozenset[str] = frozenset(
|
||||
{
|
||||
"beat",
|
||||
"beats",
|
||||
"surge",
|
||||
"surges",
|
||||
"soar",
|
||||
"record",
|
||||
"growth",
|
||||
"profit",
|
||||
"upgrade",
|
||||
"upgraded",
|
||||
"outperform",
|
||||
"bullish",
|
||||
"approval",
|
||||
"approved",
|
||||
"win",
|
||||
"wins",
|
||||
"buyback",
|
||||
"dividend",
|
||||
"expansion",
|
||||
"breakthrough",
|
||||
}
|
||||
)
|
||||
_NEGATIVE_TERMS: frozenset[str] = frozenset(
|
||||
{
|
||||
"miss",
|
||||
"misses",
|
||||
"plunge",
|
||||
"plunges",
|
||||
"fall",
|
||||
"falls",
|
||||
"loss",
|
||||
"losses",
|
||||
"downgrade",
|
||||
"downgraded",
|
||||
"underperform",
|
||||
"bearish",
|
||||
"fraud",
|
||||
"probe",
|
||||
"lawsuit",
|
||||
"recall",
|
||||
"default",
|
||||
"bankruptcy",
|
||||
"halt",
|
||||
"warning",
|
||||
"decline",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def default_lexicon_scorer(title: str, summary: str) -> float:
|
||||
"""Score text in ``[-1, 1]`` by net positive/negative term frequency.
|
||||
|
||||
A deterministic, dependency-free baseline. It is intentionally simple — the
|
||||
provider is the data layer; richer scoring (e.g. an LLM judge) is plugged in
|
||||
via the ``scorer`` argument on :meth:`RSSHubEventProvider.query_events`.
|
||||
|
||||
Args:
|
||||
title: Item headline.
|
||||
summary: Item summary/description (may be empty).
|
||||
|
||||
Returns:
|
||||
Sentiment score in ``[-1.0, 1.0]``; ``0.0`` when no terms match.
|
||||
"""
|
||||
tokens = f"{title} {summary}".lower().replace("/", " ").split()
|
||||
if not tokens:
|
||||
return 0.0
|
||||
pos = sum(1 for tok in tokens if tok.strip(".,;:!?\"'()") in _POSITIVE_TERMS)
|
||||
neg = sum(1 for tok in tokens if tok.strip(".,;:!?\"'()") in _NEGATIVE_TERMS)
|
||||
hits = pos + neg
|
||||
if hits == 0:
|
||||
return 0.0
|
||||
return max(-1.0, min(1.0, (pos - neg) / hits))
|
||||
|
||||
|
||||
def _knowable_date(published: pd.Timestamp, cutoff_hour: int) -> pd.Timestamp:
|
||||
"""Return the date an item becomes actionable (post-cutoff rolls to next day).
|
||||
|
||||
Args:
|
||||
published: Parsed publication timestamp (tz dropped to naive local).
|
||||
cutoff_hour: Hour at/after which publication rolls to the next day.
|
||||
|
||||
Returns:
|
||||
Normalised (midnight) timestamp of the knowable date.
|
||||
"""
|
||||
stamp = published.tz_localize(None) if published.tzinfo is not None else published
|
||||
base = stamp.normalize()
|
||||
if stamp.hour >= cutoff_hour:
|
||||
base = base + pd.Timedelta(days=1)
|
||||
return base
|
||||
|
||||
|
||||
def _clean_summary(text: str | None) -> str:
|
||||
"""Collapse whitespace and strip commas so the value is CSV-safe."""
|
||||
if not text:
|
||||
return ""
|
||||
return " ".join(text.replace(",", " ").split())
|
||||
|
||||
|
||||
class RSSHubEventProvider:
|
||||
"""Point-in-time-safe event/sentiment provider backed by RSSHub.
|
||||
|
||||
Attributes:
|
||||
base_url: Root of the RSSHub instance (no trailing slash).
|
||||
feeds: Registered feed specs keyed by name.
|
||||
close_cutoff_hour: Hour after which items roll to the next knowable day.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
*,
|
||||
feeds: Iterable[FeedSpec] | None = None,
|
||||
client: Any | None = None,
|
||||
close_cutoff_hour: int = DEFAULT_CLOSE_CUTOFF_HOUR,
|
||||
) -> None:
|
||||
"""Initialise the provider.
|
||||
|
||||
Args:
|
||||
base_url: RSSHub root URL; defaults to ``$RSSHUB_BASE_URL``.
|
||||
feeds: Feed specs to register; defaults to :data:`DEFAULT_FEEDS`.
|
||||
client: Optional injected HTTP client exposing ``get(url, timeout=...)``
|
||||
that returns an object with ``.text`` and ``.raise_for_status()``
|
||||
(an ``httpx.Client``-like). Injectable for offline testing.
|
||||
close_cutoff_hour: Knowable-date roll cutoff (0-23).
|
||||
"""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
resolved = (base_url if base_url is not None else get_env_config().data.rsshub_base_url).strip()
|
||||
self.base_url = resolved.rstrip("/")
|
||||
specs = tuple(feeds) if feeds is not None else DEFAULT_FEEDS
|
||||
self.feeds: dict[str, FeedSpec] = {spec.name: spec for spec in specs}
|
||||
self.close_cutoff_hour = close_cutoff_hour
|
||||
self._client = client
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Whether a usable RSSHub base URL is configured."""
|
||||
return self.base_url not in {p.rstrip("/") for p in RSSHUB_BASE_URL_PLACEHOLDERS}
|
||||
|
||||
def list_feeds(self) -> list[str]:
|
||||
"""Return registered feed names in stable order."""
|
||||
return sorted(self.feeds)
|
||||
|
||||
def describe_feed(self, feed: str) -> FeedSpec:
|
||||
"""Return the spec for a registered feed.
|
||||
|
||||
Raises:
|
||||
UnknownFeedError: If ``feed`` is not registered.
|
||||
"""
|
||||
try:
|
||||
return self.feeds[feed]
|
||||
except KeyError as exc:
|
||||
raise UnknownFeedError(f"Unknown RSSHub feed: {feed}") from exc
|
||||
|
||||
def query_events(
|
||||
self,
|
||||
codes: Iterable[str],
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
feeds: Iterable[str] | None = None,
|
||||
scorer: ScorerFn | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch and normalise feed items, dropping anything not yet knowable.
|
||||
|
||||
Args:
|
||||
codes: Instrument codes to query.
|
||||
as_of: Point-in-time boundary; items with ``knowable_date > as_of``
|
||||
are excluded (no look-ahead).
|
||||
feeds: Feed names to query; defaults to all registered feeds.
|
||||
scorer: Optional ``scorer(title, summary) -> float`` override;
|
||||
defaults to :func:`default_lexicon_scorer`.
|
||||
|
||||
Returns:
|
||||
Tidy frame with :data:`EVENT_COLUMNS`, sorted by
|
||||
``(ts_code, knowable_date)`` and de-duplicated.
|
||||
"""
|
||||
score_fn = scorer or default_lexicon_scorer
|
||||
feed_names = list(feeds) if feeds is not None else self.list_feeds()
|
||||
as_of_date = pd.Timestamp(as_of).normalize()
|
||||
|
||||
code_list = list(codes)
|
||||
rows: list[dict[str, Any]] = []
|
||||
attempted = 0
|
||||
failed = 0
|
||||
for feed_name in feed_names:
|
||||
spec = self.describe_feed(feed_name)
|
||||
for code in code_list:
|
||||
attempted += 1
|
||||
xml_text = self._fetch_feed(spec, code)
|
||||
if not xml_text:
|
||||
failed += 1
|
||||
continue
|
||||
rows.extend(self._parse_items(xml_text, code, spec, score_fn))
|
||||
|
||||
# A configured-but-fully-unreachable provider must fail loudly rather than
|
||||
# silently scoring every bar 0.0 (which reads as "sentiment considered, no
|
||||
# signal"). A reachable feed that simply returned no items is legitimate.
|
||||
if attempted and failed == attempted:
|
||||
raise EventProviderError(
|
||||
f"All {attempted} RSSHub feed fetch(es) failed for base_url "
|
||||
f"{self.base_url!r}; verify the instance is reachable and the "
|
||||
f"configured routes exist (feeds={feed_names})"
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=EVENT_COLUMNS)
|
||||
|
||||
frame = pd.DataFrame(rows, columns=list(EVENT_COLUMNS))
|
||||
frame = frame[frame["knowable_date"] <= as_of_date]
|
||||
frame = frame.drop_duplicates(subset=["ts_code", "knowable_date", "event_type", "summary"])
|
||||
frame = frame.sort_values(["ts_code", "knowable_date"]).reset_index(drop=True)
|
||||
return frame
|
||||
|
||||
def _fetch_feed(self, spec: FeedSpec, code: str) -> str:
|
||||
"""Fetch raw RSS XML for one feed/code, bounded by a wall-clock budget.
|
||||
|
||||
Returns the response body on success, or ``""`` when the fetch could not
|
||||
complete within the budget — the caller logs and counts that as a failed
|
||||
feed so an all-feeds-unreachable run can be surfaced loudly.
|
||||
"""
|
||||
if spec.is_per_symbol:
|
||||
route = spec.route_template.format(code=format_code_for_route(code, spec.code_style))
|
||||
else:
|
||||
route = spec.route_template
|
||||
url = f"{self.base_url}{route}"
|
||||
timeout = positive_env_float(RSSHUB_TIMEOUT_ENV, DEFAULT_TIMEOUT_S)
|
||||
budget = positive_env_float(RSSHUB_BUDGET_ENV, DEFAULT_BUDGET_S)
|
||||
client = self._client or self._default_client()
|
||||
deadline = time.monotonic() + budget
|
||||
|
||||
def _do() -> str:
|
||||
response = client.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
try:
|
||||
return retry_with_budget(
|
||||
_do,
|
||||
transient=Exception,
|
||||
deadline=deadline,
|
||||
label=f"rsshub fetch {spec.name} for {code}",
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"RSSHub feed %r for %s unreachable within %.0fs budget (%s); "
|
||||
"no items contributed from this feed",
|
||||
spec.name,
|
||||
code,
|
||||
budget,
|
||||
url,
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _default_client() -> Any:
|
||||
"""Construct a default httpx client (imported lazily)."""
|
||||
import httpx
|
||||
|
||||
return httpx.Client(follow_redirects=True)
|
||||
|
||||
def _parse_items(
|
||||
self,
|
||||
xml_text: str,
|
||||
code: str,
|
||||
spec: FeedSpec,
|
||||
score_fn: ScorerFn,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Parse RSS 2.0 ``<item>`` nodes into normalised event rows.
|
||||
|
||||
Uses ``defusedxml`` to neutralise XXE / entity-expansion attacks in the
|
||||
untrusted feed payload; malformed or hostile XML yields no rows.
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except (ParseError, DefusedXmlException):
|
||||
return []
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in root.iter("item"):
|
||||
title = (item.findtext("title") or "").strip()
|
||||
summary = _clean_summary(item.findtext("description"))
|
||||
pub_raw = item.findtext("pubDate")
|
||||
published = _parse_pubdate(pub_raw)
|
||||
if published is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"ts_code": code,
|
||||
"knowable_date": _knowable_date(published, self.close_cutoff_hour),
|
||||
"event_type": spec.event_type,
|
||||
"score": float(score_fn(title, summary)),
|
||||
"source": spec.name,
|
||||
"summary": summary or title,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_pubdate(value: str | None) -> pd.Timestamp | None:
|
||||
"""Parse an RFC-822 (RSS) or ISO ``pubDate`` into a Timestamp, or None."""
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return pd.Timestamp(parsedate_to_datetime(value))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return pd.Timestamp(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def enrich_price_frames_with_events(
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
provider: RSSHubEventProvider,
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
feeds: Iterable[str] | None = None,
|
||||
decay_lambda: float = 0.1,
|
||||
lookback: int = 30,
|
||||
min_abs_score: float = 0.0,
|
||||
scorer: ScorerFn | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Attach a point-in-time-safe ``event_score`` column to price frames.
|
||||
|
||||
For each bar ``t`` the score aggregates only events whose ``knowable_date``
|
||||
falls in ``(t - lookback, t]``, exponentially decayed by age — the same
|
||||
formula as the ``event-driven`` skill — so no future item can influence an
|
||||
earlier bar. Two columns are added: ``event_score`` (decayed sum, clipped to
|
||||
``[-1, 1]``) and ``event_count`` (events in the window).
|
||||
|
||||
Args:
|
||||
data_map: Mapping ``{code: OHLCV DataFrame}`` (DatetimeIndex).
|
||||
provider: Configured :class:`RSSHubEventProvider`.
|
||||
as_of: Point-in-time boundary passed through to the provider.
|
||||
feeds: Feed names to query; defaults to all registered feeds.
|
||||
decay_lambda: Exponential decay per day (higher decays faster).
|
||||
lookback: Window in days; older events are excluded.
|
||||
min_abs_score: Drop events with ``|score|`` below this threshold.
|
||||
scorer: Optional scoring override forwarded to the provider.
|
||||
|
||||
Returns:
|
||||
A new mapping with the same frames plus ``event_score``/``event_count``.
|
||||
"""
|
||||
if not data_map:
|
||||
return data_map
|
||||
|
||||
codes = list(data_map)
|
||||
events = provider.query_events(codes, as_of=as_of, feeds=feeds, scorer=scorer)
|
||||
enriched = {code: frame.copy() for code, frame in data_map.items()}
|
||||
if events.empty:
|
||||
for frame in enriched.values():
|
||||
frame["event_score"] = 0.0
|
||||
frame["event_count"] = 0
|
||||
return enriched
|
||||
|
||||
if min_abs_score > 0.0:
|
||||
events = events[events["score"].abs() >= min_abs_score]
|
||||
|
||||
for code, frame in enriched.items():
|
||||
rows = events[events["ts_code"] == code]
|
||||
bar_dates = pd.DatetimeIndex(pd.to_datetime(frame.index)).normalize()
|
||||
scores = pd.Series(0.0, index=frame.index)
|
||||
counts = pd.Series(0, index=frame.index)
|
||||
if not rows.empty and len(frame) > 0:
|
||||
ev_dates = rows["knowable_date"].to_numpy(dtype="datetime64[ns]")
|
||||
ev_scores = rows["score"].to_numpy(dtype=float)
|
||||
window = np.timedelta64(lookback, "D")
|
||||
for pos, bar in enumerate(bar_dates):
|
||||
bar64 = np.datetime64(bar, "ns")
|
||||
mask = (ev_dates <= bar64) & (ev_dates > bar64 - window)
|
||||
if not mask.any():
|
||||
continue
|
||||
ages = (bar64 - ev_dates[mask]) / np.timedelta64(1, "D")
|
||||
decayed = float((ev_scores[mask] * np.exp(-decay_lambda * ages)).sum())
|
||||
scores.iloc[pos] = max(-1.0, min(1.0, decayed))
|
||||
counts.iloc[pos] = int(mask.sum())
|
||||
frame["event_score"] = scores
|
||||
frame["event_count"] = counts
|
||||
return enriched
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Shared SEC EDGAR REST client: ticker->CIK mapping + filings/facts fetch.
|
||||
|
||||
The U.S. SEC publishes free, no-auth JSON endpoints for company filings and
|
||||
XBRL financial facts. Three public facts drive this module:
|
||||
|
||||
* ``https://www.sec.gov/files/company_tickers.json`` maps every reporting
|
||||
ticker to its numeric CIK (Central Index Key).
|
||||
* ``https://data.sec.gov/submissions/CIK##########.json`` returns a company's
|
||||
recent filing index, keyed by the **zero-padded 10-digit** CIK.
|
||||
* ``https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json`` returns the
|
||||
full set of reported XBRL financial concepts for that same padded CIK.
|
||||
|
||||
SEC rate-limits by source IP and asks every client to send a descriptive
|
||||
``User-Agent`` carrying a contact address; bursting without one earns a
|
||||
temporary block. Every request therefore routes through the shared
|
||||
:mod:`backtest.loaders._http` throttle under the ``"sec"`` host bucket, and the
|
||||
UA defaults to a Vibe-Trading contact string overridable via
|
||||
``VIBE_TRADING_SEC_UA``.
|
||||
|
||||
This module is a thin transport client, not a :class:`DataLoaderProtocol`
|
||||
implementation: it returns raw decoded JSON for downstream loaders/tools to
|
||||
shape. The ticker->CIK table is fetched once per process and memoized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from backtest.loaders._http import (
|
||||
DEFAULT_USER_AGENT,
|
||||
resolve_min_interval,
|
||||
throttled_get_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HOST_KEY = "sec"
|
||||
# SEC asks for no more than ~10 requests/second; 0.12s spacing keeps us well
|
||||
# under that even with jitter, and is the floor mandated by the parcel contract.
|
||||
_MIN_INTERVAL_DEFAULT = 0.12
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_SEC_MIN_INTERVAL"
|
||||
|
||||
_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
|
||||
|
||||
# Symbol suffixes that denote a market rather than a share class, so they can be
|
||||
# stripped before a ticker lookup.
|
||||
_MARKET_SUFFIXES = frozenset({"US"})
|
||||
_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json"
|
||||
_COMPANY_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
|
||||
|
||||
_UA_ENV = "VIBE_TRADING_SEC_UA"
|
||||
# SEC's fair-access policy wants a real contact in the UA. Override via env to
|
||||
# present your own address; this default keeps the client compliant out of box.
|
||||
_DEFAULT_SEC_UA = f"Vibe-Trading/1.0 (contact: vibe-trading@example.com) {DEFAULT_USER_AGENT}"
|
||||
|
||||
# Process-wide memoized ticker->CIK map ("AAPL" -> "0000320193"), built lazily.
|
||||
_TICKER_CACHE: Optional[Dict[str, str]] = None
|
||||
_TICKER_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the effective per-request spacing, never below the 0.12s floor."""
|
||||
return max(_MIN_INTERVAL_DEFAULT, resolve_min_interval(_MIN_INTERVAL_ENV, _MIN_INTERVAL_DEFAULT))
|
||||
|
||||
|
||||
def _user_agent() -> str:
|
||||
"""Return the compliant contact UA, honoring the ``VIBE_TRADING_SEC_UA`` override."""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
override = get_env_config().data.vibe_trading_sec_ua
|
||||
if override and override.strip():
|
||||
return override.strip()
|
||||
return _DEFAULT_SEC_UA
|
||||
|
||||
|
||||
def _sec_get_json(url: str) -> Any:
|
||||
"""GET ``url`` as JSON through the shared SEC-bucket throttle.
|
||||
|
||||
Args:
|
||||
url: Fully-qualified SEC endpoint URL.
|
||||
|
||||
Returns:
|
||||
The decoded JSON body (typically a ``dict``).
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On a non-2xx status or undecodable body,
|
||||
propagated unchanged for the caller to classify.
|
||||
"""
|
||||
return throttled_get_json(
|
||||
url,
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
headers={"User-Agent": _user_agent(), "Accept": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def _pad_cik(cik: str | int) -> str:
|
||||
"""Normalize a CIK to the SEC's zero-padded 10-digit string form.
|
||||
|
||||
Args:
|
||||
cik: A CIK as an int or a string that may already be padded or carry a
|
||||
leading ``"CIK"`` prefix.
|
||||
|
||||
Returns:
|
||||
The CIK as exactly 10 digits, e.g. ``"0000320193"``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``cik`` contains no digits.
|
||||
"""
|
||||
digits = "".join(ch for ch in str(cik) if ch.isdigit())
|
||||
if not digits:
|
||||
raise ValueError(f"CIK has no digits: {cik!r}")
|
||||
return digits.zfill(10)
|
||||
|
||||
|
||||
def _build_ticker_map(payload: Any) -> Dict[str, str]:
|
||||
"""Build an uppercase ticker->padded-CIK map from the tickers payload.
|
||||
|
||||
The SEC payload is a dict of positional string keys to records shaped like
|
||||
``{"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."}``. A
|
||||
malformed row is skipped rather than aborting the whole map.
|
||||
|
||||
Args:
|
||||
payload: Decoded ``company_tickers.json`` body.
|
||||
|
||||
Returns:
|
||||
Mapping of upper-cased ticker to zero-padded 10-digit CIK.
|
||||
"""
|
||||
records = payload.values() if isinstance(payload, dict) else payload
|
||||
mapping: Dict[str, str] = {}
|
||||
for record in records or []:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
ticker = record.get("ticker")
|
||||
cik = record.get("cik_str", record.get("cik"))
|
||||
if not ticker or cik is None:
|
||||
continue
|
||||
try:
|
||||
mapping[str(ticker).strip().upper()] = _pad_cik(cik)
|
||||
except ValueError:
|
||||
logger.debug("skipping ticker row with bad CIK: %r", record)
|
||||
return mapping
|
||||
|
||||
|
||||
def _ticker_map() -> Dict[str, str]:
|
||||
"""Return the process-wide ticker->CIK map, fetching once and memoizing.
|
||||
|
||||
Returns:
|
||||
Mapping of upper-cased ticker to zero-padded 10-digit CIK.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the one-time tickers fetch fails.
|
||||
"""
|
||||
global _TICKER_CACHE
|
||||
if _TICKER_CACHE is not None:
|
||||
return _TICKER_CACHE
|
||||
with _TICKER_CACHE_LOCK:
|
||||
if _TICKER_CACHE is None:
|
||||
payload = _sec_get_json(_TICKERS_URL)
|
||||
_TICKER_CACHE = _build_ticker_map(payload)
|
||||
return _TICKER_CACHE
|
||||
|
||||
|
||||
def cik_for(ticker: str) -> Optional[str]:
|
||||
"""Resolve a ticker symbol to its zero-padded 10-digit CIK.
|
||||
|
||||
Args:
|
||||
ticker: A U.S. equity ticker (case-insensitive), e.g. ``"AAPL"``.
|
||||
|
||||
Returns:
|
||||
The padded CIK string (``"0000320193"``) or ``None`` when the ticker is
|
||||
empty or absent from the SEC table.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the one-time tickers fetch fails.
|
||||
"""
|
||||
if not ticker or not ticker.strip():
|
||||
return None
|
||||
table = _ticker_map()
|
||||
candidate = ticker.strip().upper()
|
||||
if candidate in table:
|
||||
return table[candidate]
|
||||
# ``AAPL.US`` is this project's US symbol convention while the SEC table
|
||||
# carries the bare ticker. Only a known market suffix may be stripped — a
|
||||
# trailing ``.B`` is a share class, not a market.
|
||||
head, _, tail = candidate.rpartition(".")
|
||||
if head and tail in _MARKET_SUFFIXES:
|
||||
candidate = head
|
||||
if candidate in table:
|
||||
return table[candidate]
|
||||
# The SEC table writes share classes with a dash (``BRK-B``, ``MOG-A``) and
|
||||
# contains no dots at all; users and vendors commonly write a dot.
|
||||
return table.get(candidate.replace(".", "-"))
|
||||
|
||||
|
||||
def get_submissions(cik: str | int) -> Dict[str, Any]:
|
||||
"""Fetch the filing-index ("submissions") JSON for a CIK.
|
||||
|
||||
Args:
|
||||
cik: A CIK as int or string; padded to 10 digits internally.
|
||||
|
||||
Returns:
|
||||
The decoded submissions document (company metadata + recent filings).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``cik`` contains no digits.
|
||||
requests.RequestException: On a non-2xx status or undecodable body.
|
||||
"""
|
||||
return _sec_get_json(_SUBMISSIONS_URL.format(cik=_pad_cik(cik)))
|
||||
|
||||
|
||||
def get_company_facts(cik: str | int) -> Dict[str, Any]:
|
||||
"""Fetch the XBRL ``companyfacts`` JSON for a CIK.
|
||||
|
||||
Args:
|
||||
cik: A CIK as int or string; padded to 10 digits internally.
|
||||
|
||||
Returns:
|
||||
The decoded company-facts document (all reported XBRL concepts).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``cik`` contains no digits.
|
||||
requests.RequestException: On a non-2xx status or undecodable body.
|
||||
"""
|
||||
return _sec_get_json(_COMPANY_FACTS_URL.format(cik=_pad_cik(cik)))
|
||||
|
||||
|
||||
def _reset_ticker_cache_for_tests() -> None:
|
||||
"""Clear the memoized ticker map. Test-only hook; never called in prod."""
|
||||
global _TICKER_CACHE
|
||||
with _TICKER_CACHE_LOCK:
|
||||
_TICKER_CACHE = None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Shared period-frame selection for SEC XBRL ``companyfacts`` rows.
|
||||
|
||||
``companyfacts`` interleaves several kinds of fact row that can share an ``end``
|
||||
date:
|
||||
|
||||
* **instant** facts (balance-sheet concepts) carry no ``start`` at all;
|
||||
* **true-quarter** duration frames span roughly 90 days;
|
||||
* **year-to-date** duration frames span roughly 180 or 270 days;
|
||||
* **full-year** duration frames span roughly 365 days.
|
||||
|
||||
A 10-Q reports the true quarter *and* the year-to-date frame for the same
|
||||
``end``, under the same ``fy``, ``fp``, ``form`` and ``accn``. Any consumer that
|
||||
identifies a period by ``end`` — with or without those four companions —
|
||||
therefore silently conflates a nine-month figure with the quarter that ends on
|
||||
the same day. On live AAPL revenue data 36 of 81 such keys collide.
|
||||
|
||||
The only field that separates them is ``start``, so the identity of a reporting
|
||||
period is exactly the ``(start, end)`` span. This module is the single
|
||||
implementation of that rule; ``fundamentals_loader`` and the SEC tools all route
|
||||
through it rather than re-deriving the thresholds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# Inclusive day-count windows. Quarters run 89-93 days but issuers with 4-4-5
|
||||
# fiscal calendars stretch further, and 52/53-week years land either side of
|
||||
# 365, so both windows are deliberately generous.
|
||||
QUARTER_SPAN_DAYS = (60, 120)
|
||||
ANNUAL_SPAN_DAYS = (330, 380)
|
||||
|
||||
#: Period-type labels emitted by :func:`classify_span`.
|
||||
INSTANT = "instant"
|
||||
QUARTER = "quarter"
|
||||
ANNUAL = "annual"
|
||||
YTD = "ytd"
|
||||
|
||||
|
||||
def span_days(row: dict[str, Any]) -> int | None:
|
||||
"""Return the number of days a duration fact covers.
|
||||
|
||||
Args:
|
||||
row: One SEC ``companyfacts`` unit row.
|
||||
|
||||
Returns:
|
||||
The day count between ``start`` and ``end``, or ``None`` when the row is
|
||||
an instant fact (no ``start``) or either date fails to parse.
|
||||
"""
|
||||
start, end = row.get("start"), row.get("end")
|
||||
if not start or not end:
|
||||
return None
|
||||
start_ts = pd.to_datetime(start, errors="coerce")
|
||||
end_ts = pd.to_datetime(end, errors="coerce")
|
||||
if pd.isna(start_ts) or pd.isna(end_ts):
|
||||
return None
|
||||
return int((end_ts - start_ts).days)
|
||||
|
||||
|
||||
def is_quarter_span(days: int | None) -> bool:
|
||||
"""Return whether ``days`` falls in the true-quarter window."""
|
||||
return days is not None and QUARTER_SPAN_DAYS[0] <= days <= QUARTER_SPAN_DAYS[1]
|
||||
|
||||
|
||||
def is_annual_span(days: int | None) -> bool:
|
||||
"""Return whether ``days`` falls in the full-year window."""
|
||||
return days is not None and ANNUAL_SPAN_DAYS[0] <= days <= ANNUAL_SPAN_DAYS[1]
|
||||
|
||||
|
||||
def classify_span(days: int | None) -> str:
|
||||
"""Label a span as instant, quarter, annual, or year-to-date.
|
||||
|
||||
Args:
|
||||
days: Output of :func:`span_days`.
|
||||
|
||||
Returns:
|
||||
One of :data:`INSTANT`, :data:`QUARTER`, :data:`ANNUAL`, :data:`YTD`.
|
||||
Anything that is a duration but matches neither the quarter nor the
|
||||
annual window is year-to-date or a stub period, and is labelled
|
||||
:data:`YTD` so a caller never mistakes it for a reporting period.
|
||||
"""
|
||||
if days is None:
|
||||
return INSTANT
|
||||
if is_quarter_span(days):
|
||||
return QUARTER
|
||||
if is_annual_span(days):
|
||||
return ANNUAL
|
||||
return YTD
|
||||
|
||||
|
||||
def frame_key(row: dict[str, Any]) -> tuple[Any, Any]:
|
||||
"""Return the identity of the reporting period a fact row belongs to.
|
||||
|
||||
``(start, end)`` and nothing else. Including ``fy`` would split one period
|
||||
into a separate row per filing that reports it as a comparative, and
|
||||
including only ``end`` would collide a year-to-date frame with the true
|
||||
quarter ending the same day.
|
||||
|
||||
Args:
|
||||
row: One SEC ``companyfacts`` unit row.
|
||||
|
||||
Returns:
|
||||
The ``(start, end)`` pair; ``start`` is ``None`` for instant facts.
|
||||
"""
|
||||
return (row.get("start"), row.get("end"))
|
||||
|
||||
|
||||
def matches_cadence(row: dict[str, Any], period: str) -> bool:
|
||||
"""Return whether a fact row is a reporting period at the requested cadence.
|
||||
|
||||
Args:
|
||||
row: One SEC ``companyfacts`` unit row.
|
||||
period: ``"annual"`` or ``"quarter"``.
|
||||
|
||||
Returns:
|
||||
``True`` for instant facts (they are valid at any cadence and are keyed
|
||||
on ``end``), for full-year frames when ``period`` is ``"annual"``, and
|
||||
for true-quarter frames when ``period`` is ``"quarter"``. Year-to-date
|
||||
frames never match.
|
||||
"""
|
||||
kind = classify_span(span_days(row))
|
||||
if kind == INSTANT:
|
||||
return True
|
||||
if period == "annual":
|
||||
return kind == ANNUAL
|
||||
return kind == QUARTER
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Sina Finance loader: free, no-auth US daily OHLCV via the JSONP K-line API.
|
||||
|
||||
Sina exposes US daily candles through a JSONP endpoint that wraps a JSON array
|
||||
of ``{d,o,h,l,c,v}`` bars in a JavaScript variable assignment. We request it,
|
||||
strip the JSONP wrapper, and reshape into the loader's standard OHLCV frame.
|
||||
|
||||
API format (JSONP)::
|
||||
|
||||
https://stock.finance.sina.com.cn/usstock/api/jsonp_v2.php/var%20x=/US_MinKService.getDailyK?symbol=AAPL
|
||||
|
||||
Like Eastmoney, Sina rate-limits by source IP, so every request routes through
|
||||
the shared per-host throttle in :mod:`backtest.loaders._http`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = (
|
||||
"https://stock.finance.sina.com.cn/usstock/api/jsonp_v2.php/"
|
||||
"var%20x=/US_MinKService.getDailyK"
|
||||
)
|
||||
_HOST_KEY = "sina"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_SINA_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL = 0.5
|
||||
|
||||
# Sina wraps the payload as ``var x=([...]);`` — the JSON array sits inside an
|
||||
# optional ``(...)``. Capture the outermost bracketed array, ignoring the
|
||||
# assignment/paren scaffolding around it.
|
||||
_JSONP_ARRAY_RE = re.compile(r"(\[.*\])", re.DOTALL)
|
||||
|
||||
|
||||
def _is_us_equity(code: str) -> bool:
|
||||
"""Return whether ``code`` is a US-equity symbol this loader handles."""
|
||||
return bool(code) and code.upper().endswith(".US")
|
||||
|
||||
|
||||
def _to_sina_symbol(code: str) -> str:
|
||||
"""Map an internal code to Sina's bare ticker (``"AAPL.US"`` -> ``"AAPL"``)."""
|
||||
return code.upper().rsplit(".", 1)[0]
|
||||
|
||||
|
||||
def _strip_jsonp(raw: str) -> list:
|
||||
"""Extract the JSON array embedded in Sina's JSONP wrapper.
|
||||
|
||||
Args:
|
||||
raw: Raw response body, e.g. ``var x=([{"d":"2024-01-02",...}]);``.
|
||||
|
||||
Returns:
|
||||
The decoded list of bar dicts.
|
||||
|
||||
Raises:
|
||||
ValueError: If no JSON array can be located or decoded.
|
||||
"""
|
||||
match = _JSONP_ARRAY_RE.search(raw.strip())
|
||||
if not match:
|
||||
raise ValueError("no JSON array found in Sina JSONP response")
|
||||
bars = json.loads(match.group(1))
|
||||
if not isinstance(bars, list):
|
||||
raise ValueError("Sina JSONP payload is not a list of bars")
|
||||
return bars
|
||||
|
||||
|
||||
def _bars_to_frame(bars: list, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""Reshape Sina ``{d,o,h,l,c,v}`` bars into the standard OHLCV frame.
|
||||
|
||||
Args:
|
||||
bars: List of per-day dicts with ``d/o/h/l/c/v`` keys.
|
||||
start_date: Inclusive window start (YYYY-MM-DD).
|
||||
end_date: Inclusive window end (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
A DatetimeIndex (named ``trade_date``) frame with float columns
|
||||
``open/high/low/close/volume`` clipped to the window, or ``None`` when
|
||||
no usable rows survive parsing.
|
||||
"""
|
||||
rows = []
|
||||
for bar in bars:
|
||||
if not isinstance(bar, dict) or "d" not in bar:
|
||||
continue
|
||||
try:
|
||||
rows.append(
|
||||
{
|
||||
"trade_date": bar["d"],
|
||||
"open": float(bar["o"]),
|
||||
"high": float(bar["h"]),
|
||||
"low": float(bar["l"]),
|
||||
"close": float(bar["c"]),
|
||||
"volume": float(bar["v"]),
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
frame = pd.DataFrame(rows)
|
||||
frame["trade_date"] = pd.to_datetime(frame["trade_date"])
|
||||
frame = frame.set_index("trade_date").sort_index()
|
||||
frame = frame[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
window = frame.loc[
|
||||
(frame.index >= pd.Timestamp(start_date))
|
||||
& (frame.index <= pd.Timestamp(end_date))
|
||||
]
|
||||
return window if not window.empty else None
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Sina Finance US-equity daily OHLCV loader (free, HTTP/JSONP, no auth)."""
|
||||
|
||||
name = "sina"
|
||||
markets = {"us_equity"}
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available — uses plain throttled HTTP, no credentials."""
|
||||
return True
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch US daily OHLCV for each code; skip and log per-symbol failures.
|
||||
|
||||
Args:
|
||||
codes: Symbols in ``TICKER.US`` form (e.g. ``"AAPL.US"``).
|
||||
start_date: Inclusive window start (YYYY-MM-DD).
|
||||
end_date: Inclusive window end (YYYY-MM-DD).
|
||||
interval: Bar interval; only daily (``"1D"``) is supported.
|
||||
fields: Ignored — the standard OHLCV columns are always returned.
|
||||
|
||||
Returns:
|
||||
Mapping ``{code: DataFrame}`` for every code that yielded bars.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``interval`` is not daily or the date range is invalid.
|
||||
"""
|
||||
if interval.upper() not in {"1D", "D", "DAY", "DAILY"}:
|
||||
raise ValueError(f"Unsupported interval {interval!r}; sina is daily-only")
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("sina failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and parse one symbol's daily bars, or ``None`` if non-US/empty."""
|
||||
if not _is_us_equity(code):
|
||||
return None
|
||||
|
||||
symbol = _to_sina_symbol(code)
|
||||
response = throttled_get(
|
||||
_BASE_URL,
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL),
|
||||
params={"symbol": symbol},
|
||||
headers={"Referer": "https://stock.finance.sina.com.cn/"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
bars = _strip_jsonp(response.text)
|
||||
return _bars_to_frame(bars, start_date, end_date)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Stooq loader: free, no-auth US-equity EOD OHLCV via CSV download.
|
||||
|
||||
Stooq publishes free end-of-day bars from a plain CSV endpoint
|
||||
(``https://stooq.com/q/d/l/``) with no API key. Like other free quote
|
||||
providers it rate-limits by source IP and must be throttled, so every request
|
||||
routes through :mod:`backtest.loaders._http` (shared per-host spacing + session
|
||||
reuse) under the ``"stooq"`` host bucket.
|
||||
|
||||
API format:
|
||||
https://stooq.com/q/d/l/?s=aapl.us&d1=20240101&d2=20240131&i=d
|
||||
|
||||
Symbol convention (Vibe-Trading -> Stooq):
|
||||
* ``AAPL.US`` -> ``aapl.us`` (Stooq tickers are lowercase; the ``.US`` market
|
||||
suffix is kept, just lower-cased).
|
||||
|
||||
Response body is CSV ``Date,Open,High,Low,Close,Volume``. An unknown symbol or
|
||||
empty window yields the literal ``"N/D"`` or an empty body, both treated as
|
||||
"no data" for that symbol (skipped, never fatal to the batch).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://stooq.com/q/d/l/"
|
||||
HOST_KEY = "stooq"
|
||||
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_STOOQ_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL_S = 0.6
|
||||
|
||||
# Stooq's CSV header columns mapped to our output field names.
|
||||
_COLUMN_MAP = {
|
||||
"Open": "open",
|
||||
"High": "high",
|
||||
"Low": "low",
|
||||
"Close": "close",
|
||||
"Volume": "volume",
|
||||
}
|
||||
_OUTPUT_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum spacing, honoring the env override."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
|
||||
|
||||
|
||||
def map_symbol(symbol: str) -> str:
|
||||
"""Translate a Vibe-Trading symbol into Stooq's ticker convention.
|
||||
|
||||
Args:
|
||||
symbol: Project-side symbol, e.g. ``AAPL.US``.
|
||||
|
||||
Returns:
|
||||
The lower-cased Stooq ticker, e.g. ``aapl.us``.
|
||||
"""
|
||||
return symbol.strip().lower()
|
||||
|
||||
|
||||
def _compact_date(value: str) -> str:
|
||||
"""Render a date string as Stooq's ``YYYYMMDD`` form."""
|
||||
return pd.Timestamp(value).strftime("%Y%m%d")
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Stooq US-equity EOD OHLCV loader (free, HTTP CSV, no auth)."""
|
||||
|
||||
name = "stooq"
|
||||
markets = {"us_equity"}
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available — uses plain throttled HTTP, no credentials."""
|
||||
return True
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch daily OHLCV bars for ``codes`` over ``[start_date, end_date]``.
|
||||
|
||||
Args:
|
||||
codes: Project-side symbols (e.g. ``["AAPL.US", "MSFT.US"]``).
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
interval: Bar size; only daily (``"1D"``) is supported. Other
|
||||
intervals return an empty map so the fallback chain can continue.
|
||||
fields: Unused — the CSV always carries the full OHLCV set.
|
||||
|
||||
Returns:
|
||||
Mapping ``{symbol: DataFrame}`` for symbols that returned data. Each
|
||||
frame has a ``DatetimeIndex`` named ``trade_date`` and float columns
|
||||
``open/high/low/close/volume`` in ascending date order. A symbol that
|
||||
errors or has no data is omitted, never aborting the batch.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``start_date > end_date`` or a date is unparseable.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
# Daily-only CSV endpoint; do not silently return day bars for ``1H``.
|
||||
if str(interval).strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning(
|
||||
"stooq supports daily bars only; rejecting interval=%r",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort the batch
|
||||
logger.warning("stooq failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and parse one symbol's CSV; ``None`` when Stooq has no data."""
|
||||
params = {
|
||||
"s": map_symbol(code),
|
||||
"d1": _compact_date(start_date),
|
||||
"d2": _compact_date(end_date),
|
||||
"i": "d",
|
||||
}
|
||||
response = throttled_get(
|
||||
_BASE_URL,
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_csv(response.text)
|
||||
|
||||
|
||||
def _parse_csv(body: str) -> Optional[pd.DataFrame]:
|
||||
"""Convert a Stooq EOD CSV body into our OHLCV frame, ``None`` on no data.
|
||||
|
||||
Args:
|
||||
body: Raw CSV text ``Date,Open,High,Low,Close,Volume``. An empty body or
|
||||
the sentinel ``"N/D"`` (unknown symbol / empty window) means no data.
|
||||
|
||||
Returns:
|
||||
A frame indexed by ``trade_date`` with float OHLCV columns sorted
|
||||
ascending, or ``None`` when the body carries no usable rows.
|
||||
"""
|
||||
text = (body or "").strip()
|
||||
if not text or text.upper().startswith("N/D"):
|
||||
return None
|
||||
|
||||
frame = pd.read_csv(io.StringIO(text))
|
||||
if frame.empty or "Date" not in frame.columns:
|
||||
return None
|
||||
|
||||
rename = {col: _COLUMN_MAP[col] for col in _COLUMN_MAP if col in frame.columns}
|
||||
frame = frame.rename(columns=rename)
|
||||
if not all(col in frame.columns for col in _OUTPUT_COLUMNS):
|
||||
return None
|
||||
|
||||
frame["trade_date"] = pd.to_datetime(frame["Date"], errors="coerce")
|
||||
frame = frame.dropna(subset=["trade_date"]).set_index("trade_date").sort_index()
|
||||
frame.index.name = "trade_date"
|
||||
|
||||
for col in _OUTPUT_COLUMNS:
|
||||
frame[col] = pd.to_numeric(frame[col], errors="coerce")
|
||||
frame = frame[_OUTPUT_COLUMNS].dropna(subset=["open", "high", "low", "close"])
|
||||
if frame.empty:
|
||||
return None
|
||||
return frame.astype(float)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tencent Finance loader: free, no-auth A-share data via HTTP API.
|
||||
|
||||
Uses Tencent's ifzq.gtimg.cn API which is not blocked by eastmoney's CDN.
|
||||
Covers: A-shares (SH/SZ). No API token required.
|
||||
|
||||
API format:
|
||||
https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh601595,day,2026-06-01,2026-06-13,500,qfq
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
|
||||
|
||||
|
||||
def _is_a_share(code: str) -> bool:
|
||||
return code.upper().endswith((".SZ", ".SH"))
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Tencent Finance A-share OHLCV loader (free, HTTP, no auth)."""
|
||||
|
||||
name = "tencent"
|
||||
markets = {"a_share"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available — uses plain HTTP."""
|
||||
return True
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
validate_date_range(start_date, end_date)
|
||||
del fields
|
||||
|
||||
if interval.strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning(
|
||||
"tencent supports daily bars only; rejecting interval=%s",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("tencent failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
if not _is_a_share(code):
|
||||
return None
|
||||
|
||||
parts = code.upper().split(".")
|
||||
symbol = parts[0]
|
||||
suffix = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if suffix == "SH":
|
||||
tencent_code = f"sh{symbol}"
|
||||
elif suffix == "SZ":
|
||||
tencent_code = f"sz{symbol}"
|
||||
else:
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"{_BASE_URL}?param={tencent_code},day,"
|
||||
f"{start_date},{end_date},500,qfq"
|
||||
)
|
||||
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://web.ifzq.gtimg.cn/",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
|
||||
data = json.loads(raw)
|
||||
# Response: {"code":0,"data":{"sh601595":{"day":[["2026-06-01","21.32",...], ...]}}}
|
||||
stock_data = data.get("data", {})
|
||||
if not stock_data:
|
||||
return None
|
||||
|
||||
# Get the first (only) key
|
||||
stock_key = next(iter(stock_data), None)
|
||||
if not stock_key:
|
||||
return None
|
||||
|
||||
# Try "day" first, then "qfqday" (forward-adjusted)
|
||||
klines = stock_data[stock_key].get("qfqday") or stock_data[stock_key].get("day")
|
||||
if not klines:
|
||||
return None
|
||||
|
||||
# Each row: ["date", "open", "close", "high", "low", "volume"]
|
||||
rows = []
|
||||
for k in klines:
|
||||
if len(k) >= 6:
|
||||
rows.append({
|
||||
"trade_date": k[0],
|
||||
"open": float(k[1]),
|
||||
"close": float(k[2]),
|
||||
"high": float(k[3]),
|
||||
"low": float(k[4]),
|
||||
"volume": float(k[5]),
|
||||
})
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date").sort_index()
|
||||
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return df
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tiingo loader: US-equity daily OHLCV via the Tiingo REST API (key-gated).
|
||||
|
||||
Tiingo serves end-of-day US-equity bars from a documented public endpoint:
|
||||
|
||||
GET https://api.tiingo.com/tiingo/daily/{symbol}/prices
|
||||
?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&token={KEY}
|
||||
|
||||
The response is a JSON array of per-day objects, each carrying ``date`` plus
|
||||
``open``/``high``/``low``/``close``/``volume`` (and adjusted variants we ignore).
|
||||
A token is required; it is read from the ``TIINGO_API_KEY`` environment variable
|
||||
and is never hard-coded. Like every other ban-prone HTTP loader in this package,
|
||||
all requests route through :mod:`backtest.loaders._http` for per-host throttling
|
||||
and session reuse — Tiingo rate-limits by client and rejects unspaced bursts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders._http import resolve_min_interval, throttled_get_json
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.tiingo.com/tiingo/daily"
|
||||
|
||||
# Throttle/session bucket plus the per-provider minimum spacing override.
|
||||
_HOST_KEY = "tiingo"
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_TIINGO_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL = 0.5
|
||||
|
||||
_AUTH_ENV = "TIINGO_API_KEY"
|
||||
|
||||
# Placeholder values that must not count as a real key (mirrors how other
|
||||
# key-gated loaders treat blank/example tokens as "not configured").
|
||||
_KEY_PLACEHOLDERS = {"", "your_tiingo_api_key", "changeme", "xxx"}
|
||||
|
||||
# Target OHLCV schema, in column order.
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
|
||||
|
||||
def _resolve_key() -> str:
|
||||
"""Return the configured Tiingo API key, or an empty string when unset.
|
||||
|
||||
Returns:
|
||||
The trimmed ``TIINGO_API_KEY`` value, or ``""`` when absent or a known
|
||||
placeholder.
|
||||
"""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
key = get_env_config().data.tiingo_api_key.strip()
|
||||
return "" if key.lower() in _KEY_PLACEHOLDERS else key
|
||||
|
||||
|
||||
def _to_tiingo_symbol(code: str) -> Optional[str]:
|
||||
"""Map a project symbol to a Tiingo ticker, or ``None`` when unsupported.
|
||||
|
||||
Args:
|
||||
code: Project symbol such as ``AAPL.US`` or ``AAPL``.
|
||||
|
||||
Returns:
|
||||
The bare lower-cased ticker Tiingo expects (e.g. ``aapl``), or ``None``
|
||||
for symbols that are clearly not US equities (HK/crypto/A-share).
|
||||
"""
|
||||
upper = code.strip().upper()
|
||||
if not upper:
|
||||
return None
|
||||
if upper.endswith(".US"):
|
||||
upper = upper[:-3]
|
||||
# Reject non-US market suffixes and crypto pairs outright.
|
||||
if "." in upper or "-" in upper:
|
||||
return None
|
||||
if not upper.isalnum():
|
||||
return None
|
||||
return upper.lower()
|
||||
|
||||
|
||||
def _rows_to_frame(rows: List[dict]) -> Optional[pd.DataFrame]:
|
||||
"""Convert Tiingo's per-day records into the normalized OHLCV frame.
|
||||
|
||||
Args:
|
||||
rows: JSON array decoded from the prices endpoint; each item carries a
|
||||
``date`` plus ``open``/``high``/``low``/``close``/``volume``.
|
||||
|
||||
Returns:
|
||||
A DataFrame indexed by a ``trade_date`` :class:`~pandas.DatetimeIndex`
|
||||
with float ``open``/``high``/``low``/``close``/``volume`` columns, or
|
||||
``None`` when no usable bar is present.
|
||||
"""
|
||||
parsed: List[dict] = []
|
||||
for row in rows:
|
||||
date = row.get("date")
|
||||
if date is None:
|
||||
continue
|
||||
parsed.append(
|
||||
{
|
||||
"trade_date": date,
|
||||
"open": row.get("open"),
|
||||
"high": row.get("high"),
|
||||
"low": row.get("low"),
|
||||
"close": row.get("close"),
|
||||
"volume": row.get("volume"),
|
||||
}
|
||||
)
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
frame = pd.DataFrame(parsed)
|
||||
# Tiingo stamps each bar with an ISO-8601 datetime (e.g.
|
||||
# "2024-01-02T00:00:00.000Z"); normalize to a tz-naive midnight index.
|
||||
index = pd.DatetimeIndex(pd.to_datetime(frame["trade_date"], utc=True, errors="coerce"))
|
||||
if getattr(index, "tz", None) is not None:
|
||||
index = index.tz_convert(None)
|
||||
frame = frame.drop(columns=["trade_date"])
|
||||
frame.index = index.normalize()
|
||||
frame.index.name = "trade_date"
|
||||
|
||||
frame = frame.apply(pd.to_numeric, errors="coerce")
|
||||
# Coerce every OHLCV column to float64 so the schema is uniform: integer
|
||||
# JSON volumes would otherwise land as int64, breaking downstream code that
|
||||
# assumes a single numeric dtype.
|
||||
frame = frame.astype("float64")
|
||||
frame["volume"] = frame["volume"].fillna(0.0)
|
||||
frame = frame.loc[:, _OHLCV_COLUMNS].sort_index()
|
||||
frame = frame.dropna(subset=["open", "high", "low", "close"])
|
||||
return frame if not frame.empty else None
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Fetch US-equity daily OHLCV bars from Tiingo (key-gated REST)."""
|
||||
|
||||
name = "tiingo"
|
||||
markets = {"us_equity"}
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the loader (no network or SDK touched at construction)."""
|
||||
pass
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return whether a usable Tiingo API key is configured."""
|
||||
return bool(_resolve_key())
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV history keyed by the original project symbols.
|
||||
|
||||
Each symbol is fetched independently so one failing ticker (bad symbol,
|
||||
transient HTTP error) never aborts the rest of the batch.
|
||||
|
||||
Args:
|
||||
codes: Project symbols such as ``AAPL.US`` or ``MSFT``.
|
||||
start_date: Inclusive start date in ``YYYY-MM-DD`` format.
|
||||
end_date: Inclusive end date in ``YYYY-MM-DD`` format.
|
||||
interval: Backtest interval; only ``1D`` is served by this endpoint.
|
||||
fields: Ignored; included for interface compatibility.
|
||||
|
||||
Returns:
|
||||
Mapping of input symbol to a normalized OHLCV DataFrame. Symbols
|
||||
with no usable data are omitted.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``start_date`` > ``end_date`` or dates are unparseable.
|
||||
"""
|
||||
del fields
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
# Daily-only EOD endpoint; do not silently return day bars for ``1H``.
|
||||
if str(interval).strip().lower() not in {"1d", "d", "day", "daily"}:
|
||||
logger.warning(
|
||||
"tiingo supports daily bars only; rejecting interval=%r",
|
||||
interval,
|
||||
)
|
||||
return {}
|
||||
|
||||
key = _resolve_key()
|
||||
if not key:
|
||||
logger.warning("tiingo skipped: %s not configured", _AUTH_ENV)
|
||||
return {}
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
frame = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(code, start_date, end_date, key),
|
||||
)
|
||||
if frame is not None and not frame.empty:
|
||||
result[code] = frame
|
||||
except Exception as exc: # noqa: BLE001 - one symbol must not kill the batch
|
||||
logger.warning("tiingo failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, key: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and normalize a single symbol's daily bars.
|
||||
|
||||
Args:
|
||||
code: Original project symbol.
|
||||
start_date: Inclusive start date in ``YYYY-MM-DD`` format.
|
||||
end_date: Inclusive end date in ``YYYY-MM-DD`` format.
|
||||
key: Resolved Tiingo API key.
|
||||
|
||||
Returns:
|
||||
Normalized OHLCV DataFrame, or ``None`` when the symbol is not a US
|
||||
equity or the endpoint returns no usable bars.
|
||||
"""
|
||||
ticker = _to_tiingo_symbol(code)
|
||||
if ticker is None:
|
||||
return None
|
||||
|
||||
url = f"{_BASE_URL}/{ticker}/prices"
|
||||
payload = throttled_get_json(
|
||||
url,
|
||||
host_key=_HOST_KEY,
|
||||
min_interval=resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL),
|
||||
params={
|
||||
"startDate": start_date,
|
||||
"endDate": end_date,
|
||||
"token": key,
|
||||
},
|
||||
)
|
||||
|
||||
# The prices endpoint returns a JSON array; anything else is empty/error.
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return None
|
||||
return _rows_to_frame(payload)
|
||||
@@ -4,18 +4,115 @@ Supports ``interval``: 1D (default) / 1m / 5m / 15m / 30m / 1H.
|
||||
Minute data uses ``pro.stk_mins()`` (Tushare points >= 2000).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
from backtest.loaders._symbol_utils import _is_etf_listed
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.cn_adjust import apply_qfq
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
#: Substrings that identify a Tushare per-minute quota rejection rather than a
|
||||
#: real failure. Tushare returns these as an ordinary exception message, so the
|
||||
#: only way to tell "you are going too fast" from "this symbol does not exist"
|
||||
#: is to read the text.
|
||||
_RATE_LIMIT_MARKERS: tuple[str, ...] = (
|
||||
"每分钟",
|
||||
"每天",
|
||||
"抽取",
|
||||
"访问该接口",
|
||||
"频率",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
)
|
||||
|
||||
#: Backoff schedule in seconds. The quota window is a minute, so the last wait
|
||||
#: has to cross one; three attempts totalling ~65s clear it without turning a
|
||||
#: genuinely broken call into a two-minute hang.
|
||||
_RATE_LIMIT_BACKOFF_SECONDS: tuple[float, ...] = (5.0, 20.0, 40.0)
|
||||
|
||||
|
||||
def _is_rate_limited(exc: Exception) -> bool:
|
||||
"""Return whether an exception is a quota rejection rather than a failure.
|
||||
|
||||
Args:
|
||||
exc: The exception raised by a Tushare endpoint call.
|
||||
|
||||
Returns:
|
||||
True when the message carries any marker in :data:`_RATE_LIMIT_MARKERS`.
|
||||
"""
|
||||
message = str(exc).lower()
|
||||
return any(marker.lower() in message for marker in _RATE_LIMIT_MARKERS)
|
||||
|
||||
|
||||
def _call_with_backoff(fn: Callable[..., Any], /, **kwargs: Any) -> Any:
|
||||
"""Call a Tushare endpoint, waiting out a per-minute quota rejection.
|
||||
|
||||
Only quota rejections are retried. Retrying an ordinary failure would turn
|
||||
a broken symbol or a bad date range into a silent multi-second stall and
|
||||
then the same error anyway, so those propagate on the first attempt.
|
||||
|
||||
Args:
|
||||
fn: The endpoint callable, e.g. ``api.daily``.
|
||||
**kwargs: Passed straight through.
|
||||
|
||||
Returns:
|
||||
Whatever the endpoint returns.
|
||||
|
||||
Raises:
|
||||
Exception: The endpoint's own exception, either immediately (not a
|
||||
quota issue) or after the backoff schedule is exhausted.
|
||||
"""
|
||||
for delay in _RATE_LIMIT_BACKOFF_SECONDS:
|
||||
try:
|
||||
return fn(**kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - classified immediately below
|
||||
if not _is_rate_limited(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"tushare quota hit (%s); waiting %.0fs before retrying", exc, delay
|
||||
)
|
||||
time.sleep(delay)
|
||||
return fn(**kwargs)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
|
||||
|
||||
|
||||
def _is_index(code: str) -> bool:
|
||||
"""Detect A-share index symbols (000xxx.SH, 000300.SH, 399xxx.SZ)."""
|
||||
upper = code.upper()
|
||||
if upper.endswith(".SH"):
|
||||
digits = upper.split(".")[0]
|
||||
return len(digits) == 6 and digits.isdigit() and digits.startswith("000")
|
||||
if upper.endswith(".SZ"):
|
||||
digits = upper.split(".")[0]
|
||||
return len(digits) == 6 and digits.isdigit() and digits.startswith("399")
|
||||
return False
|
||||
|
||||
|
||||
def _is_hk_equity(code: str) -> bool:
|
||||
"""Detect Hong Kong equity symbols (e.g. 00700.HK)."""
|
||||
return code.upper().endswith(".HK")
|
||||
|
||||
|
||||
def _is_us_equity(code: str) -> bool:
|
||||
"""Detect US equity symbols (e.g. AAPL.US)."""
|
||||
return code.upper().endswith(".US")
|
||||
|
||||
|
||||
def _is_crypto(code: str) -> bool:
|
||||
"""Detect crypto symbols (e.g. BTC-USDT, ETH/USDT)."""
|
||||
upper = code.upper()
|
||||
return upper.endswith("-USDT") or upper.endswith("/USDT")
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Tushare-backed OHLCV loader."""
|
||||
@@ -26,13 +123,17 @@ class DataLoader:
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when TUSHARE_TOKEN is set."""
|
||||
return os.getenv("TUSHARE_TOKEN", "").strip() not in TUSHARE_TOKEN_PLACEHOLDERS
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
return get_env_config().data.tushare_token.strip() not in TUSHARE_TOKEN_PLACEHOLDERS
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Tushare pro API."""
|
||||
import tushare as ts
|
||||
|
||||
token = os.getenv("TUSHARE_TOKEN", "")
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
token = get_env_config().data.tushare_token
|
||||
self.api = ts.pro_api(token)
|
||||
|
||||
def fetch(
|
||||
@@ -40,8 +141,9 @@ class DataLoader:
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: Optional[List[str]] = None,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch A-share bars via Tushare API.
|
||||
|
||||
@@ -57,33 +159,110 @@ class DataLoader:
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
if interval != "1D":
|
||||
# Tencent/sina-style daily aliases; bare ``1d`` must not take the minute path.
|
||||
if str(interval).strip().lower() in {"1d", "d", "day", "daily"}:
|
||||
interval = "1D"
|
||||
elif interval != "1D":
|
||||
return self._fetch_minutes(codes, start_date, end_date, interval)
|
||||
|
||||
sd = start_date.replace("-", "")
|
||||
ed = end_date.replace("-", "")
|
||||
cache_fields = list(fields or [])
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
# Every code goes through the opt-in cache helper, which is a direct
|
||||
# passthrough when the cache is disabled. Fundamentals are merged inside
|
||||
# the cached unit so a cached entry already carries its extra columns.
|
||||
for code in codes:
|
||||
try:
|
||||
df = self.api.daily(ts_code=code, start_date=sd, end_date=ed)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
df = df.sort_values("trade_date")
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date")
|
||||
df = df.rename(columns={"vol": "volume"})
|
||||
for col in ["open", "high", "low", "close", "volume"]:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
ohlcv = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
result[code] = ohlcv
|
||||
except Exception as exc:
|
||||
print(f"[WARN] failed to fetch {code}: {exc}")
|
||||
def _fetch_one(code: str = code) -> Optional[pd.DataFrame]:
|
||||
try:
|
||||
df = self._fetch_daily_frame(code, sd, ed)
|
||||
if df is None:
|
||||
return None
|
||||
merged = self._merge_basic_fields(
|
||||
{code: df}, [code], start_date, end_date, cache_fields
|
||||
)
|
||||
return merged.get(code)
|
||||
except Exception as exc:
|
||||
logger.warning("failed to fetch %s: %s", code, exc)
|
||||
return None
|
||||
|
||||
return self._merge_basic_fields(result, codes, start_date, end_date, fields)
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe="1D",
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=cache_fields,
|
||||
fetch=_fetch_one,
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
|
||||
return result
|
||||
|
||||
def _fetch_daily_frame(
|
||||
self,
|
||||
code: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and normalize one daily OHLCV frame, routing by symbol type."""
|
||||
if _is_us_equity(code) or _is_crypto(code):
|
||||
logger.warning("tushare does not support %s (US/crypto); skipping", code)
|
||||
return None
|
||||
|
||||
if _is_etf_listed(code):
|
||||
endpoint_name = "fund_daily"
|
||||
df = self.api.fund_daily(ts_code=code, start_date=start_date, end_date=end_date)
|
||||
adjust = getattr(self.api, "fund_adj", None)
|
||||
elif _is_index(code):
|
||||
endpoint_name = "index_daily"
|
||||
df = self.api.index_daily(ts_code=code, start_date=start_date, end_date=end_date)
|
||||
# An index level is already continuous across its members' ex-dates.
|
||||
adjust = None
|
||||
elif _is_hk_equity(code):
|
||||
endpoint_name = "hk_daily"
|
||||
df = _call_with_backoff(self.api.hk_daily, ts_code=code, start_date=start_date, end_date=end_date)
|
||||
# Tushare publishes no HK adjustment-factor series.
|
||||
adjust = None
|
||||
else:
|
||||
endpoint_name = "daily"
|
||||
df = _call_with_backoff(self.api.daily, ts_code=code, start_date=start_date, end_date=end_date)
|
||||
adjust = getattr(self.api, "adj_factor", None)
|
||||
|
||||
if df is None or df.empty:
|
||||
logger.warning("tushare returned empty for %s via %s", code, endpoint_name)
|
||||
return None
|
||||
df = df.sort_values("trade_date")
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
df = df.set_index("trade_date")
|
||||
df = df.rename(columns={"vol": "volume"})
|
||||
for col in ["open", "high", "low", "close", "volume"]:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
ohlcv = df[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
if adjust is None:
|
||||
return ohlcv
|
||||
|
||||
try:
|
||||
factor = _call_with_backoff(adjust, ts_code=code, start_date=start_date, end_date=end_date)
|
||||
except Exception as exc: # noqa: BLE001 - one bad fetch must not raise
|
||||
logger.warning("tushare adjustment-factor fetch failed for %s: %s", code, exc)
|
||||
factor = None
|
||||
adjusted = apply_qfq(ohlcv, factor)
|
||||
if adjusted is None:
|
||||
# Returning the raw frame here is what produced the defect: a
|
||||
# close-to-close return across an ex-date spans the mechanical gap,
|
||||
# measured at -47.2%% on 300750.SZ 2023-04-26 against a true +5.4%%.
|
||||
logger.warning(
|
||||
"tushare: no usable adjustment factors for %s — dropping the "
|
||||
"symbol rather than backtesting it on unadjusted prices",
|
||||
code,
|
||||
)
|
||||
return adjusted
|
||||
|
||||
def _merge_basic_fields(
|
||||
self,
|
||||
@@ -113,6 +292,15 @@ class DataLoader:
|
||||
active_codes = [c for c in codes if c in result]
|
||||
|
||||
for code in active_codes:
|
||||
if (
|
||||
_is_etf_listed(code)
|
||||
or _is_index(code)
|
||||
or _is_hk_equity(code)
|
||||
or _is_us_equity(code)
|
||||
or _is_crypto(code)
|
||||
):
|
||||
# daily_basic is stock-only; skip fundamental enrichment for non-stock symbols
|
||||
continue
|
||||
try:
|
||||
basic = self.api.daily_basic(
|
||||
ts_code=code,
|
||||
@@ -127,7 +315,7 @@ class DataLoader:
|
||||
if f in basic.columns:
|
||||
result[code][f] = basic[f]
|
||||
except Exception as exc:
|
||||
print(f"[WARN] daily_basic for {code} failed: {exc}")
|
||||
logger.warning("daily_basic for %s failed: %s", code, exc)
|
||||
|
||||
return result
|
||||
|
||||
@@ -152,7 +340,7 @@ class DataLoader:
|
||||
freq_map = {"1m": "1min", "5m": "5min", "15m": "15min", "30m": "30min", "1H": "60min"}
|
||||
freq = freq_map.get(interval)
|
||||
if not freq:
|
||||
print(f"[ERROR] unsupported Tushare interval: {interval}")
|
||||
logger.error("unsupported Tushare interval: %s", interval)
|
||||
return {}
|
||||
|
||||
sd = start_date.replace("-", "")
|
||||
@@ -160,10 +348,23 @@ class DataLoader:
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
for code in codes:
|
||||
if _is_etf_listed(code):
|
||||
# tushare has no fund_mins endpoint; ETF intraday is unavailable
|
||||
logger.warning("tushare does not support intraday data for %s (ETF); skipping", code)
|
||||
continue
|
||||
if _is_index(code) or _is_hk_equity(code) or _is_us_equity(code) or _is_crypto(code):
|
||||
sym_type = (
|
||||
"index" if _is_index(code)
|
||||
else "HK" if _is_hk_equity(code)
|
||||
else "US" if _is_us_equity(code)
|
||||
else "crypto"
|
||||
)
|
||||
logger.warning("tushare does not support intraday data for %s (%s); skipping", code, sym_type)
|
||||
continue
|
||||
try:
|
||||
df = self.api.stk_mins(ts_code=code, freq=freq, start_date=sd, end_date=ed)
|
||||
if df is None or df.empty:
|
||||
print(f"[WARN] empty Tushare minute data: {code} (points >= 2000 required)")
|
||||
logger.warning("empty Tushare minute data: %s (points >= 2000 required)", code)
|
||||
continue
|
||||
df = df.sort_values("trade_time")
|
||||
df["trade_date"] = pd.to_datetime(df["trade_time"])
|
||||
@@ -177,5 +378,5 @@ class DataLoader:
|
||||
)
|
||||
result[code] = ohlcv
|
||||
except Exception as exc:
|
||||
print(f"[WARN] failed to fetch minute data {code}: {exc}")
|
||||
logger.warning("failed to fetch minute data %s: %s", code, exc)
|
||||
return result
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -115,7 +114,9 @@ class TushareFundamentalProvider:
|
||||
if api is None:
|
||||
import tushare as ts
|
||||
|
||||
token = os.getenv("TUSHARE_TOKEN", "").strip()
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
token = get_env_config().data.tushare_token.strip()
|
||||
if token in TUSHARE_TOKEN_PLACEHOLDERS:
|
||||
token = ""
|
||||
api = ts.pro_api(token)
|
||||
@@ -141,7 +142,57 @@ class TushareFundamentalProvider:
|
||||
periods: Iterable[str] | None = None,
|
||||
fields: Iterable[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Query a fundamental table and filter out rows unpublished by ``as_of``."""
|
||||
"""Query a fundamental table and filter out rows unpublished by ``as_of``.
|
||||
|
||||
After the PIT cut, rows are deduplicated so that each ``(ts_code,
|
||||
end_date)`` pair keeps only the entry with the latest effective pit
|
||||
date (``f_ann_date`` when present and non-null, falling back to
|
||||
``ann_date``). Ties on the same pit date preserve the last row in
|
||||
input order, which matches the natural Tushare ordering where
|
||||
restatements appear after originals.
|
||||
"""
|
||||
result = self._query_pit_cut(table, codes, as_of=as_of, periods=periods, fields=fields)
|
||||
if result.empty:
|
||||
return result
|
||||
|
||||
schema = self.describe_table(table)
|
||||
pit_column = schema.point_in_time_column
|
||||
if pit_column not in result.columns or result[pit_column].isna().all():
|
||||
pit_column = "ann_date"
|
||||
|
||||
pit_values = result[pit_column]
|
||||
if pit_column != "ann_date" and "ann_date" in result.columns:
|
||||
pit_values = pit_values.where(pit_values.notna(), result["ann_date"])
|
||||
result = result.copy()
|
||||
result["_eff_pit_date"] = pit_values.map(_parse_tushare_date)
|
||||
|
||||
# Keep the row with the latest effective pit date per (ts_code, end_date).
|
||||
# stable sort + keep='last' means that within ties the last row in
|
||||
# input order wins, which is what we want for restatements appended
|
||||
# after the original.
|
||||
result = result.sort_values("_eff_pit_date", kind="stable")
|
||||
result = result.drop_duplicates(subset=["ts_code", "end_date"], keep="last")
|
||||
result = result.drop(columns=["_eff_pit_date"])
|
||||
|
||||
output_columns = self._output_columns(schema, result, fields)
|
||||
result = result.loc[:, output_columns].sort_values(["ts_code", "end_date"]).reset_index(drop=True)
|
||||
return result
|
||||
|
||||
def _query_pit_cut(
|
||||
self,
|
||||
table: str,
|
||||
codes: Iterable[str],
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
periods: Iterable[str] | None = None,
|
||||
fields: Iterable[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return all rows visible by ``as_of`` without deduplication.
|
||||
|
||||
This is the raw PIT cut used internally by
|
||||
:func:`enrich_price_frames_with_fundamentals` so that per-trading-day
|
||||
visibility can be evaluated before deduplication is applied.
|
||||
"""
|
||||
schema = self.describe_table(table)
|
||||
requested_periods = set(periods or [])
|
||||
frames: list[pd.DataFrame] = []
|
||||
@@ -175,7 +226,7 @@ class TushareFundamentalProvider:
|
||||
result = result[pit_dates <= as_of_date]
|
||||
|
||||
output_columns = self._output_columns(schema, result, fields)
|
||||
result = result.loc[:, output_columns].sort_values(["ts_code", "end_date"]).reset_index(drop=True)
|
||||
result = result.loc[:, output_columns].reset_index(drop=True)
|
||||
return result
|
||||
|
||||
def _validate_schema(self, schema: TableSchema, frame: pd.DataFrame) -> None:
|
||||
@@ -223,6 +274,14 @@ def enrich_price_frames_with_fundamentals(
|
||||
Fundamental columns are prefixed with their table name, for example
|
||||
``income_total_revenue`` and ``fina_indicator_roe``. Each row becomes
|
||||
visible only on or after its announcement/disclosure date.
|
||||
|
||||
Restatement handling: when a later disclosure covers an *older* reporting
|
||||
period than the most-recently-seen period, the snapshot does **not**
|
||||
regress to that older period. Specifically, the effective announcement
|
||||
timeline is built by scanning rows in ascending pit-date order and only
|
||||
accepting a row when its ``end_date`` is >= the ``end_date`` that is
|
||||
currently visible. Same-period restatements (same ``end_date``, later
|
||||
pit date) do update the visible values.
|
||||
"""
|
||||
if not data_map or not fields_by_table:
|
||||
return data_map
|
||||
@@ -232,7 +291,9 @@ def enrich_price_frames_with_fundamentals(
|
||||
|
||||
for table, fields in fields_by_table.items():
|
||||
field_list = list(fields or [])
|
||||
fundamentals = provider.query_fundamentals(
|
||||
# Use the raw PIT cut (no dedup) so that per-day visibility can be
|
||||
# evaluated correctly before same-period deduplication is applied.
|
||||
fundamentals = provider._query_pit_cut(
|
||||
table,
|
||||
codes,
|
||||
as_of=as_of,
|
||||
@@ -256,12 +317,44 @@ def enrich_price_frames_with_fundamentals(
|
||||
if pit_column != "ann_date" and "ann_date" in rows.columns:
|
||||
pit_values = pit_values.where(pit_values.notna(), rows["ann_date"])
|
||||
rows["_pit_date"] = pit_values.map(_parse_tushare_date)
|
||||
rows = rows.dropna(subset=["_pit_date"]).sort_values("_pit_date")
|
||||
rows["_end_date_parsed"] = rows["end_date"].map(_parse_tushare_date)
|
||||
rows = rows.dropna(subset=["_pit_date", "_end_date_parsed"]).sort_values(
|
||||
["_pit_date", "_end_date_parsed"], kind="stable"
|
||||
)
|
||||
if rows.empty:
|
||||
continue
|
||||
|
||||
value_columns = [column for column in rows.columns if column not in {"ts_code", "_pit_date"}]
|
||||
right = rows[["_pit_date", *value_columns]].rename(
|
||||
# Build effective timeline: scan in ascending pit-date order and
|
||||
# only include a row when its end_date >= the currently-visible
|
||||
# end_date. This prevents an old-period restatement (announced
|
||||
# later) from regressing the snapshot to an earlier period.
|
||||
# Same-period restatements (same end_date, later pit_date) pass
|
||||
# the check and are appended as additional timeline entries;
|
||||
# merge_asof will then naturally surface the later restatement for
|
||||
# trade dates on or after its pit_date while keeping the original
|
||||
# visible for earlier dates.
|
||||
timeline_rows: list[pd.Series] = []
|
||||
current_end_date: pd.Timestamp | None = None
|
||||
for _, row in rows.iterrows():
|
||||
row_end_date: pd.Timestamp = row["_end_date_parsed"]
|
||||
if current_end_date is None or row_end_date >= current_end_date:
|
||||
timeline_rows.append(row)
|
||||
current_end_date = row_end_date
|
||||
# Rows where row_end_date < current_end_date are silently
|
||||
# dropped — they represent old-period restatements that must
|
||||
# not regress the visible snapshot.
|
||||
|
||||
if not timeline_rows:
|
||||
continue
|
||||
|
||||
timeline = pd.DataFrame(timeline_rows).drop(columns=["_end_date_parsed"])
|
||||
|
||||
value_columns = [
|
||||
column
|
||||
for column in timeline.columns
|
||||
if column not in {"ts_code", "_pit_date"}
|
||||
]
|
||||
right = timeline[["_pit_date", *value_columns]].rename(
|
||||
columns={column: f"{table}_{column}" for column in value_columns}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Shared Yahoo Finance public-API client: chart, quote summary, options, search.
|
||||
|
||||
Yahoo Finance exposes several unauthenticated JSON endpoints (the same ones the
|
||||
finance.yahoo.com site itself calls) that, like other free quote providers,
|
||||
rate-limit by source IP and must be throttled. Every request here routes
|
||||
through :mod:`backtest.loaders._http` so calls to ``query*.finance.yahoo.com``
|
||||
share one process-wide minimum-spacing gate and a reused session.
|
||||
|
||||
The ``v10/finance/quoteSummary`` endpoint additionally requires a session
|
||||
cookie plus a matching ``crumb`` token. This client fetches both lazily on the
|
||||
first call that needs them and refreshes them once on a 401 (the documented
|
||||
"crumb expired / unauthorized" signal), so callers never manage that handshake.
|
||||
|
||||
Symbol convention (Vibe-Trading -> Yahoo):
|
||||
* US ``AAPL.US`` -> ``AAPL`` (Yahoo carries US tickers bare)
|
||||
* HK ``00700.HK`` -> ``0700.HK`` (Yahoo drops the leading zero to 4 digits)
|
||||
* India ``RELIANCE.NS`` / ``500325.BO`` -> unchanged (Yahoo carries the
|
||||
``.NS``/``.BO`` suffix verbatim)
|
||||
* Anything else is passed through unchanged (e.g. ``BTC-USD``, ``^GSPC``).
|
||||
|
||||
This module is provider-specific glue only; it returns plain Python
|
||||
dicts/lists so a downstream loader can map them into the project's OHLCV frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from backtest.loaders._http import (
|
||||
DEFAULT_USER_AGENT,
|
||||
resolve_min_interval,
|
||||
throttled_get,
|
||||
throttled_get_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All Yahoo endpoints share one throttle/session bucket so spacing is enforced
|
||||
# across chart/quote/options/search regardless of which host alias is used.
|
||||
HOST_KEY = "yahoo"
|
||||
|
||||
# Yahoo serves the same data from query1/query2; query2 is conventionally used
|
||||
# for the crumb-gated endpoints. We keep the chart on query1 like the website.
|
||||
_CHART_BASE = "https://query1.finance.yahoo.com/v8/finance/chart"
|
||||
_QUOTE_SUMMARY_BASE = "https://query2.finance.yahoo.com/v10/finance/quoteSummary"
|
||||
_OPTIONS_BASE = "https://query2.finance.yahoo.com/v7/finance/options"
|
||||
_SEARCH_BASE = "https://query2.finance.yahoo.com/v1/finance/search"
|
||||
_CRUMB_URL = "https://query2.finance.yahoo.com/v1/test/getcrumb"
|
||||
# Hitting fc.yahoo.com first hands back the consent/session cookie the crumb
|
||||
# endpoint then validates.
|
||||
_COOKIE_URL = "https://fc.yahoo.com"
|
||||
|
||||
_MIN_INTERVAL_ENV = "VIBE_TRADING_YAHOO_MIN_INTERVAL"
|
||||
_DEFAULT_MIN_INTERVAL_S = 0.6
|
||||
|
||||
# The columns Yahoo packs into indicators.quote[0], in our output field order.
|
||||
_QUOTE_FIELDS = ("open", "high", "low", "close", "volume")
|
||||
|
||||
|
||||
def _min_interval() -> float:
|
||||
"""Resolve the per-call minimum spacing, honoring the env override."""
|
||||
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
|
||||
|
||||
|
||||
def map_symbol(symbol: str) -> str:
|
||||
"""Translate a Vibe-Trading symbol into Yahoo's ticker convention.
|
||||
|
||||
Args:
|
||||
symbol: Project-side symbol, e.g. ``AAPL.US``, ``00700.HK``, ``BTC-USD``.
|
||||
|
||||
Returns:
|
||||
The Yahoo ticker: ``.US`` suffix stripped; ``.HK`` codes normalized to
|
||||
a 4-digit base (``00700.HK`` -> ``0700.HK``); India ``.NS``/``.BO`` and
|
||||
everything else unchanged (Yahoo carries those suffixes verbatim).
|
||||
"""
|
||||
cleaned = symbol.strip()
|
||||
upper = cleaned.upper()
|
||||
if upper.endswith(".US"):
|
||||
return cleaned[: -len(".US")]
|
||||
if upper.endswith(".HK"):
|
||||
base = cleaned[: -len(".HK")]
|
||||
digits = base.lstrip("0") or "0"
|
||||
return f"{digits.zfill(4)}.HK"
|
||||
return cleaned
|
||||
|
||||
|
||||
class _CrumbStore:
|
||||
"""Process-wide cookie jar + crumb token for the quoteSummary handshake.
|
||||
|
||||
Holds a single :class:`requests.Session`-independent crumb string and the
|
||||
cookies returned by Yahoo's consent endpoint. Refreshing is serialized by a
|
||||
lock so a burst of concurrent callers performs the handshake at most once.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._crumb: Optional[str] = None
|
||||
self._cookies: Dict[str, str] = {}
|
||||
|
||||
def get(self, *, force_refresh: bool = False) -> tuple[str, Dict[str, str]]:
|
||||
"""Return ``(crumb, cookies)``, fetching them on first use or on refresh.
|
||||
|
||||
Args:
|
||||
force_refresh: Discard any cached crumb/cookies and re-handshake.
|
||||
|
||||
Returns:
|
||||
A tuple of the crumb token and the cookie mapping to send with the
|
||||
crumb-gated request.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the cookie or crumb fetch fails.
|
||||
ValueError: If Yahoo returns an empty crumb.
|
||||
"""
|
||||
with self._lock:
|
||||
if force_refresh or self._crumb is None:
|
||||
self._crumb, self._cookies = self._handshake()
|
||||
return self._crumb, dict(self._cookies)
|
||||
|
||||
def _handshake(self) -> tuple[str, Dict[str, str]]:
|
||||
cookie_resp = throttled_get(
|
||||
_COOKIE_URL,
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
)
|
||||
cookies = requests.utils.dict_from_cookiejar(cookie_resp.cookies)
|
||||
crumb_resp = throttled_get(
|
||||
_CRUMB_URL,
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
headers={"Cookie": _cookie_header(cookies)} if cookies else None,
|
||||
)
|
||||
crumb_resp.raise_for_status()
|
||||
crumb = (crumb_resp.text or "").strip()
|
||||
if not crumb:
|
||||
raise ValueError("Yahoo returned an empty crumb token")
|
||||
return crumb, cookies
|
||||
|
||||
|
||||
def _cookie_header(cookies: Dict[str, str]) -> str:
|
||||
"""Render a cookie mapping as a single ``Cookie:`` header value."""
|
||||
return "; ".join(f"{name}={value}" for name, value in cookies.items())
|
||||
|
||||
|
||||
# One shared crumb/cookie store for the whole process.
|
||||
_CRUMB_STORE = _CrumbStore()
|
||||
|
||||
|
||||
def get_chart(
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str = "1d",
|
||||
period1: Optional[int] = None,
|
||||
period2: Optional[int] = None,
|
||||
range_: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch OHLCV bars from the v8 chart endpoint as ascending rows.
|
||||
|
||||
Either supply a ``range_`` (e.g. ``"1y"``, ``"max"``) or a
|
||||
``period1``/``period2`` epoch-second window; ``range_`` wins when both are
|
||||
given. Yahoo returns parallel timestamp/indicator arrays which this function
|
||||
zips into row dicts, dropping any bar whose OHLC is incomplete (Yahoo emits
|
||||
``null`` for non-trading slots).
|
||||
|
||||
Args:
|
||||
symbol: Project-side symbol (mapped via :func:`map_symbol`).
|
||||
interval: Bar size accepted by Yahoo (``1d``, ``1h``, ``5m``, ...).
|
||||
period1: Inclusive start as epoch seconds, or ``None``.
|
||||
period2: Exclusive end as epoch seconds, or ``None``.
|
||||
range_: Relative range string; takes precedence over period1/period2.
|
||||
|
||||
Returns:
|
||||
Ascending list of ``{trade_date, open, high, low, close, volume}`` dicts
|
||||
(``trade_date`` is the bar's epoch-second timestamp). Empty when Yahoo
|
||||
reports no data for the symbol/window.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On a network/HTTP failure.
|
||||
ValueError: If Yahoo reports an error for the symbol or the payload is
|
||||
structurally unusable.
|
||||
"""
|
||||
yahoo_symbol = map_symbol(symbol)
|
||||
params: Dict[str, Any] = {"interval": interval}
|
||||
if range_:
|
||||
params["range"] = range_
|
||||
else:
|
||||
if period1 is not None:
|
||||
params["period1"] = int(period1)
|
||||
if period2 is not None:
|
||||
params["period2"] = int(period2)
|
||||
|
||||
payload = throttled_get_json(
|
||||
f"{_CHART_BASE}/{yahoo_symbol}",
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params=params,
|
||||
)
|
||||
return _parse_chart(payload, yahoo_symbol)
|
||||
|
||||
|
||||
def _parse_chart(payload: Any, yahoo_symbol: str) -> List[Dict[str, Any]]:
|
||||
"""Convert a v8 chart payload into ascending OHLCV row dicts."""
|
||||
chart = (payload or {}).get("chart") or {}
|
||||
error = chart.get("error")
|
||||
if error:
|
||||
description = error.get("description") if isinstance(error, dict) else error
|
||||
raise ValueError(f"Yahoo chart error for {yahoo_symbol}: {description}")
|
||||
|
||||
results = chart.get("result") or []
|
||||
if not results:
|
||||
return []
|
||||
result = results[0] or {}
|
||||
|
||||
timestamps = result.get("timestamp") or []
|
||||
quotes = (((result.get("indicators") or {}).get("quote")) or [{}])[0] or {}
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for index, ts in enumerate(timestamps):
|
||||
values = {field: _at(quotes.get(field), index) for field in _QUOTE_FIELDS}
|
||||
# A non-trading slot leaves OHLC null; skip rather than emit a NaN bar.
|
||||
if any(values[field] is None for field in ("open", "high", "low", "close")):
|
||||
continue
|
||||
row: Dict[str, Any] = {"trade_date": ts}
|
||||
row.update({field: _to_float(values[field]) for field in _QUOTE_FIELDS})
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _at(series: Any, index: int) -> Any:
|
||||
"""Return ``series[index]`` when in range, else ``None``."""
|
||||
if isinstance(series, list) and 0 <= index < len(series):
|
||||
return series[index]
|
||||
return None
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
"""Coerce a Yahoo numeric (or ``None``) to ``float``, ``None`` on failure."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def get_quote_summary(symbol: str, modules: List[str]) -> Dict[str, Any]:
|
||||
"""Fetch v10 quoteSummary modules, handling the cookie+crumb handshake.
|
||||
|
||||
On a 401 (expired/invalid crumb) the crumb and cookies are refreshed once
|
||||
and the request is retried; a second 401 propagates.
|
||||
|
||||
Args:
|
||||
symbol: Project-side symbol (mapped via :func:`map_symbol`).
|
||||
modules: Yahoo module names, e.g. ``["price", "summaryDetail"]``.
|
||||
|
||||
Returns:
|
||||
The ``quoteSummary.result[0]`` mapping, or ``{}`` when Yahoo returns no
|
||||
result for the symbol.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On a non-401 HTTP failure or a second 401.
|
||||
ValueError: If Yahoo reports an error for the symbol.
|
||||
"""
|
||||
yahoo_symbol = map_symbol(symbol)
|
||||
modules_param = ",".join(modules)
|
||||
|
||||
payload = _quote_summary_request(yahoo_symbol, modules_param, force_refresh=False)
|
||||
return _parse_quote_summary(payload, yahoo_symbol)
|
||||
|
||||
|
||||
def _quote_summary_request(
|
||||
yahoo_symbol: str, modules_param: str, *, force_refresh: bool
|
||||
) -> Any:
|
||||
"""Issue one crumb-gated quoteSummary GET, retrying once on a 401."""
|
||||
crumb, cookies = _CRUMB_STORE.get(force_refresh=force_refresh)
|
||||
headers = {"Cookie": _cookie_header(cookies)} if cookies else None
|
||||
response = throttled_get(
|
||||
f"{_QUOTE_SUMMARY_BASE}/{yahoo_symbol}",
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params={"modules": modules_param, "crumb": crumb},
|
||||
headers=headers,
|
||||
)
|
||||
if response.status_code == 401 and not force_refresh:
|
||||
logger.info("Yahoo quoteSummary 401 for %s; refreshing crumb", yahoo_symbol)
|
||||
return _quote_summary_request(yahoo_symbol, modules_param, force_refresh=True)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _parse_quote_summary(payload: Any, yahoo_symbol: str) -> Dict[str, Any]:
|
||||
"""Extract the first quoteSummary result, raising on a reported error."""
|
||||
summary = (payload or {}).get("quoteSummary") or {}
|
||||
error = summary.get("error")
|
||||
if error:
|
||||
description = error.get("description") if isinstance(error, dict) else error
|
||||
raise ValueError(f"Yahoo quoteSummary error for {yahoo_symbol}: {description}")
|
||||
results = summary.get("result") or []
|
||||
if not results:
|
||||
return {}
|
||||
return results[0] or {}
|
||||
|
||||
|
||||
def get_options(symbol: str, *, expiration: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Fetch the v7 option chain for a symbol, handling the cookie+crumb handshake.
|
||||
|
||||
Like ``v10/quoteSummary``, the ``v7/finance/options`` endpoint now rejects
|
||||
bare requests with HTTP 401 and demands the same session cookie plus a
|
||||
matching ``crumb`` token. This routes through the shared
|
||||
:data:`_CRUMB_STORE` handshake and, on a 401 (expired/invalid crumb),
|
||||
refreshes the crumb once and retries; a second 401 propagates.
|
||||
|
||||
Args:
|
||||
symbol: Project-side symbol (mapped via :func:`map_symbol`).
|
||||
expiration: Optional expiration as epoch seconds; omit for the nearest.
|
||||
|
||||
Returns:
|
||||
The ``optionChain.result[0]`` mapping (expirationDates, strikes, the
|
||||
calls/puts arrays for the chosen expiry), or ``{}`` when none.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On a non-401 HTTP failure or a second 401.
|
||||
ValueError: If Yahoo reports an error for the symbol.
|
||||
"""
|
||||
yahoo_symbol = map_symbol(symbol)
|
||||
payload = _options_request(yahoo_symbol, expiration, force_refresh=False)
|
||||
return _parse_options(payload, yahoo_symbol)
|
||||
|
||||
|
||||
def _options_request(
|
||||
yahoo_symbol: str, expiration: Optional[int], *, force_refresh: bool
|
||||
) -> Any:
|
||||
"""Issue one crumb-gated v7 options GET, retrying once on a 401."""
|
||||
crumb, cookies = _CRUMB_STORE.get(force_refresh=force_refresh)
|
||||
headers = {"Cookie": _cookie_header(cookies)} if cookies else None
|
||||
params: Dict[str, Any] = {"crumb": crumb}
|
||||
if expiration is not None:
|
||||
params["date"] = int(expiration)
|
||||
response = throttled_get(
|
||||
f"{_OPTIONS_BASE}/{yahoo_symbol}",
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params=params,
|
||||
headers=headers,
|
||||
)
|
||||
if response.status_code == 401 and not force_refresh:
|
||||
logger.info("Yahoo options 401 for %s; refreshing crumb", yahoo_symbol)
|
||||
return _options_request(yahoo_symbol, expiration, force_refresh=True)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _parse_options(payload: Any, yahoo_symbol: str) -> Dict[str, Any]:
|
||||
"""Extract the first optionChain result, raising on a reported error."""
|
||||
chain = (payload or {}).get("optionChain") or {}
|
||||
error = chain.get("error")
|
||||
if error:
|
||||
description = error.get("description") if isinstance(error, dict) else error
|
||||
raise ValueError(f"Yahoo options error for {yahoo_symbol}: {description}")
|
||||
results = chain.get("result") or []
|
||||
if not results:
|
||||
return {}
|
||||
return results[0] or {}
|
||||
|
||||
|
||||
def search(query: str) -> List[Dict[str, Any]]:
|
||||
"""Look up matching instruments via the v1 search endpoint.
|
||||
|
||||
Args:
|
||||
query: Free-text query (ticker fragment or company name).
|
||||
|
||||
Returns:
|
||||
The ``quotes`` list (each a dict with ``symbol``, ``shortname``,
|
||||
``exchange``, ``quoteType``, ...), or an empty list when none match.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On a network/HTTP failure.
|
||||
"""
|
||||
payload = throttled_get_json(
|
||||
_SEARCH_BASE,
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params={"q": query},
|
||||
)
|
||||
quotes = (payload or {}).get("quotes") or []
|
||||
return [quote for quote in quotes if isinstance(quote, dict)]
|
||||
|
||||
|
||||
def search_news(query: str, count: int) -> List[Dict[str, Any]]:
|
||||
"""Look up matching news via the v1 search endpoint.
|
||||
|
||||
Args:
|
||||
query: Free-text query (ticker fragment or company name).
|
||||
count: Maximum number of news items requested from Yahoo.
|
||||
|
||||
Returns:
|
||||
The ``news`` list (each a dict with ``title``, ``publisher``, ``link``,
|
||||
``providerPublishTime``, ``relatedTickers``, ...), or an empty list when
|
||||
none match.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On a network/HTTP failure.
|
||||
"""
|
||||
payload = throttled_get_json(
|
||||
_SEARCH_BASE,
|
||||
host_key=HOST_KEY,
|
||||
min_interval=_min_interval(),
|
||||
params={"q": query, "newsCount": count},
|
||||
)
|
||||
news = (payload or {}).get("news") or []
|
||||
return [item for item in news if isinstance(item, dict)]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Yahoo Finance loader: free, no-auth US/HK equity OHLCV via direct HTTP.
|
||||
|
||||
Wraps the shared :mod:`backtest.loaders.yahoo_client` (the public v8 chart
|
||||
endpoint) rather than the ``yfinance`` package, so it pulls in no new
|
||||
dependency and shares the process-wide throttle/session that keeps Yahoo from
|
||||
IP-rate-limiting us. Covers US equities (``AAPL.US``) and HK equities
|
||||
(``00700.HK``); the client maps each project symbol to Yahoo's ticker form.
|
||||
|
||||
The chart endpoint returns each bar's ``trade_date`` as an epoch-second
|
||||
timestamp; this loader converts those to a tz-naive ``DatetimeIndex`` and clips
|
||||
the ascending series to the requested inclusive date window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders import yahoo_client
|
||||
from backtest.loaders.base import cached_loader_fetch, validate_date_range
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OHLCV_COLUMNS = ("open", "high", "low", "close", "volume")
|
||||
|
||||
# Project interval -> Yahoo chart interval. Daily is the only granularity this
|
||||
# loader exposes for equities; anything else falls back to lowercasing so an
|
||||
# explicit ``5m``/``1h`` still reaches Yahoo unchanged.
|
||||
_INTERVAL_MAP = {
|
||||
"1D": "1d",
|
||||
"1H": "1h",
|
||||
# Yahoo chart has no 4h bar; match yfinance's 4H → 1h approximation.
|
||||
"4H": "1h",
|
||||
"1W": "1wk",
|
||||
"1M": "1mo",
|
||||
}
|
||||
|
||||
|
||||
def _is_supported(code: str) -> bool:
|
||||
"""Return whether *code* is a symbol this loader handles.
|
||||
|
||||
Covers US/HK/India/Korea equities plus Yahoo's own futures (``GC=F``) and
|
||||
forex (``EURUSD=X``) suffix conventions, which the public chart endpoint
|
||||
serves verbatim (the code is used as-is in the request URL, no
|
||||
conversion) (#718).
|
||||
"""
|
||||
upper = code.strip().upper()
|
||||
return upper.endswith((".US", ".HK", ".NS", ".BO", ".KS", ".KQ", "=F", "=X"))
|
||||
|
||||
|
||||
def _to_yahoo_interval(interval: str) -> str:
|
||||
"""Map a project interval string to Yahoo's chart interval string.
|
||||
|
||||
Args:
|
||||
interval: Backtest interval such as ``1D`` or ``1H``.
|
||||
|
||||
Returns:
|
||||
Yahoo-compatible interval string (e.g. ``1d``).
|
||||
"""
|
||||
normalized = str(interval or "1D").strip()
|
||||
if not normalized:
|
||||
return "1d"
|
||||
# Bare trailing ``m`` is minutes; do not uppercase into monthly ``1M`` → ``1mo``.
|
||||
if normalized.endswith("m") and not normalized.endswith("M"):
|
||||
return normalized.lower()
|
||||
return _INTERVAL_MAP.get(normalized.upper(), normalized.lower())
|
||||
|
||||
|
||||
def _is_intraday_interval(interval: str) -> bool:
|
||||
"""Return whether *interval* is finer than one day (minute/hour bars).
|
||||
|
||||
Daily-and-coarser intervals (``1D``/``1W``/``1M``) carry no meaningful
|
||||
intraday time and must be midnight-indexed to align with every other
|
||||
loader's daily bars; minute/hour intervals keep their real timestamp.
|
||||
|
||||
Project convention follows the interval map: a bare trailing ``M`` is a
|
||||
month (e.g. ``1M``) while a bare trailing lowercase ``m`` is a minute (e.g.
|
||||
``5m``), so case is significant for that one suffix and is NOT folded.
|
||||
|
||||
Args:
|
||||
interval: Backtest interval such as ``1D``, ``1H``, ``5m`` or ``30min``.
|
||||
|
||||
Returns:
|
||||
``True`` for minute/hour granularities, ``False`` for day/week/month.
|
||||
"""
|
||||
raw = str(interval or "1D").strip()
|
||||
if not raw:
|
||||
return False
|
||||
lowered = raw.lower()
|
||||
# Multi-letter month forms ('mo'/'mth'/'mon') are daily-coarser.
|
||||
if lowered.endswith(("mo", "mth", "mon")):
|
||||
return False
|
||||
# Explicit minute spellings and hours are intraday.
|
||||
if lowered.endswith(("min", "h")):
|
||||
return True
|
||||
# Bare trailing letter: lowercase 'm' = minute (intraday), uppercase
|
||||
# 'M' = month (coarser), 'd'/'w' = day/week (coarser).
|
||||
return raw.endswith("m")
|
||||
|
||||
|
||||
def _epoch_seconds(date_str: str) -> int:
|
||||
"""Convert a ``YYYY-MM-DD`` date to UTC midnight epoch seconds.
|
||||
|
||||
Computed from the parsed calendar date (not ``time.time()``) so the window
|
||||
is reproducible and independent of wall-clock fetch time.
|
||||
|
||||
Args:
|
||||
date_str: Date string in ``YYYY-MM-DD`` form.
|
||||
|
||||
Returns:
|
||||
Epoch seconds for that date's UTC midnight.
|
||||
"""
|
||||
day = pd.Timestamp(date_str).normalize().date()
|
||||
moment = dt.datetime(day.year, day.month, day.day, tzinfo=dt.timezone.utc)
|
||||
return int(moment.timestamp())
|
||||
|
||||
|
||||
def _rows_to_frame(
|
||||
rows: List[dict], start_date: str, end_date: str, interval: str = "1D"
|
||||
) -> pd.DataFrame:
|
||||
"""Build the OHLCV frame from chart rows, clipped to the inclusive window.
|
||||
|
||||
For daily-and-coarser intervals the DatetimeIndex is normalized to midnight
|
||||
so the dates line up with every other loader's midnight-indexed daily bars
|
||||
(Yahoo stamps daily bars at the US market-open epoch, e.g. 14:30 UTC, which
|
||||
would otherwise break fallback merge/join/dedup). Minute/hour intervals keep
|
||||
their real intraday timestamp.
|
||||
|
||||
Args:
|
||||
rows: Ascending ``{trade_date(epoch s), open, high, low, close, volume}``
|
||||
dicts from :func:`yahoo_client.get_chart`.
|
||||
start_date: Inclusive window start (``YYYY-MM-DD``).
|
||||
end_date: Inclusive window end (``YYYY-MM-DD``).
|
||||
interval: Backtest interval such as ``1D`` or ``1H``; selects whether the
|
||||
index is normalized to midnight.
|
||||
|
||||
Returns:
|
||||
DataFrame indexed by a tz-naive ``trade_date`` with float OHLCV columns,
|
||||
or an empty frame when no row carries usable OHLC.
|
||||
"""
|
||||
if not rows:
|
||||
return pd.DataFrame()
|
||||
|
||||
frame = pd.DataFrame(rows)
|
||||
index = pd.to_datetime(frame["trade_date"], unit="s", utc=True).dt.tz_convert(None)
|
||||
if not _is_intraday_interval(interval):
|
||||
index = index.dt.normalize()
|
||||
frame = frame.drop(columns=["trade_date"])
|
||||
frame.index = pd.DatetimeIndex(index)
|
||||
frame.index.name = "trade_date"
|
||||
|
||||
for column in _OHLCV_COLUMNS:
|
||||
if column not in frame.columns:
|
||||
frame[column] = 0.0 if column == "volume" else pd.NA
|
||||
|
||||
frame = frame.loc[:, list(_OHLCV_COLUMNS)].apply(pd.to_numeric, errors="coerce")
|
||||
frame["volume"] = frame["volume"].fillna(0.0)
|
||||
frame = frame.dropna(subset=["open", "high", "low", "close"])
|
||||
|
||||
lower = pd.Timestamp(start_date).normalize()
|
||||
upper = pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)
|
||||
frame = frame[(frame.index >= lower) & (frame.index < upper)]
|
||||
return frame.astype(float).sort_index()
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Yahoo Finance US/HK equity OHLCV loader (free, direct HTTP, no auth)."""
|
||||
|
||||
name = "yahoo"
|
||||
markets = {"us_equity", "hk_equity", "india_equity", "kr_equity"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Always available — uses the throttled public HTTP client."""
|
||||
return True
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the loader (no credentials needed for public data)."""
|
||||
pass
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV history keyed by the original project symbols.
|
||||
|
||||
Args:
|
||||
codes: Project symbols such as ``AAPL.US`` and ``00700.HK``.
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
interval: Backtest interval such as ``1D`` or ``1H``.
|
||||
fields: Ignored; included for interface compatibility.
|
||||
|
||||
Returns:
|
||||
Mapping of input symbol to a normalized OHLCV DataFrame. A symbol
|
||||
that fails or returns no data is omitted; one failure never aborts
|
||||
the batch.
|
||||
"""
|
||||
del fields
|
||||
if not codes:
|
||||
return {}
|
||||
validate_date_range(start_date, end_date)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
df = cached_loader_fetch(
|
||||
source=self.name,
|
||||
symbol=code,
|
||||
timeframe=interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
fetch=lambda code=code: self._fetch_one(
|
||||
code, start_date, end_date, interval
|
||||
),
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
result[code] = df
|
||||
except Exception as exc:
|
||||
logger.warning("yahoo failed for %s: %s", code, exc)
|
||||
return result
|
||||
|
||||
def _fetch_one(
|
||||
self, code: str, start_date: str, end_date: str, interval: str
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Fetch and normalize one symbol's chart, or ``None`` when unusable.
|
||||
|
||||
Args:
|
||||
code: A single project symbol.
|
||||
start_date: Inclusive start date (``YYYY-MM-DD``).
|
||||
end_date: Inclusive end date (``YYYY-MM-DD``).
|
||||
interval: Backtest interval string.
|
||||
|
||||
Returns:
|
||||
The OHLCV DataFrame for *code*, ``None`` if it is not a US/HK/India
|
||||
symbol or Yahoo returns no usable bars.
|
||||
"""
|
||||
if not _is_supported(code):
|
||||
return None
|
||||
|
||||
# period2 is exclusive on Yahoo; extend one day past end_date so the
|
||||
# final inclusive bar is fetched, then clip precisely in _rows_to_frame.
|
||||
period1 = _epoch_seconds(start_date)
|
||||
period2 = _epoch_seconds(end_date) + 86400
|
||||
|
||||
rows = yahoo_client.get_chart(
|
||||
code,
|
||||
interval=_to_yahoo_interval(interval),
|
||||
period1=period1,
|
||||
period2=period2,
|
||||
)
|
||||
frame = _rows_to_frame(rows, start_date, end_date, interval)
|
||||
return frame if not frame.empty else None
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backtest.loaders.base import (
|
||||
loader_cache_get,
|
||||
loader_cache_put,
|
||||
validate_date_range,
|
||||
validate_ohlc,
|
||||
)
|
||||
from backtest.loaders.registry import register
|
||||
|
||||
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
|
||||
@@ -28,6 +36,15 @@ _INTERVAL_MAP = {
|
||||
"1D": "1d",
|
||||
"1H": "1h",
|
||||
"4H": "1h",
|
||||
"4h": "1h", # yfinance has no 4h; match project ``4H`` → ``1h``
|
||||
"1W": "1wk",
|
||||
"1w": "1wk",
|
||||
"1M": "1mo",
|
||||
# Minute tokens stay lowercase; do not fold ``1M`` (month) via ``.lower()``.
|
||||
"1m": "1m",
|
||||
"5m": "5m",
|
||||
"15m": "15m",
|
||||
"30m": "30m",
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +64,13 @@ def _to_yfinance_symbol(code: str) -> str:
|
||||
digits = upper[:-3]
|
||||
width = max(4, len(digits))
|
||||
return f"{digits.zfill(width)}.HK"
|
||||
# Crypto: BTC-USDT -> BTC-USD, ETH-USDT -> ETH-USD, etc.
|
||||
if upper.endswith("-USDT"):
|
||||
return upper[:-5] + "-USD"
|
||||
if upper.endswith("-USDC"):
|
||||
return upper[:-5] + "-USD"
|
||||
# India NSE/BSE (RELIANCE.NS, 500325.BO) and Korea KRX (005930.KS,
|
||||
# 247540.KQ): yfinance carries these suffixes as-is.
|
||||
return upper
|
||||
|
||||
|
||||
@@ -63,6 +87,11 @@ def _to_yfinance_interval(interval: str) -> str:
|
||||
return _INTERVAL_MAP.get(normalized, normalized.lower())
|
||||
|
||||
|
||||
def _to_yfinance_exclusive_end(end_date: str) -> str:
|
||||
"""Convert the project-inclusive end date to yfinance's exclusive end."""
|
||||
return (pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _download_history(
|
||||
tickers: Union[List[str], str],
|
||||
start_date: str,
|
||||
@@ -172,6 +201,7 @@ def _normalize_frame(frame: pd.DataFrame, requested_interval: str) -> pd.DataFra
|
||||
normalized = normalized.sort_index()
|
||||
normalized["volume"] = normalized["volume"].fillna(0.0)
|
||||
normalized = normalized.dropna(subset=["open", "high", "low", "close"])
|
||||
normalized = validate_ohlc(normalized)
|
||||
|
||||
if requested_interval == "4H" and not normalized.empty:
|
||||
normalized = normalized.resample("4h").agg(
|
||||
@@ -194,7 +224,7 @@ class DataLoader:
|
||||
"""Fetch HK/US equity bars from Yahoo Finance via yfinance."""
|
||||
|
||||
name = "yfinance"
|
||||
markets = {"us_equity", "hk_equity"}
|
||||
markets = {"us_equity", "hk_equity", "india_equity", "kr_equity", "crypto"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
@@ -213,8 +243,9 @@ class DataLoader:
|
||||
codes: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
fields: Optional[List[str]] = None,
|
||||
*,
|
||||
interval: str = "1D",
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Fetch OHLCV history keyed by the original project symbols.
|
||||
|
||||
@@ -235,6 +266,7 @@ class DataLoader:
|
||||
|
||||
requested_interval = str(interval or "1D").strip()
|
||||
yf_interval = _to_yfinance_interval(requested_interval)
|
||||
yf_end_date = _to_yfinance_exclusive_end(end_date)
|
||||
|
||||
symbol_groups: Dict[str, List[str]] = defaultdict(list)
|
||||
for code in codes:
|
||||
@@ -243,27 +275,57 @@ class DataLoader:
|
||||
unique_symbols = list(symbol_groups.keys())
|
||||
results: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
# Serve cached symbols first so a fully-cached request skips the bulk
|
||||
# download entirely; only uncached symbols hit the network.
|
||||
pending: List[str] = []
|
||||
for symbol in unique_symbols:
|
||||
cached = loader_cache_get(
|
||||
source=self.name,
|
||||
symbol=symbol,
|
||||
timeframe=requested_interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
)
|
||||
if cached is not None:
|
||||
for original_code in symbol_groups[symbol]:
|
||||
results[original_code] = cached.copy()
|
||||
else:
|
||||
pending.append(symbol)
|
||||
|
||||
if not pending:
|
||||
return results
|
||||
|
||||
try:
|
||||
bulk_data = _download_history(unique_symbols, start_date, end_date, yf_interval)
|
||||
bulk_data = _download_history(pending, start_date, yf_end_date, yf_interval)
|
||||
except Exception as exc:
|
||||
print(f"[WARN] yfinance bulk download failed for {unique_symbols}: {exc}")
|
||||
logger.warning("yfinance bulk download failed for %s: %s", pending, exc)
|
||||
bulk_data = pd.DataFrame()
|
||||
|
||||
for symbol in unique_symbols:
|
||||
for symbol in pending:
|
||||
try:
|
||||
symbol_frame = _extract_symbol_frame(bulk_data, symbol, len(unique_symbols))
|
||||
symbol_frame = _extract_symbol_frame(bulk_data, symbol, len(pending))
|
||||
if symbol_frame.empty:
|
||||
symbol_frame = _download_history(symbol, start_date, end_date, yf_interval)
|
||||
symbol_frame = _download_history(symbol, start_date, yf_end_date, yf_interval)
|
||||
|
||||
normalized = _normalize_frame(symbol_frame, requested_interval)
|
||||
if normalized.empty:
|
||||
print(f"[WARN] yfinance returned no usable data for {symbol}")
|
||||
logger.warning("yfinance returned no usable data for %s", symbol)
|
||||
continue
|
||||
|
||||
loader_cache_put(
|
||||
source=self.name,
|
||||
symbol=symbol,
|
||||
timeframe=requested_interval,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
fields=None,
|
||||
frame=normalized,
|
||||
)
|
||||
for original_code in symbol_groups[symbol]:
|
||||
results[original_code] = normalized.copy()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Failed to fetch data for {symbol}: {exc}")
|
||||
logger.warning("Failed to fetch data for %s: %s", symbol, exc)
|
||||
continue
|
||||
|
||||
return results
|
||||
+390
-23
@@ -5,6 +5,7 @@ Provides annualisation helpers, trade statistics, and full metric calculation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -14,33 +15,251 @@ from backtest.models import TradeRecord
|
||||
|
||||
# ─── Annualisation factor mapping ───
|
||||
|
||||
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365}
|
||||
_BARS_PER_DAY = {
|
||||
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390, "akshare": 240, "ccxt": 1440},
|
||||
"5m": {"tushare": 48, "okx": 288, "yfinance": 78, "akshare": 48, "ccxt": 288},
|
||||
"15m": {"tushare": 16, "okx": 96, "yfinance": 26, "akshare": 16, "ccxt": 96},
|
||||
"30m": {"tushare": 8, "okx": 48, "yfinance": 13, "akshare": 8, "ccxt": 48},
|
||||
"1H": {"tushare": 4, "okx": 24, "yfinance": 7, "akshare": 4, "ccxt": 24},
|
||||
"4H": {"tushare": 1, "okx": 6, "yfinance": 2, "akshare": 1, "ccxt": 6},
|
||||
"1D": {"tushare": 1, "okx": 1, "yfinance": 1, "akshare": 1, "ccxt": 1},
|
||||
# mootdx (A-share) and futu (HK + A-share) are equity sources, so they mirror
|
||||
# the tushare/akshare column: 252 trading days and a 240-minute session. HK
|
||||
# sessions are marginally longer (~330 min) — an approximation in line with the
|
||||
# rest of this annualisation table; the key fix is that intraday mootdx/futu no
|
||||
# longer fall back to the bars_per_day=1 default, which mis-annualised vol/Sharpe.
|
||||
_TRADING_DAYS = {
|
||||
# existing
|
||||
"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365,
|
||||
"mootdx": 252, "futu": 252, "mt5": 260,
|
||||
# crypto
|
||||
"binance": 365,
|
||||
# A-share equity
|
||||
"baostock": 252, "tencent": 252, "eastmoney": 252, "sina": 252,
|
||||
# US / international equity
|
||||
"yahoo": 252, "finnhub": 252, "alphavantage": 252, "tiingo": 252,
|
||||
"fmp": 252, "stooq": 252, "longbridge": 252,
|
||||
# resampling sources (local files / paid data — interval depends on source
|
||||
# data granularity; model as US equity session as a conservative default)
|
||||
"local": 252, "qveris": 252,
|
||||
# Indian equity
|
||||
"india_broker": 252,
|
||||
# Korean equity (KRX)
|
||||
"pykrx": 252,
|
||||
}
|
||||
# mt5 is a forex/CFD feed: 24x5 sessions → 260 trading days, 24h intraday bars.
|
||||
# US equity (yfinance-style): 6.5h sessions → 390 1m bars/day.
|
||||
# A-share equity (tushare-style): 4.0h sessions → 240 1m bars/day.
|
||||
# Crypto (okx/ccxt-style): 24h sessions → 1440 1m bars/day.
|
||||
# Indian equity: 6.25h sessions → 375 1m bars/day.
|
||||
# Korean equity (pykrx): 6.5h sessions → 390 1m bars/day. The loader itself
|
||||
# serves daily bars only, so the intraday rows exist to keep the table complete
|
||||
# (and correct if KRX intraday ever arrives under this key), not because pykrx
|
||||
# can return them.
|
||||
_BARS_PER_DAY = {
|
||||
# --- US/international equity (6.5h session) ---
|
||||
"1m": {"yfinance": 390, "yahoo": 390, "finnhub": 390, "alphavantage": 390,
|
||||
"tiingo": 390, "fmp": 390, "stooq": 390, "longbridge": 390,
|
||||
"local": 390, "qveris": 390,
|
||||
# A-share equity (4.0h session)
|
||||
"tushare": 240, "akshare": 240, "baostock": 240, "tencent": 240,
|
||||
"eastmoney": 240, "sina": 240, "mootdx": 240, "futu": 240,
|
||||
# crypto (24h)
|
||||
"okx": 1440, "ccxt": 1440, "binance": 1440,
|
||||
# forex/CFD (24h intraday)
|
||||
"mt5": 1440,
|
||||
# Indian equity (6.25h session)
|
||||
"india_broker": 375,
|
||||
# Korean equity (6.5h session, 09:00-15:30 KST)
|
||||
"pykrx": 390,
|
||||
},
|
||||
"5m": {"yfinance": 78, "yahoo": 78, "finnhub": 78, "alphavantage": 78,
|
||||
"tiingo": 78, "fmp": 78, "stooq": 78, "longbridge": 78,
|
||||
"local": 78, "qveris": 78,
|
||||
"tushare": 48, "akshare": 48, "baostock": 48, "tencent": 48,
|
||||
"eastmoney": 48, "sina": 48, "mootdx": 48, "futu": 48,
|
||||
"okx": 288, "ccxt": 288, "binance": 288,
|
||||
"mt5": 288,
|
||||
"india_broker": 75,
|
||||
"pykrx": 78,
|
||||
},
|
||||
"15m": {"yfinance": 26, "yahoo": 26, "finnhub": 26, "alphavantage": 26,
|
||||
"tiingo": 26, "fmp": 26, "stooq": 26, "longbridge": 26,
|
||||
"local": 26, "qveris": 26,
|
||||
"tushare": 16, "akshare": 16, "baostock": 16, "tencent": 16,
|
||||
"eastmoney": 16, "sina": 16, "mootdx": 16, "futu": 16,
|
||||
"okx": 96, "ccxt": 96, "binance": 96,
|
||||
"mt5": 96,
|
||||
"india_broker": 25,
|
||||
"pykrx": 26,
|
||||
},
|
||||
"30m": {"yfinance": 13, "yahoo": 13, "finnhub": 13, "alphavantage": 13,
|
||||
"tiingo": 13, "fmp": 13, "stooq": 13, "longbridge": 13,
|
||||
"local": 13, "qveris": 13,
|
||||
"tushare": 8, "akshare": 8, "baostock": 8, "tencent": 8,
|
||||
"eastmoney": 8, "sina": 8, "mootdx": 8, "futu": 8,
|
||||
"okx": 48, "ccxt": 48, "binance": 48,
|
||||
"mt5": 48,
|
||||
"india_broker": 13,
|
||||
"pykrx": 13,
|
||||
},
|
||||
"1H": {"yfinance": 7, "yahoo": 7, "finnhub": 7, "alphavantage": 7,
|
||||
"tiingo": 7, "fmp": 7, "stooq": 7, "longbridge": 7,
|
||||
"local": 7, "qveris": 7,
|
||||
"tushare": 4, "akshare": 4, "baostock": 4, "tencent": 4,
|
||||
"eastmoney": 4, "sina": 4, "mootdx": 4, "futu": 4,
|
||||
"okx": 24, "ccxt": 24, "binance": 24,
|
||||
"mt5": 24,
|
||||
"india_broker": 7,
|
||||
"pykrx": 7,
|
||||
},
|
||||
"4H": {"yfinance": 2, "yahoo": 2, "finnhub": 2, "alphavantage": 2,
|
||||
"tiingo": 2, "fmp": 2, "stooq": 2, "longbridge": 2,
|
||||
"local": 2, "qveris": 2,
|
||||
"tushare": 1, "akshare": 1, "baostock": 1, "tencent": 1,
|
||||
"eastmoney": 1, "sina": 1, "mootdx": 1, "futu": 1,
|
||||
"okx": 6, "ccxt": 6, "binance": 6,
|
||||
"mt5": 6,
|
||||
"india_broker": 2,
|
||||
"pykrx": 2,
|
||||
},
|
||||
"1D": {"yfinance": 1, "yahoo": 1, "finnhub": 1, "alphavantage": 1,
|
||||
"tiingo": 1, "fmp": 1, "stooq": 1, "longbridge": 1,
|
||||
"local": 1, "qveris": 1,
|
||||
"tushare": 1, "akshare": 1, "baostock": 1, "tencent": 1,
|
||||
"eastmoney": 1, "sina": 1, "mootdx": 1, "futu": 1,
|
||||
"okx": 1, "ccxt": 1, "binance": 1,
|
||||
"mt5": 1,
|
||||
"india_broker": 1,
|
||||
"pykrx": 1,
|
||||
},
|
||||
}
|
||||
|
||||
# Runner/loaders also emit these aliases; map them onto the table keys above.
|
||||
_SOURCE_ALIASES = {"yahoo": "yfinance", "binance": "ccxt"}
|
||||
|
||||
|
||||
def _normalize_interval(interval: str) -> str:
|
||||
"""Map project interval tokens onto ``_BARS_PER_DAY`` keys.
|
||||
|
||||
Minute bars stay lowercase (``1m``); hour/day use the uppercase keys the
|
||||
table already stores (``1H`` / ``4H`` / ``1D``). Loaders accept both cases
|
||||
after the interval-map fixes; annualisation must too.
|
||||
"""
|
||||
token = str(interval or "1D").strip()
|
||||
lower = token.lower()
|
||||
if lower in ("1m", "5m", "15m", "30m"):
|
||||
return lower
|
||||
if lower in ("1h", "4h", "1d"):
|
||||
return lower.upper()
|
||||
return token
|
||||
|
||||
|
||||
def calc_bars_per_year(interval: str = "1D", source: str = "tushare") -> int:
|
||||
"""Number of bars per year for annualisation.
|
||||
|
||||
Args:
|
||||
interval: Bar size (1m / 5m / 15m / 30m / 1H / 4H / 1D).
|
||||
source: Data source (tushare / yfinance / okx).
|
||||
interval: Bar size (1m / 5m / 15m / 30m / 1H / 4H / 1D), case-insensitive
|
||||
like loaders accept (``1h`` → ``1H``, ``4h`` → ``4H``, ``1d`` → ``1D``).
|
||||
source: Data source (any VALID_SOURCES entry). Defaults to 252 days, 1 bar/day
|
||||
when source is missing from the table.
|
||||
|
||||
Returns:
|
||||
Bars per year.
|
||||
"""
|
||||
trading_days = _TRADING_DAYS.get(source, 252)
|
||||
bars_per_day = _BARS_PER_DAY.get(interval, {}).get(source, 1)
|
||||
interval_key = _normalize_interval(interval)
|
||||
source_key = str(source or "").strip().lower()
|
||||
source_key = _SOURCE_ALIASES.get(source_key, source_key)
|
||||
trading_days = _TRADING_DAYS.get(source_key, 252)
|
||||
bars_per_day = _BARS_PER_DAY.get(interval_key, {}).get(source_key, 1)
|
||||
return trading_days * bars_per_day
|
||||
|
||||
|
||||
# ─── Sign-safe returns ───
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bar_returns(close: Any, *, label: str = "") -> Any:
|
||||
"""Per-bar simple returns, defined only where the prior price is positive.
|
||||
|
||||
``close.pct_change()`` silently assumes ``price[t-1] > 0``. That holds for
|
||||
equities but not for instruments that can print zero or negative prices,
|
||||
such as European day-ahead power. As the divisor approaches zero an
|
||||
ordinary absolute move reads as an enormous *percentage* move, so a
|
||||
compounded aggregate explodes; at exactly zero the next bar is ``inf``,
|
||||
which ``fillna`` does not neutralise (it fills ``NaN``) and which collapses
|
||||
``(1 + r).prod()`` to ``nan`` (#872).
|
||||
|
||||
A return is therefore defined only when the previous price is finite and
|
||||
strictly positive. Otherwise it is undefined and reported as ``0.0``,
|
||||
never ``inf`` or ``nan``. For an all-positive series this is identical to
|
||||
``pct_change().fillna(0.0)``, so ordinary equity/crypto runs are unchanged.
|
||||
|
||||
The prior price is carried forward across gaps. ``pct_change`` did this
|
||||
implicitly via ``fill_method='pad'`` — its default on pandas < 3, removed
|
||||
in 3.0 — so *not* forwarding it would silently change every gapped series
|
||||
(a halt longer than ``_align``'s ``ffill_limit``, a thinly-traded symbol)
|
||||
and would make the bit-identity claim above false on the pinned pandas.
|
||||
Doing it here explicitly keeps that promise and, unlike the old default,
|
||||
gives the same answer on every supported pandas version. Note the effect
|
||||
is to attribute the whole across-gap move to the resumed bar; that is what
|
||||
a position held through the halt actually earned. Whether that is the right
|
||||
convention for *statistics* (it is not, for correlation — see
|
||||
``correlation.py``, which drops the observation instead) is a separate
|
||||
question from whether this function may change it as a side effect.
|
||||
|
||||
Args:
|
||||
close: Raw price ``Series`` or per-symbol ``DataFrame``.
|
||||
label: Optional name used when warning about undefined bars.
|
||||
|
||||
Returns:
|
||||
Returns aligned to ``close``, with the first bar ``0.0``.
|
||||
"""
|
||||
# ffill *then* shift: the divisor is the last known price, not NaN.
|
||||
prev = close.ffill().shift(1)
|
||||
# ``inf > 0`` is True, so finiteness has to be asserted, not assumed.
|
||||
usable_prev = np.isfinite(prev) & (prev > 0)
|
||||
positive_prev = prev.where(usable_prev)
|
||||
|
||||
undefined = int((prev.notna() & ~usable_prev).to_numpy().sum())
|
||||
if undefined:
|
||||
# Silence is the actual hazard here: a wrong return is a wrong reported
|
||||
# number, not a crash, so the run has to say it defaulted.
|
||||
_log.warning(
|
||||
"%s: %d bar(s) follow a non-positive or non-finite prior price; "
|
||||
"their return is undefined and reported as 0.0 (issue #872)",
|
||||
label or "returns",
|
||||
undefined,
|
||||
)
|
||||
|
||||
# ``close / prev - 1`` is exactly the expression ``pct_change`` uses, kept
|
||||
# verbatim so results for positive series are bit-identical, not merely
|
||||
# equal to within floating-point error.
|
||||
ret = close / positive_prev - 1
|
||||
return ret.replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
|
||||
|
||||
def buy_and_hold_return(close: Any) -> Optional[float]:
|
||||
"""Total buy-and-hold return as a price relative, not a compounded product.
|
||||
|
||||
``(1 + close.pct_change()).prod() - 1`` telescopes to ``P_end / P_start - 1``
|
||||
only while every price is positive; once a near-zero price enters the
|
||||
series the product diverges from what a held position actually earned. On
|
||||
the reproduction in #872 it reported ``+39,560%`` where the price relative
|
||||
gives ``-42.7%``. This computes the price relative directly, so the two
|
||||
agree exactly for ordinary series and it stays honest for the rest.
|
||||
|
||||
Args:
|
||||
close: Raw price series, already ``dropna()``-ed.
|
||||
|
||||
Returns:
|
||||
Total return, or ``None`` when the entry price is not strictly
|
||||
positive and no honest percentage exists.
|
||||
"""
|
||||
if len(close) < 2:
|
||||
return None
|
||||
first = float(close.iloc[0])
|
||||
last = float(close.iloc[-1])
|
||||
# ``inf > 0`` is True, so an infinite entry price would otherwise yield a
|
||||
# clean-looking -100% instead of "no honest percentage exists".
|
||||
if not (np.isfinite(first) and first > 0) or not np.isfinite(last):
|
||||
return None
|
||||
return last / first - 1.0
|
||||
|
||||
|
||||
def win_rate_and_stats(trades: List[TradeRecord]) -> Dict[str, float]:
|
||||
"""Win rate and P&L statistics from completed trades.
|
||||
|
||||
@@ -143,12 +362,82 @@ def by_exit_reason_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]
|
||||
return result
|
||||
|
||||
|
||||
def calc_turnover_series(positions: pd.DataFrame) -> pd.Series:
|
||||
"""Per-bar weight-implied portfolio turnover from a position frame.
|
||||
|
||||
Turnover for a bar is ``0.5 * sum_i |w_{t,i} - w_{t-1,i}|``, so a full
|
||||
rotation from one asset to another counts as 1.0 (matching the
|
||||
``turnover_aware`` optimizer's convention). The first bar's turnover is
|
||||
``0.5 * sum_i |w_{0,i}|``, treating the initial allocation as entry from
|
||||
cash. Turnover is measured on the weight frame the caller supplies. It
|
||||
does not know whether the execution engine filled, rounded, or rejected
|
||||
those target positions.
|
||||
|
||||
Args:
|
||||
positions: Position-weight matrix (index=timestamp, columns=codes).
|
||||
|
||||
Returns:
|
||||
Per-bar turnover series indexed like ``positions``; empty when the
|
||||
input is empty.
|
||||
"""
|
||||
if positions is None or positions.empty:
|
||||
return pd.Series(dtype=float)
|
||||
filled = positions.fillna(0.0)
|
||||
prev = filled.shift(1).fillna(0.0)
|
||||
return 0.5 * (filled - prev).abs().sum(axis=1)
|
||||
|
||||
|
||||
def calc_trade_turnover_series(
|
||||
trades: List[TradeRecord],
|
||||
equity_curve: pd.Series,
|
||||
) -> pd.Series:
|
||||
"""Per-bar turnover from actual entry and exit allocations.
|
||||
|
||||
Each filled leg contributes its margin-equivalent traded value. Dividing
|
||||
gross traded value by twice the portfolio equity preserves the existing
|
||||
convention: entering a 100% allocation counts as 0.5 and rotating a 100%
|
||||
allocation between two assets counts as 1.0.
|
||||
|
||||
Args:
|
||||
trades: Completed trades carrying actual entry/exit margin values.
|
||||
equity_curve: Portfolio equity used to normalize traded values.
|
||||
|
||||
Returns:
|
||||
Per-bar realized turnover aligned to ``equity_curve``. Bars without
|
||||
fills are zero and remain part of the average-turnover denominator.
|
||||
"""
|
||||
if equity_curve is None or equity_curve.empty:
|
||||
return pd.Series(dtype=float)
|
||||
|
||||
traded_margin = pd.Series(0.0, index=equity_curve.index, dtype=float)
|
||||
for trade in trades:
|
||||
for timestamp, margin in (
|
||||
(trade.entry_time, trade.entry_margin),
|
||||
(trade.exit_time, trade.exit_margin),
|
||||
):
|
||||
try:
|
||||
margin_value = float(margin)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if (
|
||||
timestamp in traded_margin.index
|
||||
and np.isfinite(margin_value)
|
||||
and margin_value > 0
|
||||
):
|
||||
traded_margin.loc[timestamp] += margin_value
|
||||
|
||||
denominator = 2.0 * equity_curve.abs().replace(0.0, np.nan)
|
||||
return (traded_margin / denominator).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
|
||||
|
||||
def calc_metrics(
|
||||
equity_curve: pd.Series,
|
||||
trades: List[TradeRecord],
|
||||
initial_cash: float,
|
||||
bars_per_year: Optional[int] = 252,
|
||||
bench_ret: Optional[pd.Series] = None,
|
||||
positions: Optional[pd.DataFrame] = None,
|
||||
turnover_series: Optional[pd.Series] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Full set of performance metrics.
|
||||
|
||||
@@ -159,6 +448,10 @@ def calc_metrics(
|
||||
bars_per_year: Bars per year for annualisation. None = auto-detect
|
||||
from equity curve dates (calendar-day method, for cross-market).
|
||||
bench_ret: Benchmark per-bar return series (optional).
|
||||
positions: Position-weight frame used as a backward-compatible
|
||||
turnover fallback when ``turnover_series`` is not supplied.
|
||||
turnover_series: Actual per-bar execution turnover (optional). When
|
||||
supplied, it takes precedence over position-implied turnover.
|
||||
|
||||
Returns:
|
||||
Metrics dictionary (compatible with daily_portfolio format).
|
||||
@@ -171,18 +464,46 @@ def calc_metrics(
|
||||
# Calendar-day annualization for cross-market (bars_per_year=None)
|
||||
if bars_per_year is None:
|
||||
first, last = equity_curve.index[0], equity_curve.index[-1]
|
||||
calendar_days = (last - first).days
|
||||
diff = last - first
|
||||
calendar_days = diff.days if hasattr(diff, "days") else 0
|
||||
years = calendar_days / 365.25 if calendar_days > 0 else 1.0
|
||||
bpy = int(n / years) if years > 0 else 252
|
||||
else:
|
||||
bpy = bars_per_year
|
||||
|
||||
port_ret = equity_curve.pct_change().fillna(0.0)
|
||||
# Equity that touches zero then recovers (100 → 0 → 50) yields non-finite
|
||||
# pct_change values; options metrics already skip risk ratios in that case.
|
||||
returns_finite = bool(np.isfinite(port_ret.to_numpy(dtype=float, copy=False)).all())
|
||||
|
||||
total_ret = float(equity_curve.iloc[-1] / initial_cash - 1)
|
||||
ann_ret = float((1 + total_ret) ** (bpy / max(n, 1)) - 1)
|
||||
vol = float(port_ret.std())
|
||||
sharpe = float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
|
||||
# A leveraged/short book can end at or below zero equity (``total_ret <= -1``).
|
||||
# ``(1 + total_ret) ** fractional`` would then raise a negative base to a
|
||||
# fractional power, which Python evaluates to a ``complex`` and crashes the
|
||||
# subsequent ``float(...)``. A total wipeout annualises to -100%.
|
||||
growth = 1 + total_ret
|
||||
if growth <= 0:
|
||||
ann_ret = -1.0
|
||||
else:
|
||||
# Explosive equity paths (e.g. 1 → 1e6 in a few bars) overflow
|
||||
# ``float(growth ** …)`` on CPython; treat as non-finite annualisation.
|
||||
try:
|
||||
ann_ret = float(growth ** (bpy / max(n, 1)) - 1)
|
||||
except OverflowError:
|
||||
ann_ret = float("inf")
|
||||
if not np.isfinite(ann_ret):
|
||||
ann_ret = float("inf")
|
||||
# ``Series.std()`` uses ddof=1, so a single-observation return series
|
||||
# (e.g. a one-bar backtest) yields NaN and poisons the Sharpe ratio.
|
||||
# Guard the small sample the same way ``downside_std`` is guarded below.
|
||||
vol = float(port_ret.std()) if len(port_ret) > 1 and returns_finite else 0.0
|
||||
sharpe = (
|
||||
float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
|
||||
if returns_finite
|
||||
else 0.0
|
||||
)
|
||||
if not np.isfinite(sharpe):
|
||||
sharpe = 0.0
|
||||
|
||||
# Drawdown
|
||||
peak = equity_curve.cummax()
|
||||
@@ -192,22 +513,62 @@ def calc_metrics(
|
||||
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
|
||||
|
||||
# Sortino
|
||||
downside = port_ret[port_ret < 0]
|
||||
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
|
||||
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
|
||||
if returns_finite:
|
||||
downside = port_ret[port_ret < 0]
|
||||
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
|
||||
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
|
||||
else:
|
||||
sortino = 0.0
|
||||
if not np.isfinite(sortino):
|
||||
sortino = 0.0
|
||||
|
||||
trade_stats = win_rate_and_stats(trades)
|
||||
|
||||
# Prefer execution-derived turnover; retain the position-frame fallback
|
||||
# for external callers of calc_metrics that do not have fill records.
|
||||
turnover_values = (
|
||||
turnover_series.reindex(equity_curve.index).fillna(0.0).clip(lower=0.0)
|
||||
if turnover_series is not None
|
||||
else calc_turnover_series(positions)
|
||||
if positions is not None
|
||||
else pd.Series(dtype=float)
|
||||
)
|
||||
avg_turnover = float(turnover_values.mean()) if len(turnover_values) > 0 else 0.0
|
||||
total_turnover = float(turnover_values.sum()) if len(turnover_values) > 0 else 0.0
|
||||
|
||||
# Benchmark comparison
|
||||
bench_return = 0.0
|
||||
excess = 0.0
|
||||
ir = 0.0
|
||||
tracking_error = 0.0
|
||||
bench_beta = 0.0
|
||||
if bench_ret is not None and len(bench_ret) > 0:
|
||||
bench_return = float((1 + bench_ret).prod() - 1)
|
||||
excess = total_ret - bench_return
|
||||
active_ret = port_ret - bench_ret.reindex(port_ret.index).fillna(0.0)
|
||||
active_std = float(active_ret.std())
|
||||
ir = float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
|
||||
aligned_bench = bench_ret.reindex(port_ret.index).fillna(0.0)
|
||||
active_ret = port_ret - aligned_bench
|
||||
# Same ddof=1 small-sample guard as ``vol`` / ``downside_std`` so the
|
||||
# information ratio stays finite for a single-observation series.
|
||||
active_std = float(active_ret.std()) if len(active_ret) > 1 and returns_finite else 0.0
|
||||
ir = (
|
||||
float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
|
||||
if returns_finite
|
||||
else 0.0
|
||||
)
|
||||
if not np.isfinite(ir):
|
||||
ir = 0.0
|
||||
# The information ratio's own denominator, annualised. A
|
||||
# benchmark-relative mandate is written around this number, and it was
|
||||
# being computed and thrown away.
|
||||
tracking_error = active_std * np.sqrt(bpy) if returns_finite else 0.0
|
||||
if not np.isfinite(tracking_error):
|
||||
tracking_error = 0.0
|
||||
bench_var = float(aligned_bench.var()) if len(aligned_bench) > 1 else 0.0
|
||||
if returns_finite and bench_var > 0:
|
||||
covariance = float(port_ret.cov(aligned_bench))
|
||||
bench_beta = covariance / bench_var
|
||||
if not np.isfinite(bench_beta):
|
||||
bench_beta = 0.0
|
||||
|
||||
return {
|
||||
"final_value": float(equity_curve.iloc[-1]),
|
||||
@@ -226,6 +587,10 @@ def calc_metrics(
|
||||
"benchmark_return": round(bench_return, 6),
|
||||
"excess_return": round(excess, 6),
|
||||
"information_ratio": round(ir, 4),
|
||||
"tracking_error": round(float(tracking_error), 6),
|
||||
"benchmark_beta": round(float(bench_beta), 4),
|
||||
"avg_turnover": round(avg_turnover, 6),
|
||||
"total_turnover": round(total_turnover, 6),
|
||||
}
|
||||
|
||||
|
||||
@@ -238,4 +603,6 @@ def _empty_metrics(initial_cash: float) -> Dict[str, Any]:
|
||||
"win_rate": 0, "profit_loss_ratio": 0, "profit_factor": 0,
|
||||
"max_consecutive_loss": 0, "avg_holding_days": 0, "trade_count": 0,
|
||||
"benchmark_return": 0, "excess_return": 0, "information_ratio": 0,
|
||||
"tracking_error": 0.0, "benchmark_beta": 0.0,
|
||||
"avg_turnover": 0.0, "total_turnover": 0.0,
|
||||
}
|
||||
@@ -53,6 +53,8 @@ class TradeRecord:
|
||||
exit_reason: Why closed (signal / liquidation / end_of_backtest).
|
||||
holding_bars: Number of bars held.
|
||||
commission: Total commission (entry + exit).
|
||||
entry_margin: Actual margin allocated at entry, after size rounding.
|
||||
exit_margin: Margin-equivalent value traded at the exit price.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
@@ -68,6 +70,8 @@ class TradeRecord:
|
||||
exit_reason: str
|
||||
holding_bars: int
|
||||
commission: float
|
||||
entry_margin: float = 0.0
|
||||
exit_margin: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Portfolio optimizer package.
|
||||
|
||||
Provides four weighting schemes:
|
||||
Provides five weighting schemes:
|
||||
- equal_volatility: inverse-volatility weights
|
||||
- risk_parity: equal risk contribution (Spinu-style)
|
||||
- mean_variance: max Sharpe via scipy
|
||||
- max_diversification: maximize diversification ratio
|
||||
- turnover_aware: mean-variance utility with an L1 turnover penalty
|
||||
|
||||
Select via ``optimizer`` in ``config.json``; default is off (1/N).
|
||||
Add a new optimizer by dropping a module here that exposes ``optimize()``.
|
||||
|
||||
@@ -16,7 +16,7 @@ class BaseOptimizer(ABC):
|
||||
|
||||
Subclasses implement ``_calc_weights``; the base handles:
|
||||
- active asset selection
|
||||
- rolling window slicing and sanity checks
|
||||
- causal rolling window slicing and sanity checks
|
||||
- covariance matrix + NaN checks
|
||||
- applying weights while preserving signal sign
|
||||
|
||||
@@ -42,7 +42,9 @@ class BaseOptimizer(ABC):
|
||||
"""Apply optimizer to position weights.
|
||||
|
||||
Args:
|
||||
ret: Return matrix (dates x codes).
|
||||
ret: Return matrix (dates x codes). For a decision at ``dt``,
|
||||
only rows strictly earlier than ``dt`` are visible to the
|
||||
optimizer because execution occurs at the decision bar's open.
|
||||
pos: Raw signal positions.
|
||||
dates: Date index aligned with ``pos``.
|
||||
|
||||
@@ -59,7 +61,12 @@ class BaseOptimizer(ABC):
|
||||
if not active or i < self.lookback:
|
||||
continue
|
||||
|
||||
window = ret.loc[:dt, active].tail(self.lookback)
|
||||
# Signals are executed at the decision bar's open. ``ret[dt]``
|
||||
# is a close-to-close return that is not observable until that
|
||||
# bar closes, so including it here would leak future information
|
||||
# into the weights applied at the open.
|
||||
history = ret.loc[ret.index < dt, active]
|
||||
window = history.tail(self.lookback)
|
||||
if len(window) < max(self.lookback // 2, 5):
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""Risk parity: equalize marginal risk contributions.
|
||||
|
||||
Iterative refinement so w_i * MRC_i is approximately equal across assets.
|
||||
"""
|
||||
"""Long-only risk parity: equalize marginal risk contributions."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -12,33 +9,44 @@ from backtest.optimizers.base import BaseOptimizer
|
||||
|
||||
|
||||
class RiskParityOptimizer(BaseOptimizer):
|
||||
"""Spinu (2013)-style inverse-vol seed + Newton-style refinement."""
|
||||
"""Equal-risk-contribution weights on the long-only simplex."""
|
||||
|
||||
def _calc_weights(self, ctx: Dict[str, Any]) -> np.ndarray:
|
||||
"""Equal risk contribution weights."""
|
||||
from scipy.optimize import minimize
|
||||
|
||||
cov = ctx["cov"]
|
||||
n = cov.shape[0]
|
||||
if n == 0:
|
||||
return self._equal_weight(0)
|
||||
|
||||
vols = np.sqrt(np.diag(cov))
|
||||
if np.any(vols < 1e-12):
|
||||
if not np.isfinite(cov).all() or np.any(vols < 1e-12):
|
||||
return self._equal_weight(n)
|
||||
|
||||
inv_vol = 1.0 / vols
|
||||
w = inv_vol / inv_vol.sum()
|
||||
seed = inv_vol / inv_vol.sum()
|
||||
|
||||
for _ in range(5):
|
||||
port_vol = np.sqrt(w @ cov @ w)
|
||||
if port_vol < 1e-12:
|
||||
break
|
||||
mrc = (cov @ w) / port_vol
|
||||
rc = w * mrc
|
||||
target = port_vol / n
|
||||
w = w * (target / (rc + 1e-12))
|
||||
w = w / w.sum()
|
||||
def contribution_error(w: np.ndarray) -> float:
|
||||
variance = float(w @ cov @ w)
|
||||
if not np.isfinite(variance) or variance <= 1e-18:
|
||||
return 1e12
|
||||
contributions = w * (cov @ w)
|
||||
target = variance / n
|
||||
return float(np.sum((contributions - target) ** 2) / variance**2)
|
||||
|
||||
return w
|
||||
result = minimize(
|
||||
contribution_error,
|
||||
seed,
|
||||
method="SLSQP",
|
||||
bounds=[(0.0, 1.0)] * n,
|
||||
constraints={"type": "eq", "fun": lambda w: w.sum() - 1.0},
|
||||
options={"maxiter": 200, "ftol": 1e-12},
|
||||
)
|
||||
|
||||
if result.success and np.isfinite(result.x).all():
|
||||
return self._normalize(result.x)
|
||||
return self._normalize(seed)
|
||||
|
||||
|
||||
def optimize(
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Turnover-aware optimizer: mean-variance utility with an L1 turnover penalty.
|
||||
|
||||
Solves, per rebalance date::
|
||||
|
||||
min -w'mu + lambda * w'Sigma w + gamma * ||w - w_prev||_1
|
||||
s.t. w >= 0, sum(w) = 1
|
||||
w_i <= max_per_name (per-name cap)
|
||||
sum_{i in group_g} w_i <= max_per_group[g] (per-group cap)
|
||||
|
||||
where ``w_prev`` is the weight vector applied at the previous rebalance,
|
||||
restricted to the current active set (assets absent last time have prior
|
||||
weight 0, so entries and exits both count as turnover). With ``gamma == 0``
|
||||
the objective reduces to the mean-variance utility baseline.
|
||||
|
||||
The penalty ``gamma`` is scale-sensitive: it is measured against the return
|
||||
term ``w'mu``, so an appropriate magnitude depends on the return units of the
|
||||
input window. For daily returns (~1e-3), even ``gamma`` around 0.5 makes the
|
||||
optimizer strongly prefer holding still. Callers should tune ``gamma`` relative
|
||||
to their data frequency.
|
||||
|
||||
Realized per-rebalance turnover (``0.5 * ||w_t - w_{t-1}||_1``) is accumulated
|
||||
on the instance for cost-adjusted analysis. This is a class-API affordance:
|
||||
the engine's module-level ``optimize`` entry constructs a fresh instance and
|
||||
returns only the positions frame, so callers who want the turnover series must
|
||||
instantiate ``TurnoverAwareOptimizer`` directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.optimizers.base import BaseOptimizer
|
||||
|
||||
|
||||
class TurnoverAwareOptimizer(BaseOptimizer):
|
||||
"""Mean-variance weights penalized for turnover against prior weights.
|
||||
|
||||
Attributes:
|
||||
risk_aversion: Weight on the variance term (lambda).
|
||||
turnover_penalty: Weight on the L1 turnover term (gamma). 0 reduces to
|
||||
the mean-variance baseline.
|
||||
max_per_name: Per-asset weight cap (None = no limit).
|
||||
groups: Asset-code → group-name mapping for per-group caps.
|
||||
max_per_group: Group-name → maximum total weight for that group.
|
||||
realized_turnover: Per-rebalance realized turnover collected during
|
||||
``optimize`` (``0.5 * ||w_t - w_{t-1}||_1``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lookback: int = 60,
|
||||
risk_aversion: float = 1.0,
|
||||
turnover_penalty: float = 0.0,
|
||||
max_per_name: Optional[float] = None,
|
||||
groups: Optional[Dict[str, str]] = None,
|
||||
max_per_group: Optional[Dict[str, float]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(lookback=lookback, **kwargs)
|
||||
self.risk_aversion = float(risk_aversion)
|
||||
self.turnover_penalty = float(turnover_penalty)
|
||||
if isinstance(max_per_name, (bool, np.bool_)):
|
||||
raise ValueError("max_per_name must be numeric, not boolean")
|
||||
self.max_per_name = float(max_per_name) if max_per_name is not None else None
|
||||
self.groups: Dict[str, str] = dict(groups) if groups else {}
|
||||
self.max_per_group: Dict[str, float] = dict(max_per_group) if max_per_group else {}
|
||||
if self.max_per_name is not None and (
|
||||
not np.isfinite(self.max_per_name) or not 0.0 < self.max_per_name <= 1.0
|
||||
):
|
||||
raise ValueError("max_per_name must be finite and in (0, 1]")
|
||||
if any(not isinstance(code, str) or not isinstance(group, str) for code, group in self.groups.items()):
|
||||
raise ValueError("groups must map string asset codes to string group names")
|
||||
unknown_groups = set(self.max_per_group) - set(self.groups.values())
|
||||
if unknown_groups:
|
||||
raise ValueError(
|
||||
"max_per_group references groups with no mapped assets: "
|
||||
+ ", ".join(sorted(unknown_groups))
|
||||
)
|
||||
for group, cap in self.max_per_group.items():
|
||||
if isinstance(cap, (bool, np.bool_)):
|
||||
raise ValueError(f"cap for group {group!r} must be numeric, not boolean")
|
||||
try:
|
||||
numeric_cap = float(cap)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"cap for group {group!r} must be numeric") from exc
|
||||
if not np.isfinite(numeric_cap) or not 0.0 < numeric_cap <= 1.0:
|
||||
raise ValueError(f"cap for group {group!r} must be finite and in (0, 1]")
|
||||
self.max_per_group[group] = numeric_cap
|
||||
self._prev: Dict[str, float] = {}
|
||||
self.realized_turnover: List[float] = []
|
||||
|
||||
def _build_context(
|
||||
self, window: pd.DataFrame, active: List[str]
|
||||
) -> "Dict[str, Any] | None":
|
||||
"""Mean vector, covariance, and active codes for the current window."""
|
||||
mu = window.mean().values
|
||||
cov = window.cov().values
|
||||
if np.isnan(cov).any() or np.isnan(mu).any():
|
||||
return None
|
||||
return {"cov": cov, "mu": mu, "active": list(active)}
|
||||
|
||||
def _calc_weights(self, ctx: Dict[str, Any]) -> np.ndarray:
|
||||
"""SLSQP weights for the penalized objective; updates turnover state."""
|
||||
from scipy.optimize import minimize
|
||||
|
||||
mu = np.asarray(ctx["mu"], dtype=float)
|
||||
cov = np.asarray(ctx["cov"], dtype=float)
|
||||
active: List[str] = ctx["active"]
|
||||
n = len(mu)
|
||||
if n == 0:
|
||||
return self._equal_weight(0)
|
||||
|
||||
w_prev = np.array([self._prev.get(code, 0.0) for code in active], dtype=float)
|
||||
lam = self.risk_aversion
|
||||
gamma = self.turnover_penalty
|
||||
|
||||
def objective(w: np.ndarray) -> float:
|
||||
ret = w @ mu
|
||||
var = w @ cov @ w
|
||||
turn = np.abs(w - w_prev).sum()
|
||||
return -ret + lam * var + gamma * turn
|
||||
|
||||
# --- bounds: per-name cap ---
|
||||
upper = 1.0
|
||||
if self.max_per_name is not None:
|
||||
upper = min(1.0, float(self.max_per_name))
|
||||
bounds = [(0.0, upper)] * n
|
||||
|
||||
# --- constraints: simplex + per-group caps ---
|
||||
constraints: list = [
|
||||
{"type": "eq", "fun": lambda w: w.sum() - 1.0},
|
||||
]
|
||||
group_rows: list[np.ndarray] = []
|
||||
group_caps: list[float] = []
|
||||
group_constraint_indices: list[List[int]] = []
|
||||
|
||||
group_indices: Dict[str, List[int]] = {}
|
||||
if self.groups and self.max_per_group:
|
||||
for i, code in enumerate(active):
|
||||
g = self.groups.get(code)
|
||||
if g is not None:
|
||||
group_indices.setdefault(g, []).append(i)
|
||||
for g, cap in self.max_per_group.items():
|
||||
indices = group_indices.get(g, [])
|
||||
if not indices:
|
||||
continue
|
||||
cap = float(cap)
|
||||
row = np.zeros(n)
|
||||
row[indices] = 1.0
|
||||
group_rows.append(row)
|
||||
group_caps.append(cap)
|
||||
group_constraint_indices.append(indices)
|
||||
constraints.append({
|
||||
"type": "ineq",
|
||||
"fun": lambda w, idx=indices, c=cap: c - w[np.array(idx)].sum(),
|
||||
})
|
||||
|
||||
has_effective_caps = upper < 1.0 or any(cap < 1.0 for cap in group_caps)
|
||||
if not has_effective_caps:
|
||||
x0 = w_prev if w_prev.sum() > 1e-12 else self._equal_weight(n)
|
||||
elif (
|
||||
np.isfinite(w_prev).all()
|
||||
and (w_prev >= 0.0).all()
|
||||
and abs(w_prev.sum() - 1.0) <= 1e-12
|
||||
and (w_prev <= upper + 1e-12).all()
|
||||
and all(
|
||||
row @ w_prev <= cap + 1e-12
|
||||
for row, cap in zip(group_rows, group_caps)
|
||||
)
|
||||
):
|
||||
x0 = w_prev
|
||||
else:
|
||||
# Groups are a single label per asset, so their caps are disjoint.
|
||||
# Allocate each bucket by capacity to obtain a feasible simplex point.
|
||||
buckets: list[tuple[List[int], float]] = []
|
||||
capped_indices: set[int] = set()
|
||||
for indices, cap in zip(group_constraint_indices, group_caps):
|
||||
capacity = min(cap, len(indices) * upper)
|
||||
buckets.append((indices, capacity))
|
||||
capped_indices.update(indices)
|
||||
uncapped = [i for i in range(n) if i not in capped_indices]
|
||||
if uncapped:
|
||||
buckets.append((uncapped, len(uncapped) * upper))
|
||||
total_capacity = sum(capacity for _, capacity in buckets)
|
||||
if total_capacity < 1.0 - 1e-12:
|
||||
raise ValueError(
|
||||
"exposure caps are infeasible for active assets "
|
||||
f"{active}: total capacity is {total_capacity:.6g}"
|
||||
)
|
||||
x0 = np.zeros(n)
|
||||
for indices, capacity in buckets:
|
||||
x0[indices] = capacity / total_capacity / len(indices)
|
||||
|
||||
result = minimize(
|
||||
objective,
|
||||
x0,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"maxiter": 200, "ftol": 1e-10},
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise RuntimeError(f"turnover-aware optimization failed: {result.message}")
|
||||
weights = np.maximum(np.asarray(result.x, dtype=float), 0.0)
|
||||
weights /= weights.sum()
|
||||
if (
|
||||
not np.isfinite(weights).all()
|
||||
or abs(weights.sum() - 1.0) > 1e-7
|
||||
or weights.max() > upper + 1e-7
|
||||
or any(row @ weights > cap + 1e-7 for row, cap in zip(group_rows, group_caps))
|
||||
):
|
||||
raise RuntimeError("optimizer returned weights that violate exposure caps")
|
||||
self._record_turnover(active, weights)
|
||||
return weights
|
||||
|
||||
def _record_turnover(self, active: List[str], weights: np.ndarray) -> None:
|
||||
"""Accumulate realized turnover and roll prior weights forward."""
|
||||
codes = set(active) | set(self._prev)
|
||||
new_map = {code: float(weights[i]) for i, code in enumerate(active)}
|
||||
turnover = 0.5 * sum(
|
||||
abs(new_map.get(code, 0.0) - self._prev.get(code, 0.0)) for code in codes
|
||||
)
|
||||
self.realized_turnover.append(turnover)
|
||||
self._prev = new_map
|
||||
|
||||
|
||||
def optimize(
|
||||
ret: pd.DataFrame,
|
||||
pos: pd.DataFrame,
|
||||
dates: pd.DatetimeIndex,
|
||||
lookback: int = 60,
|
||||
risk_aversion: float = 1.0,
|
||||
turnover_penalty: float = 0.0,
|
||||
max_per_name: Optional[float] = None,
|
||||
groups: Optional[Dict[str, str]] = None,
|
||||
max_per_group: Optional[Dict[str, float]] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Module-level entry: turnover-penalized mean-variance positions."""
|
||||
return TurnoverAwareOptimizer(
|
||||
lookback=lookback,
|
||||
risk_aversion=risk_aversion,
|
||||
turnover_penalty=turnover_penalty,
|
||||
max_per_name=max_per_name,
|
||||
groups=groups,
|
||||
max_per_group=max_per_group,
|
||||
).optimize(ret, pos, dates)
|
||||
@@ -0,0 +1,496 @@
|
||||
"""Deterministic payoff and scenario math for multi-leg option strategies.
|
||||
|
||||
The expiry summary is solved from the strategy's piecewise-linear payoff,
|
||||
independently of the caller's display grid. This keeps breakevens and finite
|
||||
extrema correct even when a chart grid omits one or more strikes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backtest.engines.options_portfolio import bs_price
|
||||
|
||||
_BREAKEVEN_TOLERANCE = 1e-9
|
||||
_DEFAULT_GRID_POINTS = 2001
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptionLeg:
|
||||
"""One European option leg in a payoff calculation.
|
||||
|
||||
Attributes:
|
||||
option_type: ``"call"`` or ``"put"``.
|
||||
strike: Positive strike price.
|
||||
qty: Signed contract quantity; positive is long and negative is short.
|
||||
premium: Optional per-share entry premium. When omitted, Black-Scholes
|
||||
pricing is used at the supplied entry conditions.
|
||||
"""
|
||||
|
||||
option_type: str
|
||||
strike: float
|
||||
qty: int
|
||||
premium: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayoffReport:
|
||||
"""Expiry payoff curve and analytic strategy summary.
|
||||
|
||||
Attributes:
|
||||
spot_grid: Caller-supplied display spots.
|
||||
payoff: Expiry P&L at each display spot.
|
||||
net_premium: Signed gross entry premium; positive is a debit.
|
||||
entry_commission: Entry commission paid in account currency.
|
||||
entry_cost: Gross premium plus entry commission.
|
||||
breakevens: Isolated non-negative expiry spots where P&L is zero.
|
||||
breakeven_intervals: Continuous zero-P&L spot intervals. A ``None``
|
||||
upper bound represents a flat zero-payoff right tail.
|
||||
max_profit: Analytic maximum, or positive infinity when unbounded.
|
||||
max_loss: Analytic minimum P&L, or negative infinity when unbounded.
|
||||
profit_unbounded: Whether right-tail profit is unbounded.
|
||||
loss_unbounded: Whether right-tail loss is unbounded.
|
||||
"""
|
||||
|
||||
spot_grid: np.ndarray
|
||||
payoff: np.ndarray
|
||||
net_premium: float
|
||||
entry_commission: float
|
||||
entry_cost: float
|
||||
breakevens: list[float]
|
||||
breakeven_intervals: list[tuple[float, float | None]]
|
||||
max_profit: float
|
||||
max_loss: float
|
||||
profit_unbounded: bool
|
||||
loss_unbounded: bool
|
||||
|
||||
|
||||
def _intrinsic(option_type: str, spots: np.ndarray, strike: float) -> np.ndarray:
|
||||
"""Return vectorized intrinsic values for one option leg."""
|
||||
if option_type == "call":
|
||||
return np.maximum(spots - strike, 0.0)
|
||||
return np.maximum(strike - spots, 0.0)
|
||||
|
||||
|
||||
def _validate_legs(legs: list[OptionLeg]) -> None:
|
||||
"""Validate option-leg fields at the calculation boundary."""
|
||||
if not legs:
|
||||
raise ValueError("at least one leg is required")
|
||||
for index, leg in enumerate(legs):
|
||||
if leg.option_type not in ("call", "put"):
|
||||
raise ValueError(f"legs[{index}].option_type must be 'call' or 'put'")
|
||||
if not np.isfinite(leg.strike) or leg.strike <= 0:
|
||||
raise ValueError(f"legs[{index}].strike must be positive and finite")
|
||||
if isinstance(leg.qty, bool) or not isinstance(leg.qty, (int, np.integer)):
|
||||
raise ValueError(f"legs[{index}].qty must be a non-zero integer")
|
||||
if leg.qty == 0:
|
||||
raise ValueError(f"legs[{index}].qty must be non-zero")
|
||||
if leg.premium is not None and (not np.isfinite(leg.premium) or leg.premium < 0):
|
||||
raise ValueError(f"legs[{index}].premium must be non-negative and finite")
|
||||
|
||||
|
||||
def _validate_market_inputs(
|
||||
*,
|
||||
entry_spot: float,
|
||||
time_to_expiry: float,
|
||||
rate: float,
|
||||
iv: float,
|
||||
multiplier: float,
|
||||
commission_rate: float,
|
||||
) -> None:
|
||||
"""Validate common market and cost inputs."""
|
||||
values = {
|
||||
"entry_spot": entry_spot,
|
||||
"time_to_expiry": time_to_expiry,
|
||||
"rate": rate,
|
||||
"iv": iv,
|
||||
"multiplier": multiplier,
|
||||
"commission_rate": commission_rate,
|
||||
}
|
||||
for name, value in values.items():
|
||||
if not np.isfinite(value):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
if entry_spot <= 0:
|
||||
raise ValueError("entry_spot must be positive")
|
||||
if time_to_expiry < 0:
|
||||
raise ValueError("time_to_expiry must be non-negative")
|
||||
if iv <= 0:
|
||||
raise ValueError("iv must be positive")
|
||||
if multiplier <= 0:
|
||||
raise ValueError("multiplier must be positive")
|
||||
if not 0 <= commission_rate < 1:
|
||||
raise ValueError("commission_rate must be in [0, 1)")
|
||||
|
||||
|
||||
def _validate_grid(values: np.ndarray, *, name: str, allow_zero: bool) -> np.ndarray:
|
||||
"""Return a validated one-dimensional numeric grid."""
|
||||
grid = np.asarray(values, dtype=float)
|
||||
if grid.ndim != 1 or grid.size == 0:
|
||||
raise ValueError(f"{name} must be a non-empty one-dimensional array")
|
||||
if not np.isfinite(grid).all():
|
||||
raise ValueError(f"{name} must contain only finite values")
|
||||
if allow_zero:
|
||||
invalid = grid < 0
|
||||
else:
|
||||
invalid = grid <= 0
|
||||
if invalid.any():
|
||||
qualifier = "non-negative" if allow_zero else "positive"
|
||||
raise ValueError(f"{name} values must be {qualifier}")
|
||||
return grid
|
||||
|
||||
|
||||
def _leg_premiums(
|
||||
legs: list[OptionLeg],
|
||||
entry_spot: float,
|
||||
time_to_expiry: float,
|
||||
rate: float,
|
||||
iv: float,
|
||||
) -> np.ndarray:
|
||||
"""Resolve explicit or Black-Scholes entry premiums for every leg."""
|
||||
premiums = []
|
||||
for leg in legs:
|
||||
premium = (
|
||||
leg.premium
|
||||
if leg.premium is not None
|
||||
else bs_price(
|
||||
entry_spot,
|
||||
leg.strike,
|
||||
time_to_expiry,
|
||||
rate,
|
||||
iv,
|
||||
leg.option_type,
|
||||
)
|
||||
)
|
||||
premiums.append(float(premium))
|
||||
return np.asarray(premiums, dtype=float)
|
||||
|
||||
|
||||
def _entry_costs(
|
||||
legs: list[OptionLeg],
|
||||
premiums: np.ndarray,
|
||||
multiplier: float,
|
||||
commission_rate: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Return gross premium, commission, and total signed entry cost."""
|
||||
net_premium = float(sum(leg.qty * premium * multiplier for leg, premium in zip(legs, premiums, strict=True)))
|
||||
entry_commission = float(
|
||||
sum(abs(leg.qty) * premium * multiplier * commission_rate for leg, premium in zip(legs, premiums, strict=True))
|
||||
)
|
||||
return net_premium, entry_commission, net_premium + entry_commission
|
||||
|
||||
|
||||
def _payoff_at_spots(
|
||||
legs: list[OptionLeg],
|
||||
spots: np.ndarray,
|
||||
*,
|
||||
multiplier: float,
|
||||
entry_cost: float,
|
||||
) -> np.ndarray:
|
||||
"""Calculate expiry P&L at arbitrary non-negative spots."""
|
||||
intrinsic = np.zeros(len(spots), dtype=float)
|
||||
for leg in legs:
|
||||
intrinsic += leg.qty * _intrinsic(leg.option_type, spots, leg.strike)
|
||||
return intrinsic * multiplier - entry_cost
|
||||
|
||||
|
||||
def _critical_points(legs: list[OptionLeg]) -> np.ndarray:
|
||||
"""Return every finite point at which expiry-payoff slope may change."""
|
||||
return np.asarray(sorted({0.0, *(float(leg.strike) for leg in legs)}))
|
||||
|
||||
|
||||
def _analytic_breakevens(
|
||||
critical_spots: np.ndarray,
|
||||
critical_payoff: np.ndarray,
|
||||
right_slope: float,
|
||||
) -> tuple[list[float], list[tuple[float, float | None]]]:
|
||||
"""Solve isolated roots and continuous zero intervals analytically."""
|
||||
roots: list[float] = []
|
||||
intervals: list[tuple[float, float | None]] = []
|
||||
|
||||
for index, (spot, value) in enumerate(zip(critical_spots, critical_payoff, strict=True)):
|
||||
if index == len(critical_spots) - 1:
|
||||
continue
|
||||
next_spot = float(critical_spots[index + 1])
|
||||
next_value = float(critical_payoff[index + 1])
|
||||
value = float(value)
|
||||
if abs(value) <= _BREAKEVEN_TOLERANCE and abs(next_value) <= _BREAKEVEN_TOLERANCE:
|
||||
intervals.append((float(spot), next_spot))
|
||||
elif value * next_value < 0:
|
||||
fraction = value / (value - next_value)
|
||||
roots.append(float(spot + fraction * (next_spot - spot)))
|
||||
|
||||
right_spot = float(critical_spots[-1])
|
||||
right_value = float(critical_payoff[-1])
|
||||
if abs(right_slope) <= _BREAKEVEN_TOLERANCE and abs(right_value) <= _BREAKEVEN_TOLERANCE:
|
||||
intervals.append((right_spot, None))
|
||||
elif abs(right_slope) > _BREAKEVEN_TOLERANCE:
|
||||
right_root = right_spot - right_value / right_slope
|
||||
if right_root > right_spot + _BREAKEVEN_TOLERANCE:
|
||||
roots.append(float(right_root))
|
||||
|
||||
intervals = _merge_zero_intervals(intervals)
|
||||
for spot, value in zip(critical_spots, critical_payoff, strict=True):
|
||||
spot = float(spot)
|
||||
if abs(float(value)) <= _BREAKEVEN_TOLERANCE and not _in_zero_interval(spot, intervals):
|
||||
roots.append(spot)
|
||||
|
||||
roots.sort()
|
||||
deduped: list[float] = []
|
||||
for root in roots:
|
||||
if not deduped or abs(root - deduped[-1]) > _BREAKEVEN_TOLERANCE:
|
||||
deduped.append(root)
|
||||
return deduped, intervals
|
||||
|
||||
|
||||
def _merge_zero_intervals(
|
||||
intervals: list[tuple[float, float | None]],
|
||||
) -> list[tuple[float, float | None]]:
|
||||
"""Merge adjacent continuous zero-payoff intervals."""
|
||||
merged: list[tuple[float, float | None]] = []
|
||||
for start, end in intervals:
|
||||
if not merged:
|
||||
merged.append((start, end))
|
||||
continue
|
||||
previous_start, previous_end = merged[-1]
|
||||
if previous_end is not None and abs(previous_end - start) <= _BREAKEVEN_TOLERANCE:
|
||||
merged[-1] = (previous_start, end)
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _in_zero_interval(spot: float, intervals: list[tuple[float, float | None]]) -> bool:
|
||||
"""Return whether a spot belongs to a continuous zero-payoff interval."""
|
||||
for start, end in intervals:
|
||||
if spot < start - _BREAKEVEN_TOLERANCE:
|
||||
continue
|
||||
if end is None or spot <= end + _BREAKEVEN_TOLERANCE:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def expiry_payoff(
|
||||
legs: list[OptionLeg],
|
||||
spot_grid: np.ndarray,
|
||||
*,
|
||||
entry_spot: float,
|
||||
time_to_expiry: float,
|
||||
rate: float = 0.05,
|
||||
iv: float = 0.3,
|
||||
multiplier: float = 1.0,
|
||||
commission_rate: float = 0.001,
|
||||
) -> PayoffReport:
|
||||
"""Calculate an expiry payoff curve and analytic risk summary.
|
||||
|
||||
Premiums are pinned at entry. An explicit per-share premium wins over the
|
||||
Black-Scholes price. Entry commission follows the existing options engine:
|
||||
long legs pay ``premium * (1 + commission_rate)`` and short legs receive
|
||||
``premium * (1 - commission_rate)``. Expiry settlement has no exit fee.
|
||||
|
||||
Args:
|
||||
legs: Signed option legs in the strategy.
|
||||
spot_grid: Non-negative display spots. Summary values do not depend on
|
||||
this grid containing strikes or breakevens.
|
||||
entry_spot: Underlying spot at entry.
|
||||
time_to_expiry: Years until expiry at entry.
|
||||
rate: Annual continuously compounded risk-free rate.
|
||||
iv: Annualized volatility used for legs without explicit premiums.
|
||||
multiplier: Currency multiplier per option price unit.
|
||||
commission_rate: Entry commission as a fraction of gross premium.
|
||||
|
||||
Returns:
|
||||
Payoff report with the display curve and analytic extrema/breakevens.
|
||||
|
||||
Raises:
|
||||
ValueError: If a leg, grid, market input, or cost input is invalid.
|
||||
"""
|
||||
_validate_legs(legs)
|
||||
_validate_market_inputs(
|
||||
entry_spot=entry_spot,
|
||||
time_to_expiry=time_to_expiry,
|
||||
rate=rate,
|
||||
iv=iv,
|
||||
multiplier=multiplier,
|
||||
commission_rate=commission_rate,
|
||||
)
|
||||
display_spots = _validate_grid(spot_grid, name="spot_grid", allow_zero=True)
|
||||
premiums = _leg_premiums(legs, entry_spot, time_to_expiry, rate, iv)
|
||||
net_premium, entry_commission, entry_cost = _entry_costs(legs, premiums, multiplier, commission_rate)
|
||||
|
||||
payoff = _payoff_at_spots(
|
||||
legs,
|
||||
display_spots,
|
||||
multiplier=multiplier,
|
||||
entry_cost=entry_cost,
|
||||
)
|
||||
|
||||
critical_spots = _critical_points(legs)
|
||||
critical_payoff = _payoff_at_spots(
|
||||
legs,
|
||||
critical_spots,
|
||||
multiplier=multiplier,
|
||||
entry_cost=entry_cost,
|
||||
)
|
||||
right_slope = float(sum(leg.qty for leg in legs if leg.option_type == "call") * multiplier)
|
||||
profit_unbounded = right_slope > _BREAKEVEN_TOLERANCE
|
||||
loss_unbounded = right_slope < -_BREAKEVEN_TOLERANCE
|
||||
max_profit = float("inf") if profit_unbounded else float(np.max(critical_payoff))
|
||||
max_loss = float("-inf") if loss_unbounded else float(np.min(critical_payoff))
|
||||
breakevens, breakeven_intervals = _analytic_breakevens(critical_spots, critical_payoff, right_slope)
|
||||
|
||||
return PayoffReport(
|
||||
spot_grid=display_spots,
|
||||
payoff=payoff,
|
||||
net_premium=net_premium,
|
||||
entry_commission=entry_commission,
|
||||
entry_cost=entry_cost,
|
||||
breakevens=breakevens,
|
||||
breakeven_intervals=breakeven_intervals,
|
||||
max_profit=max_profit,
|
||||
max_loss=max_loss,
|
||||
profit_unbounded=profit_unbounded,
|
||||
loss_unbounded=loss_unbounded,
|
||||
)
|
||||
|
||||
|
||||
def default_spot_grid(
|
||||
center: float,
|
||||
half_width_pct: float = 0.5,
|
||||
points: int = _DEFAULT_GRID_POINTS,
|
||||
) -> np.ndarray:
|
||||
"""Build a symmetric non-negative display grid around an entry spot.
|
||||
|
||||
Args:
|
||||
center: Positive central spot.
|
||||
half_width_pct: Fraction of ``center`` added to and subtracted from the
|
||||
bounds.
|
||||
points: Number of points, at least two.
|
||||
|
||||
Returns:
|
||||
Increasing NumPy spot grid.
|
||||
|
||||
Raises:
|
||||
ValueError: If a parameter is non-finite or outside its valid range.
|
||||
"""
|
||||
if not np.isfinite(center) or center <= 0:
|
||||
raise ValueError("center must be positive and finite")
|
||||
if not np.isfinite(half_width_pct) or half_width_pct <= 0:
|
||||
raise ValueError("half_width_pct must be positive and finite")
|
||||
if isinstance(points, bool) or not isinstance(points, (int, np.integer)):
|
||||
raise ValueError("points must be an integer")
|
||||
if points < 2:
|
||||
raise ValueError("points must be at least 2")
|
||||
lower = max(center * (1.0 - half_width_pct), 0.0)
|
||||
upper = center * (1.0 + half_width_pct)
|
||||
return np.linspace(lower, upper, int(points))
|
||||
|
||||
|
||||
def scenario_grid(
|
||||
legs: list[OptionLeg],
|
||||
spot_grid: np.ndarray,
|
||||
iv_values: np.ndarray,
|
||||
*,
|
||||
entry_spot: float,
|
||||
time_to_expiry: float,
|
||||
rate: float = 0.05,
|
||||
entry_iv: float = 0.3,
|
||||
multiplier: float = 1.0,
|
||||
commission_rate: float = 0.001,
|
||||
) -> np.ndarray:
|
||||
"""Calculate pre-expiry mark-to-market P&L over spot and volatility.
|
||||
|
||||
The entry cost is pinned once using the entry spot/IV and includes entry
|
||||
commission. Scenario values are marks, so no hypothetical closing
|
||||
commission is deducted.
|
||||
|
||||
Args:
|
||||
legs: Signed option legs in the strategy.
|
||||
spot_grid: Non-negative scenario spots.
|
||||
iv_values: Positive annualized volatility scenarios.
|
||||
entry_spot: Underlying spot at entry.
|
||||
time_to_expiry: Years remaining in every scenario.
|
||||
rate: Annual continuously compounded risk-free rate.
|
||||
entry_iv: Annualized volatility used to price the entry.
|
||||
multiplier: Currency multiplier per option price unit.
|
||||
commission_rate: Entry commission as a fraction of gross premium.
|
||||
|
||||
Returns:
|
||||
Matrix shaped ``(len(iv_values), len(spot_grid))``.
|
||||
|
||||
Raises:
|
||||
ValueError: If a leg, grid, market input, or cost input is invalid.
|
||||
"""
|
||||
_validate_legs(legs)
|
||||
_validate_market_inputs(
|
||||
entry_spot=entry_spot,
|
||||
time_to_expiry=time_to_expiry,
|
||||
rate=rate,
|
||||
iv=entry_iv,
|
||||
multiplier=multiplier,
|
||||
commission_rate=commission_rate,
|
||||
)
|
||||
spots = _validate_grid(spot_grid, name="spot_grid", allow_zero=True)
|
||||
ivs = _validate_grid(iv_values, name="iv_values", allow_zero=False)
|
||||
entry_premiums = _leg_premiums(legs, entry_spot, time_to_expiry, rate, entry_iv)
|
||||
_, _, entry_cost = _entry_costs(legs, entry_premiums, multiplier, commission_rate)
|
||||
|
||||
grid = np.zeros((len(ivs), len(spots)), dtype=float)
|
||||
for iv_row, iv_now in enumerate(ivs):
|
||||
for spot_col, spot_now in enumerate(spots):
|
||||
marked_value = 0.0
|
||||
for leg in legs:
|
||||
marked_value += (
|
||||
leg.qty
|
||||
* bs_price(
|
||||
float(spot_now),
|
||||
leg.strike,
|
||||
time_to_expiry,
|
||||
rate,
|
||||
float(iv_now),
|
||||
leg.option_type,
|
||||
)
|
||||
* multiplier
|
||||
)
|
||||
grid[iv_row, spot_col] = marked_value - entry_cost
|
||||
return grid
|
||||
|
||||
|
||||
def bull_call_spread(lower_strike: float, upper_strike: float, qty: int = 1) -> list[OptionLeg]:
|
||||
"""Build a long lower-strike/short upper-strike call spread."""
|
||||
if upper_strike <= lower_strike:
|
||||
raise ValueError("upper strike must sit above lower strike")
|
||||
if isinstance(qty, bool) or not isinstance(qty, (int, np.integer)) or qty <= 0:
|
||||
raise ValueError("qty must be positive")
|
||||
return [
|
||||
OptionLeg("call", lower_strike, qty),
|
||||
OptionLeg("call", upper_strike, -qty),
|
||||
]
|
||||
|
||||
|
||||
def long_straddle(strike: float, qty: int = 1) -> list[OptionLeg]:
|
||||
"""Build a long call plus long put at the same strike."""
|
||||
if isinstance(qty, bool) or not isinstance(qty, (int, np.integer)) or qty <= 0:
|
||||
raise ValueError("qty must be positive")
|
||||
return [OptionLeg("call", strike, qty), OptionLeg("put", strike, qty)]
|
||||
|
||||
|
||||
def iron_condor(
|
||||
put_wing: float,
|
||||
put_body: float,
|
||||
call_body: float,
|
||||
call_wing: float,
|
||||
qty: int = 1,
|
||||
) -> list[OptionLeg]:
|
||||
"""Build a defined-risk short iron condor."""
|
||||
if not (put_wing < put_body < call_body < call_wing):
|
||||
raise ValueError("strikes must nest as put_wing < put_body < call_body < call_wing")
|
||||
if isinstance(qty, bool) or not isinstance(qty, (int, np.integer)) or qty <= 0:
|
||||
raise ValueError("qty must be positive")
|
||||
return [
|
||||
OptionLeg("put", put_wing, qty),
|
||||
OptionLeg("put", put_body, -qty),
|
||||
OptionLeg("call", call_body, -qty),
|
||||
OptionLeg("call", call_wing, qty),
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Strict USD-M audit evidence, never read back into accounting decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA_VERSION = "1"
|
||||
RESOLUTION_LIMITATION = (
|
||||
"At 100x, an interval of at most 1 hour is only a resolution boundary; "
|
||||
"exact liquidation sequence precision is not guaranteed."
|
||||
)
|
||||
|
||||
|
||||
def build_perpetual_summary(
|
||||
*,
|
||||
config: Mapping[str, Any],
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
trades: Sequence[Any],
|
||||
terminal_status: str,
|
||||
maintenance_bracket_versions: Mapping[str, str],
|
||||
market_risk_sources: Sequence[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a strict-JSON-safe summary from append-only audit evidence."""
|
||||
funding = [event for event in events if event["event_type"] == "funding_settlement"]
|
||||
liquidation_types = {"position_liquidation", "account_liquidation"}
|
||||
liquidations = [event for event in events if event["event_type"] in liquidation_types]
|
||||
flags = sorted({flag for event in events for flag in event.get("fidelity_flags", [])})
|
||||
liquidated_positions = sum(len(event.get("symbols", [event.get("symbol")])) for event in liquidations)
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"margin_mode": str(config.get("margin_mode", "isolated")),
|
||||
"funding_mode": str(config.get("funding_mode", "fixed")),
|
||||
"interval": str(config.get("interval", "1D")),
|
||||
"leverage": float(config.get("leverage", 1.0)),
|
||||
"taker_rate": float(config.get("taker_rate", 0.0005)),
|
||||
"liquidation_fee_rate": float(config.get("liquidation_fee_rate", 0.0)),
|
||||
"terminal_status": terminal_status,
|
||||
"event_count": len(events),
|
||||
"funding_settlement_count": len(funding),
|
||||
"total_funding_pnl": sum(float(event["funding_pnl"]) for event in funding),
|
||||
"liquidation_event_count": len(liquidations),
|
||||
"liquidated_position_count": liquidated_positions,
|
||||
"total_trading_fee": sum(float(trade.commission) for trade in trades),
|
||||
"total_liquidation_fee": sum(float(event["liquidation_fee"]) for event in liquidations),
|
||||
"fee_model": {
|
||||
"market_fill_rate": "taker_rate",
|
||||
"maker_rate_used": False,
|
||||
"funding_separate": True,
|
||||
"liquidation_separate": True,
|
||||
},
|
||||
"maintenance_bracket_versions": dict(sorted(maintenance_bracket_versions.items())),
|
||||
"market_risk_sources": sorted(set(market_risk_sources)),
|
||||
"fidelity_flags": flags,
|
||||
"resolution_limitation": RESOLUTION_LIMITATION,
|
||||
"assumptions": [
|
||||
"single_asset_usdt",
|
||||
"no_open_order_margin",
|
||||
"one_way_positions",
|
||||
"bar_extrema_order_unknown",
|
||||
"not_an_identical_binance_liquidation_engine_reproduction",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_perpetual_evidence(
|
||||
run_dir: Path,
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
summary: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Write ordered lifecycle JSONL and its aggregate summary."""
|
||||
out = Path(run_dir) / "artifacts"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
event_lines = [json.dumps(event, sort_keys=True, allow_nan=False) for event in events]
|
||||
(out / "perpetual_events.jsonl").write_text(
|
||||
"\n".join(event_lines) + ("\n" if event_lines else ""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "perpetual_summary.json").write_text(
|
||||
json.dumps(summary, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Pure state and risk contracts for historical USD-M perpetual backtests.
|
||||
|
||||
The model is deliberately account-local and deterministic. It does not read
|
||||
live exchange state, model open orders, or store mutable mark prices on
|
||||
positions.
|
||||
|
||||
Maintenance-bracket data comes from the loader's already-validated artifact
|
||||
contract (``backtest.loaders.ccxt_loader._validate_bracket_artifact``): a
|
||||
symbol, a list of tiers, and a single content-hash version. This module does
|
||||
not re-fetch, re-derive, or re-hash brackets — it only re-validates their
|
||||
structural invariants (ordering, non-negativity) as a defense-in-depth check
|
||||
on whatever the caller passes in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
MarginMode = Literal["isolated", "cross"]
|
||||
TerminalStatus = Literal["active", "completed", "account_liquidation"]
|
||||
|
||||
|
||||
def _require_finite(name: str, value: float, *, positive: bool = False) -> None:
|
||||
if not math.isfinite(value) or (positive and value <= 0):
|
||||
qualifier = "positive and finite" if positive else "finite"
|
||||
raise ValueError(f"{name} must be {qualifier}")
|
||||
|
||||
|
||||
def _timestamp(value: pd.Timestamp, name: str) -> pd.Timestamp:
|
||||
timestamp = pd.Timestamp(value)
|
||||
if pd.isna(timestamp):
|
||||
raise ValueError(f"{name} must be a valid timestamp")
|
||||
return timestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaintenanceBracket:
|
||||
"""One symbol-specific maintenance-margin bracket.
|
||||
|
||||
Field names and semantics match the loader's bracket-artifact tier
|
||||
records exactly (``bracket_tier``, ``notional_cap``, ``maintenance_rate``,
|
||||
``cumulative_maintenance_amount``, optional ``notional_coefficient``).
|
||||
"""
|
||||
|
||||
bracket_tier: int
|
||||
notional_cap: float
|
||||
maintenance_rate: float
|
||||
cumulative_maintenance_amount: float
|
||||
notional_coefficient: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.bracket_tier < 0:
|
||||
raise ValueError("bracket_tier must be non-negative")
|
||||
_require_finite("notional_cap", self.notional_cap, positive=True)
|
||||
_require_finite("maintenance_rate", self.maintenance_rate)
|
||||
_require_finite(
|
||||
"cumulative_maintenance_amount", self.cumulative_maintenance_amount
|
||||
)
|
||||
if self.maintenance_rate < 0:
|
||||
raise ValueError("maintenance_rate must be non-negative")
|
||||
if self.cumulative_maintenance_amount < 0:
|
||||
raise ValueError("cumulative_maintenance_amount must be non-negative")
|
||||
if self.notional_coefficient is not None:
|
||||
_require_finite("notional_coefficient", self.notional_coefficient)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaintenanceSchedule:
|
||||
"""A validated, versioned set of maintenance brackets for one symbol.
|
||||
|
||||
``version`` is an opaque identifier supplied by the caller — in practice
|
||||
the loader's ``maintenance_bracket_version`` content-hash column. This
|
||||
class does not compute its own hash: the artifact contract
|
||||
(``_validate_bracket_artifact``) is the single source of truth for
|
||||
content integrity, checked once, before this object is ever built.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
version: str
|
||||
brackets: tuple[MaintenanceBracket, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.symbol:
|
||||
raise ValueError("symbol must not be empty")
|
||||
if not self.version:
|
||||
raise ValueError("version must not be empty")
|
||||
if not self.brackets:
|
||||
raise ValueError("brackets must not be empty")
|
||||
tiers = [bracket.bracket_tier for bracket in self.brackets]
|
||||
if tiers != sorted(tiers) or len(tiers) != len(set(tiers)):
|
||||
raise ValueError("bracket_tier values must be strictly increasing")
|
||||
caps = [bracket.notional_cap for bracket in self.brackets]
|
||||
if any(current <= previous for previous, current in zip(caps, caps[1:])):
|
||||
raise ValueError("notional caps must be strictly increasing")
|
||||
|
||||
@classmethod
|
||||
def from_loader_columns(
|
||||
cls, symbol: str, maintenance_brackets: str, maintenance_bracket_version: str,
|
||||
) -> MaintenanceSchedule:
|
||||
"""Build a schedule from the loader's ``maintenance_brackets`` /
|
||||
``maintenance_bracket_version`` DataFrame columns.
|
||||
|
||||
The JSON payload is already-validated tier records (from
|
||||
``_validate_bracket_artifact``) — this only parses and re-checks
|
||||
structural invariants, it never re-derives the version hash.
|
||||
"""
|
||||
try:
|
||||
records = json.loads(maintenance_brackets)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("maintenance_brackets is not valid JSON") from exc
|
||||
if not isinstance(records, list) or not records:
|
||||
raise ValueError("maintenance_brackets must be a non-empty list")
|
||||
brackets = tuple(
|
||||
MaintenanceBracket(
|
||||
bracket_tier=record["bracket_tier"],
|
||||
notional_cap=record["notional_cap"],
|
||||
maintenance_rate=record["maintenance_rate"],
|
||||
cumulative_maintenance_amount=record["cumulative_maintenance_amount"],
|
||||
notional_coefficient=record.get("notional_coefficient"),
|
||||
)
|
||||
for record in records
|
||||
)
|
||||
return cls(symbol=symbol, version=maintenance_bracket_version, brackets=brackets)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PositionState:
|
||||
"""Exchange-independent open-position accounting state."""
|
||||
|
||||
symbol: str
|
||||
quantity: float
|
||||
entry_price: float
|
||||
leverage: float
|
||||
accumulated_entry_fee: float
|
||||
isolated_margin: float | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.symbol:
|
||||
raise ValueError("symbol must not be empty")
|
||||
_require_finite("quantity", self.quantity)
|
||||
if self.quantity == 0:
|
||||
raise ValueError("quantity must be non-zero")
|
||||
_require_finite("entry_price", self.entry_price, positive=True)
|
||||
_require_finite("leverage", self.leverage, positive=True)
|
||||
_require_finite("accumulated_entry_fee", self.accumulated_entry_fee)
|
||||
if self.accumulated_entry_fee < 0:
|
||||
raise ValueError("accumulated_entry_fee must be non-negative")
|
||||
if self.isolated_margin is not None:
|
||||
_require_finite("isolated_margin", self.isolated_margin, positive=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PositionRisk:
|
||||
symbol: str
|
||||
mark_price: float
|
||||
notional: float
|
||||
unrealized_pnl: float
|
||||
initial_margin: float
|
||||
maintenance_margin: float
|
||||
margin_balance: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskSnapshot:
|
||||
margin_balance: float
|
||||
initial_margin: float
|
||||
maintenance_margin: float
|
||||
available_balance: float
|
||||
per_position: tuple[PositionRisk, ...]
|
||||
status: Literal["healthy", "position_liquidation", "account_liquidation"]
|
||||
liquidation_targets: tuple[str, ...]
|
||||
fidelity_flags: tuple[str, ...]
|
||||
|
||||
|
||||
def maintenance_margin(
|
||||
position: PositionState, mark_price: float, schedule: MaintenanceSchedule
|
||||
) -> float:
|
||||
if position.symbol != schedule.symbol:
|
||||
raise ValueError("position and schedule symbols must match")
|
||||
_require_finite("mark_price", mark_price, positive=True)
|
||||
notional = abs(position.quantity) * mark_price
|
||||
for bracket in schedule.brackets:
|
||||
if notional <= bracket.notional_cap:
|
||||
return notional * bracket.maintenance_rate - bracket.cumulative_maintenance_amount
|
||||
raise ValueError("notional exceeds the final maintenance bracket cap")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountState:
|
||||
"""Single-asset USDT account state without open-order margin."""
|
||||
|
||||
wallet_balance: float
|
||||
positions: tuple[PositionState, ...]
|
||||
margin_mode: MarginMode
|
||||
terminal_status: TerminalStatus = "active"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_finite("wallet_balance", self.wallet_balance)
|
||||
if self.margin_mode not in {"isolated", "cross"}:
|
||||
raise ValueError("margin_mode must be 'isolated' or 'cross'")
|
||||
if self.terminal_status not in {"active", "completed", "account_liquidation"}:
|
||||
raise ValueError("unsupported terminal_status")
|
||||
symbols = [position.symbol for position in self.positions]
|
||||
if len(symbols) != len(set(symbols)):
|
||||
raise ValueError("duplicate position symbol")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionFrame:
|
||||
"""Normal market-fill input, separate from exchange mark prices."""
|
||||
|
||||
timestamp: pd.Timestamp
|
||||
execution_open: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "timestamp", _timestamp(self.timestamp, "timestamp"))
|
||||
_require_finite("execution_open", self.execution_open, positive=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarketRiskFrame:
|
||||
"""Immutable mark, funding, bracket, provenance, and fidelity input.
|
||||
|
||||
``schedule`` is optional: the loader only attaches bracket columns when
|
||||
a caller supplies a validated artifact (``require_brackets=True`` without
|
||||
one fails closed at the loader level, before data ever reaches here). A
|
||||
frame with ``schedule=None`` is valid for execution/mark/funding-only use.
|
||||
"""
|
||||
|
||||
timestamp: pd.Timestamp
|
||||
mark_open: float
|
||||
mark_high: float
|
||||
mark_low: float
|
||||
mark_close: float
|
||||
funding_rate: float | None
|
||||
funding_settlement_time: pd.Timestamp | None
|
||||
schedule: MaintenanceSchedule | None
|
||||
source: str
|
||||
fidelity_flags: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
timestamp = _timestamp(self.timestamp, "timestamp")
|
||||
object.__setattr__(self, "timestamp", timestamp)
|
||||
for name in ("mark_open", "mark_high", "mark_low", "mark_close"):
|
||||
_require_finite(name, getattr(self, name), positive=True)
|
||||
if self.mark_high < max(self.mark_open, self.mark_low, self.mark_close):
|
||||
raise ValueError("mark_high must contain the mark OHLC range")
|
||||
if self.mark_low > min(self.mark_open, self.mark_high, self.mark_close):
|
||||
raise ValueError("mark_low must contain the mark OHLC range")
|
||||
has_rate = self.funding_rate is not None
|
||||
has_settlement = self.funding_settlement_time is not None
|
||||
if has_rate != has_settlement:
|
||||
raise ValueError("funding rate and settlement timestamp must be paired")
|
||||
if self.funding_rate is not None:
|
||||
_require_finite("funding_rate", self.funding_rate)
|
||||
settlement = _timestamp(
|
||||
self.funding_settlement_time, "funding_settlement_time"
|
||||
)
|
||||
if settlement != timestamp:
|
||||
raise ValueError("funding settlement timestamp must match frame timestamp")
|
||||
object.__setattr__(self, "funding_settlement_time", settlement)
|
||||
if not self.source:
|
||||
raise ValueError("source must not be empty")
|
||||
if len(self.fidelity_flags) != len(set(self.fidelity_flags)):
|
||||
raise ValueError("fidelity_flags must not contain duplicates")
|
||||
|
||||
|
||||
def _mark_price(
|
||||
position: PositionState, frame: MarketRiskFrame, price_field: str
|
||||
) -> float:
|
||||
if price_field == "adverse":
|
||||
return frame.mark_low if position.quantity > 0 else frame.mark_high
|
||||
if price_field not in {"mark_open", "mark_high", "mark_low", "mark_close"}:
|
||||
raise ValueError("unsupported price_field")
|
||||
return getattr(frame, price_field)
|
||||
|
||||
|
||||
def _position_risks(
|
||||
account: AccountState,
|
||||
frames: dict[str, MarketRiskFrame],
|
||||
price_field: str,
|
||||
*,
|
||||
isolated: bool,
|
||||
) -> tuple[tuple[PositionRisk, ...], tuple[str, ...]]:
|
||||
if price_field not in {"adverse", "mark_open", "mark_high", "mark_low", "mark_close"}:
|
||||
raise ValueError("unsupported price_field")
|
||||
|
||||
risks: list[PositionRisk] = []
|
||||
timestamps: set[pd.Timestamp] = set()
|
||||
fidelity_flags: list[str] = []
|
||||
for position in account.positions:
|
||||
frame = frames.get(position.symbol)
|
||||
if frame is None:
|
||||
raise ValueError("missing market risk frame")
|
||||
if frame.schedule is None:
|
||||
raise ValueError("missing maintenance schedule")
|
||||
if frame.schedule.symbol != position.symbol:
|
||||
raise ValueError("position and schedule symbols must match")
|
||||
if isolated and position.isolated_margin is None:
|
||||
raise ValueError("isolated_margin is required")
|
||||
|
||||
timestamps.add(frame.timestamp)
|
||||
fidelity_flags.extend(frame.fidelity_flags)
|
||||
mark_price = _mark_price(position, frame, price_field)
|
||||
unrealized_pnl = position.quantity * (mark_price - position.entry_price)
|
||||
initial_margin = abs(position.quantity) * mark_price / position.leverage
|
||||
margin_balance = (
|
||||
position.isolated_margin + unrealized_pnl if isolated else None
|
||||
)
|
||||
risks.append(
|
||||
PositionRisk(
|
||||
symbol=position.symbol,
|
||||
mark_price=mark_price,
|
||||
notional=abs(position.quantity) * mark_price,
|
||||
unrealized_pnl=unrealized_pnl,
|
||||
initial_margin=initial_margin,
|
||||
maintenance_margin=maintenance_margin(position, mark_price, frame.schedule),
|
||||
margin_balance=margin_balance,
|
||||
)
|
||||
)
|
||||
|
||||
if len(timestamps) > 1:
|
||||
raise ValueError("position frame timestamps must match")
|
||||
if len(account.positions) > 1 and price_field == "adverse":
|
||||
fidelity_flags.append("conservative_intrabar_assumption")
|
||||
return tuple(risks), tuple(dict.fromkeys(fidelity_flags))
|
||||
|
||||
|
||||
def _risk_snapshot(
|
||||
account: AccountState,
|
||||
risks: tuple[PositionRisk, ...],
|
||||
fidelity_flags: tuple[str, ...],
|
||||
status: Literal["healthy", "position_liquidation", "account_liquidation"],
|
||||
liquidation_targets: tuple[str, ...],
|
||||
) -> RiskSnapshot:
|
||||
margin_balance = account.wallet_balance + sum(risk.unrealized_pnl for risk in risks)
|
||||
initial_margin = sum(risk.initial_margin for risk in risks)
|
||||
maintenance = sum(risk.maintenance_margin for risk in risks)
|
||||
return RiskSnapshot(
|
||||
margin_balance=margin_balance,
|
||||
initial_margin=initial_margin,
|
||||
maintenance_margin=maintenance,
|
||||
available_balance=margin_balance - initial_margin,
|
||||
per_position=risks,
|
||||
status=status,
|
||||
liquidation_targets=liquidation_targets,
|
||||
fidelity_flags=fidelity_flags,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_isolated(
|
||||
account: AccountState,
|
||||
frames: dict[str, MarketRiskFrame],
|
||||
price_field: str = "adverse",
|
||||
) -> RiskSnapshot:
|
||||
if account.margin_mode != "isolated":
|
||||
raise ValueError("account margin_mode must be 'isolated'")
|
||||
risks, fidelity_flags = _position_risks(account, frames, price_field, isolated=True)
|
||||
liquidation_targets = tuple(
|
||||
risk.symbol
|
||||
for risk in risks
|
||||
if risk.margin_balance is not None
|
||||
and risk.margin_balance <= risk.maintenance_margin
|
||||
)
|
||||
return _risk_snapshot(
|
||||
account,
|
||||
risks,
|
||||
fidelity_flags,
|
||||
"position_liquidation" if liquidation_targets else "healthy",
|
||||
liquidation_targets,
|
||||
)
|
||||
|
||||
|
||||
class CrossMarginRiskModel:
|
||||
def evaluate(
|
||||
self,
|
||||
account: AccountState,
|
||||
frames: dict[str, MarketRiskFrame],
|
||||
price_field: str = "adverse",
|
||||
) -> RiskSnapshot:
|
||||
if account.margin_mode != "cross":
|
||||
raise ValueError("account margin_mode must be 'cross'")
|
||||
if any(position.isolated_margin is not None for position in account.positions):
|
||||
raise ValueError("cross positions must not have isolated_margin")
|
||||
risks, fidelity_flags = _position_risks(
|
||||
account, frames, price_field, isolated=False
|
||||
)
|
||||
margin_balance = account.wallet_balance + sum(
|
||||
risk.unrealized_pnl for risk in risks
|
||||
)
|
||||
maintenance = sum(risk.maintenance_margin for risk in risks)
|
||||
liquidation_targets = (
|
||||
tuple(position.symbol for position in account.positions)
|
||||
if risks and margin_balance <= maintenance
|
||||
else ()
|
||||
)
|
||||
return _risk_snapshot(
|
||||
account,
|
||||
risks,
|
||||
fidelity_flags,
|
||||
"account_liquidation" if liquidation_targets else "healthy",
|
||||
liquidation_targets,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Rebalance notes: per-rebalance turnover and weight-drift detail.
|
||||
|
||||
The Portfolio Studio epic (#456) asks for per-rebalance reporting as the
|
||||
last backend slice, alongside the shipped turnover-aware optimizer and the
|
||||
risk x-ray. The notes here are computed from the target position frame, so
|
||||
they work for every optimizer and for the no-optimizer baseline: a rebalance
|
||||
is any decision date whose target weight vector moved past ``epsilon`` from
|
||||
the previous one. Trade-derived turnover in ``metrics`` measures what the
|
||||
execution layer actually exchanged; these notes measure what the signal and
|
||||
optimizer asked for, which is where churn starts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.validation import _json_safe
|
||||
|
||||
|
||||
def compute_rebalance_notes(
|
||||
target_pos: pd.DataFrame,
|
||||
*,
|
||||
top_n: int = 5,
|
||||
epsilon: float = 1e-6,
|
||||
) -> Dict[str, Any]:
|
||||
"""Summarize per-date weight changes in a target position frame.
|
||||
|
||||
Args:
|
||||
target_pos: Target weights (dates x codes), e.g. the frame behind
|
||||
``artifacts/positions.csv``. NaN cells are treated as zero.
|
||||
top_n: How many largest per-name moves to keep per rebalance.
|
||||
epsilon: Turnover at or below this counts as "no rebalance".
|
||||
|
||||
Returns:
|
||||
JSON-safe dict with ``rebalances`` (per date: turnover, entries,
|
||||
exits, top moves by absolute weight change) and ``summary`` (count
|
||||
plus turnover aggregates).
|
||||
"""
|
||||
empty = {
|
||||
"rebalances": [],
|
||||
"summary": {
|
||||
"rebalance_count": 0,
|
||||
"turnover_total": 0.0,
|
||||
"turnover_mean": 0.0,
|
||||
"turnover_max": 0.0,
|
||||
"largest_rebalance_date": None,
|
||||
},
|
||||
}
|
||||
if target_pos.empty or len(target_pos) < 2:
|
||||
return empty
|
||||
|
||||
codes = target_pos.columns.tolist()
|
||||
values = target_pos.fillna(0.0).to_numpy(dtype=float)
|
||||
|
||||
rebalances: List[Dict[str, Any]] = []
|
||||
prev = values[0]
|
||||
for i in range(1, len(values)):
|
||||
curr = values[i]
|
||||
delta = curr - prev
|
||||
turnover = 0.5 * float(np.abs(delta).sum())
|
||||
if turnover > epsilon:
|
||||
date = target_pos.index[i]
|
||||
entries = [
|
||||
{"code": codes[j], "weight": float(curr[j])}
|
||||
for j in range(len(codes))
|
||||
if abs(prev[j]) <= epsilon and abs(curr[j]) > epsilon
|
||||
]
|
||||
exits = [
|
||||
{"code": codes[j], "weight": float(prev[j])}
|
||||
for j in range(len(codes))
|
||||
if abs(curr[j]) <= epsilon and abs(prev[j]) > epsilon
|
||||
]
|
||||
moves = sorted(
|
||||
(
|
||||
{
|
||||
"code": codes[j],
|
||||
"from": float(prev[j]),
|
||||
"to": float(curr[j]),
|
||||
"delta": float(delta[j]),
|
||||
}
|
||||
for j in range(len(codes))
|
||||
if abs(delta[j]) > epsilon
|
||||
),
|
||||
key=lambda move: -abs(move["delta"]),
|
||||
)[:top_n]
|
||||
rebalances.append(
|
||||
{
|
||||
"date": str(date.date()) if hasattr(date, "date") else str(date),
|
||||
"turnover": turnover,
|
||||
"entries": entries,
|
||||
"exits": exits,
|
||||
"top_moves": moves,
|
||||
}
|
||||
)
|
||||
prev = curr
|
||||
|
||||
turnovers = [r["turnover"] for r in rebalances]
|
||||
largest = max(range(len(rebalances)), key=lambda k: turnovers[k]) if rebalances else None
|
||||
return {
|
||||
"rebalances": rebalances,
|
||||
"summary": {
|
||||
"rebalance_count": len(rebalances),
|
||||
"turnover_total": float(sum(turnovers)),
|
||||
"turnover_mean": float(np.mean(turnovers)) if turnovers else 0.0,
|
||||
"turnover_max": float(max(turnovers)) if turnovers else 0.0,
|
||||
"largest_rebalance_date": rebalances[largest]["date"] if largest is not None else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def render_rebalance_notes_markdown(notes: Dict[str, Any]) -> str:
|
||||
"""Render notes as a compact Markdown report."""
|
||||
summary = notes["summary"]
|
||||
lines = [
|
||||
"# Rebalance Notes",
|
||||
"",
|
||||
f"- rebalances: {summary['rebalance_count']}",
|
||||
f"- turnover total / mean / max: {summary['turnover_total']:.4f} / "
|
||||
f"{summary['turnover_mean']:.4f} / {summary['turnover_max']:.4f}",
|
||||
]
|
||||
if summary["largest_rebalance_date"] is not None:
|
||||
lines.append(f"- largest rebalance: {summary['largest_rebalance_date']}")
|
||||
lines.append("")
|
||||
|
||||
for rebalance in notes["rebalances"]:
|
||||
lines.append(f"## {rebalance['date']} (turnover {rebalance['turnover']:.4f})")
|
||||
if rebalance["entries"]:
|
||||
joined = ", ".join(item["code"] for item in rebalance["entries"])
|
||||
lines.append(f"- entries: {joined}")
|
||||
if rebalance["exits"]:
|
||||
joined = ", ".join(item["code"] for item in rebalance["exits"])
|
||||
lines.append(f"- exits: {joined}")
|
||||
for move in rebalance["top_moves"]:
|
||||
lines.append(f"- {move['code']}: {move['from']:.4f} -> {move['to']:.4f} ({move['delta']:+.4f})")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_rebalance_notes(path: Path, notes: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Write notes to ``path`` as strict, RFC-8259 JSON.
|
||||
|
||||
Mirrors :func:`backtest.validation.write_validation_json`: sanitize with
|
||||
``_json_safe`` (non-finite -> null) and serialize with ``allow_nan=False``
|
||||
so every strict parser accepts the artifact. Returns the sanitized
|
||||
payload that was written.
|
||||
"""
|
||||
safe_notes = _json_safe(notes)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(safe_notes, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return safe_notes
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Correlation-regime timeline: edge density + hysteresis over time.
|
||||
|
||||
Implements Mode 1 of the ``correlation-regime`` skill on top of the same
|
||||
price data the /correlation endpoint uses: rolling pairwise correlations are
|
||||
reduced to an edge-density scalar per bar, the density series is smoothed
|
||||
with a trailing (causal) window, and a two-threshold hysteresis state
|
||||
machine labels each bar FUSED or not. Used by the /correlation/regime API
|
||||
endpoint. Descriptive risk context — not a trading signal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.correlation import _close_series, _fetch_price_series
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compute_edge_density(
|
||||
returns: pd.DataFrame,
|
||||
corr_window: int = 60,
|
||||
edge_threshold: float = 0.5,
|
||||
) -> pd.Series:
|
||||
"""Reduce rolling correlation matrices to an edge-density series.
|
||||
|
||||
Edge density is the fraction of distinct asset pairs whose rolling
|
||||
|correlation| clears ``edge_threshold`` — a scalar "how fused is the
|
||||
market" gauge in [0, 1].
|
||||
|
||||
Args:
|
||||
returns: Multi-asset return matrix, columns are symbols
|
||||
corr_window: Rolling window length (bars) for pairwise correlation
|
||||
edge_threshold: |ρ| level at which a pair counts as an "edge"
|
||||
|
||||
Returns:
|
||||
Edge-density series aligned to ``returns.index`` (NaN during warmup)
|
||||
"""
|
||||
n_assets = returns.shape[1]
|
||||
n_pairs = n_assets * (n_assets - 1) // 2
|
||||
if n_pairs == 0:
|
||||
return pd.Series(np.nan, index=returns.index)
|
||||
upper_mask = np.triu(np.ones((n_assets, n_assets), dtype=bool), k=1)
|
||||
|
||||
density = pd.Series(np.nan, index=returns.index)
|
||||
for i in range(corr_window, len(returns) + 1):
|
||||
corr = returns.iloc[i - corr_window:i].corr().abs().to_numpy()
|
||||
density.iloc[i - 1] = float((corr[upper_mask] >= edge_threshold).sum()) / n_pairs
|
||||
return density
|
||||
|
||||
|
||||
def detect_regimes(
|
||||
density: pd.Series,
|
||||
smooth_window: int = 5,
|
||||
enter_threshold: float = 0.65,
|
||||
exit_threshold: float = 0.45,
|
||||
) -> pd.DataFrame:
|
||||
"""Hysteresis (Schmitt-trigger) regime state machine on smoothed density.
|
||||
|
||||
The market is FUSED once smoothed density reaches ``enter_threshold`` and
|
||||
stays FUSED until it falls back to ``exit_threshold``. The dead band
|
||||
between the two thresholds is what suppresses chatter.
|
||||
|
||||
Args:
|
||||
density: Edge-density series from :func:`compute_edge_density`
|
||||
smooth_window: Trailing smoothing window (causal; never centered)
|
||||
enter_threshold: Density level that opens a FUSED regime
|
||||
exit_threshold: Density level that closes it (must be < enter_threshold)
|
||||
|
||||
Returns:
|
||||
DataFrame with columns ``density``, ``smoothed``, ``fused`` (0/1)
|
||||
"""
|
||||
if exit_threshold >= enter_threshold:
|
||||
raise ValueError("exit_threshold must be below enter_threshold")
|
||||
|
||||
# Trailing mean = causal. A centered window here silently reads the future.
|
||||
smoothed = density.rolling(smooth_window, min_periods=1).mean()
|
||||
|
||||
fused = False
|
||||
states = np.zeros(len(smoothed), dtype=int)
|
||||
for i, value in enumerate(smoothed.to_numpy()):
|
||||
if np.isnan(value):
|
||||
states[i] = int(fused)
|
||||
continue
|
||||
if not fused and value >= enter_threshold:
|
||||
fused = True
|
||||
elif fused and value <= exit_threshold:
|
||||
fused = False
|
||||
states[i] = int(fused)
|
||||
|
||||
return pd.DataFrame(
|
||||
{"density": density, "smoothed": smoothed, "fused": states},
|
||||
index=density.index,
|
||||
)
|
||||
|
||||
|
||||
def _aligned_returns(price_series: Dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
"""Build a date-aligned daily-returns frame (one column per code).
|
||||
|
||||
Mirrors the alignment in ``_rolling_correlation_matrix``: indexes are
|
||||
normalized to midnight so cross-market assets (e.g. crypto at UTC
|
||||
midnight vs US equity at EDT midnight) share dates, then inner-joined.
|
||||
``fill_method=None`` is explicit because under the project's
|
||||
pandas>=2,<3 pin the ``pct_change`` default forward-fills missing
|
||||
prices, silently manufacturing 0% returns.
|
||||
"""
|
||||
returns_frames = []
|
||||
for code in sorted(price_series):
|
||||
ts = _close_series(code, price_series[code])
|
||||
ts.index = ts.index.normalize()
|
||||
rets = ts.pct_change(fill_method=None).dropna()
|
||||
rets.name = code
|
||||
returns_frames.append(rets)
|
||||
|
||||
aligned = pd.concat(returns_frames, axis=1).dropna()
|
||||
if aligned.empty:
|
||||
raise ValueError("No overlapping return data between assets")
|
||||
return aligned
|
||||
|
||||
|
||||
def _fused_episodes(dates: list[str], fused: list[int]) -> list[Dict[str, Optional[str]]]:
|
||||
"""Contiguous FUSED intervals within the returned window.
|
||||
|
||||
``end`` is the last date observed FUSED, or None while the final bar is
|
||||
still FUSED (episode ongoing).
|
||||
"""
|
||||
episodes: list[Dict[str, Optional[str]]] = []
|
||||
start: Optional[str] = None
|
||||
last_fused: Optional[str] = None
|
||||
for date, state in zip(dates, fused):
|
||||
if state:
|
||||
if start is None:
|
||||
start = date
|
||||
last_fused = date
|
||||
elif start is not None:
|
||||
episodes.append({"start": start, "end": last_fused})
|
||||
start = None
|
||||
if start is not None:
|
||||
episodes.append({"start": start, "end": None})
|
||||
return episodes
|
||||
|
||||
|
||||
def compute_regime_timeline(
|
||||
codes: list[str],
|
||||
days: int = 90,
|
||||
corr_window: int = 60,
|
||||
edge_threshold: float = 0.5,
|
||||
smooth_window: int = 5,
|
||||
enter_threshold: float = 0.65,
|
||||
exit_threshold: float = 0.45,
|
||||
) -> Dict[str, object]:
|
||||
"""Fetch price data and compute the correlation-regime timeline.
|
||||
|
||||
Args:
|
||||
codes: List of asset codes, as for :func:`compute_correlation_matrix`.
|
||||
days: Number of trailing timeline bars to return.
|
||||
corr_window: Rolling window (bars) for pairwise correlation.
|
||||
edge_threshold: |ρ| level at which a pair counts as an "edge".
|
||||
smooth_window: Trailing smoothing window (bars) for the density series.
|
||||
enter_threshold: Smoothed density that opens a FUSED regime.
|
||||
exit_threshold: Smoothed density that closes it (must be below
|
||||
``enter_threshold``).
|
||||
|
||||
Returns:
|
||||
Dict with keys: labels, dates, density, smoothed, fused, episodes,
|
||||
params. ``density``/``smoothed`` use None for warmup bars; ``fused``
|
||||
is 0/1 per bar; ``episodes`` lists FUSED intervals with ``end=None``
|
||||
while the final bar is still FUSED.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
if exit_threshold >= enter_threshold:
|
||||
raise ValueError("exit_threshold must be below enter_threshold")
|
||||
|
||||
# Each returned bar needs a full corr_window of history behind it, so the
|
||||
# warmup falls outside the returned window: extend /correlation's +60
|
||||
# calendar-day fetch buffer by the correlation window (and a margin for
|
||||
# non-trading days).
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (
|
||||
datetime.now() - timedelta(days=days + corr_window + 90)
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
price_series = _fetch_price_series(codes, start_date, end_date)
|
||||
|
||||
if len(price_series) < 2:
|
||||
raise ValueError(
|
||||
f"Could not fetch price data for at least 2 assets. "
|
||||
f"Fetched: {list(price_series.keys())}"
|
||||
)
|
||||
|
||||
returns = _aligned_returns(price_series)
|
||||
density = compute_edge_density(
|
||||
returns, corr_window=corr_window, edge_threshold=edge_threshold
|
||||
)
|
||||
regimes = detect_regimes(
|
||||
density,
|
||||
smooth_window=smooth_window,
|
||||
enter_threshold=enter_threshold,
|
||||
exit_threshold=exit_threshold,
|
||||
)
|
||||
# Trim to the requested window only after the state machine has run, so
|
||||
# the regime state at the window's first bar reflects the full history.
|
||||
if len(regimes) > days:
|
||||
regimes = regimes.iloc[-days:]
|
||||
|
||||
dates = [d.strftime("%Y-%m-%d") for d in regimes.index]
|
||||
density_out = [
|
||||
None if np.isnan(v) else round(float(v), 4) for v in regimes["density"]
|
||||
]
|
||||
smoothed_out = [
|
||||
None if np.isnan(v) else round(float(v), 4) for v in regimes["smoothed"]
|
||||
]
|
||||
fused_out = [int(v) for v in regimes["fused"]]
|
||||
|
||||
return {
|
||||
"labels": list(returns.columns),
|
||||
"dates": dates,
|
||||
"density": density_out,
|
||||
"smoothed": smoothed_out,
|
||||
"fused": fused_out,
|
||||
"episodes": _fused_episodes(dates, fused_out),
|
||||
"params": {
|
||||
"days": days,
|
||||
"corr_window": corr_window,
|
||||
"edge_threshold": edge_threshold,
|
||||
"smooth_window": smooth_window,
|
||||
"enter_threshold": enter_threshold,
|
||||
"exit_threshold": exit_threshold,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Portfolio risk x-ray: concentration, volatility, drawdown, tail risk, and
|
||||
co-movement for a weighted basket, computed from close-price panels.
|
||||
|
||||
This is the Portfolio Studio risk-x-ray backend (roadmap slice). Everything
|
||||
here is a pure function of prices and weights — no I/O, no network, no
|
||||
loader imports — so the same computation serves the agent tool, the API,
|
||||
and tests. Data fetching lives in the caller.
|
||||
|
||||
Conventions:
|
||||
- Long-only v1: negative weights are rejected up front rather than silently
|
||||
mis-computing concentration (a short leg is a different feature).
|
||||
- Non-finite inputs are never trusted and non-finite outputs are never
|
||||
emitted: every reported float passes through ``_finite`` so the result
|
||||
survives ``json.dumps(..., allow_nan=False)``.
|
||||
- Look-ahead is structurally impossible: all statistics are computed over
|
||||
trailing returns only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.validation import _json_safe
|
||||
|
||||
MIN_HISTORY_DAYS = 30
|
||||
PERIODS_PER_YEAR = 252
|
||||
VAR_LEVELS = (0.95, 0.99)
|
||||
|
||||
|
||||
def _finite(value: float | None) -> float | None:
|
||||
"""Return ``value`` when finite, else ``None`` (strict-JSON safe)."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
out = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return out if math.isfinite(out) else None
|
||||
|
||||
|
||||
def _validate_weights(
|
||||
closes: pd.DataFrame, weights: Mapping[str, float]
|
||||
) -> tuple[dict[str, float], list[str]]:
|
||||
"""Normalize weights to sum 1; reject unknown symbols and bad values.
|
||||
|
||||
Returns the cleaned weight map (restricted to available columns) and any
|
||||
warnings worth surfacing to the caller.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
if not weights:
|
||||
raise ValueError("weights must name at least one symbol")
|
||||
|
||||
unknown = [sym for sym in weights if sym not in closes.columns]
|
||||
if unknown:
|
||||
raise ValueError(f"weights reference symbols with no price data: {sorted(unknown)}")
|
||||
|
||||
cleaned: dict[str, float] = {}
|
||||
for sym, raw in weights.items():
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"weight for {sym!r} is not a number: {raw!r}") from exc
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"weight for {sym!r} is not finite: {raw!r}")
|
||||
if value < 0:
|
||||
raise ValueError(
|
||||
f"weight for {sym!r} is negative ({value}); the risk x-ray is long-only for now"
|
||||
)
|
||||
cleaned[sym] = value
|
||||
|
||||
total = sum(cleaned.values())
|
||||
if total <= 0:
|
||||
raise ValueError("weights must sum to a positive value")
|
||||
if abs(total - 1.0) > 1e-6:
|
||||
warnings.append(f"weights summed to {total:.6f}; renormalized to 1.0")
|
||||
cleaned = {sym: value / total for sym, value in cleaned.items()}
|
||||
return cleaned, warnings
|
||||
|
||||
|
||||
def compute_risk_xray(
|
||||
closes: pd.DataFrame,
|
||||
weights: Mapping[str, float],
|
||||
*,
|
||||
periods_per_year: int = PERIODS_PER_YEAR,
|
||||
var_levels: Sequence[float] = VAR_LEVELS,
|
||||
min_history: int = MIN_HISTORY_DAYS,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute the risk x-ray for a weighted basket.
|
||||
|
||||
Args:
|
||||
closes: Close-price panel, one column per symbol, sorted by date.
|
||||
weights: Symbol → weight. Renormalized to 1.0 with a warning when the
|
||||
sum differs; must be long-only and reference existing columns.
|
||||
periods_per_year: Annualization factor for the bar interval.
|
||||
var_levels: Tail levels for historical VaR / expected shortfall.
|
||||
min_history: Minimum valid bars a symbol must have to be included.
|
||||
|
||||
Returns:
|
||||
A strict-JSON-safe dict (``json.dumps(..., allow_nan=False)`` must
|
||||
never fail on it) with concentration, volatility, drawdown, tail
|
||||
risk, diversification, and correlation sections, plus ``skipped``
|
||||
and ``warnings`` bookkeeping.
|
||||
|
||||
Raises:
|
||||
ValueError: On unusable input (empty panel, bad weights, or nothing
|
||||
left after the history filter / calendar alignment).
|
||||
"""
|
||||
if closes is None or closes.empty:
|
||||
raise ValueError("price panel is empty")
|
||||
frame = closes.dropna(axis=1, how="all")
|
||||
if frame.empty:
|
||||
raise ValueError("price panel has no non-NaN closes")
|
||||
|
||||
weights, warnings = _validate_weights(frame, weights)
|
||||
|
||||
# History filter: thin symbols are excluded rather than allowed to skew
|
||||
# the whole x-ray on a handful of bars.
|
||||
kept: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for sym in weights:
|
||||
valid = int(frame[sym].count())
|
||||
if valid < min_history:
|
||||
skipped.append({"symbol": sym, "reason": f"only {valid} valid bars (min {min_history})"})
|
||||
else:
|
||||
kept.append(sym)
|
||||
if not kept:
|
||||
raise ValueError(f"no symbol has at least {min_history} valid bars")
|
||||
if skipped:
|
||||
# Renormalize over the survivors so the x-ray still describes a
|
||||
# fully invested basket, and say so.
|
||||
kept_weights = {sym: weights[sym] for sym in kept}
|
||||
total = sum(kept_weights.values())
|
||||
if total <= 0:
|
||||
raise ValueError("surviving symbols have zero total weight")
|
||||
weights = {sym: value / total for sym, value in kept_weights.items()}
|
||||
warnings.append("weights renormalized over symbols that survived the history filter")
|
||||
|
||||
aligned = frame[kept].dropna(axis=0, how="any")
|
||||
if len(aligned) < 2:
|
||||
raise ValueError(
|
||||
"fewer than 2 shared trading days after aligning calendars across symbols"
|
||||
)
|
||||
|
||||
returns = aligned.pct_change(fill_method=None).dropna(how="any")
|
||||
if returns.empty:
|
||||
raise ValueError("no overlapping return observations across symbols")
|
||||
|
||||
w = np.array([weights[sym] for sym in kept], dtype=float)
|
||||
port = returns.to_numpy(dtype=float) @ w
|
||||
port_returns = pd.Series(port, index=returns.index)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"inputs": {
|
||||
"symbols": kept,
|
||||
"weights": {sym: round(float(weights[sym]), 8) for sym in kept},
|
||||
"aligned_days": int(len(aligned)),
|
||||
"return_observations": int(len(returns)),
|
||||
"first_date": str(aligned.index[0]),
|
||||
"last_date": str(aligned.index[-1]),
|
||||
},
|
||||
"concentration": _concentration(w),
|
||||
"volatility": _volatility(port_returns, periods_per_year),
|
||||
"drawdown": _drawdown(port_returns),
|
||||
"tail_risk": _tail_risk(port_returns, var_levels),
|
||||
"diversification": _diversification(returns, w, port_returns, periods_per_year),
|
||||
"correlation": _correlation(returns, port_returns),
|
||||
"skipped": skipped,
|
||||
"warnings": warnings,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _concentration(w: np.ndarray) -> dict[str, Any]:
|
||||
hhi = float(np.sum(w**2))
|
||||
order = np.argsort(w)[::-1]
|
||||
return {
|
||||
"hhi": _finite(hhi),
|
||||
"effective_n": _finite(1.0 / hhi) if hhi > 0 else None,
|
||||
"top1_weight": _finite(float(w[order[0]])) if len(order) else None,
|
||||
"top3_weight": _finite(float(w[order[:3]].sum())) if len(order) else None,
|
||||
}
|
||||
|
||||
|
||||
def _volatility(port: pd.Series, ppy: int) -> dict[str, Any]:
|
||||
vol = float(port.std(ddof=1)) if len(port) > 1 else None
|
||||
downside = port[port < 0]
|
||||
downside_dev = float(downside.std(ddof=1)) if len(downside) > 1 else None
|
||||
return {
|
||||
"daily_vol": _finite(vol),
|
||||
"annualized_vol": _finite(vol * math.sqrt(ppy)) if vol is not None else None,
|
||||
"downside_deviation_annualized": (
|
||||
_finite(downside_dev * math.sqrt(ppy)) if downside_dev is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _drawdown(port: pd.Series) -> dict[str, Any]:
|
||||
if port.empty:
|
||||
return {"max_drawdown": None, "max_drawdown_start": None, "max_drawdown_trough": None}
|
||||
equity = (1.0 + port).cumprod()
|
||||
peak = equity.cummax()
|
||||
dd = equity / peak - 1.0
|
||||
trough_idx = dd.idxmin()
|
||||
start_idx = equity.loc[:trough_idx].idxmax()
|
||||
return {
|
||||
"max_drawdown": _finite(float(dd.loc[trough_idx])),
|
||||
"max_drawdown_start": str(start_idx),
|
||||
"max_drawdown_trough": str(trough_idx),
|
||||
}
|
||||
|
||||
|
||||
def _tail_risk(port: pd.Series, levels: Sequence[float]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
losses = -port.to_numpy(dtype=float)
|
||||
for level in levels:
|
||||
if len(losses) < 2:
|
||||
var = es = None
|
||||
else:
|
||||
var = float(np.quantile(losses, level))
|
||||
tail = losses[losses >= var]
|
||||
es = float(tail.mean()) if len(tail) else None
|
||||
key = f"{int(round(level * 100))}"
|
||||
out[f"var_{key}"] = _finite(var)
|
||||
out[f"expected_shortfall_{key}"] = _finite(es)
|
||||
out["method"] = "historical simulation (non-parametric)"
|
||||
return out
|
||||
|
||||
|
||||
def _diversification(
|
||||
returns: pd.DataFrame, w: np.ndarray, port: pd.Series, ppy: int
|
||||
) -> dict[str, Any]:
|
||||
if returns.shape[1] < 2:
|
||||
return {"diversification_ratio": None, "note": "needs at least 2 assets"}
|
||||
asset_vols = returns.std(ddof=1).to_numpy(dtype=float)
|
||||
port_vol = float(port.std(ddof=1)) if len(port) > 1 else 0.0
|
||||
if port_vol <= 0 or not math.isfinite(port_vol):
|
||||
return {"diversification_ratio": None, "note": "portfolio variance is zero"}
|
||||
ratio = float(np.dot(w, asset_vols) / port_vol)
|
||||
return {"diversification_ratio": _finite(ratio)}
|
||||
|
||||
|
||||
def _correlation(returns: pd.DataFrame, port: pd.Series) -> dict[str, Any]:
|
||||
if returns.shape[1] < 2:
|
||||
return {
|
||||
"avg_pairwise_abs": None,
|
||||
"max_pair": None,
|
||||
"beta_to_equal_weight": None,
|
||||
"note": "needs at least 2 assets",
|
||||
}
|
||||
corr = returns.corr().to_numpy(dtype=float)
|
||||
n = corr.shape[0]
|
||||
off_diag = [abs(corr[i, j]) for i in range(n) for j in range(i + 1, n)]
|
||||
avg_pairwise = float(np.mean(off_diag)) if off_diag else None
|
||||
|
||||
max_pair = None
|
||||
if off_diag:
|
||||
i, j = max(
|
||||
((i, j) for i in range(n) for j in range(i + 1, n)),
|
||||
key=lambda pair: abs(corr[pair[0], pair[1]]),
|
||||
)
|
||||
max_pair = {
|
||||
"symbols": [str(returns.columns[i]), str(returns.columns[j])],
|
||||
"corr": _finite(float(corr[i, j])),
|
||||
}
|
||||
|
||||
market = returns.mean(axis=1)
|
||||
market_var = float(market.var(ddof=1)) if len(market) > 1 else 0.0
|
||||
if market_var > 0 and math.isfinite(market_var):
|
||||
beta = float(port.cov(market) / market_var)
|
||||
else:
|
||||
beta = None
|
||||
|
||||
return {
|
||||
"avg_pairwise_abs": _finite(avg_pairwise),
|
||||
"max_pair": max_pair,
|
||||
"beta_to_equal_weight": _finite(beta),
|
||||
}
|
||||
|
||||
|
||||
def average_invested_weights(target_pos: pd.DataFrame) -> tuple[dict[str, float], float]:
|
||||
"""Derive the run's average basket from the target position frame.
|
||||
|
||||
Returns ``(weights, avg_invested)``: the mean target weight per symbol
|
||||
restricted to symbols the strategy actually held on average, and the mean
|
||||
row sum (how much of the book was invested at all). The final row alone
|
||||
would be the wrong object here, since many strategies end flat.
|
||||
|
||||
Raises:
|
||||
ValueError: when the frame is empty, the strategy never held
|
||||
anything on average, or any symbol's average exposure is net
|
||||
short — ``compute_risk_xray`` is long-only, and silently
|
||||
x-raying just the long half of a long-short book would present
|
||||
a partial basket as the whole strategy.
|
||||
"""
|
||||
if target_pos is None or target_pos.empty:
|
||||
raise ValueError("target position frame is empty")
|
||||
means = target_pos.mean(axis=0)
|
||||
short_book = [str(sym) for sym, w in means.items() if float(w) < 0]
|
||||
if short_book:
|
||||
raise ValueError(
|
||||
"long-only x-ray cannot describe net short average exposure "
|
||||
f"in {', '.join(short_book)}"
|
||||
)
|
||||
weights = {str(sym): float(w) for sym, w in means.items() if float(w) > 0}
|
||||
avg_invested = float(target_pos.sum(axis=1).mean())
|
||||
if not weights:
|
||||
raise ValueError("strategy held no average exposure")
|
||||
return weights, avg_invested
|
||||
|
||||
|
||||
def render_risk_xray_markdown(report: dict[str, Any]) -> str:
|
||||
"""Render an x-ray report as a compact Markdown summary."""
|
||||
inputs = report["inputs"]
|
||||
conc = report["concentration"]
|
||||
vol = report["volatility"]
|
||||
dd = report["drawdown"]
|
||||
tail = report["tail_risk"]
|
||||
lines = [
|
||||
"# Portfolio Risk X-Ray",
|
||||
"",
|
||||
f"- basket: {', '.join(inputs['symbols'])}",
|
||||
f"- window: {inputs['first_date']} .. {inputs['last_date']} "
|
||||
f"({inputs['aligned_days']} aligned days)",
|
||||
]
|
||||
if conc.get("hhi") is not None:
|
||||
lines.append(
|
||||
f"- concentration: hhi {conc['hhi']:.4f}, "
|
||||
f"effective n {conc['effective_n']:.2f}, "
|
||||
f"top1 {conc['top1_weight']:.2%}, top3 {conc['top3_weight']:.2%}"
|
||||
)
|
||||
if vol.get("annualized_vol") is not None:
|
||||
lines.append(f"- annualized vol: {vol['annualized_vol']:.2%}")
|
||||
if dd.get("max_drawdown") is not None:
|
||||
lines.append(f"- max drawdown: {dd['max_drawdown']:.2%}")
|
||||
if tail.get("var_95") is not None:
|
||||
lines.append(
|
||||
f"- tail (historical): VaR95 {tail['var_95']:.2%}, ES95 {tail['expected_shortfall_95']:.2%}"
|
||||
)
|
||||
if report["skipped"]:
|
||||
joined = ", ".join(item["symbol"] for item in report["skipped"])
|
||||
lines.append(f"- skipped: {joined}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_risk_xray(path: Path, report: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Write the report to ``path`` as strict, RFC-8259 JSON.
|
||||
|
||||
Same contract as ``write_rebalance_notes``: sanitize with ``_json_safe``
|
||||
and serialize with ``allow_nan=False``. Returns the sanitized payload.
|
||||
"""
|
||||
safe_report = _json_safe(report)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(safe_report, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return safe_report
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
@@ -29,6 +30,7 @@ def write_run_card(
|
||||
data_sources: Sequence[str] | None = None,
|
||||
strategy_path: Path | None = None,
|
||||
warnings: Sequence[str] | None = None,
|
||||
artifact_refs: Sequence[Mapping[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write JSON and Markdown run cards for a backtest run.
|
||||
|
||||
@@ -40,6 +42,7 @@ def write_run_card(
|
||||
data_sources: Data sources used by the run.
|
||||
strategy_path: Optional strategy source file to hash for reproducibility.
|
||||
warnings: Optional warnings to include in the card.
|
||||
artifact_refs: Optional IRR-AGL artifact references.
|
||||
|
||||
Returns:
|
||||
The run card payload written to ``run_card.json``.
|
||||
@@ -67,13 +70,25 @@ def write_run_card(
|
||||
"warnings": list(warnings or []),
|
||||
"artifacts": _list_artifacts(run_dir),
|
||||
}
|
||||
normalized_refs = _normalize_artifact_refs(artifact_refs)
|
||||
if normalized_refs:
|
||||
card["artifact_refs"] = normalized_refs
|
||||
if "validation" in metrics:
|
||||
card["validation"] = metrics["validation"]
|
||||
|
||||
card = _json_safe(card)
|
||||
json_path = run_dir / "run_card.json"
|
||||
md_path = run_dir / "run_card.md"
|
||||
json_path.write_text(
|
||||
json.dumps(card, ensure_ascii=False, indent=2, sort_keys=True, default=str) + "\n",
|
||||
json.dumps(
|
||||
card,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
md_path.write_text(_render_markdown(card), encoding="utf-8")
|
||||
@@ -115,6 +130,16 @@ def _scalar_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _is_scalar(value: Any) -> bool:
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
|
||||
@@ -142,6 +167,17 @@ def _list_artifacts(run_dir: Path) -> list[dict[str, Any]]:
|
||||
return artifacts
|
||||
|
||||
|
||||
def _normalize_artifact_refs(artifact_refs: Sequence[Mapping[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
refs: list[dict[str, Any]] = []
|
||||
for ref in artifact_refs or []:
|
||||
if hasattr(ref, "model_dump"):
|
||||
value = ref.model_dump(mode="json") # type: ignore[attr-defined]
|
||||
else:
|
||||
value = dict(ref)
|
||||
refs.append(_json_safe(value))
|
||||
return refs
|
||||
|
||||
|
||||
def _render_markdown(card: Mapping[str, Any]) -> str:
|
||||
lines = [
|
||||
"# Backtest Run Card",
|
||||
@@ -197,4 +233,17 @@ def _render_markdown(card: Mapping[str, Any]) -> str:
|
||||
else:
|
||||
lines.append("- None found.")
|
||||
|
||||
artifact_refs = card.get("artifact_refs", [])
|
||||
if artifact_refs:
|
||||
lines.extend(["", "## IRR Artifact Refs"])
|
||||
for ref in artifact_refs:
|
||||
if isinstance(ref, Mapping):
|
||||
label = ref.get("artifact_id", "")
|
||||
artifact_type = ref.get("artifact_type", "")
|
||||
digest = ref.get("sha256", "")
|
||||
uri = ref.get("uri", "")
|
||||
lines.append(f"- `{label}` ({artifact_type}, sha256 `{digest}`, uri `{uri}`)")
|
||||
else:
|
||||
lines.append(f"- {ref}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
+714
-66
@@ -8,16 +8,18 @@ Usage: ``python -m backtest.runner <run_dir>``
|
||||
"""
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator, field_validator
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
@@ -28,10 +30,11 @@ except ImportError:
|
||||
from backtest.loaders.registry import (
|
||||
FALLBACK_CHAINS,
|
||||
LOADER_REGISTRY,
|
||||
VALID_SOURCES,
|
||||
get_loader_cls_with_fallback,
|
||||
resolve_loader,
|
||||
)
|
||||
from backtest.loaders.base import NoAvailableSourceError
|
||||
from backtest.loaders.base import NoAvailableSourceError, validate_ohlc
|
||||
# Symbol classification lives in ``_market_hooks`` so runner.py and
|
||||
# composite.py share a single source of truth (audit-2026-05-18 B1+C1+C2).
|
||||
# ``_detect_market`` is also re-exported here for back-compat with
|
||||
@@ -47,7 +50,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_INTERVALS = {"1m", "5m", "15m", "30m", "1H", "4H", "1D"}
|
||||
_VALID_ENGINES = {"daily", "options"}
|
||||
_VALID_SOURCES = {"tushare", "okx", "yfinance", "akshare", "ccxt", "auto"}
|
||||
_PRICE_PANEL_COLUMNS = ("open", "high", "low", "close", "volume", "vwap", "amount")
|
||||
_FUND_PREFIX = "fund:"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataFetchResult:
|
||||
"""Market data plus the routing metadata selected by the central registry."""
|
||||
|
||||
data_map: Dict[str, pd.DataFrame]
|
||||
codes: List[str]
|
||||
source: str
|
||||
loader: Any
|
||||
effective_sources: List[str]
|
||||
|
||||
|
||||
class BacktestConfigSchema(BaseModel):
|
||||
@@ -61,7 +76,12 @@ class BacktestConfigSchema(BaseModel):
|
||||
source: str = "tushare"
|
||||
interval: str = "1D"
|
||||
engine: str = "daily"
|
||||
# Returns divide by initial_cash, so a non-positive value yields inf/NaN
|
||||
# metrics (total_return, annual_return, ...). Reject it at the config
|
||||
# boundary instead of letting the run produce non-finite results.
|
||||
initial_cash: float = Field(default=1_000_000, gt=0, allow_inf_nan=False)
|
||||
fundamental_fields: Optional[Dict[str, List[str]]] = None
|
||||
event_feeds: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
@field_validator("codes")
|
||||
@classmethod
|
||||
@@ -98,8 +118,8 @@ class BacktestConfigSchema(BaseModel):
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def valid_source(cls, v: str) -> str:
|
||||
if v not in _VALID_SOURCES:
|
||||
raise ValueError(f"unsupported source {v!r}, must be one of {_VALID_SOURCES}")
|
||||
if v not in VALID_SOURCES:
|
||||
raise ValueError(f"unsupported source {v!r}, must be one of {VALID_SOURCES}")
|
||||
return v
|
||||
|
||||
@field_validator("fundamental_fields")
|
||||
@@ -117,6 +137,21 @@ class BacktestConfigSchema(BaseModel):
|
||||
raise ValueError("fundamental_fields field names must be non-empty strings")
|
||||
return v
|
||||
|
||||
@field_validator("event_feeds")
|
||||
@classmethod
|
||||
def valid_event_feeds(cls, v: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
|
||||
if v is None:
|
||||
return v
|
||||
for entry in v:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(
|
||||
"each event_feeds entry must be an object with name/route_template/event_type"
|
||||
)
|
||||
for key in ("name", "route_template", "event_type"):
|
||||
if not str(entry.get(key, "")).strip():
|
||||
raise ValueError(f"event_feeds entry missing required field: {key}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def start_before_end(self) -> "BacktestConfigSchema":
|
||||
if pd.Timestamp(self.start_date) > pd.Timestamp(self.end_date):
|
||||
@@ -224,6 +259,214 @@ def _validate_class_body(node: ast.ClassDef) -> None:
|
||||
)
|
||||
|
||||
|
||||
# --- Runtime-reachable operation scrubber (VT-001 defense-in-depth) ---
|
||||
#
|
||||
# The structural checks above only reject *import-time* execution. The real
|
||||
# exposure is that once ``SignalEngine`` is instantiated and ``.generate()`` is
|
||||
# called, arbitrary code inside its method bodies runs with no runtime sandbox.
|
||||
# This scrubber walks the code that actually executes during a backtest — every
|
||||
# ``SignalEngine`` method plus any module-level helper transitively called from
|
||||
# one — and rejects network / process-spawn / dynamic-exec / filesystem-write
|
||||
# operations there.
|
||||
#
|
||||
# It is deliberately scoped to *reachable* code, not the whole file: the bundled
|
||||
# skill examples (agent/src/skills/*/example_signal_engine.py) legitimately carry
|
||||
# ``import requests`` + ``requests.get`` inside standalone ``_fetch_okx`` helpers
|
||||
# and ``if __name__ == "__main__"`` demo blocks that the runner never executes
|
||||
# (it does ``import module; SignalEngine().generate(data_map)``). Blocking those
|
||||
# imports file-wide would reject strategies generated from ~12 shipped skills, so
|
||||
# we block the dangerous *use* along the executed path instead of a harmless
|
||||
# unused top-level import. Direct ``getattr``/``setattr``/``delattr`` indirection
|
||||
# onto ``os`` / forbidden modules is now rejected (see _reject_forbidden_getattr);
|
||||
# exotic reach via ``builtins.getattr`` or aliasing remains a documented residual
|
||||
# (VT-001) — this is defense-in-depth, not a kernel-level guarantee.
|
||||
_FORBIDDEN_IMPORT_MODULES = frozenset(
|
||||
{
|
||||
"socket",
|
||||
"socketserver",
|
||||
"subprocess",
|
||||
"urllib",
|
||||
"urllib2",
|
||||
"urllib3",
|
||||
"http",
|
||||
"requests",
|
||||
"httpx",
|
||||
"aiohttp",
|
||||
"ftplib",
|
||||
"smtplib",
|
||||
"telnetlib",
|
||||
"multiprocessing",
|
||||
"ctypes",
|
||||
}
|
||||
)
|
||||
# ``os`` itself is allowed (os.path etc.), but these attributes shell out, spawn,
|
||||
# or read the process environment — none has a place in a signal engine.
|
||||
_FORBIDDEN_OS_ATTRS = frozenset(
|
||||
{
|
||||
"system",
|
||||
"popen",
|
||||
"popen2",
|
||||
"popen3",
|
||||
"popen4",
|
||||
"fork",
|
||||
"forkpty",
|
||||
"putenv",
|
||||
"unsetenv",
|
||||
"getenv",
|
||||
"environ",
|
||||
"environb",
|
||||
"startfile",
|
||||
}
|
||||
)
|
||||
_FORBIDDEN_BUILTINS = frozenset(
|
||||
{"eval", "exec", "compile", "__import__", "globals", "locals", "vars", "breakpoint"}
|
||||
)
|
||||
# getattr/setattr/delattr can indirect around the attribute scanner
|
||||
# (``getattr(os, "system")("id")``). We reject them ONLY when the target object
|
||||
# is ``os`` or a forbidden module — keyed off the target, not the attribute
|
||||
# string, so ``getattr(os, "sys" + "tem")`` is caught too. Legitimate dynamic
|
||||
# access on user objects (``getattr(tech, name, None)``, ``getattr(self, x)``)
|
||||
# is unaffected.
|
||||
_GETATTR_INDIRECTION = frozenset({"getattr", "setattr", "delattr"})
|
||||
_OPEN_WRITE_MODE_CHARS = frozenset("wax+")
|
||||
_SCRUB_MSG = "is not allowed inside generated strategy code"
|
||||
|
||||
|
||||
def _is_forbidden_os_attr(attr: str) -> bool:
|
||||
"""Return whether ``os.<attr>`` shells out, spawns, execs, or reads env."""
|
||||
return attr in _FORBIDDEN_OS_ATTRS or attr.startswith(("spawn", "exec"))
|
||||
|
||||
|
||||
def _attribute_root_name(node: ast.Attribute) -> str | None:
|
||||
"""Return the leftmost ``Name`` id of an attribute chain (``a.b.c`` -> ``a``)."""
|
||||
current: ast.AST = node
|
||||
while isinstance(current, ast.Attribute):
|
||||
current = current.value
|
||||
return current.id if isinstance(current, ast.Name) else None
|
||||
|
||||
|
||||
def _reject_forbidden_open(node: ast.Call) -> None:
|
||||
"""Reject ``open()`` used to write files or read a non-relative-literal path."""
|
||||
func = node.func
|
||||
is_builtin_open = isinstance(func, ast.Name) and func.id == "open"
|
||||
is_io_os_open = (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "open"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id in {"io", "os"}
|
||||
)
|
||||
if not (is_builtin_open or is_io_os_open):
|
||||
return
|
||||
|
||||
mode_node: ast.AST | None = node.args[1] if len(node.args) >= 2 else None
|
||||
for kw in node.keywords:
|
||||
if kw.arg == "mode":
|
||||
mode_node = kw.value
|
||||
if mode_node is not None:
|
||||
if not (isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str)):
|
||||
raise ValueError(f"open() with a non-literal mode {_SCRUB_MSG}")
|
||||
if any(ch in _OPEN_WRITE_MODE_CHARS for ch in mode_node.value):
|
||||
raise ValueError(f"Writing files via open(mode={mode_node.value!r}) {_SCRUB_MSG}")
|
||||
|
||||
path_node = node.args[0] if node.args else None
|
||||
if not (isinstance(path_node, ast.Constant) and isinstance(path_node.value, str)):
|
||||
raise ValueError(f"open() with a non-literal path {_SCRUB_MSG}")
|
||||
path = path_node.value
|
||||
if path.startswith(("/", "~", "\\")) or ".." in path or (len(path) > 1 and path[1] == ":"):
|
||||
raise ValueError(f"open() with a non-relative path {path!r} {_SCRUB_MSG}")
|
||||
|
||||
|
||||
def _reject_forbidden_getattr(node: ast.Call) -> None:
|
||||
"""Reject getattr/setattr/delattr indirection onto ``os`` / forbidden modules.
|
||||
|
||||
Closes the documented bypass ``getattr(os, "system")("id")`` — and computed
|
||||
variants like ``getattr(os, "sys" + "tem")`` — by keying off the *target*
|
||||
object (first positional arg), not the attribute string. Dynamic access on
|
||||
ordinary user objects (``getattr(tech, name, None)``) is left untouched.
|
||||
"""
|
||||
func = node.func
|
||||
if not (isinstance(func, ast.Name) and func.id in _GETATTR_INDIRECTION):
|
||||
return
|
||||
if not node.args:
|
||||
return
|
||||
target = node.args[0]
|
||||
if isinstance(target, ast.Name):
|
||||
root: str | None = target.id
|
||||
elif isinstance(target, ast.Attribute):
|
||||
root = _attribute_root_name(target)
|
||||
else:
|
||||
root = None
|
||||
if root == "os" or root in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"{func.id}() indirection onto {root!r} {_SCRUB_MSG}")
|
||||
|
||||
|
||||
def _reject_forbidden_node(node: ast.AST) -> None:
|
||||
"""Raise ``ValueError`` if a single AST node performs a forbidden operation."""
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.split(".")[0] in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"Import of {alias.name!r} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
root = (node.module or "").split(".")[0]
|
||||
if root in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"Import from {node.module!r} {_SCRUB_MSG}")
|
||||
if root == "os":
|
||||
for alias in node.names:
|
||||
if _is_forbidden_os_attr(alias.name):
|
||||
raise ValueError(f"Import of os.{alias.name} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.Attribute):
|
||||
root = _attribute_root_name(node)
|
||||
if root in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"Use of {root}.{node.attr} {_SCRUB_MSG}")
|
||||
if root == "os" and _is_forbidden_os_attr(node.attr):
|
||||
raise ValueError(f"Use of os.{node.attr} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in _FORBIDDEN_BUILTINS:
|
||||
raise ValueError(f"Use of {node.id!r} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.Call):
|
||||
_reject_forbidden_open(node)
|
||||
_reject_forbidden_getattr(node)
|
||||
|
||||
|
||||
def _scan_runtime_reachable(tree: ast.Module) -> None:
|
||||
"""Reject forbidden ops in ``SignalEngine`` methods + their transitive callees.
|
||||
|
||||
Entry points are every method defined directly on the ``SignalEngine`` class.
|
||||
From there, any bare-name call that resolves to a module-level function is
|
||||
followed and scanned too, so a payload hidden in a helper that ``generate()``
|
||||
calls is still caught. Module-level functions never reached from a
|
||||
``SignalEngine`` method (standalone data-fetch helpers, ``__main__`` demos)
|
||||
are intentionally left unscanned.
|
||||
"""
|
||||
engine_cls = next(
|
||||
(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "SignalEngine"),
|
||||
None,
|
||||
)
|
||||
if engine_cls is None:
|
||||
return
|
||||
|
||||
module_funcs = {
|
||||
n.name: n
|
||||
for n in tree.body
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
worklist: list[ast.AST] = [
|
||||
m for m in engine_cls.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
]
|
||||
visited: set[int] = set()
|
||||
while worklist:
|
||||
fn = worklist.pop()
|
||||
if id(fn) in visited:
|
||||
continue
|
||||
visited.add(id(fn))
|
||||
for node in ast.walk(fn):
|
||||
_reject_forbidden_node(node)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
target = module_funcs.get(node.func.id)
|
||||
if target is not None:
|
||||
worklist.append(target)
|
||||
|
||||
|
||||
def _validate_signal_engine_source(file_path: Path) -> None:
|
||||
"""Reject import-time executable statements before loading signal_engine.py."""
|
||||
try:
|
||||
@@ -253,6 +496,10 @@ def _validate_signal_engine_source(file_path: Path) -> None:
|
||||
f"Executable top-level statement {type(node).__name__} is not allowed"
|
||||
)
|
||||
|
||||
# Deep pass: the structural loop above only guards import-time execution;
|
||||
# this walks the code that runs on SignalEngine().generate() (VT-001).
|
||||
_scan_runtime_reachable(tree)
|
||||
|
||||
|
||||
def _validate_signal_engine_class(engine_cls) -> None:
|
||||
"""Pre-flight check: SignalEngine can be instantiated with no args and has generate()."""
|
||||
@@ -284,6 +531,8 @@ _MARKET_TO_SOURCE = {
|
||||
"a_share": "tushare",
|
||||
"us_equity": "yfinance",
|
||||
"hk_equity": "yfinance",
|
||||
"india_equity": "yahoo",
|
||||
"kr_equity": "pykrx",
|
||||
"crypto": "okx",
|
||||
"futures": "tushare",
|
||||
"fund": "tushare",
|
||||
@@ -370,6 +619,252 @@ def _normalize_codes(codes: List[str], source: str) -> List[str]:
|
||||
return codes
|
||||
|
||||
|
||||
def _restore_original_codes(
|
||||
data_map: dict,
|
||||
original_codes: List[str],
|
||||
normalized_codes: List[str],
|
||||
) -> dict:
|
||||
"""Map provider-normalized result keys back to requested symbols."""
|
||||
aliases = dict(zip(normalized_codes, original_codes))
|
||||
return {aliases.get(code, code): frame for code, frame in data_map.items()}
|
||||
|
||||
|
||||
def _columns_required_from_factor_spec(spec: Any) -> list[str]:
|
||||
"""Extract ``columns_required`` from supported factor spec shapes.
|
||||
|
||||
Args:
|
||||
spec: Factor metadata as a dict, an Alpha-like object with ``meta``, an
|
||||
object with ``columns_required``, or a raw string alpha id.
|
||||
|
||||
Returns:
|
||||
Declared panel columns, or an empty list when the shape has none.
|
||||
"""
|
||||
if isinstance(spec, dict):
|
||||
meta = spec.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
return [str(c) for c in meta.get("columns_required", [])]
|
||||
return [str(c) for c in spec.get("columns_required", [])]
|
||||
meta = getattr(spec, "meta", None)
|
||||
if isinstance(meta, dict):
|
||||
return [str(c) for c in meta.get("columns_required", [])]
|
||||
columns = getattr(spec, "columns_required", None)
|
||||
if columns is not None:
|
||||
return [str(c) for c in columns]
|
||||
return []
|
||||
|
||||
|
||||
def _selected_factor_specs(config: dict) -> list[Any]:
|
||||
"""Return selected factor metadata configured for the run.
|
||||
|
||||
The current runner has no dedicated factor-zoo execution path, so this
|
||||
accepts the shapes used by callers that already know factor metadata
|
||||
(``selected_factors``/``factors``/``alpha_metas``) and alpha-id lists that
|
||||
can be resolved through the registry.
|
||||
|
||||
Args:
|
||||
config: Backtest config.
|
||||
|
||||
Returns:
|
||||
Factor specs or metadata dictionaries.
|
||||
"""
|
||||
specs: list[Any] = []
|
||||
for key in ("selected_factors", "factors", "alpha_metas"):
|
||||
raw = config.get(key)
|
||||
if isinstance(raw, list):
|
||||
specs.extend(raw)
|
||||
elif raw:
|
||||
specs.append(raw)
|
||||
|
||||
alpha_ids: list[str] = []
|
||||
for key in ("alpha_ids", "factor_ids", "alphas"):
|
||||
raw = config.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
if isinstance(raw, str):
|
||||
alpha_ids.append(raw)
|
||||
else:
|
||||
alpha_ids.extend(str(item) for item in raw)
|
||||
|
||||
if alpha_ids:
|
||||
from src.factors.registry import get_default_registry
|
||||
|
||||
registry = get_default_registry()
|
||||
for alpha_id in alpha_ids:
|
||||
try:
|
||||
specs.append(registry.get(alpha_id).meta)
|
||||
except KeyError:
|
||||
logger.warning("selected alpha_id %r is not registered; skipping", alpha_id)
|
||||
return specs
|
||||
|
||||
|
||||
def _fund_columns_required(selected_factors: Iterable[Any]) -> list[str]:
|
||||
"""Collect requested ``fund:*`` panel columns from selected factors.
|
||||
|
||||
Args:
|
||||
selected_factors: Factor metadata/spec objects.
|
||||
|
||||
Returns:
|
||||
Stable, de-duplicated ``fund:*`` column names.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for spec in selected_factors:
|
||||
for column in _columns_required_from_factor_spec(spec):
|
||||
if not column.startswith(_FUND_PREFIX) or column in seen:
|
||||
continue
|
||||
seen.add(column)
|
||||
out.append(column)
|
||||
return out
|
||||
|
||||
|
||||
def _build_price_panel(data_map: dict[str, pd.DataFrame]) -> dict[str, pd.DataFrame]:
|
||||
"""Convert ``code -> OHLCV frame`` rows into the factor panel shape.
|
||||
|
||||
Args:
|
||||
data_map: Backtest loader output.
|
||||
|
||||
Returns:
|
||||
``column -> dates x symbols`` panel for price columns present in the
|
||||
data map.
|
||||
"""
|
||||
panel: dict[str, pd.DataFrame] = {}
|
||||
for column in _PRICE_PANEL_COLUMNS:
|
||||
series_by_symbol = {
|
||||
symbol: frame[column]
|
||||
for symbol, frame in data_map.items()
|
||||
if column in frame.columns
|
||||
}
|
||||
if series_by_symbol:
|
||||
panel[column] = pd.DataFrame(series_by_symbol)
|
||||
return panel
|
||||
|
||||
|
||||
def _nan_fundamental_frame(
|
||||
index: pd.DatetimeIndex,
|
||||
symbols: list[str],
|
||||
) -> pd.DataFrame:
|
||||
"""Build an all-NaN fundamental panel frame."""
|
||||
return pd.DataFrame(float("nan"), index=index, columns=symbols)
|
||||
|
||||
|
||||
def _inject_fundamental_panel(
|
||||
panel: dict[str, pd.DataFrame],
|
||||
*,
|
||||
symbols: list[str],
|
||||
fund_columns: Iterable[str],
|
||||
start: str,
|
||||
end: str,
|
||||
freq: str = "ttm",
|
||||
pit: bool = True,
|
||||
source: str = "auto",
|
||||
index: pd.DatetimeIndex | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Inject required fundamental fields into a factor panel.
|
||||
|
||||
Args:
|
||||
panel: Existing factor panel keyed by column name.
|
||||
symbols: Symbols to load.
|
||||
fund_columns: Requested ``fund:*`` columns.
|
||||
start: Start date string.
|
||||
end: End date string.
|
||||
freq: Fundamental frequency. Defaults to ``ttm``.
|
||||
pit: Whether point-in-time loading is enforced.
|
||||
source: Fundamental data source route.
|
||||
index: Optional price index to align to. Defaults to ``panel["close"]``.
|
||||
|
||||
Returns:
|
||||
The same panel dictionary with ``fund:<field>`` frames added.
|
||||
"""
|
||||
fields = [column[len(_FUND_PREFIX):] for column in fund_columns if column.startswith(_FUND_PREFIX)]
|
||||
fields = list(dict.fromkeys(fields))
|
||||
if not fields:
|
||||
return panel
|
||||
|
||||
price_index = index
|
||||
if price_index is None:
|
||||
close = panel.get("close")
|
||||
price_index = close.index if close is not None else pd.DatetimeIndex([])
|
||||
|
||||
try:
|
||||
from backtest.loaders.fundamentals_loader import load_fundamental_panel
|
||||
|
||||
loaded = load_fundamental_panel(
|
||||
symbols=symbols,
|
||||
fields=fields,
|
||||
start=start,
|
||||
end=end,
|
||||
freq=freq,
|
||||
pit=pit,
|
||||
source=source,
|
||||
index=price_index,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - data-source failure must not kill backtest
|
||||
logger.warning(
|
||||
"fundamental panel load failed for fields=%s symbols=%s: %s; injecting NaN frames",
|
||||
fields,
|
||||
symbols,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
loaded = {}
|
||||
|
||||
for field in fields:
|
||||
frame = loaded.get(field)
|
||||
if not isinstance(frame, pd.DataFrame):
|
||||
frame = _nan_fundamental_frame(price_index, symbols)
|
||||
else:
|
||||
frame = frame.reindex(index=price_index, columns=symbols)
|
||||
panel[f"{_FUND_PREFIX}{field}"] = frame
|
||||
return panel
|
||||
|
||||
|
||||
def _project_panel_fields_to_data_map(
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
panel: dict[str, pd.DataFrame],
|
||||
fund_columns: Iterable[str],
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Copy injected panel fields back to per-symbol backtest frames."""
|
||||
out = {symbol: frame.copy() for symbol, frame in data_map.items()}
|
||||
for column in fund_columns:
|
||||
frame = panel.get(column)
|
||||
if frame is None:
|
||||
continue
|
||||
for symbol, symbol_frame in out.items():
|
||||
if symbol in frame.columns:
|
||||
symbol_frame[column] = frame[symbol].reindex(symbol_frame.index)
|
||||
else:
|
||||
symbol_frame[column] = float("nan")
|
||||
return out
|
||||
|
||||
|
||||
def _maybe_inject_fundamentals_for_factor_panel(
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
config: dict,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Inject ``fund:*`` factor dependencies when selected factors request them."""
|
||||
selected_factors = _selected_factor_specs(config)
|
||||
fund_columns = _fund_columns_required(selected_factors)
|
||||
if not fund_columns:
|
||||
return data_map
|
||||
|
||||
panel = _build_price_panel(data_map)
|
||||
close = panel.get("close")
|
||||
price_index = close.index if close is not None else pd.DatetimeIndex([])
|
||||
symbols = list(data_map)
|
||||
_inject_fundamental_panel(
|
||||
panel,
|
||||
symbols=symbols,
|
||||
fund_columns=fund_columns,
|
||||
start=config.get("start_date", ""),
|
||||
end=config.get("end_date", ""),
|
||||
freq="ttm",
|
||||
pit=True,
|
||||
source="auto",
|
||||
index=price_index,
|
||||
)
|
||||
return _project_panel_fields_to_data_map(data_map, panel, fund_columns)
|
||||
|
||||
|
||||
# --- Main entry ---
|
||||
|
||||
def main(run_dir: Path) -> None:
|
||||
@@ -442,50 +937,18 @@ def main(run_dir: Path) -> None:
|
||||
print(json.dumps({"error": f"SignalEngine interface error: {exc}"}))
|
||||
sys.exit(1)
|
||||
|
||||
# Data: auto split vs single loader
|
||||
fetch_result = fetch_data_map(config)
|
||||
data_map = fetch_result.data_map
|
||||
codes = fetch_result.codes
|
||||
source = fetch_result.source
|
||||
loader = fetch_result.loader
|
||||
config["codes"] = codes
|
||||
config["_run_card_effective_sources"] = fetch_result.effective_sources
|
||||
interval = config.get("interval", "1D")
|
||||
|
||||
if source == "auto":
|
||||
data_map = _fetch_auto(codes, config, interval)
|
||||
else:
|
||||
codes = _normalize_codes(codes, source)
|
||||
config["codes"] = codes
|
||||
LoaderCls = _get_loader(source)
|
||||
loader = LoaderCls()
|
||||
data_map = loader.fetch(
|
||||
codes,
|
||||
config.get("start_date", ""),
|
||||
config.get("end_date", ""),
|
||||
fields=config.get("extra_fields") or None,
|
||||
interval=interval,
|
||||
)
|
||||
# Runtime fallback: try next sources in chain when primary returns empty
|
||||
if not data_map and codes:
|
||||
market = _detect_market(codes[0])
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if fb_name == source or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(codes, fb_name)
|
||||
data_map = fb_loader.fetch(
|
||||
fb_codes, config.get("start_date", ""),
|
||||
config.get("end_date", ""), interval=interval,
|
||||
)
|
||||
if data_map:
|
||||
logger.info("Runtime fallback: %s -> %s", source, fb_name)
|
||||
source = fb_name
|
||||
loader = fb_loader
|
||||
break
|
||||
if not data_map:
|
||||
print(json.dumps({"error": "No data fetched"}))
|
||||
sys.exit(1)
|
||||
|
||||
if source == "auto":
|
||||
config["_run_card_effective_sources"] = sorted(_group_codes_by_source(codes))
|
||||
else:
|
||||
config["_run_card_effective_sources"] = [source]
|
||||
data_map = _maybe_inject_fundamentals_for_factor_panel(data_map, config)
|
||||
|
||||
# Engine
|
||||
engine_type = config.get("engine", "daily")
|
||||
@@ -501,9 +964,10 @@ def main(run_dir: Path) -> None:
|
||||
else:
|
||||
bars_per_year = calc_bars_per_year(interval, effective_source)
|
||||
|
||||
# Auto mode: wrap preloaded data in a dummy loader
|
||||
if source == "auto":
|
||||
loader = _AutoLoader(data_map)
|
||||
# Every source has already been fetched, sanitized, and enriched above.
|
||||
# Reuse that exact snapshot so provider costs and run-card provenance stay
|
||||
# aligned with the data consumed by the engine.
|
||||
loader = _AutoLoader(data_map)
|
||||
|
||||
if engine_type == "options":
|
||||
from backtest.engines.options_portfolio import run_options_backtest
|
||||
@@ -550,6 +1014,19 @@ def _create_market_engine(source: str, config: dict, codes: List[str]):
|
||||
from backtest.engines.forex import ForexEngine
|
||||
return ForexEngine(config)
|
||||
|
||||
# India equity routing — must precede source-based routing because India's
|
||||
# effective source is ``yahoo``, which has no Wave-1 branch and would
|
||||
# otherwise fall through to the crypto default.
|
||||
if "india_equity" in markets:
|
||||
from backtest.engines.india_equity import IndiaEquityEngine
|
||||
return IndiaEquityEngine(config)
|
||||
|
||||
# Korea equity routing — same reason as India: its effective source
|
||||
# (``pykrx``) has no Wave-1 branch and would fall through to the default.
|
||||
if "kr_equity" in markets:
|
||||
from backtest.engines.korea_equity import KoreaEquityEngine
|
||||
return KoreaEquityEngine(config)
|
||||
|
||||
# Original routing (Wave 1)
|
||||
if source in ("okx", "ccxt"):
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
@@ -566,6 +1043,13 @@ def _create_market_engine(source: str, config: dict, codes: List[str]):
|
||||
market = _detect_submarket(codes)
|
||||
return GlobalEquityEngine(config, market=market)
|
||||
else:
|
||||
# Sources without a dedicated branch (local, stooq, ...): follow the
|
||||
# instrument market rather than the loader name, so e.g. a local
|
||||
# AAPL.US dataset gets US-equity execution rules instead of crypto.
|
||||
if markets & {"us_equity", "hk_equity"}:
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
market = _detect_submarket(codes)
|
||||
return GlobalEquityEngine(config, market=market)
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
return CryptoEngine(config)
|
||||
|
||||
@@ -594,7 +1078,12 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
|
||||
|
||||
Args:
|
||||
codes: All symbols.
|
||||
config: Backtest config dict.
|
||||
config: Backtest config dict. On success the loaders that actually
|
||||
returned rows are recorded under the private key
|
||||
``_actual_sources`` so the caller can report true provenance
|
||||
instead of the symbol-pattern guess (``_group_codes_by_source``
|
||||
names the *head* of each chain, which lies whenever that head is an
|
||||
optional package that is not installed).
|
||||
interval: Bar interval string.
|
||||
|
||||
Returns:
|
||||
@@ -602,6 +1091,7 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
|
||||
"""
|
||||
market_groups = _group_codes_by_market(codes)
|
||||
merged = {}
|
||||
served_by: set[str] = set()
|
||||
start_date = config.get("start_date", "")
|
||||
end_date = config.get("end_date", "")
|
||||
|
||||
@@ -618,29 +1108,187 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
|
||||
src_name = getattr(loader, "name", "unknown")
|
||||
normalized_codes = _normalize_codes(market_codes, src_name)
|
||||
fields = config.get("extra_fields") if src_name == "tushare" else None
|
||||
result = loader.fetch(normalized_codes, start_date, end_date, fields=fields, interval=interval)
|
||||
result = loader.fetch(
|
||||
normalized_codes,
|
||||
start_date,
|
||||
end_date,
|
||||
fields=fields,
|
||||
interval=interval,
|
||||
)
|
||||
market_result = _restore_original_codes(
|
||||
result, market_codes, normalized_codes
|
||||
)
|
||||
if market_result:
|
||||
served_by.add(src_name)
|
||||
missing = [code for code in market_codes if code not in market_result]
|
||||
|
||||
# Runtime fallback: try remaining sources when primary returns empty
|
||||
if not result:
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if fb_name == src_name or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(market_codes, fb_name)
|
||||
result = fb_loader.fetch(fb_codes, start_date, end_date, interval=interval)
|
||||
if result:
|
||||
logger.info("Runtime fallback: %s -> %s for %s", src_name, fb_name, market)
|
||||
break
|
||||
# Retry only missing symbols so a partial primary response does not
|
||||
# silently shrink the requested universe or refetch successful data.
|
||||
for fb_name in FALLBACK_CHAINS.get(market, []):
|
||||
if not missing:
|
||||
break
|
||||
if fb_name == src_name or fb_name not in LOADER_REGISTRY:
|
||||
continue
|
||||
fb_loader = LOADER_REGISTRY[fb_name]()
|
||||
if not fb_loader.is_available():
|
||||
continue
|
||||
fb_codes = _normalize_codes(missing, fb_name)
|
||||
fallback_result = fb_loader.fetch(
|
||||
fb_codes, start_date, end_date, interval=interval
|
||||
)
|
||||
mapped = _restore_original_codes(fallback_result, missing, fb_codes)
|
||||
if mapped:
|
||||
market_result.update(mapped)
|
||||
missing = [code for code in missing if code not in mapped]
|
||||
served_by.add(str(getattr(fb_loader, "name", fb_name) or fb_name))
|
||||
logger.info(
|
||||
"Runtime fallback: %s -> %s for %s", src_name, fb_name, market
|
||||
)
|
||||
|
||||
merged.update(result)
|
||||
if missing:
|
||||
raise NoAvailableSourceError(
|
||||
f"incomplete data for {market}; missing symbols: {missing}"
|
||||
)
|
||||
merged.update(market_result)
|
||||
|
||||
config["_actual_sources"] = sorted(served_by)
|
||||
return merged
|
||||
|
||||
|
||||
def fetch_data_map(config: dict) -> DataFetchResult:
|
||||
"""Fetch and sanitize bars through the canonical loader registry.
|
||||
|
||||
This is the shared entry point for backtest execution and reconstruction
|
||||
consumers. It preserves auto routing and the runtime fallback chain.
|
||||
|
||||
Args:
|
||||
config: Backtest configuration containing codes, dates, source, and interval.
|
||||
|
||||
Returns:
|
||||
Data and effective routing metadata. The input config is not mutated.
|
||||
"""
|
||||
config = copy.deepcopy(config)
|
||||
source = str(config.get("source") or "tushare")
|
||||
codes = list(config.get("codes") or [])
|
||||
interval = str(config.get("interval") or "1D")
|
||||
|
||||
if source == "auto":
|
||||
data_map = _fetch_auto(codes, config, interval)
|
||||
loader: Any = _AutoLoader(data_map)
|
||||
# Prefer the loaders that actually served rows; the symbol-pattern guess
|
||||
# is only a fallback for a stubbed/patched fetcher that recorded nothing.
|
||||
recorded = config.pop("_actual_sources", None)
|
||||
used_sources: list[str] = [
|
||||
str(name) for name in recorded or [] if str(name).strip()
|
||||
] or sorted(_group_codes_by_source(codes))
|
||||
else:
|
||||
codes = _normalize_codes(codes, source)
|
||||
primary_source = source
|
||||
loader = _get_loader(source)()
|
||||
# ``_get_loader`` may hand back a *different* loader when the requested
|
||||
# one is unavailable (e.g. an optional package like pykrx is missing, so
|
||||
# the kr_equity chain resolves to yahoo). Record who actually served the
|
||||
# bars, never the name that was asked for — a run card that claims
|
||||
# ``pykrx`` while Yahoo supplied the data is a provenance lie.
|
||||
served_by = str(getattr(loader, "name", source) or source)
|
||||
if served_by != source:
|
||||
logger.warning(
|
||||
"source=%s is unavailable; %s served this request",
|
||||
source,
|
||||
served_by,
|
||||
)
|
||||
data_map = loader.fetch(
|
||||
codes,
|
||||
config.get("start_date", ""),
|
||||
config.get("end_date", ""),
|
||||
fields=config.get("extra_fields") or None,
|
||||
interval=interval,
|
||||
)
|
||||
used_sources = [served_by] if data_map else []
|
||||
missing = [code for code in codes if code not in data_map]
|
||||
if missing:
|
||||
logger.warning(
|
||||
"source=%s returned data for %d/%d symbols; missing: %s",
|
||||
source,
|
||||
len(data_map),
|
||||
len(codes),
|
||||
missing,
|
||||
)
|
||||
if missing:
|
||||
market = _detect_market(codes[0])
|
||||
for fallback_source in FALLBACK_CHAINS.get(market, []):
|
||||
if not missing:
|
||||
break
|
||||
if (
|
||||
fallback_source == primary_source
|
||||
or fallback_source not in LOADER_REGISTRY
|
||||
):
|
||||
continue
|
||||
fallback_loader = LOADER_REGISTRY[fallback_source]()
|
||||
if not fallback_loader.is_available():
|
||||
continue
|
||||
fallback_codes = _normalize_codes(missing, fallback_source)
|
||||
fallback_result = fallback_loader.fetch(
|
||||
fallback_codes,
|
||||
config.get("start_date", ""),
|
||||
config.get("end_date", ""),
|
||||
interval=interval,
|
||||
)
|
||||
mapped = _restore_original_codes(
|
||||
fallback_result, missing, fallback_codes
|
||||
)
|
||||
if mapped:
|
||||
data_map.update(mapped)
|
||||
missing = [code for code in missing if code not in mapped]
|
||||
fb_served_by = str(
|
||||
getattr(fallback_loader, "name", fallback_source)
|
||||
or fallback_source
|
||||
)
|
||||
if not used_sources:
|
||||
source = fb_served_by
|
||||
loader = fallback_loader
|
||||
used_sources.append(fb_served_by)
|
||||
logger.info(
|
||||
"Runtime fallback: %s -> %s", primary_source, fb_served_by
|
||||
)
|
||||
|
||||
if missing:
|
||||
raise NoAvailableSourceError(
|
||||
f"incomplete data for source={primary_source}; missing symbols: {missing}"
|
||||
)
|
||||
|
||||
data_map = _sanitize_data_map(data_map)
|
||||
return DataFetchResult(
|
||||
data_map=data_map,
|
||||
codes=codes,
|
||||
source=source,
|
||||
loader=loader,
|
||||
effective_sources=used_sources,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_data_map(data_map: dict) -> dict:
|
||||
"""Drop structurally-invalid OHLC bars from every fetched frame.
|
||||
|
||||
Each loader only drops NaN rows, so a bar that violates the OHLC
|
||||
invariants (``high < low``, a non-positive price, or a high/low that fails
|
||||
to bracket open/close) can still reach the backtest and surface as NaN/inf
|
||||
metrics. Applying :func:`validate_ohlc` here — the single point every
|
||||
fetched map converges through — guards every source uniformly (``auto``,
|
||||
single-source, runtime fallback, and any future loader), so the per-loader
|
||||
checks no longer have to be added one at a time.
|
||||
|
||||
Args:
|
||||
data_map: ``code -> DataFrame`` map as returned by a loader fetch.
|
||||
|
||||
Returns:
|
||||
The same mapping with each frame's invalid bars removed.
|
||||
"""
|
||||
return {code: validate_ohlc(frame) for code, frame in data_map.items()}
|
||||
|
||||
|
||||
class _AutoLoader:
|
||||
"""Dummy loader for auto mode: returns pre-fetched data maps."""
|
||||
"""Loader adapter that returns a pre-fetched data map."""
|
||||
|
||||
def __init__(self, data_map: dict):
|
||||
self._data = data_map
|
||||
|
||||
+137
-46
@@ -11,6 +11,9 @@ is present, or invoked directly on backtest outputs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from numbers import Integral, Real
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -44,6 +47,13 @@ def monte_carlo_test(
|
||||
Dict with actual_sharpe, p_value_sharpe, actual_max_dd,
|
||||
p_value_max_dd, simulated_sharpes (percentiles).
|
||||
"""
|
||||
if isinstance(n_simulations, bool) or not isinstance(n_simulations, Integral) or n_simulations < 1:
|
||||
return {
|
||||
"error": f"n_simulations must be >= 1, got {n_simulations}",
|
||||
"p_value_sharpe": 1.0,
|
||||
}
|
||||
if isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0:
|
||||
return {"error": f"seed must be >= 0, got {seed}", "p_value_sharpe": 1.0}
|
||||
if len(trades) < 3:
|
||||
return {"error": "need at least 3 trades", "p_value_sharpe": 1.0}
|
||||
|
||||
@@ -54,9 +64,15 @@ def monte_carlo_test(
|
||||
sharpe_count = 0
|
||||
dd_count = 0
|
||||
sim_sharpes = []
|
||||
# The full path matrix feeds the fan-chart payload; skip it for
|
||||
# pathological sizes so a huge run cannot balloon memory or the JSON.
|
||||
keep_paths = n_simulations * len(pnls) <= 2_000_000
|
||||
sim_equities = np.empty((n_simulations, len(pnls))) if keep_paths else None
|
||||
|
||||
for _ in range(n_simulations):
|
||||
for i in range(n_simulations):
|
||||
shuffled = rng.permutation(pnls)
|
||||
if sim_equities is not None:
|
||||
sim_equities[i] = initial_capital + np.cumsum(shuffled)
|
||||
sim = _path_metrics(shuffled, initial_capital)
|
||||
sim_sharpes.append(sim["sharpe"])
|
||||
if sim["sharpe"] >= actual["sharpe"]:
|
||||
@@ -65,7 +81,7 @@ def monte_carlo_test(
|
||||
dd_count += 1
|
||||
|
||||
sim_arr = np.array(sim_sharpes)
|
||||
return {
|
||||
result = {
|
||||
"actual_sharpe": round(actual["sharpe"], 4),
|
||||
"actual_max_dd": round(actual["max_dd"], 4),
|
||||
"p_value_sharpe": round(sharpe_count / n_simulations, 4),
|
||||
@@ -76,7 +92,25 @@ def monte_carlo_test(
|
||||
"simulated_sharpe_p95": round(float(np.percentile(sim_arr, 95)), 4),
|
||||
"n_simulations": n_simulations,
|
||||
"n_trades": len(trades),
|
||||
"sharpe_samples": [round(float(s), 4) for s in sim_sharpes],
|
||||
}
|
||||
if sim_equities is not None:
|
||||
idx = np.unique(np.linspace(0, len(pnls) - 1, min(len(pnls), 400)).astype(int))
|
||||
sample_rows = np.unique(
|
||||
np.linspace(0, n_simulations - 1, min(30, n_simulations)).astype(int)
|
||||
)
|
||||
result["equity_paths"] = {
|
||||
"steps": (idx + 1).tolist(),
|
||||
"initial_capital": round(float(initial_capital), 2),
|
||||
"actual": np.round((initial_capital + np.cumsum(pnls))[idx], 2).tolist(),
|
||||
"band_p5": np.round(np.percentile(sim_equities[:, idx], 5, axis=0), 2).tolist(),
|
||||
"band_p25": np.round(np.percentile(sim_equities[:, idx], 25, axis=0), 2).tolist(),
|
||||
"band_p50": np.round(np.percentile(sim_equities[:, idx], 50, axis=0), 2).tolist(),
|
||||
"band_p75": np.round(np.percentile(sim_equities[:, idx], 75, axis=0), 2).tolist(),
|
||||
"band_p95": np.round(np.percentile(sim_equities[:, idx], 95, axis=0), 2).tolist(),
|
||||
"samples": np.round(sim_equities[np.ix_(sample_rows, idx)], 2).tolist(),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _path_metrics(pnls: np.ndarray, initial_capital: float) -> Dict[str, float]:
|
||||
@@ -114,6 +148,18 @@ def bootstrap_sharpe_ci(
|
||||
Dict with observed_sharpe, ci_lower, ci_upper, median_sharpe,
|
||||
prob_positive (fraction of samples with Sharpe > 0).
|
||||
"""
|
||||
if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, Integral) or n_bootstrap < 1:
|
||||
return {"error": f"n_bootstrap must be >= 1, got {n_bootstrap}"}
|
||||
if (
|
||||
isinstance(confidence, bool)
|
||||
or not isinstance(confidence, Real)
|
||||
or not math.isfinite(float(confidence))
|
||||
or not 0.0 < confidence < 1.0
|
||||
):
|
||||
return {"error": f"confidence must be in (0, 1), got {confidence}"}
|
||||
if isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0:
|
||||
return {"error": f"seed must be >= 0, got {seed}"}
|
||||
|
||||
returns = equity_curve.pct_change().dropna().values
|
||||
if len(returns) < 5:
|
||||
return {"error": "need at least 5 return observations"}
|
||||
@@ -132,7 +178,7 @@ def bootstrap_sharpe_ci(
|
||||
upper = float(np.percentile(arr, (1 - alpha) * 100))
|
||||
prob_pos = float(np.mean(arr > 0))
|
||||
|
||||
return {
|
||||
result = {
|
||||
"observed_sharpe": round(observed, 4),
|
||||
"ci_lower": round(lower, 4),
|
||||
"ci_upper": round(upper, 4),
|
||||
@@ -141,6 +187,9 @@ def bootstrap_sharpe_ci(
|
||||
"confidence": confidence,
|
||||
"n_bootstrap": n_bootstrap,
|
||||
}
|
||||
if n_bootstrap <= 20_000:
|
||||
result["sharpe_samples"] = [round(float(s), 4) for s in boot_sharpes]
|
||||
return result
|
||||
|
||||
|
||||
def _sharpe(returns: np.ndarray, bars_per_year: int = 252) -> float:
|
||||
@@ -170,6 +219,8 @@ def walk_forward_analysis(
|
||||
Returns:
|
||||
Dict with per_window stats, consistency metrics.
|
||||
"""
|
||||
if isinstance(n_windows, bool) or not isinstance(n_windows, Integral) or n_windows < 1:
|
||||
return {"error": f"n_windows must be >= 1, got {n_windows}"}
|
||||
if len(equity_curve) < n_windows * 2:
|
||||
return {"error": f"need at least {n_windows * 2} bars for {n_windows} windows"}
|
||||
|
||||
@@ -185,10 +236,7 @@ def walk_forward_analysis(
|
||||
win_end = indices[end_idx - 1]
|
||||
|
||||
# Per-window trades
|
||||
win_trades = [
|
||||
t for t in trades
|
||||
if win_start <= t.entry_time <= win_end
|
||||
]
|
||||
win_trades = [t for t in trades if win_start <= t.entry_time <= win_end]
|
||||
|
||||
# Per-window metrics
|
||||
ret = float(win_eq.iloc[-1] / win_eq.iloc[0] - 1) if win_eq.iloc[0] > 0 else 0.0
|
||||
@@ -200,21 +248,20 @@ def walk_forward_analysis(
|
||||
max_dd = float(dd.min())
|
||||
|
||||
win_pnls = [t.pnl for t in win_trades]
|
||||
win_rate = (
|
||||
len([p for p in win_pnls if p > 0]) / len(win_pnls)
|
||||
if win_pnls else 0.0
|
||||
)
|
||||
win_rate = len([p for p in win_pnls if p > 0]) / len(win_pnls) if win_pnls else 0.0
|
||||
|
||||
windows.append({
|
||||
"window": i + 1,
|
||||
"start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start),
|
||||
"end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end),
|
||||
"return": round(ret, 6),
|
||||
"sharpe": round(sharpe, 4),
|
||||
"max_dd": round(max_dd, 6),
|
||||
"trades": len(win_trades),
|
||||
"win_rate": round(win_rate, 4),
|
||||
})
|
||||
windows.append(
|
||||
{
|
||||
"window": i + 1,
|
||||
"start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start),
|
||||
"end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end),
|
||||
"return": round(ret, 6),
|
||||
"sharpe": round(sharpe, 4),
|
||||
"max_dd": round(max_dd, 6),
|
||||
"trades": len(win_trades),
|
||||
"win_rate": round(win_rate, 4),
|
||||
}
|
||||
)
|
||||
|
||||
# Consistency metrics
|
||||
returns_list = [w["return"] for w in windows]
|
||||
@@ -266,7 +313,8 @@ def run_validation(
|
||||
if "monte_carlo" in v_cfg:
|
||||
mc_cfg = v_cfg["monte_carlo"] if isinstance(v_cfg["monte_carlo"], dict) else {}
|
||||
results["monte_carlo"] = monte_carlo_test(
|
||||
trades, initial_capital,
|
||||
trades,
|
||||
initial_capital,
|
||||
n_simulations=mc_cfg.get("n_simulations", 1000),
|
||||
seed=mc_cfg.get("seed", 42),
|
||||
)
|
||||
@@ -274,7 +322,8 @@ def run_validation(
|
||||
if "bootstrap" in v_cfg:
|
||||
bs_cfg = v_cfg["bootstrap"] if isinstance(v_cfg["bootstrap"], dict) else {}
|
||||
results["bootstrap"] = bootstrap_sharpe_ci(
|
||||
equity_curve, bars_per_year=bars_per_year,
|
||||
equity_curve,
|
||||
bars_per_year=bars_per_year,
|
||||
n_bootstrap=bs_cfg.get("n_bootstrap", 1000),
|
||||
confidence=bs_cfg.get("confidence", 0.95),
|
||||
seed=bs_cfg.get("seed", 42),
|
||||
@@ -283,7 +332,8 @@ def run_validation(
|
||||
if "walk_forward" in v_cfg:
|
||||
wf_cfg = v_cfg["walk_forward"] if isinstance(v_cfg["walk_forward"], dict) else {}
|
||||
results["walk_forward"] = walk_forward_analysis(
|
||||
equity_curve, trades,
|
||||
equity_curve,
|
||||
trades,
|
||||
n_windows=wf_cfg.get("n_windows", 5),
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
@@ -298,7 +348,12 @@ def _load_equity(run_dir: Path) -> pd.Series:
|
||||
"""Load equity curve from artifacts/equity.csv."""
|
||||
path = run_dir / "artifacts" / "equity.csv"
|
||||
df = pd.read_csv(path, index_col=0, parse_dates=True)
|
||||
return df["equity"]
|
||||
for col in ("equity", "nav", "value"):
|
||||
if col in df.columns:
|
||||
return df[col]
|
||||
raise ValueError(
|
||||
f"equity.csv must contain an equity/nav/value column; got {list(df.columns)}"
|
||||
)
|
||||
|
||||
|
||||
def _load_trades(run_dir: Path) -> List[TradeRecord]:
|
||||
@@ -312,21 +367,25 @@ def _load_trades(run_dir: Path) -> List[TradeRecord]:
|
||||
trades = []
|
||||
exit_rows = df[df["pnl"] != 0].reset_index(drop=True)
|
||||
for _, row in exit_rows.iterrows():
|
||||
trades.append(TradeRecord(
|
||||
symbol=str(row.get("code", "")),
|
||||
direction=1 if row.get("side") == "sell" else -1,
|
||||
entry_price=0.0,
|
||||
exit_price=float(row.get("price", 0)),
|
||||
entry_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
|
||||
exit_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
|
||||
size=float(row.get("qty", 0)),
|
||||
leverage=1.0,
|
||||
pnl=float(row.get("pnl", 0)),
|
||||
pnl_pct=float(row.get("return_pct", 0)),
|
||||
exit_reason=str(row.get("reason", "signal")),
|
||||
holding_bars=int(row.get("holding_days", 0)),
|
||||
commission=0.0,
|
||||
))
|
||||
hold = pd.to_numeric(row.get("holding_days", 0), errors="coerce")
|
||||
holding_bars = 0 if pd.isna(hold) else int(hold)
|
||||
trades.append(
|
||||
TradeRecord(
|
||||
symbol=str(row.get("code", "")),
|
||||
direction=1 if row.get("side") == "sell" else -1,
|
||||
entry_price=0.0,
|
||||
exit_price=float(row.get("price", 0)),
|
||||
entry_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
|
||||
exit_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
|
||||
size=float(row.get("qty", 0)),
|
||||
leverage=1.0,
|
||||
pnl=float(row.get("pnl", 0)),
|
||||
pnl_pct=float(row.get("return_pct", 0)),
|
||||
exit_reason=str(row.get("reason", "signal")),
|
||||
holding_bars=holding_bars,
|
||||
commission=0.0,
|
||||
)
|
||||
)
|
||||
return trades
|
||||
|
||||
|
||||
@@ -355,6 +414,41 @@ def _parse_run_dir(argv: List[str]) -> Path:
|
||||
return run_dir
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
"""Return a JSON-strict copy of validation results."""
|
||||
if isinstance(value, np.ndarray):
|
||||
return [_json_safe(item) for item in value.tolist()]
|
||||
if isinstance(value, np.generic):
|
||||
return _json_safe(value.item())
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def write_validation_json(path: Path, results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Write validation results to ``path`` as strict, RFC-8259 JSON.
|
||||
|
||||
A validation metric can be non-finite (e.g. a Sharpe computed from a path
|
||||
whose equity touches zero), and ``json.dumps`` emits bare ``NaN`` /
|
||||
``Infinity`` tokens for those by default (``allow_nan=True``) — tokens that
|
||||
strict parsers reject. Sanitize with :func:`_json_safe` (non-finite → null)
|
||||
and serialize with ``allow_nan=False`` so every writer of
|
||||
``artifacts/validation.json`` produces the same valid JSON. Returns the
|
||||
sanitized payload that was written.
|
||||
"""
|
||||
safe_results = _json_safe(results)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(safe_results, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return safe_results
|
||||
|
||||
|
||||
def main(run_dir: Path) -> Dict[str, Any]:
|
||||
"""Run all three validations on existing backtest artifacts.
|
||||
|
||||
@@ -366,8 +460,6 @@ def main(run_dir: Path) -> Dict[str, Any]:
|
||||
Returns:
|
||||
Validation results dict.
|
||||
"""
|
||||
import json
|
||||
|
||||
# Load config for initial_cash
|
||||
config_path = run_dir / "config.json"
|
||||
if config_path.exists():
|
||||
@@ -385,12 +477,11 @@ def main(run_dir: Path) -> Dict[str, Any]:
|
||||
"walk_forward": walk_forward_analysis(equity, trades),
|
||||
}
|
||||
|
||||
# Write results
|
||||
out = run_dir / "artifacts" / "validation.json"
|
||||
out.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
safe_results = write_validation_json(out, results)
|
||||
|
||||
print(json.dumps(results, indent=2))
|
||||
return results
|
||||
print(json.dumps(safe_results, indent=2, allow_nan=False))
|
||||
return safe_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+1276
-69
File diff suppressed because it is too large.
Load diff
+27
-7
@@ -1,26 +1,46 @@
|
||||
"""Single source of truth for the CLI version string.
|
||||
|
||||
Reads ``vibe-trading-ai``'s installed package metadata when available
|
||||
(``pip install -e .`` is enough). Falls back to the static constant
|
||||
below for un-installed checkouts (e.g. running straight from a clone
|
||||
with ``PYTHONPATH=agent``).
|
||||
|
||||
Keep the fallback in sync with ``pyproject.toml`` ``[project] version``.
|
||||
(``pip install -e .`` is enough). For an un-installed checkout (e.g. running
|
||||
straight from a clone with ``PYTHONPATH=agent``) it falls back to reading the
|
||||
version straight out of ``pyproject.toml`` — so ``pyproject.toml`` is the one
|
||||
and only place the version is ever written. There is deliberately no hardcoded
|
||||
version constant to drift out of sync on release (issue #156).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
def _version_from_pyproject() -> str:
|
||||
"""Read ``[project] version`` from the repo's ``pyproject.toml``.
|
||||
|
||||
Returns:
|
||||
The declared version, or ``"unknown"`` if the file cannot be located
|
||||
or parsed (only reachable for an un-installed checkout whose tree has
|
||||
been moved away from its ``pyproject.toml``).
|
||||
"""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
# agent/cli/_version.py -> parents[2] is the repo root holding pyproject.toml.
|
||||
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
try:
|
||||
return tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
|
||||
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||
|
||||
try:
|
||||
__version__: Final[str] = _pkg_version("vibe-trading-ai")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.1.8"
|
||||
__version__ = _version_from_pyproject()
|
||||
except ImportError: # pragma: no cover — importlib.metadata is stdlib on 3.8+
|
||||
__version__ = "0.1.8"
|
||||
__version__ = _version_from_pyproject()
|
||||
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -11,7 +11,6 @@ fallback workflows.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from rich.console import Console
|
||||
@@ -50,8 +49,11 @@ def cmd_model(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unuse
|
||||
|
||||
_show_settings()
|
||||
except Exception as exc: # noqa: BLE001 — legacy may be absent on partial install
|
||||
provider = os.getenv("LANGCHAIN_PROVIDER", "(not set)")
|
||||
model = os.getenv("LANGCHAIN_MODEL_NAME", "(not set)")
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
cfg = get_env_config()
|
||||
provider = cfg.llm.langchain_provider or "(not set)"
|
||||
model = cfg.llm.langchain_model_name or "(not set)"
|
||||
console.print(Text(f"Provider: {provider}", style="bold"))
|
||||
console.print(Text(f"Model: {model}", style="bold"))
|
||||
console.print(Text(f"(legacy _show_settings unavailable: {exc})", style="dim"))
|
||||
|
||||
@@ -73,7 +73,9 @@ def _session_id(ctx: Any, *, title: str = "Goal research", create: bool) -> str
|
||||
existing = getattr(ctx, "session_id", None)
|
||||
if existing:
|
||||
return str(existing)
|
||||
env_session_id = os.getenv("VIBE_GOAL_SESSION_ID")
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
env_session_id = get_env_config().paths.vibe_goal_session_id
|
||||
if env_session_id:
|
||||
return env_session_id
|
||||
if not create:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Institutional research workflow slash commands.
|
||||
|
||||
This package turns five recurring sell-side / buy-side workflows into explicit,
|
||||
discoverable slash commands instead of prose buried in a swarm preset:
|
||||
|
||||
``/comps`` comparable company analysis
|
||||
``/dcf`` discounted cash flow
|
||||
``/attrib`` Brinson-Fachler performance attribution
|
||||
``/memo`` investment memo
|
||||
``/earnings`` earnings surprise bridge
|
||||
``/screen`` systematic idea screen
|
||||
|
||||
Layout — deliberately import-cycle free:
|
||||
|
||||
``playbooks.py``
|
||||
Pure data. Imports nothing from :mod:`cli`, so
|
||||
:mod:`cli.commands.slash_router` can read the command specs at its own
|
||||
import time without a circular import.
|
||||
``runner.py``
|
||||
Rendering + prompt construction + the shared ``run_playbook`` entry.
|
||||
``comps.py`` / ``dcf.py`` / ``attrib.py`` / ``memo.py`` / ``earnings.py`` /
|
||||
``screen.py``
|
||||
One thin ``run(ctx, *args)`` module per command. The REPL dispatcher
|
||||
(``cli.main._dispatch_slash``) passes the command keyword only for the
|
||||
modules listed in ``_MULTI_COMMAND_MODULES``; every other handler module
|
||||
owns exactly one command, so each command gets its own module here.
|
||||
|
||||
This ``__init__`` intentionally stays import-free.
|
||||
"""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""``/attrib`` — Brinson-Fachler performance attribution.
|
||||
|
||||
Thin handler: the execution skeleton, the worked numeric example and the
|
||||
missing-data policy all live in :mod:`.playbooks`, and the render/dispatch
|
||||
logic in :mod:`.runner`. Running it produces allocation / selection /
|
||||
interaction per sector, reconciled to the total active return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .runner import run_playbook
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Render or dispatch the ``/attrib`` playbook.
|
||||
|
||||
Args:
|
||||
ctx: Interactive context supplying ``pending_prompt``.
|
||||
*args: Raw slash-command arguments.
|
||||
|
||||
Returns:
|
||||
Process-style exit code; ``0`` on every user-facing path.
|
||||
"""
|
||||
return run_playbook("attrib", ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""``/comps`` — Comparable company analysis.
|
||||
|
||||
Thin handler: the execution skeleton, the worked numeric example and the
|
||||
missing-data policy all live in :mod:`.playbooks`, and the render/dispatch
|
||||
logic in :mod:`.runner`. Running it produces peer multiple table, the
|
||||
subject's premium or discount versus the peer median, and an implied value
|
||||
range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .runner import run_playbook
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Render or dispatch the ``/comps`` playbook.
|
||||
|
||||
Args:
|
||||
ctx: Interactive context supplying ``pending_prompt``.
|
||||
*args: Raw slash-command arguments.
|
||||
|
||||
Returns:
|
||||
Process-style exit code; ``0`` on every user-facing path.
|
||||
"""
|
||||
return run_playbook("comps", ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""``/dcf`` — Discounted cash flow valuation.
|
||||
|
||||
Thin handler: the execution skeleton, the worked numeric example and the
|
||||
missing-data policy all live in :mod:`.playbooks`, and the render/dispatch
|
||||
logic in :mod:`.runner`. Running it produces value per share, the terminal-
|
||||
value share of enterprise value, an exit-multiple cross-check and a WACC x g
|
||||
sensitivity grid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .runner import run_playbook
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Render or dispatch the ``/dcf`` playbook.
|
||||
|
||||
Args:
|
||||
ctx: Interactive context supplying ``pending_prompt``.
|
||||
*args: Raw slash-command arguments.
|
||||
|
||||
Returns:
|
||||
Process-style exit code; ``0`` on every user-facing path.
|
||||
"""
|
||||
return run_playbook("dcf", ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""``/earnings`` — Earnings review.
|
||||
|
||||
Thin handler: the execution skeleton, the worked numeric example and the
|
||||
missing-data policy all live in :mod:`.playbooks`, and the render/dispatch
|
||||
logic in :mod:`.runner`. Running it produces a line-by-line surprise bridge
|
||||
from revenue to EPS, splitting the recurring beat from the non-recurring one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .runner import run_playbook
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Render or dispatch the ``/earnings`` playbook.
|
||||
|
||||
Args:
|
||||
ctx: Interactive context supplying ``pending_prompt``.
|
||||
*args: Raw slash-command arguments.
|
||||
|
||||
Returns:
|
||||
Process-style exit code; ``0`` on every user-facing path.
|
||||
"""
|
||||
return run_playbook("earnings", ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""``/memo`` — Investment memo.
|
||||
|
||||
Thin handler: the execution skeleton, the worked numeric example and the
|
||||
missing-data policy all live in :mod:`.playbooks`, and the render/dispatch
|
||||
logic in :mod:`.runner`. Running it produces thesis, variant view,
|
||||
probability-weighted scenarios, falsifiable kill criteria and a monitoring
|
||||
set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .runner import run_playbook
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Render or dispatch the ``/memo`` playbook.
|
||||
|
||||
Args:
|
||||
ctx: Interactive context supplying ``pending_prompt``.
|
||||
*args: Raw slash-command arguments.
|
||||
|
||||
Returns:
|
||||
Process-style exit code; ``0`` on every user-facing path.
|
||||
"""
|
||||
return run_playbook("memo", ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,654 @@
|
||||
"""Playbook definitions for the institutional research slash commands.
|
||||
|
||||
Pure data — this module imports nothing from :mod:`cli`, so
|
||||
:mod:`cli.commands.slash_router` can pull the command specs at its own import
|
||||
time without a circular import.
|
||||
|
||||
Each :class:`Playbook` carries four things that make the command more than a
|
||||
prompt forwarder:
|
||||
|
||||
1. ``steps`` — an ordered execution skeleton where every step names its
|
||||
inputs, the computation, and the artifact it must produce.
|
||||
2. ``worked_example`` — a fully arithmetic-consistent numeric walkthrough.
|
||||
Every number below was computed and reconciled by hand; the totals tie out
|
||||
(the Brinson decomposition sums exactly to the active return, the earnings
|
||||
bridge sums exactly to the EPS delta). Both the user and the agent can
|
||||
reproduce them, which is what makes the skeleton checkable.
|
||||
3. ``tools`` — the real registered tool names the agent should reach for.
|
||||
Verified against ``src/tools/*`` (``get_financial_statements``,
|
||||
``get_stock_profile``, ``get_market_data``, ``get_sec_filings``,
|
||||
``get_macro_series``, ``screen_market``, ``get_stock_news``,
|
||||
``get_research_reports``, ``portfolio_risk_xray``).
|
||||
4. ``gap_policy`` — what to do when a number cannot be fetched. The shared
|
||||
default forbids filling gaps from model memory.
|
||||
|
||||
Numbers in the worked examples are illustrative teaching figures, not
|
||||
quotes for any real security.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Step:
|
||||
"""One step of a playbook's execution skeleton.
|
||||
|
||||
Attributes:
|
||||
title: Short imperative label, e.g. ``"Fix the peer set"``.
|
||||
inputs: What the step consumes (data, prior-step artifacts).
|
||||
compute: The transformation to perform.
|
||||
output: The artifact the step must produce before moving on.
|
||||
"""
|
||||
|
||||
title: str
|
||||
inputs: str
|
||||
compute: str
|
||||
output: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Playbook:
|
||||
"""A single institutional workflow exposed as a slash command.
|
||||
|
||||
Attributes:
|
||||
slug: Slash command keyword, without the leading ``/``.
|
||||
summary: One-line description shown in ``/help`` and the typeahead.
|
||||
usage: Usage line, e.g. ``"/dcf <ticker> [horizon=5y] [wacc=9%]"``.
|
||||
examples: Concrete invocations to show when arguments are missing.
|
||||
ask: Friendly follow-up question printed when arguments are missing.
|
||||
objective: One sentence stating what a finished run delivers.
|
||||
steps: Ordered execution skeleton.
|
||||
worked_example: Pre-rendered lines of the numeric walkthrough.
|
||||
tools: Registered tool names the agent should prefer.
|
||||
aliases: Extra keywords resolving to this command.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
summary: str
|
||||
usage: str
|
||||
examples: tuple[str, ...]
|
||||
ask: str
|
||||
objective: str
|
||||
steps: tuple[Step, ...]
|
||||
worked_example: tuple[str, ...]
|
||||
tools: tuple[str, ...]
|
||||
aliases: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def handler_module(self) -> str:
|
||||
"""Dotted path of the module exposing ``run(ctx, *args)``."""
|
||||
return f"cli.commands.institutional.{self.slug}"
|
||||
|
||||
|
||||
# Shared policy text. Every playbook ships both blocks so the rule travels with
|
||||
# the prompt rather than living only in a system prompt the user cannot see.
|
||||
GAP_POLICY: tuple[str, ...] = (
|
||||
"Every figure must carry its source: the tool call and the fiscal period it",
|
||||
"came from. If a figure cannot be fetched, do NOT substitute a remembered or",
|
||||
"typical value. Instead: (a) name the missing item explicitly, (b) mark it",
|
||||
"MISSING in the table, (c) continue the analysis with the evidence actually",
|
||||
"retrieved, and (d) state which conclusions are now unsupported. A partial",
|
||||
"analysis with named holes is the correct deliverable; a complete-looking",
|
||||
"analysis with invented inputs is not.",
|
||||
)
|
||||
|
||||
NOT_ADVICE: str = (
|
||||
"This is research tooling, not investment advice, and not a recommendation "
|
||||
"to buy or sell any security. Outputs are analytical artifacts to be "
|
||||
"reviewed by the user."
|
||||
)
|
||||
|
||||
|
||||
_COMPS = Playbook(
|
||||
slug="comps",
|
||||
summary="Comparable company analysis (peer multiples -> implied range)",
|
||||
usage="/comps <ticker> [peer ...] [--asof YYYY-MM-DD]",
|
||||
examples=("/comps AAPL", "/comps 0700.HK 9988.HK BABA", "/comps NVDA AMD AVGO TSM"),
|
||||
ask="Which company should I run comps on? Peers are optional — I will build the peer set if you omit them.",
|
||||
objective=(
|
||||
"Produce a peer multiple table, the subject's premium/discount versus the "
|
||||
"peer median, and an implied value RANGE with the operating reason for the gap."
|
||||
),
|
||||
steps=(
|
||||
Step(
|
||||
title="Fix the peer set",
|
||||
inputs="Subject ticker; user-supplied peers, or screen_market by sector and size when none are given.",
|
||||
compute=(
|
||||
"Keep names that share the subject's revenue driver and sit inside a "
|
||||
"0.25x-4x market-cap band. Reject everything else."
|
||||
),
|
||||
output="Peer table listing each INCLUDED name with its inclusion reason, and each REJECTED name with its rejection reason.",
|
||||
),
|
||||
Step(
|
||||
title="Pull the raw inputs",
|
||||
inputs="get_stock_profile + get_financial_statements (latest annual and quarterly) for the subject and every peer.",
|
||||
compute=(
|
||||
"Enterprise value = market cap + total debt - cash. Then EV/Sales, "
|
||||
"EV/EBITDA and P/E on trailing-twelve-month figures."
|
||||
),
|
||||
output="One row per name, every cell tagged with the fiscal period it was taken from.",
|
||||
),
|
||||
Step(
|
||||
title="Normalize",
|
||||
inputs="The raw multiple table from step 2.",
|
||||
compute=(
|
||||
"Mark negative or non-meaningful EBITDA / earnings as 'nm' — never as "
|
||||
"zero, and never dropped silently. Then compute the median and the "
|
||||
"25th/75th percentile per multiple."
|
||||
),
|
||||
output="Peer median + interquartile range, plus the COUNT of names actually used in each column.",
|
||||
),
|
||||
Step(
|
||||
title="Position the subject",
|
||||
inputs="Subject multiples versus the peer median.",
|
||||
compute="Premium/discount in percent for each multiple.",
|
||||
output=(
|
||||
"One verdict line per multiple, plus the two operating metrics "
|
||||
"(revenue growth, margin) that explain the largest gap. A discount is "
|
||||
"not a conclusion until it is explained."
|
||||
),
|
||||
),
|
||||
Step(
|
||||
title="Implied value range",
|
||||
inputs="Peer 25th / median / 75th percentile multiple x the subject's own metric.",
|
||||
compute="Implied EV -> minus net debt -> implied equity -> divide by diluted shares.",
|
||||
output="A RANGE with the spot price marked inside or outside it. Never a single point estimate.",
|
||||
),
|
||||
),
|
||||
worked_example=(
|
||||
"Subject: TTM revenue 4,000 | TTM EBITDA 800 (20.0% margin) | debt 900 |",
|
||||
"cash 400 (net debt 500) | 250m diluted shares | price 22.00",
|
||||
"",
|
||||
" market cap = 250 x 22.00 = 5,500",
|
||||
" EV = 5,500 + 900 - 400 = 6,000",
|
||||
" EV/EBITDA = 6,000 / 800 = 7.50x",
|
||||
"",
|
||||
"Peer EV/EBITDA sorted: 8.00, 9.20, 10.40, 11.60 (n=4, 0 marked nm)",
|
||||
" P25 = 8.00 + 0.75 x (9.20 - 8.00) = 8.90x",
|
||||
" median = 9.20 + 0.50 x (10.40 - 9.20) = 9.80x",
|
||||
" P75 = 10.40 + 0.25 x (11.60 - 10.40) = 10.70x",
|
||||
"",
|
||||
"Position: 7.50 / 9.80 - 1 = -23.5% (discount)",
|
||||
"",
|
||||
"Implied equity value per share (EV - net debt 500, / 250 shares):",
|
||||
" at P25 8.90 x 800 = 7,120 -> 6,620 = 26.48",
|
||||
" at median 9.80 x 800 = 7,840 -> 7,340 = 29.36",
|
||||
" at P75 10.70 x 800 = 8,560 -> 8,060 = 32.24",
|
||||
" spot 22.00 sits BELOW the P25 case; median implies +33.5%",
|
||||
"",
|
||||
"Step 4 verdict (the part that is actually analysis):",
|
||||
" subject revenue growth 4% vs peer median 11%. A -23.5% EV/EBITDA",
|
||||
" discount on 7pp less growth is roughly the fair penalty, so the",
|
||||
" gap is EXPLAINED, not an opportunity. The comp set only becomes a",
|
||||
" buy case if the growth gap is closing — check the last 4 quarters",
|
||||
" of year-over-year revenue before concluding anything.",
|
||||
),
|
||||
tools=("get_stock_profile", "get_financial_statements", "screen_market", "get_market_data"),
|
||||
aliases=("peers",),
|
||||
)
|
||||
|
||||
|
||||
_DCF = Playbook(
|
||||
slug="dcf",
|
||||
summary="Discounted cash flow valuation with sensitivity grid",
|
||||
usage="/dcf <ticker> [horizon=5] [wacc=9%] [g=2.5%]",
|
||||
examples=("/dcf MSFT", "/dcf 600519.SH horizon=10 g=3%", "/dcf TSLA wacc=11%"),
|
||||
ask="Which company should I value? Optionally pass horizon, wacc and terminal growth, e.g. /dcf MSFT horizon=5 wacc=9% g=2.5%.",
|
||||
objective=(
|
||||
"Produce an unlevered DCF: value per share, the terminal-value share of "
|
||||
"enterprise value, an exit-multiple cross-check, and a WACC x g sensitivity grid."
|
||||
),
|
||||
steps=(
|
||||
Step(
|
||||
title="Anchor the base year",
|
||||
inputs="get_financial_statements — last 3 fiscal years plus trailing twelve months. get_sec_filings for the 10-K when a line item is ambiguous.",
|
||||
compute="Revenue, EBIT, effective tax rate, D&A, capex, change in net working capital. Then unlevered FCF = EBIT x (1 - tax) + D&A - capex - dNWC.",
|
||||
output="Base-year FCF with every line traced to a specific filing period.",
|
||||
),
|
||||
Step(
|
||||
title="Set the drivers",
|
||||
inputs="History from step 1, plus management guidance if get_stock_news or get_sec_filings surfaces it.",
|
||||
compute="Revenue growth path, EBIT margin path, capex and D&A as a percent of revenue converging by the final year, dNWC as a percent of the revenue increment.",
|
||||
output="A driver table where every cell is labelled FILED, GUIDED, or ASSUMPTION. No unlabelled numbers.",
|
||||
),
|
||||
Step(
|
||||
title="Discount rate",
|
||||
inputs="get_macro_series for the risk-free rate (e.g. DGS10); beta, equity risk premium, cost of debt, target debt weight.",
|
||||
compute="WACC = E/V x (rf + beta x ERP) + D/V x kd x (1 - tax).",
|
||||
output="WACC with each input tagged sourced or assumed. The equity risk premium is ALWAYS an assumption — say so.",
|
||||
),
|
||||
Step(
|
||||
title="Terminal value, computed two ways",
|
||||
inputs="Final-year FCF and final-year EBITDA.",
|
||||
compute="Gordon growth TV = FCF_N x (1+g) / (WACC - g), with g capped at long-run nominal GDP. Separately, exit-multiple TV = EV/EBITDA x EBITDA_N.",
|
||||
output="Both terminal values, the implied exit multiple from the Gordon method, and whether it falls inside the peer range from /comps.",
|
||||
),
|
||||
Step(
|
||||
title="Bridge to value per share",
|
||||
inputs="Discounted explicit-horizon FCF plus discounted terminal value.",
|
||||
compute="Enterprise value - net debt + non-operating assets - minority interest = equity value; divide by diluted shares.",
|
||||
output="Value per share, percent versus spot, and the terminal-value share of EV.",
|
||||
),
|
||||
Step(
|
||||
title="Sensitivity, then the honest read",
|
||||
inputs="Base case from step 5.",
|
||||
compute="3x3 grid: WACC +/-100bp and terminal g +/-50bp.",
|
||||
output="The grid, the single driver that moves value most, and the WACC/g pair the CURRENT market price implies. That last number is the useful one.",
|
||||
),
|
||||
),
|
||||
worked_example=(
|
||||
"Base year: revenue 1,000 | EBIT margin 15% | tax 25% | D&A 60 | capex 80 | dNWC 10",
|
||||
" FCF0 = 150 x 0.75 + 60 - 80 - 10 = 82.5",
|
||||
"Drivers: revenue +8%/yr for 5y | margin flat 15% | D&A 6% of revenue",
|
||||
" capex 8% of revenue | dNWC 10% of the revenue increment",
|
||||
"",
|
||||
" yr revenue EBIT NOPAT D&A capex dNWC FCF DF@9% PV",
|
||||
" 1 1,080 162.0 121.5 64.8 86.4 8.0 91.9 0.9174 84.31",
|
||||
" 2 1,166 175.0 131.2 70.0 93.3 8.6 99.3 0.8417 83.54",
|
||||
" 3 1,260 189.0 141.7 75.6 100.8 9.3 107.2 0.7722 82.77",
|
||||
" 4 1,360 204.1 153.1 81.6 108.8 10.1 115.8 0.7084 82.02",
|
||||
" 5 1,469 220.4 165.3 88.2 117.6 10.9 125.0 0.6499 81.26",
|
||||
" sum PV(FCF) = 413.9",
|
||||
"",
|
||||
"Terminal value, g = 2.5%: (carry the unrounded FCF5 = 125.03, not the",
|
||||
"displayed 125.0 — rounding the terminal driver moves EV by ~0.4)",
|
||||
" TV = 125.03 x 1.025 / (0.09 - 0.025) = 1,971.6",
|
||||
" PV(TV) = 1,971.6 x 0.6499 = 1,281.4",
|
||||
" EV = 413.9 + 1,281.4 = 1,695.3",
|
||||
" equity = 1,695.3 - net debt 200 = 1,495.3",
|
||||
" per share, 100m shares = 14.95 (spot 12.50, +19.6%)",
|
||||
"",
|
||||
"Two mandatory cross-checks:",
|
||||
" TV share of EV = 1,281.4 / 1,695.3 = 75.6%",
|
||||
" -> above ~75% the answer IS the terminal assumption, so the exit",
|
||||
" multiple check below carries the conclusion, not the 5y forecast.",
|
||||
" implied exit EV/EBITDA = 1,971.6 / (220.4 + 88.2 = 308.6) = 6.39x",
|
||||
" -> inside a 6-9x peer range, so the Gordon g is not smuggling in",
|
||||
" a re-rating. If it landed at 14x, the model would be broken.",
|
||||
"",
|
||||
"Sensitivity corners (value per share):",
|
||||
" WACC 8.0%, g 2.5%: PV(FCF) 425.5 + PV(TV) 1,586.0 -> equity 1,811.5 = 18.12",
|
||||
" WACC 9.0%, g 2.5%: base case = 14.95",
|
||||
" WACC 10.0%, g 2.0%: PV(FCF) 402.8 + PV(TV) 989.8 -> equity 1,192.6 = 11.93",
|
||||
"",
|
||||
"The deliverable is NOT 'worth 14.95'. It is: the market's 12.50 sits",
|
||||
"just above the WACC 10% / g 2.0% corner, so the price is already",
|
||||
"discounting a ~10% cost of capital and near-zero real terminal growth.",
|
||||
"The question to research next is whether that is too harsh.",
|
||||
),
|
||||
tools=("get_financial_statements", "get_sec_filings", "get_macro_series", "get_stock_profile"),
|
||||
)
|
||||
|
||||
|
||||
_ATTRIB = Playbook(
|
||||
slug="attrib",
|
||||
summary="Brinson-Fachler attribution (allocation vs selection)",
|
||||
usage="/attrib <portfolio-or-holdings> [--benchmark SPY] [--from YYYY-MM-DD] [--to YYYY-MM-DD]",
|
||||
examples=(
|
||||
"/attrib my portfolio --benchmark SPY --from 2026-01-01",
|
||||
"/attrib AAPL:0.3 MSFT:0.4 XOM:0.3 --benchmark SPY",
|
||||
"/attrib holdings.csv --benchmark 000300.SH",
|
||||
),
|
||||
ask="Which portfolio should I attribute, and against which benchmark? e.g. /attrib holdings.csv --benchmark SPY --from 2026-01-01",
|
||||
objective=(
|
||||
"Decompose active return into allocation, selection and interaction per "
|
||||
"sector, reconcile the decomposition to the total active return, and state "
|
||||
"whether the alpha came from the source the mandate promised."
|
||||
),
|
||||
steps=(
|
||||
Step(
|
||||
title="Fix the weights",
|
||||
inputs="Portfolio holdings and weights at the START of the window; benchmark weights; a sector map for both.",
|
||||
compute="Normalize both weight vectors to 100%. Assign every name a sector.",
|
||||
output="Two weight vectors that each sum to 100%, plus the unclassified residual named explicitly rather than dumped into 'Other'.",
|
||||
),
|
||||
Step(
|
||||
title="Get returns",
|
||||
inputs="get_market_data over the window for every holding, every benchmark constituent (or the benchmark's own sector series), and the benchmark itself.",
|
||||
compute="Sector return = weight-averaged constituent return, computed separately for the portfolio (Rp_i) and the benchmark (Rb_i).",
|
||||
output="Rp_i and Rb_i per sector, plus total Rp and Rb.",
|
||||
),
|
||||
Step(
|
||||
title="Decompose",
|
||||
inputs="Weights from step 1 and returns from step 2.",
|
||||
compute=(
|
||||
"Per sector i: allocation = (wp_i - wb_i) x (Rb_i - Rb); "
|
||||
"selection = wb_i x (Rp_i - Rb_i); "
|
||||
"interaction = (wp_i - wb_i) x (Rp_i - Rb_i)."
|
||||
),
|
||||
output="A per-sector table with the three effects and their column totals.",
|
||||
),
|
||||
Step(
|
||||
title="Reconcile — this step is a gate",
|
||||
inputs="Column totals from step 3 and the total active return Rp - Rb.",
|
||||
compute="sum(allocation + selection + interaction) versus (Rp - Rb).",
|
||||
output=(
|
||||
"The residual. If it exceeds 1bp, STOP and report the gap and its "
|
||||
"likely cause (intra-period trades, cash, corporate actions, FX). Do "
|
||||
"not present an attribution table that does not tie out."
|
||||
),
|
||||
),
|
||||
Step(
|
||||
title="Attribute the residual",
|
||||
inputs="The gap from step 4 plus the trade log if available.",
|
||||
compute="Split into trading/timing, cash drag, and FX translation.",
|
||||
output="Each named with a number. Whatever remains is reported as unexplained — never absorbed into selection.",
|
||||
),
|
||||
Step(
|
||||
title="Mandate verdict",
|
||||
inputs="The allocation-versus-selection split.",
|
||||
compute="Share of active return from each source.",
|
||||
output="A one-line verdict on whether the return came from the skill the mandate is paid for, and whether the top contributor is repeatable or a single lucky tilt.",
|
||||
),
|
||||
),
|
||||
worked_example=(
|
||||
"Benchmark: Tech wb 50%, Rb 10.0% | Health wb 30%, Rb 4.0% | Energy wb 20%, Rb -2.0%",
|
||||
"Portfolio: Tech wp 60%, Rp 12.0% | Health wp 30%, Rp 3.0% | Energy wp 10%, Rp -1.0%",
|
||||
"",
|
||||
" Rb = 0.50x10.0 + 0.30x4.0 + 0.20x(-2.0) = 5.80%",
|
||||
" Rp = 0.60x12.0 + 0.30x3.0 + 0.10x(-1.0) = 8.00%",
|
||||
" active return = +2.20%",
|
||||
"",
|
||||
" sector alloc (wp-wb)(Rb_i-Rb) selection wb(Rp_i-Rb_i) interaction",
|
||||
" Tech (0.10)(10.0-5.80)=+0.420 (0.50)(12.0-10.0)=+1.000 +0.200",
|
||||
" Health (0.00)(4.0-5.80) = 0.000 (0.30)(3.0- 4.0)=-0.300 0.000",
|
||||
" Energy (-0.10)(-2.0-5.80)=+0.780 (0.20)(-1.0-(-2.0))=+0.200 -0.100",
|
||||
" total +1.200 +0.900 +0.100",
|
||||
"",
|
||||
" reconciliation: 1.200 + 0.900 + 0.100 = 2.200 = active PASS",
|
||||
"",
|
||||
"Step 6 verdict (the part that is actually analysis):",
|
||||
" 55% of the +220bp is ALLOCATION, and +78bp of that is a single",
|
||||
" Energy underweight in a sector that fell 2%. Selection contributed",
|
||||
" +90bp, of which +100bp is Tech stock picking partly given back by",
|
||||
" -30bp in Health. For a stock-selection mandate this is an OFF-MANDATE",
|
||||
" quarter: the headline beat is mostly one sector tilt, which is one",
|
||||
" decision, not a repeatable process. Report it that way.",
|
||||
),
|
||||
tools=("get_market_data", "portfolio_risk_xray", "get_sector_info", "get_stock_profile"),
|
||||
aliases=("attribution",),
|
||||
)
|
||||
|
||||
|
||||
_MEMO = Playbook(
|
||||
slug="memo",
|
||||
summary="Investment memo — thesis, variant view, scenarios, kill criteria",
|
||||
usage="/memo <ticker> [long|short] [horizon=12m]",
|
||||
examples=("/memo NVDA long", "/memo BABA short horizon=6m", "/memo 005930.KS"),
|
||||
ask="Which company should the memo cover? Optionally say long or short and a horizon, e.g. /memo NVDA long horizon=12m.",
|
||||
objective=(
|
||||
"Produce a decision-ready memo: a one-sentence thesis, the variant view, "
|
||||
"probability-weighted scenarios, falsifiable kill criteria, and the "
|
||||
"quarterly monitoring set."
|
||||
),
|
||||
steps=(
|
||||
Step(
|
||||
title="Thesis and variant view",
|
||||
inputs="get_research_reports and get_stock_news for what consensus currently believes.",
|
||||
compute="State consensus in one sentence, your view in one sentence, and the specific fact or mechanism that separates them.",
|
||||
output="If you cannot name what you believe that consensus does not, there is no memo — say so and stop.",
|
||||
),
|
||||
Step(
|
||||
title="Business and unit economics",
|
||||
inputs="get_stock_profile, get_financial_statements, get_sec_filings (segment disclosure).",
|
||||
compute="Revenue drivers (volume x price x mix), contribution margin, what one marginal unit earns and how that trends.",
|
||||
output="A driver tree where the top line decomposes into things that can be independently observed.",
|
||||
),
|
||||
Step(
|
||||
title="The numbers",
|
||||
inputs="Three years of history plus your forecast.",
|
||||
compute="Growth, margins, returns on capital, cash conversion, leverage.",
|
||||
output="A table where each historical cell cites a filing period and each forecast cell cites the step-2 driver it comes from.",
|
||||
),
|
||||
Step(
|
||||
title="Valuation with two independent anchors",
|
||||
inputs="A /dcf run and a /comps run.",
|
||||
compute="Bear / base / bull value per share, with an explicit probability on each. Expected value = sum(probability x value).",
|
||||
output="The three cases, the expected value, the upside/downside skew, and the break-even probability of the base case.",
|
||||
),
|
||||
Step(
|
||||
title="What kills it",
|
||||
inputs="The driver tree from step 2.",
|
||||
compute="Three risks, each expressed as an observable metric with a numeric threshold.",
|
||||
output="'Thesis is wrong if X falls below Y for two consecutive quarters' — falsifiable. 'Competition may intensify' is not a risk, it is filler.",
|
||||
),
|
||||
Step(
|
||||
title="Monitoring set",
|
||||
inputs="Step 5 thresholds.",
|
||||
compute="Pick the three datapoints that move first when the thesis breaks.",
|
||||
output="Three metrics, their release cadence, their current value, and the exit trigger.",
|
||||
),
|
||||
),
|
||||
worked_example=(
|
||||
"Step 4 scenario math, spot 12.50:",
|
||||
"",
|
||||
" case prob value/share source",
|
||||
" bear 25% 9.00 comps at peer P25 on trough EBITDA",
|
||||
" base 50% 15.00 DCF, WACC 9.0%, g 2.5%",
|
||||
" bull 25% 22.00 DCF, margin path to 19% by year 5",
|
||||
"",
|
||||
" expected value = 0.25x9.00 + 0.50x15.00 + 0.25x22.00",
|
||||
" = 2.25 + 7.50 + 5.50 = 15.25 (+22.0% vs spot)",
|
||||
" downside = 9.00/12.50 - 1 = -28.0%",
|
||||
" upside = 22.00/12.50 - 1 = +76.0%",
|
||||
" skew = 76.0 / 28.0 = 2.7 : 1",
|
||||
"",
|
||||
" break-even test — hold bear at 25% and solve for the bull weight p",
|
||||
" that makes expected value equal the 12.50 spot:",
|
||||
" 2.25 + (0.75 - p)x15.00 + p x 22.00 = 12.50",
|
||||
" 13.50 + 7.00p = 12.50 -> p = -0.14",
|
||||
"",
|
||||
" A NEGATIVE break-even weight means even with zero probability on the",
|
||||
" bull case the expected value (2.25 + 0.75x15.00 = 13.50) still clears",
|
||||
" the spot price. So the position does not depend on the bull case at",
|
||||
" all — it depends entirely on the base case being right. That redirects",
|
||||
" the remaining work: stop arguing the upside, stress-test the 15.00.",
|
||||
" Concretely, the base case fails if the 15% EBIT margin is not held;",
|
||||
" at 12% margin the same DCF gives roughly 11.60, below spot.",
|
||||
),
|
||||
tools=(
|
||||
"get_financial_statements",
|
||||
"get_sec_filings",
|
||||
"get_stock_profile",
|
||||
"get_research_reports",
|
||||
"get_stock_news",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_EARNINGS = Playbook(
|
||||
slug="earnings",
|
||||
summary="Earnings review — surprise bridge from revenue to EPS",
|
||||
usage="/earnings <ticker> [quarter=Q3-2026]",
|
||||
examples=("/earnings AAPL", "/earnings MSFT quarter=Q2-2026", "/earnings 0700.HK"),
|
||||
ask="Whose earnings should I review? e.g. /earnings AAPL quarter=Q3-2026 (quarter defaults to the most recent report).",
|
||||
objective=(
|
||||
"Turn a headline beat or miss into a line-by-line bridge, separate the "
|
||||
"recurring operating surprise from the non-recurring one, and state what "
|
||||
"actually changed in the model."
|
||||
),
|
||||
steps=(
|
||||
Step(
|
||||
title="Pull the print",
|
||||
inputs="get_financial_statements for the latest quarter; get_sec_filings for the 8-K / 10-Q; get_stock_news or get_research_reports for the consensus figure.",
|
||||
compute="Line up actual versus consensus for revenue, gross margin, opex, tax rate, share count, EPS.",
|
||||
output="A two-column table. The consensus column is tagged SOURCED or ASSUMPTION — a remembered consensus number is an ASSUMPTION and must be labelled one.",
|
||||
),
|
||||
Step(
|
||||
title="Build the bridge",
|
||||
inputs="The step-1 table.",
|
||||
compute=(
|
||||
"Walk from consensus EPS to actual EPS ONE variable at a time, holding "
|
||||
"the others at consensus, then re-baselining after each step. Order: "
|
||||
"revenue, gross margin, opex, below-the-line, tax rate, share count."
|
||||
),
|
||||
output="A bridge whose steps sum EXACTLY to actual EPS minus consensus EPS. If it does not sum, a variable is missing — find it before writing anything.",
|
||||
),
|
||||
Step(
|
||||
title="Grade the quality",
|
||||
inputs="The bridge.",
|
||||
compute="Split each step into recurring operating (volume, price, mix, structural cost) and non-recurring (one-off tax items, buybacks, FX, asset sales, legal reversals).",
|
||||
output="Operating-only surprise versus headline surprise, as a number and as a percentage of the headline.",
|
||||
),
|
||||
Step(
|
||||
title="Guidance delta",
|
||||
inputs="New guidance versus prior guidance versus consensus for the year.",
|
||||
compute="Implied remainder of the year = full-year guide minus reported year-to-date.",
|
||||
output="Whether the implied remainder is an acceleration or a quiet cut hidden by a strong quarter already banked.",
|
||||
),
|
||||
Step(
|
||||
title="What changed in the model",
|
||||
inputs="Steps 3 and 4.",
|
||||
compute="The three inputs whose forward path you now revise, and the resulting change in fair value.",
|
||||
output="Old fair value, new fair value, and the single line item responsible for most of the change.",
|
||||
),
|
||||
),
|
||||
worked_example=(
|
||||
"Consensus: revenue 1,000 | GM 60.0% | opex 400 | interest -20 | tax 25%",
|
||||
" | 100.0m shares -> EPS 1.35",
|
||||
"Actual: revenue 1,050 | GM 58.5% | opex 405 | interest -20 | tax 22%",
|
||||
" | 99.0m shares -> EPS 1.49",
|
||||
"",
|
||||
"Bridge, one variable at a time, re-baselining after each step:",
|
||||
" start (consensus) EPS 1.3500",
|
||||
" + revenue 1,000 -> 1,050 at 60% GM GP 630, NI 157.50 EPS 1.5750 +0.2250",
|
||||
" + GM 60.0% -> 58.5% GP 614.25, NI 145.69 EPS 1.4569 -0.1181",
|
||||
" + opex 400 -> 405 NI 141.94 EPS 1.4194 -0.0375",
|
||||
" + tax 25% -> 22% NI 147.62 EPS 1.4762 +0.0568",
|
||||
" + shares 100.0m -> 99.0m NI 147.62 EPS 1.4911 +0.0149",
|
||||
" sum of steps = 0.2250-0.1181-0.0375+0.0568+0.0149 = +0.1411",
|
||||
" check: 1.4911 - 1.3500 = +0.1411 PASS",
|
||||
"",
|
||||
"Step 3 quality grade (the part that is actually analysis):",
|
||||
" recurring operating = +0.2250 - 0.1181 - 0.0375 = +0.0694",
|
||||
" non-recurring = +0.0568 (one-off R&D tax credit)",
|
||||
" + 0.0149 (buyback) = +0.0717",
|
||||
" non-recurring share of the headline beat = 0.0717/0.1411 = 50.8%",
|
||||
"",
|
||||
" So 'beat by 14 cents on a 5% revenue beat' is really a 7-cent",
|
||||
" operating beat. The 5% volume upside was half given back by a 150bp",
|
||||
" mix-driven gross margin decline — that mix shift is the datapoint",
|
||||
" that changes the model, not the EPS headline. Check whether the",
|
||||
" lower-margin revenue is a one-quarter mix accident or the new run rate.",
|
||||
),
|
||||
tools=(
|
||||
"get_financial_statements",
|
||||
"get_sec_filings",
|
||||
"get_stock_news",
|
||||
"get_research_reports",
|
||||
"get_market_data",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_SCREEN = Playbook(
|
||||
slug="screen",
|
||||
summary="Systematic idea screen — hypothesis, funnel, survivor queue",
|
||||
usage="/screen <hypothesis or criteria> [--universe sp500|csi300|...]",
|
||||
examples=(
|
||||
"/screen quality compounders trading below 15x FCF --universe sp500",
|
||||
"/screen ROIC > 12% and net debt/EBITDA < 2 --universe csi300",
|
||||
"/screen oversold industrials with rising order books",
|
||||
),
|
||||
ask="What should I screen for? Give me the economic hypothesis or the hard criteria, e.g. /screen quality compounders below 15x FCF --universe sp500",
|
||||
objective=(
|
||||
"Turn a hypothesis into hard thresholds, run the screen, publish the funnel "
|
||||
"count at every filter, and hand back a de-duplicated research queue with an "
|
||||
"honest false-positive caveat."
|
||||
),
|
||||
steps=(
|
||||
Step(
|
||||
title="State the hypothesis first",
|
||||
inputs="The user's words.",
|
||||
compute="Write the economic mechanism in one sentence BEFORE choosing any threshold, so the filters are derived from the idea rather than reverse-engineered from a pretty result.",
|
||||
output="One sentence. If the hypothesis cannot be stated without referring to the metrics, it is a data-mining exercise — label it as one.",
|
||||
),
|
||||
Step(
|
||||
title="Translate to hard criteria",
|
||||
inputs="The hypothesis.",
|
||||
compute="Each clause becomes a metric with a numeric threshold and a direction. Every threshold gets a one-line justification.",
|
||||
output="A criteria table: metric, operator, threshold, why this number.",
|
||||
),
|
||||
Step(
|
||||
title="Run the funnel",
|
||||
inputs="screen_market over the stated universe, then get_financial_statements for any criterion the screener cannot express.",
|
||||
compute="Apply filters in order, from cheapest and most eliminating to most expensive.",
|
||||
output="The FUNNEL: the surviving count after every single filter. A screen that only reports the final list is unreviewable.",
|
||||
),
|
||||
Step(
|
||||
title="Data-completeness pass",
|
||||
inputs="The survivor list.",
|
||||
compute="Drop names missing an input for a binding criterion.",
|
||||
output="Names dropped for missing data are listed SEPARATELY from names dropped for failing a test. Conflating the two silently biases the result toward well-covered large caps.",
|
||||
),
|
||||
Step(
|
||||
title="De-duplicate by driver",
|
||||
inputs="Survivors.",
|
||||
compute="Group names sharing one underlying driver (same commodity, same customer, same rate sensitivity).",
|
||||
output="One representative per cluster plus the cluster members, so the queue is not five bets on one macro variable.",
|
||||
),
|
||||
Step(
|
||||
title="Hand back a queue, not a portfolio",
|
||||
inputs="The de-duplicated survivors.",
|
||||
compute="For each name: the single metric it is weakest on, and the one question to answer first.",
|
||||
output=(
|
||||
"A ranked research queue plus a threshold-sensitivity caveat stated in "
|
||||
"numbers: re-run the funnel with each threshold loosened one notch and "
|
||||
"report how much of the survivor set is an artifact of where the lines "
|
||||
"were drawn. Never quote a per-name false-positive rate — a deterministic "
|
||||
"screen is not a hypothesis test and has none."
|
||||
),
|
||||
),
|
||||
),
|
||||
worked_example=(
|
||||
"Hypothesis: 'Businesses that earn well above their cost of capital and",
|
||||
"are not levered can reinvest through a downturn; the market underpays",
|
||||
"for that when the free cash flow yield is already high.'",
|
||||
"",
|
||||
"Criteria and funnel (universe: 3,200 listed names):",
|
||||
" filter surviving removed",
|
||||
" start 3,200",
|
||||
" market cap >= 2.0bn (liquidity) 812 -2,388",
|
||||
" ROIC >= 12% (above cost of capital) 245 -567",
|
||||
" net debt/EBITDA <= 2.0 (survives a downturn) 96 -149",
|
||||
" FCF yield >= 5% (the underpayment) 31 -65",
|
||||
" data-completeness pass (step 4) 19 -12 MISSING DATA",
|
||||
" de-duplicate by driver (step 5) 6 -13 same driver",
|
||||
"",
|
||||
" the 12 dropped for missing data are listed by name and by which",
|
||||
" field was absent; they are NOT counted as failures.",
|
||||
" the 13 collapsed names are 6 semis-cycle, 4 US housing, 3 freight.",
|
||||
"",
|
||||
"Step 6 caveat, stated in numbers rather than hedged in prose:",
|
||||
" A screen is a deterministic filter on observed data, so there is no",
|
||||
" per-name 'false positive rate' to quote — the selection risk lives in",
|
||||
" the CHOICE of the 4 thresholds, not in the 3,200 rows. Size it that way:",
|
||||
" the funnel keeps 31/3,200 = 0.97% of the universe, so moving any one",
|
||||
" threshold by a notch (ROIC 12% -> 10%, FCF yield 5% -> 4%) is what to",
|
||||
" report. Here that widens 31 -> 74, i.e. 58% of the survivor set is an",
|
||||
" artifact of where the lines were drawn rather than a stable property.",
|
||||
" So the screen cannot establish that these 6 names are good; it can only",
|
||||
" establish that they are worth the next 6 hours. Treat the output as a",
|
||||
" research queue and require an out-of-sample or fundamental confirmation",
|
||||
" before any of it becomes a position.",
|
||||
),
|
||||
tools=("screen_market", "get_financial_statements", "get_stock_profile", "iwencai_search"),
|
||||
aliases=("screener",),
|
||||
)
|
||||
|
||||
|
||||
PLAYBOOKS: tuple[Playbook, ...] = (_COMPS, _DCF, _ATTRIB, _MEMO, _EARNINGS, _SCREEN)
|
||||
|
||||
PLAYBOOKS_BY_SLUG: dict[str, Playbook] = {pb.slug: pb for pb in PLAYBOOKS}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GAP_POLICY",
|
||||
"NOT_ADVICE",
|
||||
"PLAYBOOKS",
|
||||
"PLAYBOOKS_BY_SLUG",
|
||||
"Playbook",
|
||||
"Step",
|
||||
]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Shared rendering and dispatch for the institutional workflow commands.
|
||||
|
||||
Every ``/comps``-style command funnels through :func:`run_playbook`:
|
||||
|
||||
* no arguments (or ``help`` / ``-h`` / ``--help`` / ``?``) renders the full
|
||||
playbook card — objective, numbered execution skeleton, the worked numeric
|
||||
example, the data-gap policy and the not-advice notice — followed by a
|
||||
friendly follow-up question. This is a normal, successful outcome, so it
|
||||
returns ``0``; missing arguments are not an error here.
|
||||
* with arguments the same skeleton is rendered into a prompt and queued on
|
||||
``ctx.pending_prompt``, which ``cli.main._interactive_loop`` drains into a
|
||||
real agent turn (the mechanism ``/journal`` and ``/shadow`` already use).
|
||||
When the context cannot hold a queued prompt — a bare test stub, or a
|
||||
non-interactive caller — the prompt is printed instead so it can be pasted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console, Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
from .playbooks import GAP_POLICY, NOT_ADVICE, PLAYBOOKS_BY_SLUG, Playbook
|
||||
|
||||
# Defensive ceiling on the free-text argument so a paste bomb cannot blow up
|
||||
# the queued prompt (and, downstream, the model context).
|
||||
#
|
||||
# A malformed override must not be fatal: this module is imported by
|
||||
# ``cli.main._dispatch_slash``, whose import guard only catches ``ImportError``,
|
||||
# so an exception raised here would escape the dispatcher and take down the
|
||||
# whole REPL. The config layer drops an unparseable value and hands back the
|
||||
# declared default, so this cannot raise.
|
||||
def _arg_max_chars() -> int:
|
||||
"""Resolve the argument length cap, ignoring a malformed override."""
|
||||
return max(32, get_env_config().agent_tuning.vibe_trading_slash_arg_max)
|
||||
|
||||
|
||||
_ARG_MAX_CHARS = _arg_max_chars()
|
||||
|
||||
_HELP_TOKENS = frozenset({"help", "-h", "--help", "?"})
|
||||
|
||||
|
||||
def _console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
def _render_card(playbook: Playbook) -> None:
|
||||
"""Print the full playbook card for ``playbook``.
|
||||
|
||||
Steps go into a two-column ``Table.grid`` rather than a flat ``Text`` so
|
||||
the ``in`` / ``compute`` / ``out`` labels keep a hanging indent when the
|
||||
body wraps on a narrow terminal.
|
||||
"""
|
||||
header = Text()
|
||||
header.append(playbook.objective + "\n\n")
|
||||
header.append("Usage ", style="dim")
|
||||
header.append(playbook.usage + "\n", style="bold")
|
||||
for example in playbook.examples:
|
||||
header.append(" ")
|
||||
header.append(example + "\n", style="cyan")
|
||||
header.append("\nExecution skeleton", style="bold")
|
||||
|
||||
steps = Table.grid(padding=(0, 1))
|
||||
steps.add_column(style="dim", no_wrap=True, justify="right", width=9)
|
||||
steps.add_column(ratio=1, overflow="fold")
|
||||
for index, step in enumerate(playbook.steps, start=1):
|
||||
steps.add_row(f"{index}.", Text(step.title, style="bold"))
|
||||
steps.add_row("in", Text(step.inputs, style="dim"))
|
||||
steps.add_row("compute", Text(step.compute, style="dim"))
|
||||
steps.add_row("out", Text(step.output, style="dim"))
|
||||
steps.add_row("", "")
|
||||
|
||||
example_block = Text()
|
||||
example_block.append("Worked example\n", style="bold")
|
||||
for line in playbook.worked_example:
|
||||
example_block.append(f" {line}\n", style="dim")
|
||||
example_block.append("\nTools ", style="dim")
|
||||
example_block.append(", ".join(playbook.tools) + "\n\n", style="cyan")
|
||||
example_block.append("Missing data\n", style="bold")
|
||||
for line in GAP_POLICY:
|
||||
example_block.append(f" {line}\n", style="dim")
|
||||
example_block.append("\n")
|
||||
example_block.append(NOT_ADVICE + "\n\n", style="yellow")
|
||||
example_block.append(playbook.ask, style="bold")
|
||||
|
||||
_console().print(
|
||||
Panel(
|
||||
Group(header, steps, example_block),
|
||||
title=f"/{playbook.slug}",
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_prompt(playbook: Playbook, subject: str) -> str:
|
||||
"""Render ``playbook`` plus the user's arguments into an agent prompt.
|
||||
|
||||
The full skeleton, the worked example, the gap policy and the not-advice
|
||||
notice all travel with the prompt so the behaviour does not depend on any
|
||||
particular system prompt being loaded.
|
||||
|
||||
Args:
|
||||
playbook: The workflow to run.
|
||||
subject: The user's raw arguments, already trimmed and length-capped.
|
||||
|
||||
Returns:
|
||||
The prompt text to hand to the agent loop.
|
||||
"""
|
||||
lines = [
|
||||
f"Run the {playbook.slug.upper()} research playbook.",
|
||||
"",
|
||||
f"Request: {subject}",
|
||||
"",
|
||||
f"Deliverable: {playbook.objective}",
|
||||
"",
|
||||
"Work through these steps in order. Do not skip a step, do not reorder them,",
|
||||
"and show the intermediate artifact each step is required to produce.",
|
||||
"",
|
||||
]
|
||||
for index, step in enumerate(playbook.steps, start=1):
|
||||
lines.append(f"Step {index} — {step.title}")
|
||||
lines.append(f" Inputs: {step.inputs}")
|
||||
lines.append(f" Compute: {step.compute}")
|
||||
lines.append(f" Output: {step.output}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Worked example — reproduce this level of arithmetic explicitness with")
|
||||
lines.append("the real retrieved data. The figures below are illustrative teaching")
|
||||
lines.append("numbers, not data about any real security, and must not be reused as facts.")
|
||||
lines.extend(f" {line}" for line in playbook.worked_example)
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"Preferred tools: {', '.join(playbook.tools)}.")
|
||||
# Some of these are key-gated (``get_macro_series`` needs FRED_API_KEY,
|
||||
# ``iwencai_search`` needs VIBE_TRADING_IWENCAI_KEY) and are absent from the
|
||||
# registry unless configured. Say so explicitly: an unavailable tool must
|
||||
# become a declared gap, never a remembered number.
|
||||
lines.append(
|
||||
"Any preferred tool that is not registered in this session is simply "
|
||||
"unavailable — skip it, record what it would have supplied as a gap under "
|
||||
"the missing-data policy below, and never fill that gap from memory."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Missing data:")
|
||||
lines.extend(f" {line}" for line in GAP_POLICY)
|
||||
lines.append("")
|
||||
lines.append(NOT_ADVICE)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_playbook(slug: str, ctx: Any = None, *args: str) -> int:
|
||||
"""Render or dispatch the playbook registered under ``slug``.
|
||||
|
||||
Args:
|
||||
slug: Playbook key, matching the slash command keyword.
|
||||
ctx: The interactive context. Needs a writable ``pending_prompt``
|
||||
attribute for the queued-turn path; anything else falls back to
|
||||
printing the prompt.
|
||||
*args: Raw slash-command arguments.
|
||||
|
||||
Returns:
|
||||
``0`` in every user-facing path — a missing argument is answered with
|
||||
the playbook card and a follow-up question, not an error. ``1`` only
|
||||
when ``slug`` is not a registered playbook, which is a programming
|
||||
error rather than a user mistake.
|
||||
"""
|
||||
playbook = PLAYBOOKS_BY_SLUG.get(slug)
|
||||
if playbook is None:
|
||||
_console().print(Text(f"Unknown research playbook: {slug}", style="bold red"))
|
||||
return 1
|
||||
|
||||
if args and args[0].lower() in _HELP_TOKENS:
|
||||
_render_card(playbook)
|
||||
return 0
|
||||
|
||||
subject = " ".join(args).strip()
|
||||
if not subject:
|
||||
_render_card(playbook)
|
||||
return 0
|
||||
if len(subject) > _ARG_MAX_CHARS:
|
||||
subject = subject[:_ARG_MAX_CHARS].rstrip() + " …(truncated)"
|
||||
|
||||
prompt = build_prompt(playbook, subject)
|
||||
console = _console()
|
||||
if ctx is not None and hasattr(ctx, "pending_prompt"):
|
||||
try:
|
||||
ctx.pending_prompt = prompt
|
||||
console.print(
|
||||
Text(f"→ Running the /{playbook.slug} playbook on: {subject}", style="dim")
|
||||
)
|
||||
return 0
|
||||
except Exception: # noqa: BLE001 — never let a slash handler kill the REPL
|
||||
pass
|
||||
|
||||
console.print(Text(f"/{playbook.slug} — paste this prompt to run it:", style="bold"))
|
||||
console.print(Text(prompt, style="dim"))
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["build_prompt", "run_playbook"]
|
||||
Loaded 100 of 1592 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user