Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43331c3221 | ||
|
|
8cb536fa61 | ||
|
|
a066e5545e | ||
|
|
f2c7256d56 | ||
|
|
93fa241fc8 | ||
|
|
f06d399388 | ||
|
|
5eb45502c7 | ||
|
|
d538bfa369 | ||
|
|
3f0e6b8c94 | ||
|
|
f1697117c1 | ||
|
|
566eb86ba3 | ||
|
|
be0d0a7c2c | ||
|
|
6fffd52541 | ||
|
|
e8241034e9 | ||
|
|
c350cddf8c | ||
|
|
2b01bf46d0 | ||
|
|
91c7cb23da | ||
|
|
ab3e8076d6 | ||
|
|
c0f794c2c9 | ||
|
|
e76e1dc6fb | ||
|
|
d88a591272 | ||
|
|
3074b684e1 | ||
|
|
bd610d0b67 | ||
|
|
afd0046cbd | ||
|
|
0b9df59cfe | ||
|
|
13f71e5a6a | ||
|
|
5f9144946d | ||
|
|
a44e1be357 | ||
|
|
555dcec560 | ||
|
|
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 | ||
|
|
6dc76e1ad3 | ||
|
|
97e762e51c | ||
|
|
cf51bc322b | ||
|
|
668389bdea | ||
|
|
1845e41122 | ||
|
|
9d185ba357 | ||
|
|
7b2b75fb5c | ||
|
|
41ba61bcc8 | ||
|
|
f42d1b3af0 | ||
|
|
5c6ec6c4c3 | ||
|
|
c1277e197b | ||
|
|
d55de48d42 | ||
|
|
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 | ||
|
|
a8144e9acf | ||
|
|
f9bea768f4 | ||
|
|
2a893e1a26 | ||
|
|
1e3bd7a8c0 | ||
|
|
52f5059159 | ||
|
|
4e009c7769 | ||
|
|
168122311a | ||
|
|
7c242b934c | ||
|
|
002e042d44 | ||
|
|
a7522d29bb | ||
|
|
86bb254334 | ||
|
|
7dc888eeae | ||
|
|
108bfae960 | ||
|
|
f0c799d5b4 | ||
|
|
ec5d8b18d8 | ||
|
|
7acaf74e10 | ||
|
|
46e49626b9 | ||
|
|
5a6780be2b | ||
|
|
bc63784752 | ||
|
|
6b1b180f66 | ||
|
|
d542038d36 | ||
|
|
e4e2a9d9c2 | ||
|
|
f607f7a930 | ||
|
|
3bd356c1f6 | ||
|
|
83801bad46 | ||
|
|
acab70c556 | ||
|
|
6b8241a356 | ||
|
|
cceb4def1e | ||
|
|
62869fc26a | ||
|
|
8b2273c6de | ||
|
|
2a18d33c23 | ||
|
|
74614f0e7c | ||
|
|
1404eda72b | ||
|
|
cac3f0b5ed | ||
|
|
e9c0000f5f | ||
|
|
1edf27e040 | ||
|
|
b5a44a1e56 | ||
|
|
34a30fb58d | ||
|
|
dda06ae351 | ||
|
|
008c0839cd | ||
|
|
a1e83aded7 | ||
|
|
0c39711432 | ||
|
|
c1f3898056 | ||
|
|
8a7a658260 | ||
|
|
37fb6e2f4e | ||
|
|
8a6c578a76 | ||
|
|
5684d5fe09 | ||
|
|
51148d71ee | ||
|
|
d2c94396aa | ||
|
|
07e1adcbba | ||
|
|
137689c5c1 | ||
|
|
d77c64e2ce | ||
|
|
94c2a8ffb2 | ||
|
|
1676eaa328 | ||
|
|
6d7d365892 | ||
|
|
fd9e5fc689 | ||
|
|
2f9fa22a56 | ||
|
|
39aaa62008 | ||
|
|
220abb10a1 | ||
|
|
14ba958513 | ||
|
|
0e42cbe4c8 | ||
|
|
9769e5b543 | ||
|
|
101eb9b614 | ||
|
|
bdb9dfca55 | ||
|
|
b1acd830d2 | ||
|
|
8f49fe4902 | ||
|
|
e346fa1adf | ||
|
|
55d81a6c53 | ||
|
|
db21715cf9 | ||
|
|
dce3eb94c2 | ||
|
|
1190ee9735 | ||
|
|
eba0f1f8a1 | ||
|
|
298ada79b7 | ||
|
|
7b27f6e3e2 | ||
|
|
02a16a6422 | ||
|
|
6b6683a026 | ||
|
|
45a9f525e4 | ||
|
|
75ecbce1fc | ||
|
|
4604d4c67a | ||
|
|
16102825b4 | ||
|
|
2d80b6248c | ||
|
|
0e0a584d85 | ||
|
|
32a96fb5b2 | ||
|
|
b41141fa8f | ||
|
|
0678fd195a | ||
|
|
fb7efe76f6 | ||
|
|
9e70dca97c | ||
|
|
e7d72be574 | ||
|
|
3981eb53f8 | ||
|
|
ca26b0660a | ||
|
|
5d90f3317f | ||
|
|
37e287c6c7 | ||
|
|
3daa8dfae8 | ||
|
|
7320ac8d01 | ||
|
|
96dbdb8050 | ||
|
|
ac955c5692 | ||
|
|
5731868a20 | ||
|
|
b1d363b33c | ||
|
|
fbe37e0f63 | ||
|
|
1e0a7a0dd8 | ||
|
|
bb977f2614 | ||
|
|
209334d609 | ||
|
|
f55ef1ffb9 | ||
|
|
ba7cd19564 | ||
|
|
80e177228f | ||
|
|
51d0188cc5 | ||
|
|
5eb8390921 | ||
|
|
09dd59e9aa | ||
|
|
5eef5f3bd3 | ||
|
|
9fe6698d17 | ||
|
|
79efe1d6a8 | ||
|
|
dd594080c3 | ||
|
|
5f411ddaf7 | ||
|
|
b9a82655bd | ||
|
|
bd722334d3 | ||
|
|
977fddfbff | ||
|
|
e2535cc7c3 | ||
|
|
52b624d3c8 | ||
|
|
4115960a42 | ||
|
|
4fd82a0952 | ||
|
|
4ae673b6d1 | ||
|
|
94d0589638 | ||
|
|
0f4e3d7110 | ||
|
|
d1273171a6 | ||
|
|
947d945827 | ||
|
|
41fa4ad0b0 | ||
|
|
2fc8c90d9f | ||
|
|
946d9ae3b9 | ||
|
|
f917b71960 | ||
|
|
ecf1e51915 | ||
|
|
052277bb56 | ||
|
|
7e167ea238 | ||
|
|
688d388551 | ||
|
|
012a499f74 | ||
|
|
14c97d7c19 | ||
|
|
9454d4a27a | ||
|
|
5c3d5f98a2 | ||
|
|
fd767f5c29 | ||
|
|
fd7384e472 | ||
|
|
3d812ae5ec | ||
|
|
5bafac3de8 | ||
|
|
319255c54b | ||
|
|
c3174bacb9 | ||
|
|
1cbbaab88e | ||
|
|
72e4488a45 | ||
|
|
246d5a0dc3 | ||
|
|
55972c3e9b | ||
|
|
b18f22bc94 | ||
|
|
6512c7ba2c | ||
|
|
27cc7e74a8 | ||
|
|
cb87c3a37d | ||
|
|
f1f3e00c97 | ||
|
|
6af0a86e2f |
No files matched your search
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Vibe-Trading",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [
|
||||
8899,
|
||||
5899
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8899": {
|
||||
"label": "Vibe-Trading API",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "private"
|
||||
},
|
||||
"5899": {
|
||||
"label": "Vibe-Trading Frontend",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "private"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "python -m pip install --upgrade pip && python -m pip install -e . && cd frontend && npm install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,44 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Root Python project (pyproject.toml).
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 4
|
||||
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"]
|
||||
|
||||
# Frontend npm dependencies.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 4
|
||||
groups:
|
||||
npm-minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# GitHub Actions workflow pins.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 4
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types: ["minor", "patch"]
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
@@ -22,10 +22,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- 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: |
|
||||
cd agent
|
||||
python -m py_compile cli.py
|
||||
python -m compileall -q cli
|
||||
python -m py_compile api_server.py
|
||||
python -m py_compile mcp_server.py
|
||||
python -m py_compile src/agent/loop.py
|
||||
@@ -33,9 +39,12 @@ jobs:
|
||||
python -m py_compile backtest/runner.py
|
||||
|
||||
- name: Run tests
|
||||
run: pytest --ignore=agent/tests/e2e_backtest --tb=short -q
|
||||
# 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 --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"
|
||||
cache: npm
|
||||
@@ -46,3 +55,8 @@ jobs:
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Frontend tests
|
||||
run: |
|
||||
cd frontend
|
||||
npx vitest run --reporter=verbose
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Deploy Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "wiki/**"
|
||||
- ".github/workflows/wiki-deploy.yml"
|
||||
|
||||
concurrency:
|
||||
group: wiki-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Deploy wiki
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
# 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 }}
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "wiki/**"
|
||||
- ".github/workflows/wiki.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "wiki/**"
|
||||
- ".github/workflows/wiki.yml"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Check JavaScript syntax
|
||||
run: |
|
||||
node --check wiki/main.js
|
||||
node --check wiki/theme.js
|
||||
node --check wiki/docs/content.js
|
||||
node --check wiki/docs/main.js
|
||||
|
||||
- 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
|
||||
test -f wiki/home/index.html
|
||||
test -f wiki/docs/index.html
|
||||
test -f wiki/tutorials/index.html
|
||||
test -f wiki/alpha-library/index.html
|
||||
test -f wiki/research-lab/index.html
|
||||
test -f wiki/_redirects
|
||||
test -f wiki/_headers
|
||||
test -f wiki/assets/icon.png
|
||||
test -f wiki/assets/alpha-library-og.png
|
||||
blocked="localhost\\|nano"
|
||||
blocked="${blocked}bot"
|
||||
! grep -R "$blocked" wiki --exclude=README.md
|
||||
+34
@@ -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
|
||||
|
||||
@@ -21,6 +31,17 @@ agent/tests/*_result.json
|
||||
.ruff_cache/
|
||||
frontend/dist/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
wiki/node_modules/
|
||||
wiki/dist/
|
||||
wiki/.astro/
|
||||
wiki/.vite/
|
||||
wiki/.wrangler/
|
||||
.wrangler/
|
||||
wiki/.cache/
|
||||
.vibe-dev/
|
||||
.vibe-record/
|
||||
.cache/
|
||||
.remotion/
|
||||
|
||||
# DuckDB data cache
|
||||
data/*.duckdb
|
||||
@@ -31,6 +52,10 @@ data/backup/
|
||||
|
||||
# Internal docs (plans, specs)
|
||||
docs/
|
||||
!wiki/docs/
|
||||
!wiki/docs/**
|
||||
docs/screenshots/agent-run.png
|
||||
docs/screenshots/boot.png
|
||||
data/minutes/
|
||||
data/overnight*.log
|
||||
data/*.log
|
||||
@@ -45,6 +70,9 @@ build/
|
||||
# Local demo videos (too large for git; link to external host instead)
|
||||
assets/*.mp4
|
||||
|
||||
# Local demo / promo production workspace
|
||||
demo/
|
||||
|
||||
# Manual E2E smoke tests — require live API keys (OpenRouter / yfinance),
|
||||
# would break CI if committed. Kept local; run explicitly when validating
|
||||
# a release.
|
||||
@@ -56,3 +84,9 @@ NOTES.md
|
||||
|
||||
# Agent tool aliases (auto-generated mirror of CLAUDE.md)
|
||||
AGENTS.md
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Alpha Zoo wiki artifacts (generated, not source)
|
||||
wiki/alpha-library/manifest.json
|
||||
wiki/alpha-library/content/
|
||||
@@ -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.
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Vibe-Trading are documented in this file.
|
||||
This project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added — Alpha Zoo (450+ pre-built quant alphas)
|
||||
- `agent/src/factors/` — base operators (`rank`, `scale`, `ts_*`, `delta`,
|
||||
`decay_linear`, `signed_power`, `safe_div`, market-aware `vwap`) and a
|
||||
registry that AST-extracts metadata from each alpha module without
|
||||
importing it. Lookahead is enforced at the operator level
|
||||
(`delta(d>=1)`), and registry sanity checks reject `+/-inf` and
|
||||
outputs that are more than 95 % NaN.
|
||||
- 4 zoos shipping 452 alphas total:
|
||||
- **qlib158** (154 alphas) — port of Microsoft Qlib's `Alpha158`
|
||||
feature handler under Apache-2.0, with pinned commit SHA per file.
|
||||
- **alpha101** (101 alphas) — implementation of Kakushadze (2015)
|
||||
*"101 Formulaic Alphas"* (arXiv:1601.00991), written from the paper
|
||||
appendix; the relevant trademarked string is intentionally absent.
|
||||
- **gtja191** (191 alphas) — implementation of Guotai Junan's 2014
|
||||
*"191 Short-period Trading Alpha Factors"* research report.
|
||||
- **academic** (6 factors) — Fama-French 5 + Carhart momentum, shipped
|
||||
as honest price-based proxies (not the canonical FF series).
|
||||
- `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI
|
||||
subcommand. `show` and `export-manifest` enforce path-traversal guards.
|
||||
- New agent tools: `AlphaZooTool` (browse) and `AlphaBenchTool`
|
||||
(orchestrator with Jinja2 autoescape + strict CSP HTML report).
|
||||
- `ZooSignalEngine.from_zoo(...)` — composite multi-factor signal engine
|
||||
with cross-sectional standardisation, weighting, and optional top-N /
|
||||
bottom-N long-short conversion.
|
||||
- `wiki/scripts/build_alpha_library.py` — Alpha Library renderer.
|
||||
Reads `manifest.json` produced by `vibe-trading alpha export-manifest`
|
||||
and emits 452 per-alpha HTML pages plus 4 per-zoo overviews, each with
|
||||
`script-src 'none'` CSP. The landing page hydrates per-zoo counts
|
||||
from `content/index.json`.
|
||||
- New blog post: *"Which of the 191 GTJA alphas still work in 2026?"*
|
||||
with aggregate IC statistics, theme breakdown, and the top alphas
|
||||
that survive eight years of out-of-sample data.
|
||||
|
||||
### Added — Web UI for Alpha Zoo
|
||||
- New page at `/alpha-zoo` in the Vite + React frontend with three
|
||||
views: browse (4 zoo cards + filter bar + paginated table), detail
|
||||
(formula, metadata, collapsible source code), and bench-runner
|
||||
(form → SSE-streamed progress + Alive/Reversed/Dead stat cards +
|
||||
Top-5-by-IR table + by-theme breakdown chart). "Alpha Zoo" nav
|
||||
entry added to the layout.
|
||||
- Four new REST routes in the FastAPI server:
|
||||
- `GET /alpha/list` — filterable alpha catalogue
|
||||
- `GET /alpha/{alpha_id}` — meta + source code
|
||||
- `POST /alpha/bench` — kicks off a background bench job and
|
||||
returns a `job_id`
|
||||
- `GET /alpha/bench/{job_id}/stream` — Server-Sent Events with
|
||||
`progress`, `result`, `done`, and `error` event types. In-memory
|
||||
job state with a 1-hour TTL; no Redis/Celery dependency.
|
||||
- Bench math is refactored into `agent/src/factors/bench_runner.py`
|
||||
so the CLI driver (`agent/scripts/w4a_run_benches.py`) and the new
|
||||
API worker share a single implementation.
|
||||
|
||||
### Added — Safety floor
|
||||
- `agent/tests/factors/test_alpha_purity.py` — AST allowlist scan over
|
||||
every `zoo/**/*.py` module (whitelist: pandas, numpy, scipy.\*,
|
||||
`src.factors.base`, `__future__`, `typing`, `math`, `dataclasses`;
|
||||
banned: `os`, `sys`, `subprocess`, `socket`, `urllib`, `requests`,
|
||||
`httpx`, `pathlib`, `Path`, `open`, `eval`, `exec`, `compile`,
|
||||
`__import__`, and `getattr(_, "__*")`).
|
||||
- `agent/tests/factors/test_lookahead.py` — sentinel future-row
|
||||
injection on a 300-row synthetic panel; corrupting rows after the
|
||||
probe must leave the probe value unchanged within 1e-9.
|
||||
- `tools/ci_grep_gates.sh` — CI gate that rejects `yaml.load(` without
|
||||
`safe_load`, any trademarked-name leak in shipped artifacts, and any
|
||||
per-stock-code data leak in `wiki/**/*.{json,csv,html}`.
|
||||
- `agent/tests/factors/conftest.py` — opt-in `pytest-socket` integration
|
||||
that hard-fails any test attempting outbound network during the
|
||||
factors test suite.
|
||||
|
||||
### Added — Community governance
|
||||
- `CONTRIBUTING.md` — Developer Certificate of Origin sign-off
|
||||
requirement and a contributor checklist for new alpha PRs (purity,
|
||||
lookahead, `__alpha_meta__` shape, LaTeX-matches-code, per-zoo
|
||||
LICENSE.md, DCO).
|
||||
- `NOTICE` (repo root) — Apache-2.0 attribution for Qlib and a
|
||||
declaration that the bundled formulas from Kakushadze, GTJA, and the
|
||||
academic baselines are mathematical content (paper prose, tables, and
|
||||
figures are not reproduced here).
|
||||
- Per-zoo `LICENSE.md` for each of `qlib158/`, `alpha101/`, `gtja191/`,
|
||||
and `academic/`, plus an upstream `NOTICE` for `qlib158/`.
|
||||
|
||||
### Changed
|
||||
- `agent/src/tools/factor_analysis_tool.py` extracted its IC/IR and
|
||||
layered-backtest helpers to `agent/src/factors/factor_analysis_core.py`
|
||||
so the new `alpha_bench_tool` reuses the same maths. Public tool
|
||||
signature is unchanged; `_compute_ic_series` and `_compute_group_equity`
|
||||
remain importable as backward-compatible aliases.
|
||||
- `agent/cli.py` grew by 7 lines to register the `alpha` subcommand;
|
||||
all handler logic lives in `agent/src/factors/cli_handlers.py`.
|
||||
- Packaging: `pyproject.toml` now ships `zoo/**/*.yaml`, `zoo/**/*.md`,
|
||||
and `zoo/**/NOTICE` as package data; `MANIFEST.in` recursively
|
||||
includes `agent/src/factors`.
|
||||
|
||||
### Known limitations
|
||||
- The `btc-usdt` universe is single-asset; cross-sectional IC requires
|
||||
≥2 instruments, so the bundled `alpha101_btc` bench run returns
|
||||
alive/reversed/dead = 0/0/0 by construction. Use a multi-symbol crypto
|
||||
basket (e.g. BTC + ETH + SOL + the top-N perpetuals) for meaningful
|
||||
cross-sectional results; a curated `crypto-majors` universe is planned
|
||||
for 0.2.
|
||||
|
||||
### Internal
|
||||
- `wiki/alpha-library/manifest.json` and `wiki/alpha-library/content/`
|
||||
are generated artifacts and gitignored. Run
|
||||
`vibe-trading alpha export-manifest --out wiki/alpha-library/manifest.json
|
||||
--force` followed by `python wiki/scripts/build_alpha_library.py` to
|
||||
regenerate the static site.
|
||||
+136
-140
@@ -1,169 +1,165 @@
|
||||
# Contributing to Vibe-Trading
|
||||
|
||||
Thanks for your interest in contributing! This guide will help you get started.
|
||||
Vibe-Trading is a natural-language finance research AI agent (FastAPI + ReAct
|
||||
agent backend, Vite+React frontend, vectorized daily and options backtesting).
|
||||
This guide covers contribution governance: the Developer Certificate of Origin
|
||||
(DCO) sign-off requirement, the reviewer checklist for Alpha Zoo factor
|
||||
contributions, and the quickstart for adding a new alpha.
|
||||
|
||||
## Quick Links
|
||||
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.
|
||||
|
||||
- [Discord](https://discord.gg/2vDYc2w5) — ask questions, discuss ideas
|
||||
- [Issues](https://github.com/HKUDS/Vibe-Trading/issues) — bug reports & feature requests
|
||||
- [Good First Issues](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — great starting points
|
||||
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.
|
||||
|
||||
## Development Setup
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
Every commit in a community pull request MUST carry a `Signed-off-by:`
|
||||
trailer. We do not require a CLA — the DCO is a lightweight per-commit
|
||||
attestation that you wrote the code or have the right to submit it under
|
||||
the project's MIT license. Maintainer-direct commits to `main` are not
|
||||
subject to the trailer requirement (the maintainer's authorship is
|
||||
already attested by the commit's author field), but community PRs are.
|
||||
|
||||
Sign your commits with `-s`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/Vibe-Trading.git
|
||||
cd Vibe-Trading
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
# .venv\Scripts\Activate.ps1 # Windows
|
||||
|
||||
pip install -e ".[dev]"
|
||||
cp agent/.env.example agent/.env
|
||||
# Edit agent/.env — set your LLM provider
|
||||
git commit -s -m "feat(factors): add gtja191 alpha 042"
|
||||
```
|
||||
|
||||
### Run tests
|
||||
This appends a trailer like:
|
||||
|
||||
```bash
|
||||
pytest --ignore=agent/tests/e2e_backtest --tb=short -q
|
||||
```
|
||||
Signed-off-by: Your Name <you@example.com>
|
||||
```
|
||||
|
||||
### Start dev servers
|
||||
PRs without a `Signed-off-by:` on every commit will be asked to rebase and
|
||||
resign. To fix an unsigned series, run
|
||||
`git rebase --signoff <base-branch>` and force-push the branch.
|
||||
|
||||
```bash
|
||||
# Terminal 1: API server
|
||||
vibe-trading serve --port 8899
|
||||
### DCO 1.1 (full text)
|
||||
|
||||
# Terminal 2: Frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
1 Letterman Drive
|
||||
Suite D4700
|
||||
San Francisco, CA, 94129
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Alpha PR Reviewer Checklist
|
||||
|
||||
| Directory | What lives here | Open to contribute? |
|
||||
|-----------|----------------|:-------------------:|
|
||||
| `agent/src/skills/` | 69 finance skill definitions (SKILL.md) | Yes |
|
||||
| `agent/src/tools/` | 21 agent tools | Yes |
|
||||
| `agent/backtest/` | Backtest engines, loaders, optimizers | Yes |
|
||||
| `agent/src/swarm/presets/` | 29 swarm preset YAMLs | Yes |
|
||||
| `frontend/` | React 19 + Vite web UI | Yes |
|
||||
| `agent/src/agent/` | ReAct agent core (loop, context, skills) | Ask first |
|
||||
| `agent/src/session/` | Session management | Ask first |
|
||||
| `agent/src/providers/` | LLM provider abstraction | Ask first |
|
||||
This checklist applies to any PR adding or modifying files under
|
||||
`agent/src/factors/zoo/**/*.py`. Reviewers MUST verify every box before
|
||||
merging. Authors are strongly encouraged to self-check first.
|
||||
|
||||
> **Core modules** (`agent/`, `session/`, `providers/`) are protected — please open an issue to discuss before modifying.
|
||||
- [ ] **Purity gate**: file passes `pytest agent/tests/factors/test_alpha_purity.py`.
|
||||
The AST scan rejects any imports outside the allowlist
|
||||
(`pandas`, `numpy`, `scipy.*`, `src.factors.base`, `__future__`, `typing`,
|
||||
`math`, `dataclasses`) and any reference to forbidden names: `os`,
|
||||
`subprocess`, `socket`, `urllib`, `requests`, `httpx`, `pathlib`, `Path`,
|
||||
`eval`, `exec`, `compile`, `__import__`, bare `open`, or `getattr` with a
|
||||
second argument beginning with `"__"`.
|
||||
- [ ] **Lookahead gate**: file passes `pytest agent/tests/factors/test_lookahead.py`.
|
||||
No negative shifts, no forward leakage. `delta(df, d)` must have `d >= 1`.
|
||||
- [ ] **`__alpha_meta__` present** with all required pydantic-validated fields:
|
||||
`id`, `theme`, `formula_latex`, `columns_required`, `universe`, `frequency`,
|
||||
`decay_horizon`, `min_warmup_bars`. Optional: `nickname`, `extras_required`,
|
||||
`requires_sector`, `notes`.
|
||||
- [ ] **`compute(panel)` contract**: returns a DataFrame with the same shape
|
||||
as `panel["close"]`, NaN preserved at warmup / missing-data positions,
|
||||
no `+/-inf` values.
|
||||
- [ ] **LaTeX matches code**: the `formula_latex` in `__alpha_meta__` and the
|
||||
formula in the module docstring describe what `compute()` actually does.
|
||||
- [ ] **Per-zoo `LICENSE.md` updated** to cite the source paper / report and
|
||||
to state that formulas are mathematical facts, not subject to copyright,
|
||||
and that prose / tables / figures from papers and reports are not
|
||||
reproduced in this repo. Do NOT frame the bundled formulas using US
|
||||
affirmative-defense terminology — that is a litigation posture, not a
|
||||
license grant. State the mathematical-facts rationale instead.
|
||||
- [ ] **Apache-2 attribution**: if the alpha is adapted from an Apache-2.0
|
||||
upstream (e.g. Microsoft Qlib), the file MUST carry a header of the form
|
||||
`# Adapted from <repo>@<commit-sha>:<path> (Apache-2.0). Copyright (c) <holder>.`
|
||||
- [ ] **DCO**: every commit in the PR carries `Signed-off-by:`.
|
||||
|
||||
## How to Contribute
|
||||
## Adding a New Alpha (Quickstart)
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Use the [Bug Report](https://github.com/HKUDS/Vibe-Trading/issues/new?template=bug_report.yml) template. Include:
|
||||
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Your environment (OS, Python version, provider)
|
||||
- Error logs if available
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
Use the [Feature Request](https://github.com/HKUDS/Vibe-Trading/issues/new?template=feature_request.yml) template.
|
||||
|
||||
### Submitting Code
|
||||
|
||||
1. **Fork & branch** — create a feature branch from `main`
|
||||
1. Pick the target zoo directory under `agent/src/factors/zoo/` (e.g.
|
||||
`gtja191/`, `alpha101/`, `qlib158/`, `academic/`).
|
||||
2. Create `<alpha_id_short>.py` in that directory. Define `__alpha_meta__`
|
||||
(must satisfy the pydantic `AlphaMeta` schema in
|
||||
`agent/src/factors/registry.py`) and a pure `compute(panel)` function
|
||||
that imports only from `src.factors.base` plus the allowlisted stdlib /
|
||||
numpy / pandas / scipy.
|
||||
3. Run the purity and lookahead gates locally:
|
||||
```bash
|
||||
git checkout -b feat/my-feature
|
||||
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
|
||||
```
|
||||
|
||||
2. **Make changes** — follow the code standards below
|
||||
|
||||
3. **Test** — add or update tests, ensure `pytest` passes
|
||||
4. (Optional but recommended) Run a quick bench:
|
||||
```bash
|
||||
pytest --ignore=agent/tests/e2e_backtest --tb=short -q
|
||||
vibe-trading alpha bench --zoo <zoo_id> --universe csi300 --period 2020-2025
|
||||
```
|
||||
5. Open a PR. Every commit must include `Signed-off-by:` (use
|
||||
`git commit -s`). Reviewers will walk the checklist above.
|
||||
|
||||
4. **Commit** — use [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
```
|
||||
feat: add RSI divergence skill
|
||||
fix: handle empty DataFrame in backtest loader
|
||||
docs: update CLI reference table
|
||||
test: add crypto engine edge cases
|
||||
```
|
||||
## Code Style
|
||||
|
||||
5. **Pull Request** — open a PR against `main` with:
|
||||
- Summary of changes
|
||||
- Related issue number (e.g., `Closes #42`)
|
||||
- Test plan
|
||||
- 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.
|
||||
- No hardcoded paths, secrets, or URLs — config via `.env`, YAML, or
|
||||
module-level constants.
|
||||
- Delete unused code rather than commenting it out.
|
||||
|
||||
## Code Standards
|
||||
## Attribution
|
||||
|
||||
- **Python**: Google-style docstrings, type hints encouraged
|
||||
- **No hardcoding**: config via `.env`, YAML, or constants
|
||||
- **Delete unused code** — don't comment-preserve
|
||||
- **OKX pairs**: use `BTC-USDT` format (hyphen, uppercase)
|
||||
- **UI text**: English. LLM output follows user language
|
||||
- **File size**: aim for < 400 lines, max 800
|
||||
Do NOT add `Co-Authored-By:` trailers or AI-assistant attribution lines to
|
||||
commit messages or PR descriptions. The DCO sign-off is the only required
|
||||
trailer; keep commit metadata clean.
|
||||
|
||||
## Contribution Roadmap
|
||||
|
||||
### Easy: New Skill
|
||||
|
||||
Each skill is a single `SKILL.md` file in `agent/src/skills/<category>/<skill-name>/`. Categories: `data-source`, `strategy`, `analysis`, `asset-class`, `crypto`, `flow`, `tool`. Look at existing skills for the format.
|
||||
|
||||
### Easy: New Swarm Preset
|
||||
|
||||
A YAML file in `agent/src/swarm/presets/` defining agents, roles, and DAG workflow. See existing presets.
|
||||
|
||||
### Medium: New Data Source Loader
|
||||
|
||||
1. Create `agent/backtest/loaders/<source>.py`
|
||||
2. Implement the `DataLoader` Protocol (see `agent/backtest/loaders/base.py`)
|
||||
3. Register in `agent/backtest/loaders/registry.py`
|
||||
4. Add tests in `agent/tests/`
|
||||
|
||||
### Medium: Portfolio Optimizer Constraints
|
||||
|
||||
Current optimizers (`agent/backtest/optimizers/`) support basic weight optimization. We need:
|
||||
- **Leverage constraints** — max gross exposure limits
|
||||
- **Sector/group caps** — weight limits per sector or asset group
|
||||
- **Turnover penalties** — transaction cost-aware rebalancing
|
||||
- Extend `BaseOptimizer` interface, keep backward compatibility
|
||||
|
||||
### Medium: Correlation Heatmap Dashboard
|
||||
|
||||
Add a cross-asset correlation matrix component to the frontend:
|
||||
- Time-window slider (30d / 60d / 90d / 1Y)
|
||||
- Hierarchical clustering for sector grouping
|
||||
- ECharts heatmap or custom canvas renderer
|
||||
- Wire to a new API endpoint that computes rolling correlations
|
||||
|
||||
### Hard: Intraday Backtest Engine
|
||||
|
||||
Current engines run on daily bars. We need sub-daily execution:
|
||||
- Support 1m / 5m / 15m / 30m / 1H / 4H intervals
|
||||
- Handle market session boundaries (pre-market, after-hours, overnight gaps)
|
||||
- Intraday margin / buying power tracking
|
||||
- Integrate with minute-level data loaders (OKX klines, yfinance intraday)
|
||||
- Start from `BaseEngine` — see `agent/backtest/engines/base.py` for the abstract interface
|
||||
|
||||
### Hard: Options Volatility Surface & Greeks Visualization
|
||||
|
||||
Build 3D implied-vol surface and Greeks charts in the frontend:
|
||||
- **Vol surface**: strike vs. expiry vs. implied vol (3D mesh or contour)
|
||||
- **Greeks dashboard**: delta, gamma, theta, vega curves across strikes
|
||||
- **Skew chart**: 25-delta risk reversal over time
|
||||
- Backend: extend `options_pricing_tool.py` to return surface data
|
||||
- Frontend: ECharts GL for 3D, or custom WebGL renderer
|
||||
|
||||
### Hard: Monte Carlo & Stress Testing
|
||||
|
||||
Add scenario simulation and tail-risk analysis:
|
||||
- **Monte Carlo**: generate N return paths from fitted distribution, compute VaR/CVaR confidence intervals
|
||||
- **Historical stress tests**: replay specific crisis periods (2008, 2020 COVID, 2022 crypto winter)
|
||||
- **Regime switching**: HMM-based regime detection (bull/bear/sideways), conditional risk metrics
|
||||
- Output: distribution plots, drawdown probability curves, worst-case tables
|
||||
- Can be a new tool in `agent/src/tools/` or an extension to the backtest engine
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
By contributing, you agree that your contributions are licensed under the
|
||||
project's MIT license (see `LICENSE`).
|
||||
+76
-15
@@ -1,7 +1,8 @@
|
||||
# ============================================================================
|
||||
# Stage 1: Build frontend
|
||||
# ============================================================================
|
||||
FROM node:20-slim AS frontend-build
|
||||
FROM node:20-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 AS frontend-build
|
||||
# node:20-slim digest resolved 2026-07-13
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
@@ -10,37 +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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& 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
|
||||
# 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"
|
||||
|
||||
# Copy project
|
||||
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 .
|
||||
|
||||
# Copy built frontend
|
||||
# ============================================================================
|
||||
# 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.12" \
|
||||
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 \
|
||||
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/*
|
||||
|
||||
# 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
|
||||
|
||||
# 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 --from=frontend-build /app/frontend/dist frontend/dist
|
||||
|
||||
# Install CLI entrypoint
|
||||
RUN pip install --no-cache-dir -e .
|
||||
# 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 \
|
||||
&& 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"]
|
||||
@@ -1,4 +1,5 @@
|
||||
include LICENSE
|
||||
include NOTICE
|
||||
include README.md
|
||||
include README_zh.md
|
||||
include README_ja.md
|
||||
@@ -11,6 +12,9 @@ include docker-compose.yml
|
||||
# Developer entry-point template
|
||||
include agent/.env.example
|
||||
|
||||
# Alpha Zoo factor metadata and per-zoo LICENSE / NOTICE files
|
||||
recursive-include agent/src/factors *.yaml *.md
|
||||
|
||||
# Tests shipped so dev installs can run `pytest` against the package.
|
||||
# Files prefixed with `_` are local debug scripts kept untracked — keep
|
||||
# them out of the sdist even if present in the working tree.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
Vibe-Trading
|
||||
Copyright 2026 HKUDS contributors.
|
||||
|
||||
This product includes software developed at HKUDS (https://github.com/HKUDS).
|
||||
|
||||
This product bundles Microsoft Qlib feature definitions under the Apache 2.0
|
||||
license. See agent/src/factors/zoo/qlib158/NOTICE and LICENSE.md for the
|
||||
upstream NOTICE and attribution.
|
||||
|
||||
Mathematical formulas from the following sources are reimplemented in this
|
||||
repository as factual mathematical content (formulas are not copyrightable):
|
||||
|
||||
- Kakushadze, Z. (2015) "101 Formulaic Alphas" (arXiv:1601.00991)
|
||||
See agent/src/factors/zoo/alpha101/LICENSE.md
|
||||
- Guotai Junan Securities (2014) "191 Short-period Trading Alpha Factors"
|
||||
See agent/src/factors/zoo/gtja191/LICENSE.md
|
||||
- Fama-French five-factor model, Carhart momentum, Hou-Xue-Zhang q-factor
|
||||
See agent/src/factors/zoo/academic/LICENSE.md
|
||||
|
||||
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.
|
||||
+891
-259
File diff suppressed because it is too large.
Load diff
+912
-280
File diff suppressed because it is too large.
Load diff
+900
-266
File diff suppressed because it is too large.
Load diff
+954
-294
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.
|
||||
|
||||
+154
-19
@@ -1,60 +1,103 @@
|
||||
# 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-v3.2
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v4-pro
|
||||
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-4o
|
||||
# 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.4
|
||||
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
|
||||
|
||||
# --- DeepSeek ---
|
||||
# LANGCHAIN_PROVIDER=deepseek
|
||||
# LANGCHAIN_MODEL_NAME=deepseek-chat
|
||||
# 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
|
||||
# LANGCHAIN_MODEL_NAME=gemini-2.5-flash
|
||||
# LANGCHAIN_MODEL_NAME=gemini-3.5-flash
|
||||
# GEMINI_API_KEY=xxx
|
||||
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
|
||||
|
||||
# --- Groq ---
|
||||
# LANGCHAIN_PROVIDER=groq
|
||||
# LANGCHAIN_MODEL_NAME=llama-3.3-70b-versatile
|
||||
# LANGCHAIN_MODEL_NAME=meta-llama/llama-4-maverick-17b-128e-instruct
|
||||
# GROQ_API_KEY=gsk_xxx
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
|
||||
# --- DashScope / Qwen ---
|
||||
# LANGCHAIN_PROVIDER=dashscope
|
||||
# LANGCHAIN_MODEL_NAME=qwen-plus
|
||||
# LANGCHAIN_MODEL_NAME=qwen-plus-latest
|
||||
# DASHSCOPE_API_KEY=sk-xxx
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
# --- Zhipu ---
|
||||
# LANGCHAIN_PROVIDER=zhipu
|
||||
# LANGCHAIN_MODEL_NAME=glm-4-plus
|
||||
# LANGCHAIN_MODEL_NAME=glm-5.1
|
||||
# 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.5
|
||||
# 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 ---
|
||||
@@ -63,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
|
||||
@@ -72,7 +122,7 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# --- Ollama (local) ---
|
||||
# LANGCHAIN_PROVIDER=ollama
|
||||
# LANGCHAIN_MODEL_NAME=qwen2.5:32b
|
||||
# OLLAMA_BASE_URL=http://localhost:11434/v1
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# LLM parameters
|
||||
LANGCHAIN_TEMPERATURE=0.0
|
||||
@@ -80,12 +130,12 @@ 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.
|
||||
# Forwarded as extra_body.reasoning.effort = low / medium / high / max.
|
||||
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
|
||||
# LANGCHAIN_REASONING_EFFORT=medium
|
||||
|
||||
# ============================================================================
|
||||
# Data Sources
|
||||
# Data Sources (DataConfig)
|
||||
# ============================================================================
|
||||
# A-shares: Tushare Pro token (https://tushare.pro)
|
||||
TUSHARE_TOKEN=your-tushare-token
|
||||
@@ -97,19 +147,75 @@ 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=
|
||||
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
# VIBE_TRADING_ENABLE_SCHEDULER=1
|
||||
|
||||
# ============================================================================
|
||||
# API Server (optional)
|
||||
# API Server (APIConfig, optional)
|
||||
# ============================================================================
|
||||
# Bearer token for API auth (empty = no auth, dev mode)
|
||||
# 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_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
# Enable session runtime
|
||||
# ENABLE_SESSION_RUNTIME=true
|
||||
# Docker compose sets this together with a 127.0.0.1 port bind so browser
|
||||
# requests from the host machine still count as local dev-mode access.
|
||||
# Do not enable this when publishing port 8899 on 0.0.0.0 without API_AUTH_KEY.
|
||||
# VIBE_TRADING_TRUST_DOCKER_LOOPBACK=0
|
||||
# Advanced local-only opt-in: expose shell execution tools to the agent.
|
||||
# VIBE_TRADING_ENABLE_SHELL_TOOLS=0
|
||||
# Optional import roots for read_document/analyze_trade_journal.
|
||||
# Use comma-separated absolute paths. Defaults include agent/uploads, agent/runs,
|
||||
# ./uploads, ./data, ~/.vibe-trading/uploads, and ~/.vibe-trading/imports.
|
||||
# VIBE_TRADING_ALLOWED_FILE_ROOTS=
|
||||
# Optional run roots for generated-code tools such as write_file/backtest.
|
||||
# Defaults include agent/runs, ./runs, and ~/.vibe-trading/shadow_runs.
|
||||
# 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
|
||||
@@ -118,3 +224,32 @@ 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
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
+205
-18
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: vibe-trading
|
||||
version: 0.1.6
|
||||
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, options pricing, 72 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 6 data sources (tushare, yfinance, okx, akshare, ccxt, futu).
|
||||
version: 0.1.12
|
||||
description: Professional finance research toolkit — backtesting (8 engines + benchmark comparison panel), factor analysis, Alpha Zoo (462 pre-built alphas across qlib158/alpha101/gtja191/academic/fundamental), options pricing, 88 finance skills, 30 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 23 market-data sources (tushare, yfinance, okx, binance, akshare, baostock, tencent, mootdx, ccxt, futu, mt5, local, eastmoney, sina, stooq, yahoo, india_broker, qveris, longbridge, plus optional-key finnhub/alphavantage/tiingo/fmp).
|
||||
dependencies:
|
||||
python: ">=3.11"
|
||||
pip:
|
||||
@@ -14,7 +14,7 @@ env:
|
||||
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other tools work without it."
|
||||
required: false
|
||||
- name: LANGCHAIN_MODEL_NAME
|
||||
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v3.2). Only needed if using run_swarm."
|
||||
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v4-pro). Only needed if using run_swarm."
|
||||
required: false
|
||||
mcp:
|
||||
command: vibe-trading-mcp
|
||||
@@ -23,7 +23,7 @@ mcp:
|
||||
|
||||
# Vibe-Trading
|
||||
|
||||
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 72 specialized skills, 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 (8 engines), multi-agent teams, 88 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
|
||||
|
||||
**21 of 22 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, and all 72 skills are ready to use for HK/US equities and crypto.
|
||||
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 88 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,18 @@ 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 6 data sources:
|
||||
- **HK/US equities** via yfinance (free, no API key)
|
||||
Create and run quantitative strategies across 8 engines (ChinaA, GlobalEquity, IndiaEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 23 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).
|
||||
- **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` universe, so they compute on NSE/BSE 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 +94,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,11 +102,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.
|
||||
|
||||
### Finance Skills (72)
|
||||
### 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** (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 (88)
|
||||
Comprehensive knowledge base covering:
|
||||
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC, harmonic, chanlun)
|
||||
- Quantitative methods (factor research, ML strategy, pair trading, multi-factor)
|
||||
@@ -112,17 +129,39 @@ Comprehensive knowledge base covering:
|
||||
|
||||
Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
|
||||
## Available MCP Tools (22)
|
||||
## Available MCP Tools (54)
|
||||
|
||||
| Tool | Description | API Key |
|
||||
|------|-------------|---------|
|
||||
| `list_skills` | List all 72 finance skills | None |
|
||||
| `list_skills` | List all 88 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 |
|
||||
| `add_goal_evidence` | Attach evidence to a research goal | None |
|
||||
| `update_research_goal_status` | Update goal lifecycle status | None |
|
||||
| `backtest` | Run vectorized backtest engine | None* |
|
||||
| `factor_analysis` | IC/IR analysis + layered backtest | None* |
|
||||
| `analyze_options` | Black-Scholes price + Greeks | 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 23 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 |
|
||||
@@ -138,8 +177,18 @@ Use `load_skill(name)` to access full methodology docs with code templates.
|
||||
| `get_swarm_status` | Poll swarm run status without blocking | None |
|
||||
| `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 |
|
||||
| `trading_account` | Read account summary from selected connector | Connector app/OAuth |
|
||||
| `trading_positions` | Read positions from selected connector | Connector app/OAuth |
|
||||
| `trading_orders` | Read open orders from selected connector | Connector app/OAuth |
|
||||
| `trading_quote` | Read a quote snapshot from selected connector | Connector app/OAuth |
|
||||
| `trading_history` | Read historical bars from selected connector | Connector app/OAuth |
|
||||
|
||||
<sub>*A-share symbols require `TUSHARE_TOKEN`. HK/US/crypto are free.</sub>
|
||||
<sub>*A-share symbols require `TUSHARE_TOKEN`. HK/US/crypto are free. Trading connector rows use the selected connector profile, e.g. IBKR local TWS/Gateway or Robinhood MCP OAuth.</sub>
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -147,7 +196,145 @@ 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`, and all 72 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 88 skills immediately.
|
||||
|
||||
## Loading Tools from External MCP Servers
|
||||
|
||||
The built-in agent can load tools from your own external MCP servers in addition to its local toolset.
|
||||
|
||||
> **Note:** This is the *MCP client* path — the opposite of the MCP plugin listed above. The plugin above makes Vibe-Trading's tools available to your agents. This section lets Vibe-Trading's own agent call tools from *your* servers.
|
||||
|
||||
### Setup
|
||||
|
||||
Create `~/.vibe-trading/agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "uvx",
|
||||
"args": ["my-mcp-server"],
|
||||
"toolTimeout": 30,
|
||||
"enabledTools": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ordinary external MCP tools appear automatically in every `vibe-trading run` / `vibe-trading chat` call. They are injected after local tools under stable names: `mcp_<server>_<tool>`. Live-broker MCP servers are consumed through the connector-scoped `trading_*` tools instead of exposing raw `mcp_<broker>_*` tools to the agent.
|
||||
|
||||
### Official IBKR MCP read-only probe
|
||||
|
||||
Add Interactive Brokers' official MCP endpoint as a read-only external server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ibkr": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://api.ibkr.com/v1/api/mcp",
|
||||
"auth": {
|
||||
"type": "oauth",
|
||||
"scopes": ["mcp.read"],
|
||||
"clientName": "Vibe-Trading",
|
||||
"cacheDir": "~/.vibe-trading/live/ibkr/oauth"
|
||||
},
|
||||
"enabledTools": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Authorize it with `vibe-trading connector authorize ibkr-live-official-mcp-readonly`. The wildcard is accepted
|
||||
only for this `mcp.read` probe. Generic `trading_account` and `trading_positions`
|
||||
calls stay disabled until IBKR publishes stable read tool names that Vibe-Trading
|
||||
can map safely; `mcp.write` requires an explicit tool allowlist and live
|
||||
order-guard handling. If IBKR issues a pre-registered OAuth client, add
|
||||
`clientId` and `clientSecret` inside `auth`.
|
||||
|
||||
### Trading connector profiles
|
||||
|
||||
The public trading surface is connector-first. Choose a connector profile, then
|
||||
paper/live is just an attribute under that connector.
|
||||
|
||||
```bash
|
||||
pip install "vibe-trading-ai[ibkr]"
|
||||
vibe-trading connector list
|
||||
vibe-trading connector use ibkr-paper-local
|
||||
vibe-trading connector configure ibkr-paper-local --yes
|
||||
vibe-trading connector check
|
||||
vibe-trading connector account
|
||||
vibe-trading connector positions
|
||||
vibe-trading connector orders
|
||||
vibe-trading connector quote AAPL
|
||||
vibe-trading connector history AAPL --duration "30 D" --bar-size "1 day"
|
||||
```
|
||||
|
||||
Default ports are TWS paper `7497`, IB Gateway paper `4002`, TWS live-readonly
|
||||
`7496`, and IB Gateway live-readonly `4001`.
|
||||
|
||||
### Config fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `type` | stdio: no, HTTP: yes | inferred only for stdio | Transport type. Use `sse` or `streamableHttp` for URL-based servers. |
|
||||
| `command` | stdio: yes | — | Executable to launch |
|
||||
| `args` | no | `[]` | Command arguments |
|
||||
| `env` | no | `{}` | Extra env vars for the subprocess |
|
||||
| `url` | HTTP: yes | — | Remote SSE / streamable HTTP endpoint URL |
|
||||
| `headers` | no | `{}` | Extra HTTP headers for SSE / streamable HTTP servers |
|
||||
| `toolTimeout` | no | `30` | Seconds before a tool call is cancelled |
|
||||
| `enabledTools` | no | `["*"]` | Allowlist of remote tool names. `["*"]` enables all |
|
||||
|
||||
For URL-based transports, `type` is required. The agent no longer guesses between SSE and streamable HTTP from the URL suffix.
|
||||
|
||||
### Per-session override (API)
|
||||
|
||||
> **Security — disabled by default.** `mcpServers` defines subprocess `command`/`args`/`env` and is therefore restricted to operator-level trust. API callers **cannot** inject MCP server definitions through `POST /sessions` unless the server operator explicitly opts in.
|
||||
|
||||
To enable session-level MCP injection, set the environment variable on the server before starting the agent:
|
||||
|
||||
```bash
|
||||
export ALLOW_SESSION_MCP_SERVERS=1
|
||||
```
|
||||
|
||||
With the opt-in active, pass `mcpServers` inside `session.config` to extend or replace the global config for that session only:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"mcpServers": {
|
||||
"research": {
|
||||
"command": "uvx",
|
||||
"args": ["research-mcp"],
|
||||
"enabledTools": ["search"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `ALLOW_SESSION_MCP_SERVERS=1`, any `mcpServers` key in `session.config` is silently stripped before config loading. The global operator config on disk (`~/.vibe-trading/agent.json`) is always respected regardless of this flag.
|
||||
|
||||
### v1 limits
|
||||
|
||||
- **Transport:** stdio, SSE, and streamable HTTP.
|
||||
- **Execution:** serial only. MCP tools never enter the parallel readonly path.
|
||||
- **Surfaces:** tools only. Resources and prompts are not exposed.
|
||||
- **Swarm:** MCP tools are excluded from Swarm worker registries in v1.
|
||||
- **Hot reload:** not supported. Restart the process to pick up config changes.
|
||||
|
||||
### Failure handling
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| Missing config file | falls back to empty config — no MCP servers loaded |
|
||||
| Invalid config file | logs a warning and falls back to empty config |
|
||||
| Server fails to start | that server is skipped; local tools and other servers still load |
|
||||
| Tool call times out | returns a normalized error payload instead of raising |
|
||||
| Two server names collide after sanitization | deterministic hash suffix appended; operator warning emitted |
|
||||
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
+265
-974
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@ 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
|
||||
|
||||
@@ -42,6 +42,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 +53,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 +66,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
|
||||
|
||||
@@ -134,11 +150,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 +190,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,293 @@
|
||||
"""Cross-asset correlation matrix computation.
|
||||
|
||||
Computes pairwise Pearson or Spearman correlation of daily returns
|
||||
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.
|
||||
|
||||
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"
|
||||
if code_upper.endswith(".HK"):
|
||||
return "hk_equity"
|
||||
if code_upper.endswith((".SH", ".SZ", ".BJ")):
|
||||
return "a_share"
|
||||
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,
|
||||
method: Literal["pearson", "spearman"],
|
||||
) -> tuple[list[str], list[list[float]]]:
|
||||
"""Compute correlation matrix for multiple price series.
|
||||
|
||||
Args:
|
||||
price_series: Mapping of asset code -> DataFrame with a ``close`` column.
|
||||
window: Rolling window size in days.
|
||||
method: "pearson" or "spearman".
|
||||
|
||||
Returns:
|
||||
(labels, matrix) where labels is the sorted list of codes and matrix
|
||||
is a symmetric NxN matrix of correlation coefficients.
|
||||
"""
|
||||
if not price_series:
|
||||
return [], []
|
||||
|
||||
codes = sorted(price_series.keys())
|
||||
|
||||
# Build a aligned returns DataFrame (row index = date)
|
||||
returns_frames = []
|
||||
closes = {}
|
||||
for code, df in price_series.items():
|
||||
closes[code] = _close_series(code, df)
|
||||
|
||||
for code in codes:
|
||||
ts = closes[code]
|
||||
# Normalize to date-only (midnight) so that cross-market assets
|
||||
# (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()
|
||||
rets.name = code
|
||||
returns_frames.append(rets)
|
||||
|
||||
# Align all series to a common index (inner join)
|
||||
aligned = pd.concat(returns_frames, axis=1).dropna()
|
||||
if aligned.empty:
|
||||
ranges = {
|
||||
code: f"{closes[code].index.min()} .. {closes[code].index.max()}"
|
||||
for code in codes
|
||||
if len(closes[code]) > 0
|
||||
}
|
||||
raise ValueError(
|
||||
f"No overlapping return data between assets. "
|
||||
f"Date ranges: {ranges}"
|
||||
)
|
||||
|
||||
# Apply the trailing window — only use the last `window` rows of aligned data
|
||||
if len(aligned) > window:
|
||||
aligned = aligned.iloc[-window:]
|
||||
|
||||
n = len(aligned)
|
||||
if n < 2:
|
||||
raise ValueError("Not enough data points to compute correlation")
|
||||
|
||||
labels = codes
|
||||
n_assets = len(labels)
|
||||
matrix = [[1.0] * n_assets for _ in range(n_assets)]
|
||||
|
||||
for i in range(n_assets):
|
||||
for j in range(i + 1, n_assets):
|
||||
xi = aligned.iloc[:, i].values
|
||||
xj = aligned.iloc[:, j].values
|
||||
if method == "spearman":
|
||||
corr, _ = spearmanr(xi, xj)
|
||||
else:
|
||||
corr = np.corrcoef(xi, xj)[0, 1]
|
||||
if np.isnan(corr):
|
||||
corr = 0.0
|
||||
matrix[i][j] = round(corr, 4)
|
||||
matrix[j][i] = round(corr, 4)
|
||||
|
||||
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,
|
||||
method: Literal["pearson", "spearman"] = "pearson",
|
||||
) -> Dict[str, object]:
|
||||
"""Fetch price data and compute correlation matrix for a list of assets.
|
||||
|
||||
Args:
|
||||
codes: List of asset codes (e.g. ["BTC-USDT", "ETH-USDT", "SPY"]).
|
||||
days: Lookback window in days (default 90).
|
||||
method: Correlation method.
|
||||
|
||||
Returns:
|
||||
Dict with keys: labels, matrix, window, method.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days + 60)).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())}"
|
||||
)
|
||||
|
||||
labels, matrix = _rolling_correlation_matrix(price_series, days, method)
|
||||
return {
|
||||
"labels": labels,
|
||||
"matrix": matrix,
|
||||
"window": days,
|
||||
"method": method,
|
||||
}
|
||||
@@ -1,17 +1,131 @@
|
||||
"""Extracted per-bar market hooks as pure functions.
|
||||
"""Extracted per-bar market hooks and symbol-classification helpers.
|
||||
|
||||
Both the original engines (CryptoEngine, ForexEngine) and CompositeEngine
|
||||
call these same functions. Zero duplication — one source of truth.
|
||||
|
||||
Also hosts symbol -> market detection helpers shared by ``runner.py`` and
|
||||
``composite.py``: ``_MARKET_PATTERNS``, ``_detect_market``,
|
||||
``_is_china_futures``, ``_detect_submarket``. Keep regex tables here so the
|
||||
truncated-duplicate routing bug (bare ``RB2410`` getting routed to
|
||||
GlobalFutures because composite.py used a suffix-only check) cannot recur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.models import Position
|
||||
|
||||
|
||||
# ── Symbol -> market classification (shared by runner.py + composite.py) ──
|
||||
|
||||
_MARKET_PATTERNS = [
|
||||
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
|
||||
(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"),
|
||||
(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)
|
||||
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
|
||||
# Global futures: product+month-code (e.g. ESZ4, CLF25, GCM2025)
|
||||
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
|
||||
# Global futures: product+YYMM (e.g. CL2412, ES2503)
|
||||
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
|
||||
# Global futures: bare product code with exchange (e.g. ES.CME)
|
||||
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
|
||||
# Forex pairs: XXX/YYY or XXXXXX.FX
|
||||
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
|
||||
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
|
||||
]
|
||||
|
||||
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
|
||||
|
||||
# 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.
|
||||
# Stored lowercase; ``_is_china_futures`` lowercases the extracted product
|
||||
# before lookup so callers can pass any case (``RB2410`` and ``rb2410``
|
||||
# both resolve correctly).
|
||||
_CN_FUTURES_PRODUCTS = {
|
||||
"if", "ic", "ih", "im", "t", "tf", "ts", "tl",
|
||||
"au", "ag", "cu", "al", "zn", "pb", "ni", "sn", "ss",
|
||||
"rb", "hc", "i", "j", "jm",
|
||||
"sc", "fu", "lu", "bu", "nr",
|
||||
"c", "cs", "m", "y", "a", "p", "jd", "lh",
|
||||
"cf", "sr", "ta", "ma", "ap", "rm", "oi",
|
||||
"pp", "l", "v", "eg", "eb", "pf", "sa", "fg", "ur",
|
||||
"si", "lc",
|
||||
}
|
||||
|
||||
|
||||
def _detect_market(code: str) -> str:
|
||||
"""Infer market type from symbol format.
|
||||
|
||||
Args:
|
||||
code: Ticker / symbol string.
|
||||
|
||||
Returns:
|
||||
Market type (a_share/us_equity/hk_equity/crypto/futures/forex);
|
||||
unknown defaults to ``a_share``.
|
||||
"""
|
||||
for pattern, market in _MARKET_PATTERNS:
|
||||
if pattern.match(code):
|
||||
return market
|
||||
return "a_share"
|
||||
|
||||
|
||||
def _is_china_futures(code: str) -> bool:
|
||||
"""Check whether a futures code belongs to a Chinese exchange.
|
||||
|
||||
Recognises two forms:
|
||||
1. ``<product><delivery>.<exchange>`` where exchange is one of
|
||||
CFFEX/SHFE/DCE/ZCE/INE/GFEX (e.g. ``IF2406.CFFEX``, ``rb2410.SHFE``).
|
||||
2. Bare ``<product><delivery>`` with no exchange suffix — matched
|
||||
against ``_CN_FUTURES_PRODUCTS`` (e.g. ``RB2410`` -> True).
|
||||
|
||||
Args:
|
||||
code: Symbol string.
|
||||
|
||||
Returns:
|
||||
True if it looks like a Chinese futures contract.
|
||||
"""
|
||||
parts = code.upper().split(".")
|
||||
if len(parts) == 2:
|
||||
# Has an exchange suffix — trust it. CN exchange = True, anything
|
||||
# else = False. Without this guard the product-code heuristic below
|
||||
# would misclassify global futures whose product letters happen to
|
||||
# collide with a CN product (e.g. ``M2412.CBOT`` — US soybean meal).
|
||||
return parts[1] in _CHINA_EXCHANGES
|
||||
# Bare code (no exchange suffix): fall back to product-code heuristic.
|
||||
m = re.match(r"([A-Za-z]+)\d+", parts[0])
|
||||
if m:
|
||||
product = m.group(1).lower()
|
||||
if product in _CN_FUTURES_PRODUCTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_submarket(codes: List[str]) -> str:
|
||||
"""Detect US vs HK from symbol suffixes.
|
||||
|
||||
Args:
|
||||
codes: Instrument codes.
|
||||
|
||||
Returns:
|
||||
``"hk"`` if any code ends with ``.HK``, else ``"us"``.
|
||||
"""
|
||||
for code in codes:
|
||||
if code.upper().endswith(".HK"):
|
||||
return "hk"
|
||||
return "us"
|
||||
|
||||
# ── Crypto: OKX tiered maintenance margin table (simplified) ──
|
||||
|
||||
_TIER_TABLE = [
|
||||
|
||||
+596
-96
@@ -11,28 +11,84 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re as _re
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backtest.loaders.rsshub_events import (
|
||||
FeedSpec,
|
||||
RSSHubEventProvider,
|
||||
enrich_price_frames_with_events,
|
||||
feed_specs_from_config,
|
||||
)
|
||||
from backtest.loaders.tushare_fundamentals import (
|
||||
TushareFundamentalProvider,
|
||||
enrich_price_frames_with_fundamentals,
|
||||
)
|
||||
from backtest.metrics import (
|
||||
by_exit_reason_stats,
|
||||
by_symbol_stats,
|
||||
calc_metrics,
|
||||
calc_trade_turnover_series,
|
||||
)
|
||||
|
||||
|
||||
def _json_safe_scalar_metrics(metrics: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Scalar metrics for stdout JSON; non-finite floats become null."""
|
||||
return {
|
||||
k: (None if isinstance(v, float) and not math.isfinite(v) else v)
|
||||
for k, v in metrics.items()
|
||||
if not isinstance(v, dict)
|
||||
}
|
||||
from backtest.models import EquitySnapshot, Position, TradeRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _OpenOrder:
|
||||
"""A fully priced opening order that can be committed atomically."""
|
||||
|
||||
symbol: str
|
||||
direction: int
|
||||
price: float
|
||||
size: float
|
||||
leverage: float
|
||||
margin: float
|
||||
commission: float
|
||||
|
||||
@property
|
||||
def cost(self) -> float:
|
||||
"""Cash consumed by the fill."""
|
||||
return self.margin + self.commission
|
||||
|
||||
|
||||
def _run_card_data_sources(config: Dict[str, Any], loader: Any) -> List[str]:
|
||||
"""Return source names for run-card evidence."""
|
||||
configured = config.get("_run_card_effective_sources")
|
||||
if isinstance(configured, list):
|
||||
return [str(source) for source in configured if str(source).strip()]
|
||||
if isinstance(configured, str) and configured.strip():
|
||||
return [configured.strip()]
|
||||
|
||||
loader_name = getattr(loader, "name", None)
|
||||
if loader_name:
|
||||
return [str(loader_name)]
|
||||
|
||||
source = config.get("source")
|
||||
return [str(source)] if source else []
|
||||
|
||||
|
||||
# ─── Market detection (lightweight, for signal alignment only) ───
|
||||
|
||||
import re as _re
|
||||
|
||||
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
|
||||
_FOREX_RE = _re.compile(r"^[A-Z]{3}/[A-Z]{3}$|^[A-Z]{6}\.FX$")
|
||||
|
||||
@@ -46,6 +102,31 @@ def _detect_market_for_align(code: str) -> str:
|
||||
return "equity"
|
||||
|
||||
|
||||
# ─── Forward-fill helpers (numpy, avoid pandas overhead) ───
|
||||
|
||||
|
||||
def _ffill_1d(col: np.ndarray, limit: int) -> None:
|
||||
"""In-place forward-fill a 1D array with limit."""
|
||||
count = 0
|
||||
last_valid = np.nan
|
||||
for i in range(len(col)):
|
||||
if np.isnan(col[i]):
|
||||
count += 1
|
||||
if count <= limit and not np.isnan(last_valid):
|
||||
col[i] = last_valid
|
||||
else:
|
||||
last_valid = col[i]
|
||||
count = 0
|
||||
|
||||
|
||||
def _ffill_2d(arr: np.ndarray, limit: int = 5) -> np.ndarray:
|
||||
"""Forward-fill NaN values column-wise with limit."""
|
||||
out = arr.copy()
|
||||
for col in range(out.shape[1]):
|
||||
_ffill_1d(out[:, col], limit)
|
||||
return out
|
||||
|
||||
|
||||
# ─── Signal alignment (reused from daily_portfolio logic) ───
|
||||
|
||||
|
||||
@@ -69,37 +150,74 @@ def _align(
|
||||
Returns:
|
||||
(dates, close_df, positions_df, returns_df)
|
||||
"""
|
||||
all_dates: set = set()
|
||||
for c in codes:
|
||||
all_dates.update(data_map[c].index)
|
||||
dates = pd.DatetimeIndex(sorted(all_dates))
|
||||
# Build unified sorted date index from all symbols' trading calendars
|
||||
all_idx_arrays = [data_map[c].index.values for c in codes]
|
||||
merged = np.unique(np.concatenate(all_idx_arrays))
|
||||
dates = pd.DatetimeIndex(merged)
|
||||
|
||||
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
|
||||
for c in codes:
|
||||
close[c] = data_map[c]["close"].reindex(dates)
|
||||
n_dates = len(dates)
|
||||
n_codes = len(codes)
|
||||
|
||||
# Use int64 view for O(log n) searchsorted lookups
|
||||
dates_i8 = dates.values.view("i8")
|
||||
|
||||
# ffill with limit to avoid masking long suspensions (e.g. 3-week halt)
|
||||
# Cross-market needs larger limit (Chinese New Year can be 9-10 bars)
|
||||
ffill_limit = 10 if len({_detect_market_for_align(c) for c in codes}) > 1 else 5
|
||||
close = close.ffill(limit=ffill_limit)
|
||||
|
||||
# Build close matrix via numpy direct fill + searchsorted index mapping
|
||||
close_arr = np.full((n_dates, n_codes), np.nan)
|
||||
for j, c in enumerate(codes):
|
||||
series = data_map[c]["close"]
|
||||
row_idx = np.searchsorted(dates_i8, series.index.values.view("i8"))
|
||||
close_arr[row_idx, j] = series.values
|
||||
|
||||
# Vectorized ffill with limit using pandas (C-optimized internals)
|
||||
_tmp = pd.DataFrame(close_arr)
|
||||
close_arr = _tmp.ffill(limit=ffill_limit).values
|
||||
|
||||
# Drop symbols that are entirely NaN (no data overlap with date range)
|
||||
all_nan_cols = [c for c in codes if close[c].isna().all()]
|
||||
all_nan_mask = np.all(np.isnan(close_arr), axis=0)
|
||||
all_nan_cols = [codes[j] for j in range(n_codes) if all_nan_mask[j]]
|
||||
if all_nan_cols:
|
||||
logger.warning("Symbols dropped (no usable price data): %s", all_nan_cols)
|
||||
codes = [c for c in codes if c not in all_nan_cols]
|
||||
keep_mask = ~all_nan_mask
|
||||
codes = [codes[j] for j in range(n_codes) if keep_mask[j]]
|
||||
if not codes:
|
||||
raise ValueError(f"All symbols have no data in the requested date range")
|
||||
close = close[codes]
|
||||
raise ValueError("All symbols have no data in the requested date range")
|
||||
close_arr = close_arr[:, keep_mask]
|
||||
n_codes = len(codes)
|
||||
|
||||
pos = pd.DataFrame(0.0, index=dates, columns=codes)
|
||||
for c in codes:
|
||||
# Shift on each symbol's OWN trading calendar, then ffill to unified
|
||||
own_dates = data_map[c].index
|
||||
raw = signal_map[c].reindex(own_dates).fillna(0.0).clip(-1.0, 1.0)
|
||||
shifted = raw.shift(1).fillna(0.0)
|
||||
pos[c] = shifted.reindex(dates).ffill(limit=ffill_limit).fillna(0.0)
|
||||
# Build position matrix: shift on each symbol's OWN calendar, then fill
|
||||
pos_arr = np.full((n_dates, n_codes), np.nan)
|
||||
for j, c in enumerate(codes):
|
||||
# Get signal values aligned to own trading calendar.
|
||||
# copy=True guarantees a writable array: with copy=False, an already
|
||||
# float64 source returns a read-only view (e.g. pandas copy-on-write),
|
||||
# which the in-place nan_to_num/clip below would reject.
|
||||
own_idx = data_map[c].index
|
||||
sig_vals = signal_map[c].reindex(own_idx).values.astype(np.float64, copy=True)
|
||||
# fillna(0) + clip in numpy
|
||||
np.nan_to_num(sig_vals, copy=False, nan=0.0)
|
||||
np.clip(sig_vals, -1.0, 1.0, out=sig_vals)
|
||||
# shift(1) + fillna(0): prepend 0, drop last
|
||||
shifted_vals = np.empty_like(sig_vals)
|
||||
shifted_vals[0] = 0.0
|
||||
shifted_vals[1:] = sig_vals[:-1]
|
||||
# Place into unified grid via searchsorted
|
||||
row_idx = np.searchsorted(dates_i8, own_idx.values.view("i8"))
|
||||
pos_arr[row_idx, j] = shifted_vals
|
||||
|
||||
# Vectorized ffill with limit using pandas (C-optimized)
|
||||
_tmp = pd.DataFrame(pos_arr)
|
||||
# ``.values`` may be a read-only view under pandas copy-on-write; take the
|
||||
# copy-returning nan_to_num (not in-place) so the fill never writes a
|
||||
# read-only destination.
|
||||
pos_arr = np.nan_to_num(_tmp.ffill(limit=ffill_limit).values, nan=0.0)
|
||||
|
||||
# Construct DataFrames for return
|
||||
close = pd.DataFrame(close_arr, index=dates, columns=codes)
|
||||
pos = pd.DataFrame(pos_arr, index=dates, columns=codes)
|
||||
ret = close.pct_change().fillna(0.0)
|
||||
|
||||
if optimizer is not None:
|
||||
@@ -132,6 +250,98 @@ def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
|
||||
return None
|
||||
|
||||
|
||||
def _normalise_fundamental_fields(config: Dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""Read the optional statement-table field map from backtest config."""
|
||||
raw_fields = config.get("fundamental_fields")
|
||||
if raw_fields in (None, {}):
|
||||
return {}
|
||||
if not isinstance(raw_fields, dict):
|
||||
raise ValueError("fundamental_fields must map table names to field-name lists")
|
||||
|
||||
normalized: dict[str, list[str]] = {}
|
||||
for table, fields in raw_fields.items():
|
||||
if not isinstance(table, str) or not table.strip():
|
||||
raise ValueError("fundamental_fields table names must be non-empty strings")
|
||||
if fields is None:
|
||||
continue
|
||||
if isinstance(fields, str) or not isinstance(fields, Iterable):
|
||||
raise ValueError(f"fundamental_fields[{table!r}] must be a list of field names")
|
||||
|
||||
field_list = list(fields)
|
||||
if not field_list:
|
||||
continue
|
||||
invalid = [field for field in field_list if not isinstance(field, str) or not field.strip()]
|
||||
if invalid:
|
||||
raise ValueError(f"fundamental_fields[{table!r}] contains invalid field names")
|
||||
normalized[table.strip()] = field_list
|
||||
return normalized
|
||||
|
||||
|
||||
def _maybe_enrich_fundamentals(
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
config: Dict[str, Any],
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Attach configured Tushare statement fields before signal generation."""
|
||||
fields_by_table = _normalise_fundamental_fields(config)
|
||||
if not fields_by_table:
|
||||
return data_map
|
||||
|
||||
try:
|
||||
provider = TushareFundamentalProvider()
|
||||
return enrich_price_frames_with_fundamentals(
|
||||
data_map,
|
||||
provider,
|
||||
fields_by_table,
|
||||
as_of=config.get("end_date", ""),
|
||||
periods=config.get("fundamental_periods"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"fundamental_fields requested but Tushare enrichment failed: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _event_feed_specs(config: Dict[str, Any]) -> List[FeedSpec]:
|
||||
"""Parse the optional ``event_feeds`` feed definitions from backtest config.
|
||||
|
||||
``event_feeds`` is a list of feed-definition dicts (there is no built-in
|
||||
catalogue) — each with ``name``/``route_template``/``event_type`` and an
|
||||
optional ``code_style``. An empty/absent value means "no event enrichment".
|
||||
"""
|
||||
raw_feeds = config.get("event_feeds")
|
||||
if raw_feeds in (None, [], {}):
|
||||
return []
|
||||
if not isinstance(raw_feeds, (list, tuple)):
|
||||
raise ValueError("event_feeds must be a list of feed definitions")
|
||||
return feed_specs_from_config(raw_feeds)
|
||||
|
||||
|
||||
def _maybe_enrich_events(
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
config: Dict[str, Any],
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
"""Attach a point-in-time-safe ``event_score`` column before signal generation."""
|
||||
specs = _event_feed_specs(config)
|
||||
if not specs:
|
||||
return data_map
|
||||
|
||||
try:
|
||||
provider = RSSHubEventProvider(feeds=specs)
|
||||
if not provider.is_available():
|
||||
raise RuntimeError(f"RSSHub base URL not configured (set ${'RSSHUB_BASE_URL'})")
|
||||
return enrich_price_frames_with_events(
|
||||
data_map,
|
||||
provider,
|
||||
as_of=config.get("end_date", ""),
|
||||
decay_lambda=float(config.get("event_decay_lambda", 0.1)),
|
||||
lookback=int(config.get("event_lookback", 30)),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"event_feeds requested but RSSHub enrichment failed: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
# ─── Base Engine ───
|
||||
|
||||
|
||||
@@ -238,6 +448,11 @@ class BaseEngine(ABC):
|
||||
"""Convert target notional exposure to number of units/contracts."""
|
||||
return target_notional / price
|
||||
|
||||
def _leverage_for_symbol(self, symbol: str) -> float:
|
||||
"""Return leverage used to size and margin one symbol."""
|
||||
del symbol
|
||||
return self.default_leverage
|
||||
|
||||
# ── Main entry ──
|
||||
|
||||
def run_backtest(
|
||||
@@ -277,9 +492,25 @@ class BaseEngine(ABC):
|
||||
if not data_map:
|
||||
print(json.dumps({"error": "No data fetched"}))
|
||||
sys.exit(1)
|
||||
data_map = _maybe_enrich_fundamentals(data_map, config)
|
||||
data_map = _maybe_enrich_events(data_map, config)
|
||||
|
||||
# 2. Generate signals
|
||||
signal_map = signal_engine.generate(data_map)
|
||||
if not isinstance(signal_map, dict):
|
||||
print(json.dumps({"error": (
|
||||
f"SignalEngine.generate() must return Dict[str, pd.Series], "
|
||||
f"got {type(signal_map).__name__}. "
|
||||
"Return a dict mapping symbol codes to pandas Series of signals."
|
||||
)}))
|
||||
sys.exit(1)
|
||||
for _code, _sig in signal_map.items():
|
||||
if not isinstance(_sig, pd.Series):
|
||||
print(json.dumps({"error": (
|
||||
f"SignalEngine.generate() returned {type(_sig).__name__} for '{_code}', "
|
||||
"expected pd.Series. Each value must be a pandas Series with DatetimeIndex."
|
||||
)}))
|
||||
sys.exit(1)
|
||||
valid_codes = sorted(c for c in signal_map if c in data_map)
|
||||
if not valid_codes:
|
||||
print(json.dumps({"error": "No valid signals generated"}))
|
||||
@@ -303,43 +534,63 @@ class BaseEngine(ABC):
|
||||
index=[s.timestamp for s in self.equity_snapshots],
|
||||
)
|
||||
bench_ret = ret_df.mean(axis=1) if ret_df.shape[1] > 0 else pd.Series(0.0, index=dates)
|
||||
benchmark_metadata = {}
|
||||
|
||||
# ── External benchmark fetch ──────────────────────────────────────────
|
||||
bench_ticker = config.get("benchmark")
|
||||
if bench_ticker and bench_ticker != "auto":
|
||||
from backtest.benchmark import resolve_benchmark
|
||||
bench_source = config.get("source", "yfinance")
|
||||
bench_result = resolve_benchmark(
|
||||
strategy_codes=codes,
|
||||
source=config.get("source", "yfinance"),
|
||||
source=bench_source,
|
||||
start_date=config.get("start_date", ""),
|
||||
end_date=config.get("end_date", ""),
|
||||
interval=interval,
|
||||
explicit=bench_ticker,
|
||||
# Explicit source: fetch the benchmark through its own loader
|
||||
# (keeps e.g. source=local offline). Auto keeps the yfinance
|
||||
# default — its loader only wraps the preloaded strategy data.
|
||||
loader=loader if bench_source != "auto" else None,
|
||||
)
|
||||
if bench_result is not None:
|
||||
bench_ret = bench_result.ret_series.reindex(dates).fillna(0.0)
|
||||
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
|
||||
m["benchmark_ticker"] = bench_result.ticker
|
||||
m["benchmark_return"] = bench_result.total_ret
|
||||
benchmark_metadata = {
|
||||
"benchmark_ticker": bench_result.ticker,
|
||||
"benchmark_return": bench_result.total_ret,
|
||||
}
|
||||
# ── External benchmark fetch ──────────────────────────────────────────
|
||||
|
||||
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
|
||||
|
||||
# 6. Metrics
|
||||
m = calc_metrics(equity_series, self.trades, self.initial_capital, bars_per_year, bench_ret)
|
||||
realized_turnover = calc_trade_turnover_series(self.trades, equity_series)
|
||||
m = calc_metrics(
|
||||
equity_series,
|
||||
self.trades,
|
||||
self.initial_capital,
|
||||
bars_per_year,
|
||||
bench_ret,
|
||||
target_pos,
|
||||
turnover_series=realized_turnover,
|
||||
)
|
||||
m.update(benchmark_metadata)
|
||||
m["by_symbol"] = by_symbol_stats(self.trades)
|
||||
m["by_exit_reason"] = by_exit_reason_stats(self.trades)
|
||||
|
||||
# 7. Validation (optional — triggered by config["validation"])
|
||||
if config.get("validation"):
|
||||
from backtest.validation import run_validation
|
||||
from backtest.validation import run_validation, write_validation_json
|
||||
v_results = run_validation(
|
||||
config, equity_series, self.trades, self.initial_capital, bars_per_year,
|
||||
)
|
||||
m["validation"] = v_results
|
||||
# Write validation.json artifact
|
||||
v_path = run_dir / "artifacts" / "validation.json"
|
||||
v_path.write_text(json.dumps(v_results, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
# Write validation.json through the shared strict writer so a
|
||||
# non-finite validation metric is serialized as null rather than an
|
||||
# invalid bare NaN/Infinity token (matching the standalone
|
||||
# `python -m backtest.validation` path and run_card). The writer
|
||||
# also creates the artifacts dir, which step 8 otherwise creates.
|
||||
write_validation_json(run_dir / "artifacts" / "validation.json", v_results)
|
||||
|
||||
# 8. Artifacts
|
||||
self._write_artifacts(
|
||||
@@ -347,8 +598,21 @@ class BaseEngine(ABC):
|
||||
target_pos, m, valid_codes,
|
||||
)
|
||||
|
||||
# Print scalar metrics (skip nested dicts for JSON compat)
|
||||
print(json.dumps({k: v for k, v in m.items() if not isinstance(v, dict)}, indent=2))
|
||||
# 9. Trust Layer run card
|
||||
from backtest.run_card import write_run_card
|
||||
write_run_card(
|
||||
run_dir,
|
||||
config,
|
||||
m,
|
||||
data_sources=_run_card_data_sources(config, loader),
|
||||
strategy_path=run_dir / "code" / "signal_engine.py",
|
||||
warnings=config.get("content_filter_warnings") or None,
|
||||
)
|
||||
|
||||
# Print scalar metrics (skip nested dicts for JSON compat).
|
||||
# Explosive annual_return may be +inf; match options/run_card and emit
|
||||
# null instead of a bare Infinity token (invalid RFC-8259 JSON).
|
||||
print(json.dumps(_json_safe_scalar_metrics(m), indent=2, allow_nan=False))
|
||||
return m
|
||||
|
||||
# ── Execution loop ──
|
||||
@@ -362,29 +626,132 @@ class BaseEngine(ABC):
|
||||
codes: List[str],
|
||||
) -> None:
|
||||
"""Bar-by-bar execution with market rule enforcement."""
|
||||
# Pre-extract numpy arrays for O(1) indexed access instead of DataFrame.at[]
|
||||
# Explicit column reindex ensures array column order matches codes parameter,
|
||||
# regardless of DataFrame internal column ordering (which may be alphabetical).
|
||||
_target_arr = target_pos[codes].values # (n_dates, n_codes) ndarray
|
||||
_close_arr = close_df[codes].values # (n_dates, n_codes) ndarray
|
||||
_code_to_col = {c: j for j, c in enumerate(codes)}
|
||||
# Store as instance attrs for use in _calc_equity / _safe_price
|
||||
self._close_arr = _close_arr
|
||||
self._code_to_col = _code_to_col
|
||||
|
||||
for i, ts in enumerate(dates):
|
||||
self._bar_idx = i
|
||||
|
||||
# a. Per-bar hooks (funding fees, liquidation checks)
|
||||
# a. Value the book at prices observable when orders execute.
|
||||
# Rebalances happen at the bar open, so using close_df[ts] here
|
||||
# would let the yet-unknown decision-bar close affect order size.
|
||||
equity = self._calc_open_equity(data_map, close_df, ts)
|
||||
target_weights: Dict[str, Optional[float]] = {}
|
||||
for c in codes:
|
||||
try:
|
||||
val = _target_arr[i, _code_to_col[c]]
|
||||
target_weights[c] = float(val) if not np.isnan(val) else 0.0
|
||||
except Exception as exc:
|
||||
target_weights[c] = None
|
||||
logger.warning("Target weight failed for %s at %s: %s", c, ts, exc)
|
||||
|
||||
# b. Release capital before opening replacement positions. A
|
||||
# single mixed close/open pass makes rotations depend on symbol
|
||||
# iteration order when the new name is visited before the old one.
|
||||
for c in codes:
|
||||
target_w = target_weights[c]
|
||||
current_pos = self.positions.get(c)
|
||||
if target_w is None or current_pos is None:
|
||||
continue
|
||||
target_dir = 1 if target_w > 1e-9 else (-1 if target_w < -1e-9 else 0)
|
||||
if target_dir == 0 or target_dir != current_pos.direction:
|
||||
try:
|
||||
self._rebalance(c, 0.0, data_map.get(c), ts, equity)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Rebalance close failed for %s at %s: %s", c, ts, exc
|
||||
)
|
||||
|
||||
# c. Price every opening order before committing any of them. If
|
||||
# the requested basket does not fit after fees/lot rounding, apply
|
||||
# one common scale factor to all target weights. This preserves
|
||||
# portfolio proportions and makes fills independent of input code
|
||||
# order; sequential cash clipping would privilege the first name.
|
||||
open_targets: list[tuple[str, float, Optional[pd.DataFrame]]] = []
|
||||
for c in sorted(codes):
|
||||
target_w = target_weights[c]
|
||||
if target_w is None:
|
||||
continue
|
||||
target_dir = 1 if target_w > 1e-9 else (-1 if target_w < -1e-9 else 0)
|
||||
current_pos = self.positions.get(c)
|
||||
if current_pos is not None and (
|
||||
target_dir == 0 or target_dir != current_pos.direction
|
||||
):
|
||||
continue
|
||||
if current_pos is None and target_dir != 0:
|
||||
open_targets.append((c, target_w, data_map.get(c)))
|
||||
|
||||
def _plans(scale: float) -> list[_OpenOrder]:
|
||||
result: list[_OpenOrder] = []
|
||||
for c, target_w, frame in open_targets:
|
||||
try:
|
||||
order = self._plan_open_order(
|
||||
c, target_w * scale, frame, ts, equity
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Rebalance open plan failed for %s at %s: %s",
|
||||
c,
|
||||
ts,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if order is not None:
|
||||
result.append(order)
|
||||
return result
|
||||
|
||||
planned = _plans(1.0)
|
||||
if sum(order.cost for order in planned) > self.capital + 1e-9:
|
||||
low, high = 0.0, 1.0
|
||||
for _ in range(50):
|
||||
mid = (low + high) / 2.0
|
||||
candidate = _plans(mid)
|
||||
if sum(order.cost for order in candidate) <= self.capital + 1e-9:
|
||||
low, planned = mid, candidate
|
||||
else:
|
||||
high = mid
|
||||
|
||||
for order in planned:
|
||||
self._execute_open_order(order, ts)
|
||||
|
||||
# d. Apply close/within-bar hooks after open execution. Hooks use
|
||||
# the current bar's close for funding, swaps, and liquidation, so
|
||||
# running them first could liquidate a position that was scheduled
|
||||
# to exit at the open (or charge a position before it was opened).
|
||||
for c in codes:
|
||||
if ts in data_map[c].index:
|
||||
self.on_bar(c, data_map[c].loc[ts], ts)
|
||||
|
||||
# b. Rebalance each symbol to target weight
|
||||
equity = self._calc_equity(close_df, ts)
|
||||
for c in codes:
|
||||
try:
|
||||
target_w = float(target_pos.at[ts, c]) if ts in target_pos.index else 0.0
|
||||
self._rebalance(c, target_w, data_map.get(c), ts, equity)
|
||||
except Exception as exc:
|
||||
logger.warning("Rebalance failed for %s at %s: %s", c, ts, exc)
|
||||
|
||||
# c. Record equity snapshot
|
||||
# e. Record equity snapshot
|
||||
snap_equity = self._calc_equity(close_df, ts)
|
||||
total_unrealized = 0.0
|
||||
for p in self.positions.values():
|
||||
cp = self._safe_price(close_df, ts, p.symbol, p.entry_price)
|
||||
total_unrealized += self._calc_pnl(p.symbol, p.direction, p.size, p.entry_price, cp)
|
||||
if self.positions and type(self)._calc_pnl is BaseEngine._calc_pnl:
|
||||
_syms = list(self.positions.keys())
|
||||
_eps = np.array([p.entry_price for p in self.positions.values()])
|
||||
_dirs = np.array([p.direction for p in self.positions.values()])
|
||||
_sizes = np.array([p.size for p in self.positions.values()])
|
||||
_cps = np.array([
|
||||
self._safe_price(
|
||||
close_df, ts, s, ep,
|
||||
_arr=_close_arr, _row=i, _col=_code_to_col.get(s),
|
||||
)
|
||||
for s, ep in zip(_syms, _eps)
|
||||
])
|
||||
total_unrealized = float(np.sum(_dirs * _sizes * (_cps - _eps)))
|
||||
else:
|
||||
total_unrealized = 0.0
|
||||
for p in self.positions.values():
|
||||
cp = self._safe_price(
|
||||
close_df, ts, p.symbol, p.entry_price,
|
||||
_arr=_close_arr, _row=i, _col=_code_to_col.get(p.symbol),
|
||||
)
|
||||
total_unrealized += self._calc_pnl(p.symbol, p.direction, p.size, p.entry_price, cp)
|
||||
self.equity_snapshots.append(EquitySnapshot(
|
||||
timestamp=ts,
|
||||
capital=self.capital,
|
||||
@@ -393,18 +760,120 @@ class BaseEngine(ABC):
|
||||
positions=len(self.positions),
|
||||
))
|
||||
|
||||
# d. Force close all remaining positions
|
||||
# f. Force close all remaining positions
|
||||
if len(dates) > 0:
|
||||
last_ts = dates[-1]
|
||||
_last_row = len(dates) - 1
|
||||
for c in list(self.positions.keys()):
|
||||
price = self._safe_price(close_df, last_ts, c, self.positions[c].entry_price)
|
||||
self._close_position(c, price, last_ts, "end_of_backtest")
|
||||
pos = self.positions[c]
|
||||
mark_price = self._safe_price(
|
||||
close_df, last_ts, c, pos.entry_price,
|
||||
_arr=_close_arr, _row=_last_row, _col=_code_to_col.get(c),
|
||||
)
|
||||
self._active_symbol = c
|
||||
exit_price = self.apply_slippage(mark_price, -pos.direction)
|
||||
self._close_position(c, exit_price, last_ts, "end_of_backtest")
|
||||
|
||||
# The final snapshot feeds metrics and artifacts. Replace its
|
||||
# pre-liquidation mark with post-liquidation cash so terminal
|
||||
# slippage and exit commission are reflected in reported equity.
|
||||
if self.equity_snapshots:
|
||||
self.equity_snapshots[-1] = EquitySnapshot(
|
||||
timestamp=last_ts,
|
||||
capital=self.capital,
|
||||
unrealized=0.0,
|
||||
equity=self.capital,
|
||||
positions=0,
|
||||
)
|
||||
|
||||
# Clean up temporary instance attributes
|
||||
self._close_arr = None
|
||||
self._code_to_col = None
|
||||
|
||||
def _calc_open_equity(
|
||||
self,
|
||||
data_map: Dict[str, pd.DataFrame],
|
||||
close_df: pd.DataFrame,
|
||||
ts: pd.Timestamp,
|
||||
) -> float:
|
||||
"""Value current positions at the execution bar's observable open.
|
||||
|
||||
For a symbol that has a bar at ``ts``, its open is the mark available
|
||||
when next-bar-open orders execute. Symbols without a bar on the
|
||||
unified calendar retain the aligned close fallback, which is the most
|
||||
recent price carried by ``_align``.
|
||||
"""
|
||||
if not self.positions:
|
||||
return self.capital
|
||||
|
||||
def _calc_equity(self, close_df: pd.DataFrame, ts: pd.Timestamp) -> float:
|
||||
"""Total equity = free cash + sum(margin + unrealised) per position."""
|
||||
equity = self.capital
|
||||
for sym, pos in self.positions.items():
|
||||
cp = self._safe_price(close_df, ts, sym, pos.entry_price)
|
||||
_arr = getattr(self, "_close_arr", None)
|
||||
_row = getattr(self, "_bar_idx", None)
|
||||
_c2c = getattr(self, "_code_to_col", None)
|
||||
current_price = self._safe_price(
|
||||
close_df, ts, sym, pos.entry_price,
|
||||
_arr=_arr, _row=_row, _col=(_c2c.get(sym) if _c2c else None),
|
||||
)
|
||||
frame = data_map.get(sym)
|
||||
if frame is not None and ts in frame.index:
|
||||
open_price = frame.loc[ts].get("open")
|
||||
if (
|
||||
open_price is not None
|
||||
and pd.notna(open_price)
|
||||
and float(open_price) > 0
|
||||
):
|
||||
current_price = float(open_price)
|
||||
|
||||
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
|
||||
unrealized = self._calc_pnl(
|
||||
sym, pos.direction, pos.size, pos.entry_price, current_price
|
||||
)
|
||||
equity += margin + unrealized
|
||||
return equity
|
||||
|
||||
def _calc_equity(self, close_df: pd.DataFrame, ts: pd.Timestamp) -> float:
|
||||
"""Total equity = free cash + sum(margin + unrealised) per position.
|
||||
|
||||
Uses vectorized numpy path when _calc_pnl/_calc_margin are not
|
||||
overridden by a subclass (FuturesBaseEngine, CompositeEngine).
|
||||
"""
|
||||
if not self.positions:
|
||||
return self.capital
|
||||
|
||||
_base_pnl = type(self)._calc_pnl is BaseEngine._calc_pnl
|
||||
_base_margin = type(self)._calc_margin is BaseEngine._calc_margin
|
||||
|
||||
# Use array fast-path when available
|
||||
_arr = getattr(self, "_close_arr", None)
|
||||
_row = getattr(self, "_bar_idx", None)
|
||||
_c2c = getattr(self, "_code_to_col", None)
|
||||
|
||||
if _base_pnl and _base_margin:
|
||||
syms = list(self.positions.keys())
|
||||
sizes = np.array([p.size for p in self.positions.values()])
|
||||
entry_prices = np.array([p.entry_price for p in self.positions.values()])
|
||||
directions = np.array([p.direction for p in self.positions.values()])
|
||||
leverages = np.array([p.leverage for p in self.positions.values()])
|
||||
|
||||
current_prices = np.array([
|
||||
self._safe_price(
|
||||
close_df, ts, s, ep,
|
||||
_arr=_arr, _row=_row, _col=(_c2c.get(s) if _c2c else None),
|
||||
)
|
||||
for s, ep in zip(syms, entry_prices)
|
||||
])
|
||||
|
||||
margins = sizes * entry_prices / leverages
|
||||
pnls = directions * sizes * (current_prices - entry_prices)
|
||||
return self.capital + float(np.sum(margins + pnls))
|
||||
|
||||
equity = self.capital
|
||||
for sym, pos in self.positions.items():
|
||||
cp = self._safe_price(
|
||||
close_df, ts, sym, pos.entry_price,
|
||||
_arr=_arr, _row=_row, _col=(_c2c.get(sym) if _c2c else None),
|
||||
)
|
||||
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
|
||||
unrealized = self._calc_pnl(sym, pos.direction, pos.size, pos.entry_price, cp)
|
||||
equity += margin + unrealized
|
||||
@@ -442,50 +911,69 @@ class BaseEngine(ABC):
|
||||
else:
|
||||
return # blocked (e.g. limit-down can't sell)
|
||||
|
||||
# Open new if target non-zero and no remaining position
|
||||
if target_dir != 0 and symbol not in self.positions:
|
||||
if not self.can_execute(symbol, target_dir, bar):
|
||||
return # blocked (e.g. A-share no-short)
|
||||
order = self._plan_open_order(symbol, target_weight, df, ts, equity)
|
||||
if order is not None and order.cost <= self.capital + 1e-9:
|
||||
self._execute_open_order(order, ts)
|
||||
|
||||
open_price = float(bar.get("open", bar.get("close", 0)))
|
||||
if open_price <= 0:
|
||||
return
|
||||
def _plan_open_order(
|
||||
self,
|
||||
symbol: str,
|
||||
target_weight: float,
|
||||
df: Optional[pd.DataFrame],
|
||||
ts: pd.Timestamp,
|
||||
equity: float,
|
||||
) -> Optional[_OpenOrder]:
|
||||
"""Price an opening order without mutating portfolio state."""
|
||||
self._active_symbol = symbol
|
||||
direction = 1 if target_weight > 1e-9 else (-1 if target_weight < -1e-9 else 0)
|
||||
if direction == 0 or symbol in self.positions or df is None or ts not in df.index:
|
||||
return None
|
||||
bar = df.loc[ts]
|
||||
if not self.can_execute(symbol, direction, bar):
|
||||
return None
|
||||
open_price = float(bar.get("open", bar.get("close", 0)))
|
||||
if open_price <= 0:
|
||||
return None
|
||||
price = self.apply_slippage(open_price, direction)
|
||||
leverage = self._leverage_for_symbol(symbol)
|
||||
target_notional = abs(target_weight) * equity * leverage
|
||||
size = self.round_size(
|
||||
self._calc_raw_size(symbol, target_notional, price), price
|
||||
)
|
||||
if size <= 0:
|
||||
return None
|
||||
margin = self._calc_margin(symbol, size, price, leverage)
|
||||
commission = self.calc_commission(
|
||||
size, price, direction, is_open=True
|
||||
)
|
||||
return _OpenOrder(
|
||||
symbol=symbol,
|
||||
direction=direction,
|
||||
price=price,
|
||||
size=size,
|
||||
leverage=leverage,
|
||||
margin=margin,
|
||||
commission=commission,
|
||||
)
|
||||
|
||||
slipped = self.apply_slippage(open_price, target_dir)
|
||||
leverage = self.default_leverage
|
||||
target_notional = abs(target_weight) * equity * leverage
|
||||
raw_size = self._calc_raw_size(symbol, target_notional, slipped)
|
||||
size = self.round_size(raw_size, slipped)
|
||||
if size <= 0:
|
||||
return
|
||||
|
||||
margin = self._calc_margin(symbol, size, slipped, leverage)
|
||||
comm = self.calc_commission(size, slipped, target_dir, is_open=True)
|
||||
|
||||
# Capital check — reduce if insufficient
|
||||
if margin + comm > self.capital:
|
||||
available = self.capital - comm
|
||||
if available <= 0:
|
||||
return
|
||||
size = self.round_size(
|
||||
self._calc_raw_size(symbol, available * leverage, slipped), slipped,
|
||||
)
|
||||
if size <= 0:
|
||||
return
|
||||
margin = self._calc_margin(symbol, size, slipped, leverage)
|
||||
comm = self.calc_commission(size, slipped, target_dir, is_open=True)
|
||||
|
||||
self.capital -= (margin + comm)
|
||||
self.positions[symbol] = Position(
|
||||
symbol=symbol,
|
||||
direction=target_dir,
|
||||
entry_price=slipped,
|
||||
entry_time=ts,
|
||||
size=size,
|
||||
leverage=leverage,
|
||||
entry_bar_idx=self._bar_idx,
|
||||
entry_commission=comm,
|
||||
def _execute_open_order(self, order: _OpenOrder, ts: pd.Timestamp) -> None:
|
||||
"""Commit a previously priced opening order."""
|
||||
if order.cost > self.capital + 1e-7:
|
||||
raise RuntimeError(
|
||||
f"planned order for {order.symbol} exceeds available capital"
|
||||
)
|
||||
self.capital -= order.cost
|
||||
self.positions[order.symbol] = Position(
|
||||
symbol=order.symbol,
|
||||
direction=order.direction,
|
||||
entry_price=order.price,
|
||||
entry_time=ts,
|
||||
size=order.size,
|
||||
leverage=order.leverage,
|
||||
entry_bar_idx=self._bar_idx,
|
||||
entry_commission=order.commission,
|
||||
)
|
||||
|
||||
def _close_position(
|
||||
self,
|
||||
@@ -502,6 +990,7 @@ class BaseEngine(ABC):
|
||||
|
||||
pnl = self._calc_pnl(symbol, pos.direction, pos.size, pos.entry_price, exit_price)
|
||||
margin = self._calc_margin(symbol, pos.size, pos.entry_price, pos.leverage)
|
||||
exit_margin = self._calc_margin(symbol, pos.size, exit_price, pos.leverage)
|
||||
pnl_pct = pnl / margin * 100 if margin > 1e-9 else 0.0
|
||||
exit_comm = self.calc_commission(pos.size, exit_price, pos.direction, is_open=False)
|
||||
|
||||
@@ -523,6 +1012,8 @@ class BaseEngine(ABC):
|
||||
exit_reason=reason,
|
||||
holding_bars=holding_bars,
|
||||
commission=pos.entry_commission + exit_comm,
|
||||
entry_margin=margin,
|
||||
exit_margin=exit_margin,
|
||||
))
|
||||
|
||||
# ── Artifacts ──
|
||||
@@ -612,8 +1103,17 @@ class BaseEngine(ABC):
|
||||
ts: pd.Timestamp,
|
||||
symbol: str,
|
||||
fallback: float,
|
||||
*,
|
||||
_arr: "np.ndarray | None" = None,
|
||||
_row: "int | None" = None,
|
||||
_col: "int | None" = None,
|
||||
) -> float:
|
||||
"""Get close price with fallback."""
|
||||
"""Get close price with fallback. Uses array fast-path when available."""
|
||||
# Fast path: pre-computed array indexing (O(1) vs DataFrame.at hash lookup)
|
||||
if _arr is not None and _row is not None and _col is not None:
|
||||
val = _arr[_row, _col]
|
||||
return float(val) if not np.isnan(val) else fallback
|
||||
# Original path (backward compatible for subclasses)
|
||||
if ts in close_df.index and symbol in close_df.columns:
|
||||
val = close_df.at[ts, symbol]
|
||||
if pd.notna(val):
|
||||
|
||||
@@ -76,8 +76,12 @@ class ChinaAEngine(BaseEngine):
|
||||
"""Round down to 100-share lots."""
|
||||
return max(int(raw_size / 100) * 100, 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""A-share fee structure: commission + stamp tax (sell) + transfer fee."""
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""A-share fee structure: commission + stamp tax (sell) + transfer fee.
|
||||
|
||||
``_direction`` is unused today — reserved for future asymmetric
|
||||
long/short fee schedules (margin trading, securities lending).
|
||||
"""
|
||||
notional = size * price
|
||||
# Commission: 万2.5, min ¥5
|
||||
comm = max(notional * self.commission_rate, self.commission_min)
|
||||
@@ -113,6 +117,9 @@ 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:
|
||||
|
||||
@@ -152,6 +152,7 @@ class ChinaFuturesEngine(FuturesBaseEngine):
|
||||
config = {**config, "leverage": leverage}
|
||||
super().__init__(config)
|
||||
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:
|
||||
@@ -191,8 +192,12 @@ class ChinaFuturesEngine(FuturesBaseEngine):
|
||||
"""Minimum 1 contract, integer lots only."""
|
||||
return max(int(raw_size), 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""Commission varies by product: fixed per-lot or percentage of notional."""
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Commission varies by product: fixed per-lot or percentage of notional.
|
||||
|
||||
``_direction`` is unused — reserved for future open/close-fee
|
||||
asymmetry (some products charge different rates for close-today).
|
||||
"""
|
||||
if self._commission_override is not None:
|
||||
return size * price * self._commission_override
|
||||
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
|
||||
@@ -239,10 +244,21 @@ 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.
|
||||
|
||||
|
||||
@@ -8,53 +8,20 @@ All state (capital, positions, trades) lives in CompositeEngine.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import (
|
||||
_detect_market,
|
||||
_is_china_futures,
|
||||
calc_crypto_funding_fee,
|
||||
check_crypto_liquidation,
|
||||
calc_forex_swap,
|
||||
)
|
||||
|
||||
|
||||
# ── Market detection (same patterns as runner.py) ──
|
||||
|
||||
_MARKET_PATTERNS = [
|
||||
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
|
||||
(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"),
|
||||
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
|
||||
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
|
||||
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
|
||||
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
|
||||
]
|
||||
|
||||
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
|
||||
|
||||
|
||||
def _detect_market(code: str) -> str:
|
||||
"""Infer market type from symbol format."""
|
||||
for pattern, market in _MARKET_PATTERNS:
|
||||
if pattern.match(code):
|
||||
return market
|
||||
return "a_share"
|
||||
|
||||
|
||||
def _is_china_futures(code: str) -> bool:
|
||||
"""Check if futures code belongs to a Chinese exchange."""
|
||||
parts = code.upper().split(".")
|
||||
return len(parts) == 2 and parts[1] in _CHINA_EXCHANGES
|
||||
|
||||
|
||||
def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]:
|
||||
"""Instantiate one sub-engine per market type detected in codes."""
|
||||
markets = {_detect_market(c) for c in codes}
|
||||
@@ -70,6 +37,9 @@ 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 == "crypto":
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
engines["crypto"] = CryptoEngine(config)
|
||||
@@ -77,15 +47,13 @@ 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
|
||||
|
||||
@@ -120,7 +88,14 @@ class CompositeEngine(BaseEngine):
|
||||
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 ──
|
||||
|
||||
@@ -185,6 +160,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:
|
||||
|
||||
@@ -48,8 +48,12 @@ class CryptoEngine(BaseEngine):
|
||||
"""Crypto supports fractional sizes, round to 6 decimals."""
|
||||
return round(max(raw_size, 0.0), 6)
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""Maker/Taker separated. Opens typically hit taker, closes hit maker."""
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Maker/Taker separated. Opens typically hit taker, closes hit maker.
|
||||
|
||||
``_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
|
||||
return size * price * rate
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ from __future__ import annotations
|
||||
import pandas as pd
|
||||
|
||||
from backtest.engines.base import BaseEngine
|
||||
from backtest.engines._market_hooks import calc_forex_swap
|
||||
# ``_normalize_symbol`` lives in ``_market_hooks`` (single source of truth);
|
||||
# re-imported here so external callers (tests) keep their existing import path.
|
||||
from backtest.engines._market_hooks import _normalize_symbol, calc_forex_swap
|
||||
|
||||
|
||||
# ── Typical spreads in pips (1 pip = 0.0001 for most pairs, 0.01 for JPY) ──
|
||||
@@ -51,23 +53,6 @@ def _pip_value(symbol: str) -> float:
|
||||
return 0.01 if quote.upper() == "JPY" else 0.0001
|
||||
|
||||
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
"""Normalize forex symbol to 'XXX/YYY' format.
|
||||
|
||||
Args:
|
||||
symbol: Raw symbol (e.g. 'EURUSD.FX', 'EUR/USD', 'EURUSD').
|
||||
|
||||
Returns:
|
||||
Normalized pair (e.g. 'EUR/USD').
|
||||
"""
|
||||
s = symbol.replace(".FX", "").replace(".", "").strip()
|
||||
if "/" in s:
|
||||
return s.upper()
|
||||
if len(s) == 6:
|
||||
return f"{s[:3]}/{s[3:]}".upper()
|
||||
return s.upper()
|
||||
|
||||
|
||||
class ForexEngine(BaseEngine):
|
||||
"""Forex engine for spot / CFD pairs.
|
||||
|
||||
@@ -100,11 +85,13 @@ class ForexEngine(BaseEngine):
|
||||
"""
|
||||
return max(int(raw_size / 1000) * 1000, 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Forex: spread is the cost, embedded in slippage. No explicit commission.
|
||||
|
||||
Some ECN brokers charge per-lot commission; for simplicity, zero here.
|
||||
The cost is captured via apply_slippage (half-spread applied to execution).
|
||||
``_direction`` is unused — reserved for future ECN per-lot fee
|
||||
modelling (asymmetric long/short funding).
|
||||
"""
|
||||
return 0.0
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -56,8 +59,12 @@ class GlobalEquityEngine(BaseEngine):
|
||||
return max(int(raw_size / 100) * 100, 0)
|
||||
return round(max(raw_size, 0.0), 2)
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""US: zero commission. HK: stamp tax + levies."""
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""US: zero commission. HK: stamp tax + levies.
|
||||
|
||||
``_direction`` is unused — reserved for future short-borrow fees
|
||||
(US Reg-T margin, HK SBL costs).
|
||||
"""
|
||||
if self.market == "hk":
|
||||
notional = size * price
|
||||
comm = notional * self.hk_commission # broker commission
|
||||
|
||||
@@ -47,6 +47,7 @@ _MULTIPLIER: dict[str, float] = {
|
||||
}
|
||||
|
||||
# ── Margin per contract (approximate USD, initial margin) ──
|
||||
# Reference table — future use for margin-call checks. Not yet consumed.
|
||||
|
||||
_MARGIN_PER_CONTRACT: dict[str, float] = {
|
||||
# Equity index
|
||||
@@ -178,8 +179,12 @@ class GlobalFuturesEngine(FuturesBaseEngine):
|
||||
"""Integer contracts, minimum 1."""
|
||||
return max(int(raw_size), 0)
|
||||
|
||||
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
|
||||
"""Per-contract fixed commission (uses _active_symbol for product lookup)."""
|
||||
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
||||
"""Per-contract fixed commission (uses _active_symbol for product lookup).
|
||||
|
||||
``_direction`` is unused — reserved for future borrow/financing
|
||||
asymmetry on short positions.
|
||||
"""
|
||||
if self._comm_override is not None:
|
||||
return size * self._comm_override
|
||||
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
|
||||
@@ -215,6 +220,11 @@ 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""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 _calc_pct_change
|
||||
|
||||
|
||||
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 (single configurable band; disabled when falsy).
|
||||
if self.price_limit:
|
||||
pct_chg = _calc_pct_change(bar)
|
||||
if pct_chg is not None:
|
||||
limit = float(self.price_limit)
|
||||
if direction == 1 and pct_chg >= limit - 0.001:
|
||||
return False # upper circuit: can't buy
|
||||
if direction == 0 and pct_chg <= -limit + 0.001:
|
||||
return False # lower circuit: can't sell
|
||||
|
||||
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
|
||||
@@ -16,7 +16,6 @@ Artifacts: equity.csv, metrics.csv, trades.csv, greeks.csv.
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -47,8 +46,9 @@ def bs_price(S: float, K: float, T: float, r: float, sigma: float,
|
||||
>>> 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
|
||||
# Non-positive S/K makes log(S/K) undefined; reuse the intrinsic fallback
|
||||
# (iv_smile_adjustment already soft-guards non-positive S/K).
|
||||
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
|
||||
if option_type == "call":
|
||||
return max(S - K, 0.0)
|
||||
return max(K - S, 0.0)
|
||||
@@ -79,7 +79,7 @@ def bs_greeks(S: float, K: float, T: float, r: float, sigma: float,
|
||||
Returns:
|
||||
Dict containing delta, gamma, theta, vega.
|
||||
"""
|
||||
if T <= 0 or sigma <= 0:
|
||||
if T <= 0 or sigma <= 0 or S <= 0 or K <= 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}
|
||||
@@ -156,36 +156,6 @@ def iv_smile_adjustment(S: float, K: float, base_iv: float,
|
||||
return max(adj, 0.01)
|
||||
|
||||
|
||||
# --- American option early exercise heuristic (v2) ---
|
||||
|
||||
|
||||
def american_exercise_value(
|
||||
S: float, K: float, T: float, r: float, sigma: float,
|
||||
option_type: str = "put",
|
||||
) -> float:
|
||||
"""Check whether early exercise is optimal for American options.
|
||||
|
||||
For American puts: exercise if intrinsic > BS continuation value.
|
||||
For American calls on non-dividend stocks: never exercise early.
|
||||
For American calls with dividends: simplified — exercise if deep ITM
|
||||
and time value < dividend capture.
|
||||
|
||||
Args:
|
||||
S: Spot price.
|
||||
K: Strike price.
|
||||
T: Time to expiry in years.
|
||||
r: Risk-free rate.
|
||||
sigma: Volatility.
|
||||
option_type: "call" or "put".
|
||||
|
||||
Returns:
|
||||
Max of (intrinsic, BS continuation value).
|
||||
"""
|
||||
intrinsic = max(K - S, 0.0) if option_type == "put" else max(S - K, 0.0)
|
||||
continuation = bs_price(S, K, T, r, sigma, option_type)
|
||||
return max(intrinsic, continuation)
|
||||
|
||||
|
||||
# --- Option positions ---
|
||||
|
||||
|
||||
@@ -290,7 +260,6 @@ def run_options_backtest(
|
||||
commission = config.get("commission", 0.001)
|
||||
options_cfg = config.get("options_config", {})
|
||||
risk_free_rate = options_cfg.get("risk_free_rate", 0.05)
|
||||
iv_source = options_cfg.get("iv_source", "historical")
|
||||
contract_multiplier = options_cfg.get("contract_multiplier", 1.0)
|
||||
exercise_style = options_cfg.get("exercise_style", "european") # v2: "european" or "american"
|
||||
iv_skew = options_cfg.get("iv_skew", 0.0) # v2: smile skew param (0 = flat)
|
||||
@@ -464,12 +433,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)
|
||||
@@ -485,11 +468,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
|
||||
@@ -554,7 +550,17 @@ def run_options_backtest(
|
||||
pd.DataFrame(greeks_records).to_csv(out / "greeks.csv", index=False)
|
||||
pd.DataFrame([metrics]).to_csv(out / "metrics.csv", index=False)
|
||||
|
||||
print(json.dumps(metrics, indent=2))
|
||||
from backtest.run_card import write_run_card
|
||||
write_run_card(
|
||||
run_dir,
|
||||
config,
|
||||
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, allow_nan=False))
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -607,34 +613,130 @@ 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 = [
|
||||
float(t["pnl"])
|
||||
for t in trades
|
||||
if t.get("pnl", 0) != 0 and np.isfinite(float(t["pnl"]))
|
||||
]
|
||||
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
|
||||
@@ -643,14 +745,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,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,7 +11,8 @@ 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__)
|
||||
@@ -39,23 +40,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 +49,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 +104,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:
|
||||
@@ -204,7 +200,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,251 @@
|
||||
"""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)
|
||||
|
||||
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,155 @@
|
||||
"""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)
|
||||
|
||||
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,11 +1,28 @@
|
||||
"""DataLoader Protocol and shared exceptions for all data source loaders."""
|
||||
"""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
|
||||
applied only to a declared transient exception class. New loaders should
|
||||
import :func:`check_budget` and :func:`retry_with_budget` rather than
|
||||
re-implementing the loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
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."""
|
||||
@@ -30,6 +47,539 @@ 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") -> 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.
|
||||
|
||||
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).
|
||||
|
||||
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)
|
||||
invalid = (
|
||||
(high < low)
|
||||
| (high < open_)
|
||||
| (high < close)
|
||||
| (low > open_)
|
||||
| (low > close)
|
||||
| (open_ <= 0)
|
||||
| (high <= 0)
|
||||
| (low <= 0)
|
||||
| (close <= 0)
|
||||
)
|
||||
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).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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``.
|
||||
|
||||
Use this between pages of a paginated fetch to fail fast instead of
|
||||
grinding through more requests once the wall-clock budget is gone.
|
||||
|
||||
Args:
|
||||
deadline: ``time.monotonic()`` instant past which we abort.
|
||||
label: Free-form label used in the exception message
|
||||
(e.g. ``"ccxt fetch for BTC/USDT"``).
|
||||
budget_s: Original budget in seconds, included verbatim in the
|
||||
message when present.
|
||||
"""
|
||||
if time.monotonic() > deadline:
|
||||
suffix = f" exceeded {budget_s:.0f}s budget" if budget_s is not None else " exceeded budget"
|
||||
raise TimeoutError(f"{label}{suffix}")
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def retry_with_budget(
|
||||
fn: Callable[[], _T],
|
||||
*,
|
||||
transient: type[BaseException] | tuple[type[BaseException], ...],
|
||||
deadline: float,
|
||||
label: str,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
backoff: tuple[float, ...] = DEFAULT_BACKOFF,
|
||||
) -> _T:
|
||||
"""Call ``fn`` with a bounded retry budget on declared transient errors.
|
||||
|
||||
Between attempts sleeps ``min(backoff[attempt], remaining_budget)`` so a
|
||||
short remaining budget never spends the full backoff. The terminal
|
||||
transient failure — whether ``max_retries`` is exhausted OR the deadline
|
||||
has passed — is wrapped in :class:`TimeoutError`, preserving the original
|
||||
exception as ``__cause__``. Anything not in ``transient`` propagates
|
||||
unchanged on the first occurrence (we never retry an exception class
|
||||
the caller didn't opt in to).
|
||||
|
||||
Args:
|
||||
fn: Zero-arg callable producing the result.
|
||||
transient: Exception class(es) considered transient and retryable.
|
||||
deadline: ``time.monotonic()`` instant past which retries are aborted.
|
||||
label: Free-form label used in the TimeoutError message
|
||||
(e.g. ``"OKX fetch for BTC-USDT"``).
|
||||
max_retries: Additional attempts after the first call. Total
|
||||
attempts = ``max_retries + 1``.
|
||||
backoff: Per-retry sleep seconds. Must have at least
|
||||
``max_retries`` entries.
|
||||
|
||||
Returns:
|
||||
Whatever ``fn`` returns.
|
||||
|
||||
Raises:
|
||||
ValueError: ``backoff`` is shorter than ``max_retries``.
|
||||
TimeoutError: All retries exhausted or the deadline crossed.
|
||||
Any non-transient exception: Propagated unchanged from ``fn``.
|
||||
"""
|
||||
if len(backoff) < max_retries:
|
||||
raise ValueError(
|
||||
f"backoff has {len(backoff)} entries; need >= max_retries ({max_retries})"
|
||||
)
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return fn()
|
||||
except transient as exc:
|
||||
remaining = deadline - time.monotonic()
|
||||
if attempt == max_retries or remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"{label} failed after {attempt + 1} attempt(s): {exc}"
|
||||
) from exc
|
||||
time.sleep(min(backoff[attempt], max(0.0, remaining)))
|
||||
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."""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
return get_env_config().data.vibe_trading_data_cache
|
||||
|
||||
|
||||
def loader_cache_root() -> Path:
|
||||
"""Return the root directory for opt-in loader cache files."""
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
root = get_env_config().data.vibe_trading_data_cache_root
|
||||
if root 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,13 +7,24 @@ 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 validate_date_range
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,6 +34,149 @@ _INTERVAL_MAP = {
|
||||
"1H": "1h", "4H": "4h", "1D": "1d",
|
||||
}
|
||||
|
||||
_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),
|
||||
}
|
||||
|
||||
# P12-b: ccxt had no request timeout and an unbounded paginated fetch with
|
||||
# no retry budget, so a transient disconnect hung get_market_data for 10+
|
||||
# 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 = 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() # noqa: env-gate — system proxy vars
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _ccxt_proxy_config() -> dict[str, str]:
|
||||
"""Build CCXT proxy settings from conventional proxy environment variables."""
|
||||
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
|
||||
|
||||
|
||||
_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:
|
||||
@@ -43,15 +197,28 @@ 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)
|
||||
exchange_cls = ccxt.binance
|
||||
return exchange_cls({"enableRateLimit": True})
|
||||
|
||||
config = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS}
|
||||
proxies = _ccxt_proxy_config()
|
||||
if proxies:
|
||||
config["proxies"] = proxies
|
||||
return exchange_cls(config)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
@@ -61,6 +228,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.
|
||||
|
||||
@@ -70,44 +239,228 @@ 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({
|
||||
"trade_date": pd.to_datetime(
|
||||
[row["timestamp"] for row in rows], unit="ms"
|
||||
),
|
||||
"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
|
||||
|
||||
all_rows: list = []
|
||||
cursor = since_ms
|
||||
limit = 1000
|
||||
deadline = time.monotonic() + _CCXT_FETCH_BUDGET_S
|
||||
label = f"ccxt fetch for {symbol}"
|
||||
hit_page_cap = True
|
||||
|
||||
for _ in range(200):
|
||||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=cursor, limit=limit)
|
||||
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(
|
||||
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
|
||||
|
||||
@@ -128,4 +481,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,322 @@
|
||||
"""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,
|
||||
"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,271 @@
|
||||
"""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)
|
||||
|
||||
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 interval != "1D":
|
||||
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,479 @@
|
||||
"""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
|
||||
|
||||
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(330, 380)]
|
||||
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(60, 120)].copy()
|
||||
annuals = df[duration.between(330, 380)]
|
||||
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]:
|
||||
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))
|
||||
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")
|
||||
+107
-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,6 +20,10 @@ 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",
|
||||
"1H": "K_60M",
|
||||
"4H": "K_240M",
|
||||
@@ -47,10 +55,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 +95,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 +112,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 +148,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 +161,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 +200,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,175 @@
|
||||
"""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).
|
||||
_PERIOD_MAP = {"1D": "1d", "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(), "1d")
|
||||
# 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,350 @@
|
||||
"""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",
|
||||
"4H": "4h",
|
||||
"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,409 @@
|
||||
"""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",
|
||||
"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
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Mootdx loader: A-share OHLCV via TCP-direct 通达信 servers (no IP ban).
|
||||
|
||||
Mootdx (https://github.com/mootdx/mootdx) talks the native 通达信 binary
|
||||
protocol over TCP and is not subject to the HTTP scraping rate limits that
|
||||
periodically break the akshare → East Money path. Public market data only,
|
||||
no token required, no per-IP throttling.
|
||||
|
||||
Scope: A-share OHLCV only (沪/深/京 auto-detected from symbol). Mootdx's
|
||||
extended-market endpoint (futures/options) is upstream-broken as of
|
||||
v0.11.7 — falls through to tushare/akshare for those markets.
|
||||
"""
|
||||
|
||||
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__)
|
||||
|
||||
# Mootdx frequency codes (see mootdx.consts).
|
||||
_INTRADAY_FREQ: dict[str, int] = {
|
||||
"1m": 8,
|
||||
"5m": 0,
|
||||
"15m": 1,
|
||||
"30m": 2,
|
||||
"1H": 3,
|
||||
}
|
||||
_DAILY_FREQ: dict[str, int] = {
|
||||
"1D": 4,
|
||||
"1W": 5,
|
||||
"1M": 6,
|
||||
}
|
||||
|
||||
# bars() returns one page of N rows ending at the latest bar. Pages older
|
||||
# than this need to be requested with ``start=offset_into_history``. We cap
|
||||
# pagination at MAX_PAGES so a wildly out-of-range request can't grind for
|
||||
# minutes against the TDX server.
|
||||
_BARS_PAGE = 800
|
||||
_MAX_PAGES = 25 # 25 × 800 = 20 000 bars (~10y daily, ~5y 1H, ~3mo 1m)
|
||||
|
||||
|
||||
def _is_a_share(code: str) -> bool:
|
||||
"""Accept either explicit `.SH/.SZ/.BJ` suffix or bare 6-digit ticker."""
|
||||
upper = code.upper()
|
||||
if upper.endswith((".SH", ".SZ", ".BJ")):
|
||||
return True
|
||||
return len(code) == 6 and code.isdigit()
|
||||
|
||||
|
||||
def _is_bj(code: str) -> bool:
|
||||
"""Detect 北交所 symbols. Mootdx std factory does not serve BJ data
|
||||
(get_k_data raises KeyError, bars() returns empty), so the loader logs
|
||||
a warning and skips these instead of silently returning nothing."""
|
||||
upper = code.upper()
|
||||
if upper.endswith(".BJ"):
|
||||
return True
|
||||
# 4xxxxx / 8xxxxx are BJ prefixes (bare 6-digit form).
|
||||
return len(code) == 6 and code.isdigit() and code[0] in ("4", "8")
|
||||
|
||||
|
||||
@register
|
||||
class DataLoader:
|
||||
"""Mootdx-backed A-share OHLCV loader (TCP-direct, no auth)."""
|
||||
|
||||
name = "mootdx"
|
||||
markets = {"a_share"}
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available if mootdx is installed."""
|
||||
try:
|
||||
import mootdx # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
from mootdx.quotes import Quotes
|
||||
self._client = Quotes.factory(market="std")
|
||||
return self._client
|
||||
|
||||
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 A-share OHLCV via mootdx.
|
||||
|
||||
Args:
|
||||
codes: Symbol list. `.SH/.SZ/.BJ` suffix or bare 6-digit
|
||||
tickers; non-A-share symbols are silently skipped.
|
||||
start_date: YYYY-MM-DD.
|
||||
end_date: YYYY-MM-DD.
|
||||
interval: One of ``1m / 5m / 15m / 30m / 1H / 1D / 1W / 1M``.
|
||||
fields: Ignored.
|
||||
|
||||
Returns:
|
||||
Mapping symbol -> OHLCV DataFrame.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``interval`` is not in the supported set.
|
||||
"""
|
||||
validate_date_range(start_date, end_date)
|
||||
if interval not in _DAILY_FREQ and interval not in _INTRADAY_FREQ:
|
||||
raise ValueError(
|
||||
f"Unsupported interval for mootdx: {interval!r}. "
|
||||
f"Supported: {sorted(_DAILY_FREQ) + sorted(_INTRADAY_FREQ)}"
|
||||
)
|
||||
|
||||
result: Dict[str, pd.DataFrame] = {}
|
||||
for code in codes:
|
||||
if not _is_a_share(code):
|
||||
logger.debug("mootdx: skipping non-A-share symbol %s", code)
|
||||
continue
|
||||
if _is_bj(code):
|
||||
logger.warning(
|
||||
"mootdx: 北交所 (%s) not supported upstream; use akshare/tushare",
|
||||
code,
|
||||
)
|
||||
continue
|
||||
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("mootdx 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]:
|
||||
symbol = code.split(".")[0]
|
||||
client = self._get_client()
|
||||
|
||||
# Daily has a native date-range API; intraday and weekly/monthly
|
||||
# only expose offset-from-latest, so we page back through history
|
||||
# until the first row of the page is older than start_date.
|
||||
if interval == "1D":
|
||||
df = client.get_k_data(code=symbol, start_date=start_date, end_date=end_date)
|
||||
return self._normalize_daily(df)
|
||||
|
||||
freq = _DAILY_FREQ.get(interval) or _INTRADAY_FREQ[interval]
|
||||
return self._fetch_bars_paginated(client, symbol, freq, start_date, end_date)
|
||||
|
||||
@staticmethod
|
||||
def _fetch_bars_paginated(
|
||||
client, symbol: str, freq: int, start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Walk backward through ``bars()`` pages until the requested
|
||||
window is covered, then clip and concatenate.
|
||||
|
||||
Mootdx ``bars()`` returns the latest ``_BARS_PAGE`` rows by default;
|
||||
``start=N`` skips the newest N rows. We page until the oldest row
|
||||
in a page is at or before ``start_date``, or until ``_MAX_PAGES``
|
||||
is exhausted (very old or thinly-traded symbols).
|
||||
"""
|
||||
start_ts = pd.Timestamp(start_date)
|
||||
chunks: list[pd.DataFrame] = []
|
||||
for page in range(_MAX_PAGES):
|
||||
df = client.bars(
|
||||
symbol=symbol,
|
||||
frequency=freq,
|
||||
start=page * _BARS_PAGE,
|
||||
offset=_BARS_PAGE,
|
||||
)
|
||||
if df is None or df.empty:
|
||||
break
|
||||
chunks.append(df)
|
||||
first_dt = pd.to_datetime(df["datetime"].iloc[0])
|
||||
if first_dt <= start_ts:
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
"incomplete mootdx history: "
|
||||
f"{symbol} frequency={freq} hit {_MAX_PAGES} pages "
|
||||
f"without reaching {start_date}"
|
||||
)
|
||||
if not chunks:
|
||||
return None
|
||||
combined = pd.concat(chunks, ignore_index=False)
|
||||
return DataLoader._normalize_bars(combined, start_date, end_date)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_daily(df: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]:
|
||||
"""Normalize `get_k_data()` output to the OHLCV contract.
|
||||
|
||||
get_k_data returns columns ``[open, close, high, low, vol, amount,
|
||||
date, code]`` with ``date`` as the index name.
|
||||
"""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
out = df.rename(columns={"vol": "volume"}).copy()
|
||||
out.index = pd.to_datetime(out.index)
|
||||
out.index.name = "trade_date"
|
||||
for col in ("open", "high", "low", "close", "volume"):
|
||||
out[col] = pd.to_numeric(out[col], errors="coerce")
|
||||
out = out[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
return out.sort_index() if not out.empty else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bars(
|
||||
df: Optional[pd.DataFrame], start_date: str, end_date: str,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Normalize `bars()` output and clip to the requested window.
|
||||
|
||||
bars() returns ``[open, close, high, low, vol, amount, year, month,
|
||||
day, hour, minute, datetime, volume]`` with a datetime index.
|
||||
``volume`` (lowercase, last column) is the canonical share count;
|
||||
``vol`` is a historical alias kept by mootdx for compatibility.
|
||||
"""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
out = df.copy()
|
||||
if "datetime" in out.columns:
|
||||
out["trade_date"] = pd.to_datetime(out["datetime"])
|
||||
out = out.set_index("trade_date")
|
||||
else:
|
||||
out.index = pd.to_datetime(out.index)
|
||||
out.index.name = "trade_date"
|
||||
out = out.sort_index()
|
||||
for col in ("open", "high", "low", "close", "volume"):
|
||||
out[col] = pd.to_numeric(out[col], errors="coerce")
|
||||
out = out[["open", "high", "low", "close", "volume"]].dropna(
|
||||
subset=["open", "high", "low", "close"]
|
||||
)
|
||||
# Inclusive end-of-day so a `2025-02-01` window keeps the 15:00 bar.
|
||||
end_ts = pd.Timestamp(end_date) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
|
||||
out = out.loc[pd.Timestamp(start_date):end_ts]
|
||||
return out if not out.empty else None
|
||||
@@ -0,0 +1,243 @@
|
||||
"""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.
|
||||
_INTERVAL_MAP = {
|
||||
"1m": "TIMEFRAME_M1", "5m": "TIMEFRAME_M5", "15m": "TIMEFRAME_M15",
|
||||
"30m": "TIMEFRAME_M30", "1H": "TIMEFRAME_H1", "4H": "TIMEFRAME_H4",
|
||||
"1D": "TIMEFRAME_D1",
|
||||
}
|
||||
|
||||
#: 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(interval)
|
||||
if timeframe_name is None:
|
||||
logger.warning("unsupported MT5 interval %s, using 1D", interval)
|
||||
interval, timeframe_name = "1D", "TIMEFRAME_D1"
|
||||
|
||||
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)
|
||||
+247
-36
@@ -1,20 +1,86 @@
|
||||
"""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
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from backtest.loaders.base import validate_date_range
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
# 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
|
||||
@@ -26,8 +92,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."""
|
||||
@@ -38,8 +118,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.
|
||||
|
||||
@@ -56,11 +137,11 @@ 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")
|
||||
logger.warning("unsupported OKX interval %s, using 1D", interval)
|
||||
interval = "1D"
|
||||
|
||||
codes = [c.replace("/", "-").upper() for c in codes]
|
||||
@@ -68,73 +149,203 @@ class DataLoader:
|
||||
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} via {endpoint.rsplit('/', 1)[-1]}"
|
||||
|
||||
for _ in range(max_pages):
|
||||
check_budget(deadline, label, budget_s=_OKX_FETCH_BUDGET_S)
|
||||
params = {
|
||||
"instId": inst_id,
|
||||
"bar": bar,
|
||||
"limit": str(_MAX_PER_PAGE),
|
||||
"after": after,
|
||||
}
|
||||
resp = requests.get(f"{BASE_URL}/market/candles", params=params, timeout=15)
|
||||
data = resp.json()
|
||||
if data.get("code") != "0" or not data.get("data"):
|
||||
|
||||
def _do_request(params=params) -> dict:
|
||||
resp = session.get(
|
||||
endpoint,
|
||||
params=params,
|
||||
timeout=_OKX_TIMEOUT,
|
||||
)
|
||||
# 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, TimeoutError),
|
||||
deadline=deadline,
|
||||
label=label,
|
||||
)
|
||||
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,675 @@
|
||||
"""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."""
|
||||
norm = interval.strip().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
|
||||
if "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 or "month" 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,39 @@ 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",
|
||||
"longbridge",
|
||||
"mt5",
|
||||
"local",
|
||||
"auto",
|
||||
}
|
||||
|
||||
|
||||
def register(cls: Type[Any]) -> Type[Any]:
|
||||
"""Class decorator: register a loader into the global registry.
|
||||
@@ -49,10 +82,27 @@ 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.longbridge",
|
||||
"backtest.loaders.mt5_loader",
|
||||
"backtest.loaders.local_loader",
|
||||
]
|
||||
import importlib
|
||||
for mod in _loader_modules:
|
||||
@@ -62,19 +112,38 @@ 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", "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"],
|
||||
# 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"],
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +210,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,216 @@
|
||||
"""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"
|
||||
_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
|
||||
return _ticker_map().get(ticker.strip().upper())
|
||||
|
||||
|
||||
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,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,190 @@
|
||||
"""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 by the
|
||||
endpoint and any other value is fetched as daily.
|
||||
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)
|
||||
|
||||
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,237 @@
|
||||
"""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)
|
||||
|
||||
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,14 +4,48 @@ Supports ``interval``: 1D (default) / 1m / 5m / 15m / 30m / 1H.
|
||||
Minute data uses ``pro.stk_mins()`` (Tushare points >= 2000).
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
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__)
|
||||
|
||||
|
||||
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:
|
||||
@@ -23,13 +57,17 @@ class DataLoader:
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when TUSHARE_TOKEN is set."""
|
||||
return bool(os.getenv("TUSHARE_TOKEN", ""))
|
||||
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(
|
||||
@@ -37,8 +75,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.
|
||||
|
||||
@@ -59,28 +98,78 @@ class DataLoader:
|
||||
|
||||
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)
|
||||
elif _is_index(code):
|
||||
endpoint_name = "index_daily"
|
||||
df = self.api.index_daily(ts_code=code, start_date=start_date, end_date=end_date)
|
||||
elif _is_hk_equity(code):
|
||||
endpoint_name = "hk_daily"
|
||||
df = self.api.hk_daily(ts_code=code, start_date=start_date, end_date=end_date)
|
||||
else:
|
||||
endpoint_name = "daily"
|
||||
df = self.api.daily(ts_code=code, start_date=start_date, end_date=end_date)
|
||||
|
||||
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"]
|
||||
)
|
||||
return ohlcv
|
||||
|
||||
def _merge_basic_fields(
|
||||
self,
|
||||
@@ -110,6 +199,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,
|
||||
@@ -124,7 +222,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
|
||||
|
||||
@@ -149,7 +247,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("-", "")
|
||||
@@ -157,10 +255,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"])
|
||||
@@ -174,5 +285,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
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Tushare fundamental data provider with point-in-time safeguards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
import pandas as pd
|
||||
|
||||
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
|
||||
|
||||
|
||||
class DataProviderError(Exception):
|
||||
"""Base error for fundamental provider failures."""
|
||||
|
||||
|
||||
class UnknownTableError(DataProviderError):
|
||||
"""Raised when a requested fundamental table is not supported."""
|
||||
|
||||
|
||||
class SchemaValidationError(DataProviderError):
|
||||
"""Raised when provider output is missing required columns."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColumnSchema:
|
||||
"""Machine-readable column metadata for a provider table."""
|
||||
|
||||
name: str
|
||||
dtype: str
|
||||
required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableSchema:
|
||||
"""Machine-readable metadata for a Tushare fundamental table."""
|
||||
|
||||
name: str
|
||||
api_name: str
|
||||
point_in_time_column: str
|
||||
columns: tuple[ColumnSchema, ...]
|
||||
|
||||
@property
|
||||
def required_columns(self) -> tuple[str, ...]:
|
||||
return tuple(column.name for column in self.columns if column.required)
|
||||
|
||||
|
||||
_SCHEMAS: dict[str, TableSchema] = {
|
||||
"balancesheet": TableSchema(
|
||||
name="balancesheet",
|
||||
api_name="balancesheet",
|
||||
point_in_time_column="f_ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("f_ann_date", "date", required=False),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("total_assets", "float"),
|
||||
ColumnSchema("total_liab", "float"),
|
||||
ColumnSchema("total_hldr_eqy_exc_min_int", "float"),
|
||||
),
|
||||
),
|
||||
"cashflow": TableSchema(
|
||||
name="cashflow",
|
||||
api_name="cashflow",
|
||||
point_in_time_column="f_ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("f_ann_date", "date", required=False),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("net_profit", "float"),
|
||||
ColumnSchema("n_cashflow_act", "float"),
|
||||
ColumnSchema("c_cash_equ_end_period", "float"),
|
||||
),
|
||||
),
|
||||
"fina_indicator": TableSchema(
|
||||
name="fina_indicator",
|
||||
api_name="fina_indicator",
|
||||
point_in_time_column="ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("eps", "float"),
|
||||
ColumnSchema("grossprofit_margin", "float"),
|
||||
ColumnSchema("netprofit_margin", "float"),
|
||||
ColumnSchema("roe", "float"),
|
||||
ColumnSchema("debt_to_assets", "float"),
|
||||
),
|
||||
),
|
||||
"income": TableSchema(
|
||||
name="income",
|
||||
api_name="income",
|
||||
point_in_time_column="f_ann_date",
|
||||
columns=(
|
||||
ColumnSchema("ts_code", "str", required=True),
|
||||
ColumnSchema("ann_date", "date", required=True),
|
||||
ColumnSchema("f_ann_date", "date", required=False),
|
||||
ColumnSchema("end_date", "date", required=True),
|
||||
ColumnSchema("total_revenue", "float"),
|
||||
ColumnSchema("revenue", "float"),
|
||||
ColumnSchema("operate_profit", "float"),
|
||||
ColumnSchema("n_income", "float"),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TushareFundamentalProvider:
|
||||
"""Small DataProvider contract for Tushare financial statement tables."""
|
||||
|
||||
def __init__(self, api: Any | None = None) -> None:
|
||||
if api is None:
|
||||
import tushare as ts
|
||||
|
||||
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)
|
||||
self.api = api
|
||||
|
||||
def list_tables(self) -> list[str]:
|
||||
"""Return supported fundamental tables in stable order."""
|
||||
return sorted(_SCHEMAS)
|
||||
|
||||
def describe_table(self, table: str) -> TableSchema:
|
||||
"""Return schema metadata for a supported table."""
|
||||
try:
|
||||
return _SCHEMAS[table]
|
||||
except KeyError as exc:
|
||||
raise UnknownTableError(f"Unsupported Tushare fundamental table: {table}") from exc
|
||||
|
||||
def query_fundamentals(
|
||||
self,
|
||||
table: str,
|
||||
codes: Iterable[str],
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
periods: Iterable[str] | None = None,
|
||||
fields: Iterable[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Query a fundamental table and filter out rows unpublished by ``as_of``."""
|
||||
schema = self.describe_table(table)
|
||||
requested_periods = set(periods or [])
|
||||
frames: list[pd.DataFrame] = []
|
||||
|
||||
api_method = getattr(self.api, schema.api_name, None)
|
||||
if api_method is None:
|
||||
raise DataProviderError(f"Tushare API object has no method: {schema.api_name}")
|
||||
|
||||
for code in codes:
|
||||
frame = api_method(ts_code=code, period=None)
|
||||
if frame is not None and not frame.empty:
|
||||
frames.append(frame.copy())
|
||||
|
||||
if not frames:
|
||||
return self._empty_frame(schema, fields)
|
||||
|
||||
result = pd.concat(frames, ignore_index=True)
|
||||
self._validate_schema(schema, result)
|
||||
|
||||
if requested_periods:
|
||||
result = result[result["end_date"].astype(str).isin(requested_periods)]
|
||||
|
||||
pit_column = schema.point_in_time_column
|
||||
if pit_column not in result.columns or result[pit_column].isna().all():
|
||||
pit_column = "ann_date"
|
||||
as_of_date = _parse_tushare_date(as_of)
|
||||
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"])
|
||||
pit_dates = pit_values.map(_parse_tushare_date)
|
||||
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)
|
||||
return result
|
||||
|
||||
def _validate_schema(self, schema: TableSchema, frame: pd.DataFrame) -> None:
|
||||
missing = [column for column in schema.required_columns if column not in frame.columns]
|
||||
if missing:
|
||||
raise SchemaValidationError(f"{schema.name} missing required columns: {', '.join(missing)}")
|
||||
|
||||
def _output_columns(
|
||||
self,
|
||||
schema: TableSchema,
|
||||
frame: pd.DataFrame,
|
||||
fields: Iterable[str] | None,
|
||||
) -> list[str]:
|
||||
identity = ["ts_code", "end_date", "ann_date"]
|
||||
if schema.point_in_time_column in frame.columns and schema.point_in_time_column not in identity:
|
||||
identity.append(schema.point_in_time_column)
|
||||
wanted = identity + list(fields or [])
|
||||
return [column for column in dict.fromkeys(wanted) if column in frame.columns]
|
||||
|
||||
def _empty_frame(self, schema: TableSchema, fields: Iterable[str] | None) -> pd.DataFrame:
|
||||
columns = self._output_columns(schema, pd.DataFrame(columns=[c.name for c in schema.columns]), fields)
|
||||
return pd.DataFrame(columns=columns)
|
||||
|
||||
|
||||
def _parse_tushare_date(value: str | pd.Timestamp) -> pd.Timestamp:
|
||||
"""Parse Tushare YYYYMMDD strings and common timestamp/date strings."""
|
||||
if isinstance(value, pd.Timestamp):
|
||||
return value.normalize()
|
||||
text = str(value)
|
||||
if len(text) == 8 and text.isdigit():
|
||||
return pd.to_datetime(text, format="%Y%m%d")
|
||||
return pd.to_datetime(text).normalize()
|
||||
|
||||
|
||||
def enrich_price_frames_with_fundamentals(
|
||||
data_map: dict[str, pd.DataFrame],
|
||||
provider: TushareFundamentalProvider,
|
||||
fields_by_table: dict[str, Iterable[str]],
|
||||
*,
|
||||
as_of: str | pd.Timestamp,
|
||||
periods: Iterable[str] | None = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""Attach PIT-safe fundamental snapshots to daily price frames.
|
||||
|
||||
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.
|
||||
"""
|
||||
if not data_map or not fields_by_table:
|
||||
return data_map
|
||||
|
||||
enriched = {code: frame.copy() for code, frame in data_map.items()}
|
||||
codes = list(enriched)
|
||||
|
||||
for table, fields in fields_by_table.items():
|
||||
field_list = list(fields or [])
|
||||
fundamentals = provider.query_fundamentals(
|
||||
table,
|
||||
codes,
|
||||
as_of=as_of,
|
||||
periods=periods,
|
||||
fields=field_list,
|
||||
)
|
||||
if fundamentals.empty:
|
||||
continue
|
||||
|
||||
schema = provider.describe_table(table)
|
||||
pit_column = schema.point_in_time_column
|
||||
if pit_column not in fundamentals.columns or fundamentals[pit_column].isna().all():
|
||||
pit_column = "ann_date"
|
||||
|
||||
for code, frame in enriched.items():
|
||||
rows = fundamentals[fundamentals["ts_code"] == code].copy()
|
||||
if rows.empty or frame.empty:
|
||||
continue
|
||||
|
||||
pit_values = rows[pit_column]
|
||||
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")
|
||||
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(
|
||||
columns={column: f"{table}_{column}" for column in value_columns}
|
||||
)
|
||||
|
||||
left = frame.copy()
|
||||
original_index = left.index
|
||||
left["_trade_date"] = pd.to_datetime(left.index).normalize()
|
||||
left["_original_order"] = range(len(left))
|
||||
|
||||
merged = pd.merge_asof(
|
||||
left.sort_values("_trade_date"),
|
||||
right.sort_values("_pit_date"),
|
||||
left_on="_trade_date",
|
||||
right_on="_pit_date",
|
||||
direction="backward",
|
||||
)
|
||||
merged = merged.sort_values("_original_order").drop(
|
||||
columns=["_trade_date", "_original_order", "_pit_date"]
|
||||
)
|
||||
merged.index = original_index
|
||||
enriched[code] = merged
|
||||
|
||||
return enriched
|
||||
@@ -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,261 @@
|
||||
"""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",
|
||||
"1W": "1wk",
|
||||
"1M": "1mo",
|
||||
}
|
||||
|
||||
|
||||
def _is_supported(code: str) -> bool:
|
||||
"""Return whether *code* is a symbol this loader handles.
|
||||
|
||||
Covers US/HK/India 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", "=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"}
|
||||
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"]
|
||||
@@ -47,6 +55,12 @@ 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): yfinance carries the suffix as-is.
|
||||
return upper
|
||||
|
||||
|
||||
@@ -63,6 +77,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 +191,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 +214,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", "crypto"}
|
||||
requires_auth = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
@@ -213,8 +233,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 +256,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 +265,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
|
||||
+151
-16
@@ -14,15 +14,21 @@ from backtest.models import TradeRecord
|
||||
|
||||
# ─── Annualisation factor mapping ───
|
||||
|
||||
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365}
|
||||
# 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 = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365, "mootdx": 252, "futu": 252, "mt5": 260}
|
||||
# mt5 is a forex/CFD feed: 24x5 sessions → 260 trading days, 24h intraday bars.
|
||||
_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},
|
||||
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390, "akshare": 240, "ccxt": 1440, "mootdx": 240, "futu": 240, "mt5": 1440},
|
||||
"5m": {"tushare": 48, "okx": 288, "yfinance": 78, "akshare": 48, "ccxt": 288, "mootdx": 48, "futu": 48, "mt5": 288},
|
||||
"15m": {"tushare": 16, "okx": 96, "yfinance": 26, "akshare": 16, "ccxt": 96, "mootdx": 16, "futu": 16, "mt5": 96},
|
||||
"30m": {"tushare": 8, "okx": 48, "yfinance": 13, "akshare": 8, "ccxt": 48, "mootdx": 8, "futu": 8, "mt5": 48},
|
||||
"1H": {"tushare": 4, "okx": 24, "yfinance": 7, "akshare": 4, "ccxt": 24, "mootdx": 4, "futu": 4, "mt5": 24},
|
||||
"4H": {"tushare": 1, "okx": 6, "yfinance": 2, "akshare": 1, "ccxt": 6, "mootdx": 1, "futu": 1, "mt5": 6},
|
||||
"1D": {"tushare": 1, "okx": 1, "yfinance": 1, "akshare": 1, "ccxt": 1, "mootdx": 1, "futu": 1, "mt5": 1},
|
||||
}
|
||||
|
||||
|
||||
@@ -143,12 +149,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 +235,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).
|
||||
@@ -178,11 +258,38 @@ def calc_metrics(
|
||||
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,12 +299,29 @@ 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
|
||||
@@ -206,8 +330,16 @@ def calc_metrics(
|
||||
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))
|
||||
# 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
|
||||
|
||||
return {
|
||||
"final_value": float(equity_curve.iloc[-1]),
|
||||
@@ -226,6 +358,8 @@ def calc_metrics(
|
||||
"benchmark_return": round(bench_return, 6),
|
||||
"excess_return": round(excess, 6),
|
||||
"information_ratio": round(ir, 4),
|
||||
"avg_turnover": round(avg_turnover, 6),
|
||||
"total_turnover": round(total_turnover, 6),
|
||||
}
|
||||
|
||||
|
||||
@@ -238,4 +372,5 @@ 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,
|
||||
"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,233 @@
|
||||
"""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
|
||||
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,249 @@
|
||||
"""Trust Layer run card generator for backtest runs."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
SCHEMA_VERSION = "0.1"
|
||||
BACKTEST_SUMMARY_KEYS = (
|
||||
"codes",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"interval",
|
||||
"engine",
|
||||
"initial_cash",
|
||||
"source",
|
||||
)
|
||||
|
||||
|
||||
def write_run_card(
|
||||
run_dir: Path,
|
||||
config: Mapping[str, Any],
|
||||
metrics: Mapping[str, Any],
|
||||
*,
|
||||
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.
|
||||
|
||||
Args:
|
||||
run_dir: Directory where run_card.json and run_card.md are written.
|
||||
config: Full backtest configuration. Only a summary and hash are stored.
|
||||
metrics: Backtest metrics. Scalar values are stored; ``validation`` is
|
||||
stored separately when present.
|
||||
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``.
|
||||
"""
|
||||
run_dir = Path(run_dir)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_file = run_dir / "config.json"
|
||||
reproducibility: dict[str, Any] = {
|
||||
"config_hash": _file_hash(config_file) if config_file.exists() else _json_hash(config),
|
||||
}
|
||||
if strategy_path is not None:
|
||||
strategy_file = Path(strategy_path)
|
||||
if strategy_file.exists() and strategy_file.is_file():
|
||||
reproducibility["strategy_hash"] = _file_hash(strategy_file)
|
||||
|
||||
card: dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": _utc_now(),
|
||||
"run_dir": str(run_dir),
|
||||
"backtest": _backtest_summary(config),
|
||||
"reproducibility": reproducibility,
|
||||
"data_sources": list(data_sources or []),
|
||||
"metrics": _scalar_metrics(metrics),
|
||||
"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,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
md_path.write_text(_render_markdown(card), encoding="utf-8")
|
||||
return card
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _json_hash(value: Mapping[str, Any]) -> str:
|
||||
payload = json.dumps(
|
||||
{key: val for key, val in value.items() if not str(key).startswith("_")},
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _file_hash(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _backtest_summary(config: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: config.get(key) for key in BACKTEST_SUMMARY_KEYS if key in config}
|
||||
|
||||
|
||||
def _scalar_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in metrics.items()
|
||||
if key != "validation" and _is_scalar(value)
|
||||
}
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _list_artifacts(run_dir: Path) -> list[dict[str, Any]]:
|
||||
candidates: list[Path] = []
|
||||
for relative in (Path("config.json"), Path("code/signal_engine.py")):
|
||||
path = run_dir / relative
|
||||
if path.exists() and path.is_file():
|
||||
candidates.append(path)
|
||||
|
||||
artifacts_dir = run_dir / "artifacts"
|
||||
if artifacts_dir.exists() and artifacts_dir.is_dir():
|
||||
candidates.extend(path for path in artifacts_dir.rglob("*") if path.is_file())
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(candidates, key=lambda item: item.relative_to(run_dir).as_posix()):
|
||||
artifacts.append(
|
||||
{
|
||||
"path": path.relative_to(run_dir).as_posix(),
|
||||
"size_bytes": path.stat().st_size,
|
||||
"sha256": _file_hash(path),
|
||||
}
|
||||
)
|
||||
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",
|
||||
"",
|
||||
f"Generated: {card['generated_at']}",
|
||||
f"Run directory: `{card['run_dir']}`",
|
||||
"",
|
||||
"## Backtest Summary",
|
||||
]
|
||||
|
||||
backtest = card.get("backtest", {})
|
||||
if backtest:
|
||||
lines.extend(f"- {key}: {value}" for key, value in backtest.items())
|
||||
else:
|
||||
lines.append("- No backtest summary fields provided.")
|
||||
|
||||
lines.extend(["", "## Reproducibility"])
|
||||
reproducibility = card.get("reproducibility", {})
|
||||
lines.append(f"- config_hash: `{reproducibility.get('config_hash', '')}`")
|
||||
if "strategy_hash" in reproducibility:
|
||||
lines.append(f"- strategy_hash: `{reproducibility['strategy_hash']}`")
|
||||
|
||||
lines.extend(["", "## Data Sources"])
|
||||
data_sources = card.get("data_sources", [])
|
||||
lines.extend(f"- {source}" for source in data_sources) if data_sources else lines.append("- None recorded.")
|
||||
|
||||
lines.extend(["", "## Metrics"])
|
||||
metric_values = card.get("metrics", {})
|
||||
lines.extend(f"- {key}: {value}" for key, value in metric_values.items()) if metric_values else lines.append("- No scalar metrics recorded.")
|
||||
|
||||
lines.extend(["", "## Validation"])
|
||||
if "validation" in card:
|
||||
validation = card["validation"]
|
||||
if isinstance(validation, Mapping):
|
||||
lines.extend(f"- {key}: {value}" for key, value in validation.items())
|
||||
else:
|
||||
lines.append(f"- {validation}")
|
||||
else:
|
||||
lines.append("- Not present.")
|
||||
|
||||
warnings = card.get("warnings", [])
|
||||
if warnings:
|
||||
lines.extend(["", "## Warnings"])
|
||||
lines.extend(f"- {warning}" for warning in warnings)
|
||||
|
||||
lines.extend(["", "## Artifacts"])
|
||||
artifacts = card.get("artifacts", [])
|
||||
if artifacts:
|
||||
lines.extend(
|
||||
f"- `{artifact['path']}` ({artifact['size_bytes']} bytes, sha256 `{artifact['sha256']}`)"
|
||||
for artifact in artifacts
|
||||
)
|
||||
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"
|
||||
+873
-147
File diff suppressed because it is too large.
Load diff
+132
-49
@@ -5,12 +5,15 @@ Three independent tools:
|
||||
- Bootstrap Sharpe CI: how stable is the risk-adjusted return?
|
||||
- Walk-Forward analysis: is performance consistent across time windows?
|
||||
|
||||
Usage: called automatically by BaseEngine.run_backtest when config["validation"]
|
||||
Usage: called automatically by BaseEngine.run_backtest when config[\"validation\"]
|
||||
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}
|
||||
|
||||
@@ -114,6 +124,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"}
|
||||
@@ -170,6 +192,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 +209,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 +221,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 +286,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 +295,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 +305,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 +321,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,24 +340,86 @@ 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,
|
||||
))
|
||||
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,
|
||||
)
|
||||
)
|
||||
return trades
|
||||
|
||||
|
||||
def _parse_run_dir(argv: List[str]) -> Path:
|
||||
"""Validate CLI input and return a usable run directory path."""
|
||||
if len(argv) < 2:
|
||||
raise SystemExit("Usage: python -m backtest.validation <run_dir>")
|
||||
|
||||
raw_run_dir = argv[1]
|
||||
if not raw_run_dir.strip():
|
||||
raise SystemExit("run_dir must be a non-empty path")
|
||||
if "\0" in raw_run_dir:
|
||||
raise SystemExit("Invalid run_dir path: embedded NUL byte")
|
||||
|
||||
try:
|
||||
run_dir = Path(raw_run_dir).expanduser()
|
||||
exists = run_dir.exists()
|
||||
is_dir = run_dir.is_dir() if exists else False
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise SystemExit(f"Invalid run_dir path: {exc}") from exc
|
||||
|
||||
if not exists:
|
||||
raise SystemExit(f"run_dir does not exist: {run_dir}")
|
||||
if not is_dir:
|
||||
raise SystemExit(f"run_dir is not a directory: {run_dir}")
|
||||
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.
|
||||
|
||||
@@ -341,8 +431,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():
|
||||
@@ -360,19 +448,14 @@ 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__":
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m backtest.validation <run_dir>")
|
||||
sys.exit(1)
|
||||
main(Path(sys.argv[1]))
|
||||
main(_parse_run_dir(sys.argv))
|
||||
-1830
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
"""Vibe-Trading CLI package.
|
||||
|
||||
The legacy single-file CLI has been preserved verbatim as
|
||||
``cli/_legacy.py`` and is the source of truth for non-interactive
|
||||
subcommands (``serve``, ``run``, ``mcp``, ``sessions``, ``swarm`` ...).
|
||||
The front door (:mod:`cli.main`) shows the banner, runs the
|
||||
onboarding wizard when needed, then drives the interactive loop
|
||||
built on :mod:`cli.input`, :mod:`cli.completer`, and
|
||||
:mod:`cli.commands.*`. Non-interactive entries still pass through to
|
||||
``_legacy.main``.
|
||||
|
||||
The console-script entry in ``pyproject.toml``
|
||||
(``vibe-trading = "cli:main"``) points at the ``main`` callable exported
|
||||
here.
|
||||
|
||||
Compatibility note: tests and downstream callers historically reached
|
||||
into ``cli._INIT_ENV_PATH`` / ``cli.cmd_memory_list`` / ``cli.Confirm``
|
||||
etc. To preserve that surface we re-export every public name from
|
||||
``_legacy`` at package import time. New code should import the same
|
||||
helpers from ``cli._legacy`` directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cli import _legacy as _legacy
|
||||
from cli.main import main
|
||||
|
||||
# Re-export the legacy module's public surface (anything not prefixed
|
||||
# with two underscores). This lets ``cli.cmd_memory_list`` /
|
||||
# ``cli._INIT_ENV_PATH`` / ``cli.Prompt`` etc. keep working without us
|
||||
# having to enumerate every name by hand.
|
||||
for _name in dir(_legacy):
|
||||
if _name.startswith("__"):
|
||||
continue
|
||||
globals().setdefault(_name, getattr(_legacy, _name))
|
||||
del _name
|
||||
|
||||
|
||||
# Symbols tests may monkeypatch on the ``cli`` package that legacy
|
||||
# callables still read from their own module globals. Keep this list
|
||||
# explicit so accidental package-level attributes don't bleed into
|
||||
# ``_legacy``.
|
||||
_LEGACY_SYNCED_GLOBALS: tuple[str, ...] = (
|
||||
"_INIT_ENV_PATH",
|
||||
"AGENT_DIR",
|
||||
"RUNS_DIR",
|
||||
"SWARM_DIR",
|
||||
"SESSIONS_DIR",
|
||||
"UPLOADS_DIR",
|
||||
"_PROVIDER_CHOICES",
|
||||
)
|
||||
|
||||
|
||||
def _sync_legacy_test_overrides() -> None:
|
||||
"""Mirror package-level monkeypatches onto ``_legacy``'s module globals.
|
||||
|
||||
Tests reach into ``cli.<NAME>`` to override constants, but legacy
|
||||
callables read ``<NAME>`` from their own module namespace. This hook
|
||||
copies any patched value back to ``_legacy`` for the allowlist below.
|
||||
"""
|
||||
pkg_globals = globals()
|
||||
for name in _LEGACY_SYNCED_GLOBALS:
|
||||
if name not in pkg_globals:
|
||||
continue
|
||||
new_value = pkg_globals[name]
|
||||
if getattr(_legacy, name, None) is not new_value:
|
||||
setattr(_legacy, name, new_value)
|
||||
|
||||
|
||||
def cmd_init() -> int:
|
||||
"""Compatibility wrapper for callers patching ``cli._INIT_ENV_PATH``."""
|
||||
_sync_legacy_test_overrides()
|
||||
return _legacy.cmd_init()
|
||||
|
||||
|
||||
# Wrap every ``cmd_*`` callable re-exported from ``_legacy`` so a package-level
|
||||
# monkeypatch of any symbol in ``_LEGACY_SYNCED_GLOBALS`` is propagated to
|
||||
# ``_legacy`` before the call. Without this, ``patch.object(cli, "RUNS_DIR",
|
||||
# tmp); cli.cmd_list()`` would silently read the unpatched ``_legacy.RUNS_DIR``.
|
||||
import functools as _functools # noqa: E402
|
||||
|
||||
def _make_synced_legacy_wrapper(legacy_fn): # noqa: ANN001
|
||||
"""Wrap ``legacy_fn`` so the package-level monkeypatch sync fires first."""
|
||||
|
||||
@_functools.wraps(legacy_fn)
|
||||
def _wrapper(*args, **kwargs):
|
||||
_sync_legacy_test_overrides()
|
||||
# Re-read the (possibly just-synced) attribute from ``_legacy`` in
|
||||
# case the test also patched the function itself.
|
||||
return getattr(_legacy, legacy_fn.__name__)(*args, **kwargs)
|
||||
|
||||
_wrapper.__wrapped__ = legacy_fn
|
||||
return _wrapper
|
||||
|
||||
|
||||
for _cmd_name in [n for n in globals() if n.startswith("cmd_") and n != "cmd_init"]:
|
||||
_cmd_obj = globals()[_cmd_name]
|
||||
if callable(_cmd_obj) and getattr(_cmd_obj, "__module__", "") == "cli._legacy":
|
||||
globals()[_cmd_name] = _make_synced_legacy_wrapper(_cmd_obj)
|
||||
del _cmd_name, _cmd_obj
|
||||
|
||||
|
||||
__all__ = ["main", *sorted(
|
||||
name for name in globals()
|
||||
if not name.startswith("_") and name != "main"
|
||||
)]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow ``python -m cli`` to run the CLI entrypoint."""
|
||||
|
||||
from cli.main import _entrypoint
|
||||
|
||||
_entrypoint()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +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). 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__ = _version_from_pyproject()
|
||||
except ImportError: # pragma: no cover — importlib.metadata is stdlib on 3.8+
|
||||
__version__ = _version_from_pyproject()
|
||||
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Slash command implementations.
|
||||
|
||||
Each command module exports a ``run(ctx, *args) -> int`` callable. Modules
|
||||
that own multiple commands (``chat``, ``show``, ``session``) also expose
|
||||
named ``cmd_<name>`` callables so the slash router can dispatch by
|
||||
command keyword.
|
||||
|
||||
The :data:`SLASH_COMMANDS` registry lives in :mod:`.slash_router`.
|
||||
"""
|
||||
|
||||
from .slash_router import SLASH_COMMANDS, Command, find_exact, match_commands
|
||||
|
||||
__all__ = [
|
||||
"Command",
|
||||
"SLASH_COMMANDS",
|
||||
"find_exact",
|
||||
"match_commands",
|
||||
]
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Chat-flow slash commands: ``/model``, ``/clear``, ``/journal``,
|
||||
``/shadow``, ``/swarm``, ``/debug``, ``/quit``.
|
||||
|
||||
``/model`` renders the current configuration via the legacy
|
||||
``_show_settings`` helper. ``/swarm`` dispatches to the legacy
|
||||
``_handle_swarm_command`` so the existing presets keep working. ``/clear``
|
||||
clears the screen and reprints the banner. ``/journal``, ``/shadow``, and
|
||||
``/debug`` show a "Coming soon" placeholder pointing at the established
|
||||
fallback workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console(console: Optional[Console] = None) -> Console:
|
||||
"""Return the shared CLI console, or a caller-supplied override."""
|
||||
if console is not None:
|
||||
return console
|
||||
return get_console()
|
||||
|
||||
|
||||
def _coming_soon(command: str, *, hint: str) -> int:
|
||||
"""Render the placeholder panel for not-yet-wired commands."""
|
||||
console = _resolve_console()
|
||||
body = Text()
|
||||
body.append(f"/{command} is not yet wired up to the interactive CLI.\n\n", style="dim")
|
||||
body.append("Until then: ", style="dim")
|
||||
body.append(hint, style="bold")
|
||||
console.print(Panel(body, title=f"/{command}", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
|
||||
# --- /model ----------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_model(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unused
|
||||
"""Print the current provider/model + how to re-run the wizard."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
from cli._legacy import _show_settings
|
||||
|
||||
_show_settings()
|
||||
except Exception as exc: # noqa: BLE001 — legacy may be absent on partial install
|
||||
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"))
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Text(
|
||||
"Run `vibe-trading init` to switch provider, model, or credentials.",
|
||||
style="dim",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# --- /clear, /journal, /shadow, /swarm, /debug, /quit ----------------------
|
||||
|
||||
|
||||
def cmd_clear(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Clear the screen and reprint the welcome banner."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
console.clear()
|
||||
except Exception: # noqa: BLE001 — clear can fail on dumb terminals
|
||||
pass
|
||||
# Best-effort: reprint the banner so the conversation appears fresh.
|
||||
# Reuse the cached stats populated at startup so the redrawn banner
|
||||
# shows the real skills / tools / sessions counts, not zeros.
|
||||
try:
|
||||
from cli.intro import print_banner
|
||||
from cli.main import _collect_banner_stats
|
||||
|
||||
print_banner(console, **_collect_banner_stats())
|
||||
except Exception: # noqa: BLE001
|
||||
console.print(Text("Conversation cleared.", style="dim"))
|
||||
# Caller is expected to also drop in-memory conversation history.
|
||||
if ctx is not None and hasattr(ctx, "history"):
|
||||
try:
|
||||
ctx.history.clear()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _queue_prompt(ctx: Any, prompt: str) -> bool:
|
||||
"""Stash ``prompt`` on ``ctx.pending_prompt`` for the loop to consume.
|
||||
|
||||
Returns ``True`` if the context exposes a writable ``pending_prompt``
|
||||
attribute, ``False`` otherwise (e.g. a tiny test stub) — the caller
|
||||
falls back to printing the canonical instruction in that case.
|
||||
"""
|
||||
if ctx is None or not hasattr(ctx, "pending_prompt"):
|
||||
return False
|
||||
try:
|
||||
ctx.pending_prompt = prompt
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — never crash the slash dispatch
|
||||
return False
|
||||
|
||||
|
||||
def cmd_journal(ctx: Any = None, *args: str) -> int:
|
||||
"""Queue an "analyze my trade journal at <path>" agent turn.
|
||||
|
||||
With a path: ``/journal trades.csv`` becomes the prompt
|
||||
``Analyze my trade journal at trades.csv`` which is queued on
|
||||
``ctx.pending_prompt`` and executed by the interactive loop on the
|
||||
next tick. Without a path the command prints the canonical
|
||||
instruction so the user can paste it themselves.
|
||||
"""
|
||||
console = _resolve_console()
|
||||
path = " ".join(args).strip()
|
||||
if not path:
|
||||
body = Text()
|
||||
body.append("Usage: ", style="dim")
|
||||
body.append("/journal <path-to-csv>\n", style="bold")
|
||||
body.append("Example: ", style="dim")
|
||||
body.append('/journal ~/Downloads/journal.csv\n\n', style="bold")
|
||||
body.append("Or type the prompt directly: ", style="dim")
|
||||
body.append('"analyze my trade journal at <path>"', style="bold")
|
||||
console.print(Panel(body, title="/journal", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
prompt = f"Analyze my trade journal at {path}"
|
||||
if _queue_prompt(ctx, prompt):
|
||||
console.print(Text(f"→ Running: {prompt}", style="dim"))
|
||||
return 0
|
||||
# Fallback when the context does not support queuing (legacy callers).
|
||||
console.print(Text(f'Type: "{prompt}"', style="bold"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_shadow(ctx: Any = None, *args: str) -> int:
|
||||
"""Queue a Shadow Account agent turn.
|
||||
|
||||
``/shadow`` opens / inspects the shadow account. ``/shadow <path>``
|
||||
trains a new shadow from a trade journal at that path. Both forms
|
||||
queue the canonical natural-language prompt on ``ctx.pending_prompt``
|
||||
so the ReAct loop picks the right tool (``extract_shadow_strategy``,
|
||||
``run_shadow_backtest``, ``render_shadow_report``).
|
||||
"""
|
||||
console = _resolve_console()
|
||||
path = " ".join(args).strip()
|
||||
if path:
|
||||
prompt = f"Train a shadow account from my trade journal at {path}"
|
||||
else:
|
||||
prompt = "Open the shadow account dashboard and show the latest report"
|
||||
|
||||
if _queue_prompt(ctx, prompt):
|
||||
console.print(Text(f"→ Running: {prompt}", style="dim"))
|
||||
return 0
|
||||
console.print(Text(f'Type: "{prompt}"', style="bold"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_swarm(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Dispatch to the legacy swarm handler."""
|
||||
try:
|
||||
from cli._legacy import _handle_swarm_command
|
||||
|
||||
_handle_swarm_command(" ".join(args))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"/swarm failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_debug(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Toggle the debug summary that prints after each agent turn.
|
||||
|
||||
When ON the interactive loop appends a single muted line after every
|
||||
turn containing ``iter``, ``tools``, ``elapsed``, and an approximate
|
||||
context-size estimate — see ``_print_debug_summary`` in
|
||||
:mod:`cli.main`. When OFF the summary is suppressed.
|
||||
"""
|
||||
console = _resolve_console()
|
||||
if ctx is not None and hasattr(ctx, "debug"):
|
||||
try:
|
||||
ctx.debug = not bool(getattr(ctx, "debug", False))
|
||||
state = "ON" if ctx.debug else "OFF"
|
||||
console.print(Text(f"Debug summary: {state}", style="bold"))
|
||||
if ctx.debug:
|
||||
console.print(
|
||||
Text(
|
||||
"After each turn a one-line summary will print: "
|
||||
"iterations, tool count, elapsed, approx context tokens.",
|
||||
style="dim",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/debug toggle failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
return _coming_soon(
|
||||
"debug",
|
||||
hint="set `VIBE_TRADING_DEBUG=1` and restart for verbose logging.",
|
||||
)
|
||||
|
||||
|
||||
def cmd_quit(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Signal exit by returning a distinct exit code.
|
||||
|
||||
The interactive loop interprets ``2`` as "user-requested quit" and
|
||||
performs session persistence + final farewell before terminating.
|
||||
"""
|
||||
return 2
|
||||
|
||||
|
||||
# --- Module-level dispatch table -----------------------------------------
|
||||
|
||||
_DISPATCH = {
|
||||
"model": cmd_model,
|
||||
"clear": cmd_clear,
|
||||
"journal": cmd_journal,
|
||||
"shadow": cmd_shadow,
|
||||
"swarm": cmd_swarm,
|
||||
"debug": cmd_debug,
|
||||
"quit": cmd_quit,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx: Any = None, command: str = "model", *args: str) -> int:
|
||||
"""Dispatch ``run("model")`` / ``run("clear")`` / ... to the right handler."""
|
||||
handler = _DISPATCH.get(command)
|
||||
if handler is None:
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"Unknown chat command: /{command}", style="bold red"))
|
||||
return 1
|
||||
return handler(ctx, *args)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"run",
|
||||
"cmd_model",
|
||||
"cmd_clear",
|
||||
"cmd_journal",
|
||||
"cmd_shadow",
|
||||
"cmd_swarm",
|
||||
"cmd_debug",
|
||||
"cmd_quit",
|
||||
]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""``/goal`` — manage the current finance research goal from the CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.console import Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
from src.goal.context import default_goal_criteria
|
||||
|
||||
_goal_store = None
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def _get_goal_store():
|
||||
"""Return the shared goal store, lazily initialized."""
|
||||
global _goal_store
|
||||
if _goal_store is None:
|
||||
from src.goal import GoalStore
|
||||
|
||||
_goal_store = GoalStore()
|
||||
return _goal_store
|
||||
|
||||
|
||||
def _default_criteria() -> list[str]:
|
||||
"""Return the MVP finance protocol checklist."""
|
||||
return default_goal_criteria()
|
||||
|
||||
|
||||
def _criterion_is_covered(criterion: dict, evidence: list[dict]) -> bool:
|
||||
"""Return whether a criterion has completion status or attached evidence."""
|
||||
status = str(criterion.get("status") or "").lower()
|
||||
if status not in {"", "pending", "open", "unsatisfied"}:
|
||||
return True
|
||||
criterion_id = criterion.get("criterion_id")
|
||||
return any(item.get("criterion_id") == criterion_id for item in evidence)
|
||||
|
||||
|
||||
def _create_cli_session(ctx: Any, title: str) -> str | None:
|
||||
"""Create a normal CLI session when /goal is used before the first turn."""
|
||||
try:
|
||||
from cli._legacy import SESSIONS_DIR
|
||||
from src.session.models import Session, SessionStatus
|
||||
from src.session.search import get_shared_index
|
||||
from src.session.store import SessionStore
|
||||
|
||||
session = Session(
|
||||
title=(title[:60] or "Goal research"),
|
||||
status=SessionStatus.ACTIVE,
|
||||
)
|
||||
SessionStore(base_dir=SESSIONS_DIR).create_session(session)
|
||||
get_shared_index().index_session(session.session_id, session.title)
|
||||
if ctx is not None:
|
||||
setattr(ctx, "session_id", session.session_id)
|
||||
return session.session_id
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _session_id(ctx: Any, *, title: str = "Goal research", create: bool) -> str | None:
|
||||
"""Return the active CLI session id, optionally creating one."""
|
||||
existing = getattr(ctx, "session_id", None)
|
||||
if existing:
|
||||
return str(existing)
|
||||
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:
|
||||
return None
|
||||
created = _create_cli_session(ctx, title)
|
||||
if created:
|
||||
return created
|
||||
return "cli-default"
|
||||
|
||||
|
||||
def _render_snapshot(snapshot: dict, *, title: str = "/goal") -> None:
|
||||
"""Render a compact goal card."""
|
||||
console = _resolve_console()
|
||||
goal = snapshot["goal"]
|
||||
criteria = snapshot.get("criteria") or []
|
||||
evidence = snapshot.get("evidence") or []
|
||||
evidence_count = int(snapshot.get("evidence_count", len(evidence)))
|
||||
covered = sum(1 for item in criteria if _criterion_is_covered(item, evidence))
|
||||
total = len(criteria)
|
||||
|
||||
meta = Table.grid(padding=(0, 2))
|
||||
meta.add_column(style="dim", no_wrap=True)
|
||||
meta.add_column(ratio=1)
|
||||
meta.add_column(style="dim", no_wrap=True)
|
||||
meta.add_column(no_wrap=True)
|
||||
meta.add_row("goal", str(goal["objective"]), "status", f"[green]{goal['status']}[/green]")
|
||||
meta.add_row("id", str(goal["goal_id"]), "progress", f"[cyan]{covered}/{total}[/cyan]")
|
||||
meta.add_row("evidence", str(evidence_count), "protocol", str(goal.get("protocol", "thesis_review")))
|
||||
|
||||
criteria_table = Table(
|
||||
box=box.SIMPLE_HEAVY,
|
||||
show_lines=False,
|
||||
expand=True,
|
||||
padding=(0, 1),
|
||||
)
|
||||
criteria_table.add_column("#", style="dim", no_wrap=True, width=3)
|
||||
criteria_table.add_column("Criterion", ratio=1)
|
||||
criteria_table.add_column("Status", no_wrap=True)
|
||||
criteria_table.add_column("Evidence", no_wrap=True)
|
||||
for index, criterion in enumerate(criteria, start=1):
|
||||
criterion_id = criterion["criterion_id"]
|
||||
criterion_evidence = [item for item in evidence if item.get("criterion_id") == criterion_id]
|
||||
is_covered = _criterion_is_covered(criterion, evidence)
|
||||
status = "[green]covered[/green]" if is_covered else "[yellow]pending[/yellow]"
|
||||
evidence_label = (
|
||||
f"{len(criterion_evidence)} item{'s' if len(criterion_evidence) != 1 else ''}"
|
||||
if criterion_evidence
|
||||
else "[dim]needed[/dim]"
|
||||
)
|
||||
criteria_table.add_row(
|
||||
str(index),
|
||||
str(criterion["text"]),
|
||||
status,
|
||||
evidence_label,
|
||||
)
|
||||
if not criteria:
|
||||
criteria_table.add_row("-", "[dim](none)[/dim]", "[dim]pending[/dim]", "[dim]needed[/dim]")
|
||||
|
||||
next_steps = Text()
|
||||
next_steps.append("Next ", style="dim")
|
||||
next_steps.append("/goal status", style="bold")
|
||||
if criteria:
|
||||
next_steps.append(" ")
|
||||
next_steps.append("/goal evidence <#> <note>", style="bold")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Group(meta, criteria_table, next_steps),
|
||||
title=title,
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_status(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Show the current goal snapshot."""
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> to start one.", style="dim"))
|
||||
return 0
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> to start one.", style="dim"))
|
||||
return 0
|
||||
_render_snapshot(snapshot)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_start(ctx: Any = None, *args: str) -> int:
|
||||
"""Start or replace the current research goal."""
|
||||
objective = " ".join(args).strip()
|
||||
if not objective:
|
||||
_resolve_console().print(Text("Usage: /goal <research objective>", style="bold red"))
|
||||
return 1
|
||||
|
||||
session_id = _session_id(ctx, title=objective, create=True)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("Could not create or resolve a session for /goal.", style="bold red"))
|
||||
return 1
|
||||
try:
|
||||
goal = _get_goal_store().replace_goal(
|
||||
session_id=session_id,
|
||||
objective=objective,
|
||||
criteria=_default_criteria(),
|
||||
source="cli",
|
||||
protocol="thesis_review",
|
||||
)
|
||||
except ValueError as exc:
|
||||
_resolve_console().print(Text(f"/goal failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
snapshot = _get_goal_store().get_goal_snapshot(goal.goal_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("Goal created but could not be reloaded.", style="bold red"))
|
||||
return 1
|
||||
_render_snapshot(snapshot, title="/goal started")
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_criterion_id(snapshot: dict, token: str) -> str:
|
||||
"""Resolve a criterion by 1-based index, exact id, or id prefix."""
|
||||
criteria = snapshot.get("criteria") or []
|
||||
if token.isdigit():
|
||||
index = int(token)
|
||||
if 1 <= index <= len(criteria):
|
||||
return str(criteria[index - 1]["criterion_id"])
|
||||
raise ValueError(f"criterion index out of range: {token}")
|
||||
|
||||
matches = [
|
||||
item["criterion_id"]
|
||||
for item in criteria
|
||||
if item["criterion_id"] == token or str(item["criterion_id"]).startswith(token)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return str(matches[0])
|
||||
if not matches:
|
||||
raise ValueError(f"unknown criterion: {token}")
|
||||
raise ValueError(f"ambiguous criterion prefix: {token}")
|
||||
|
||||
|
||||
def cmd_evidence(ctx: Any = None, *args: str) -> int:
|
||||
"""Append a manual evidence note to the current goal."""
|
||||
if len(args) < 2:
|
||||
_resolve_console().print(Text("Usage: /goal evidence <criterion-index-or-id> <note>", style="bold red"))
|
||||
return 1
|
||||
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="bold red"))
|
||||
return 1
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="bold red"))
|
||||
return 1
|
||||
|
||||
criterion_token, *text_parts = args
|
||||
text = " ".join(text_parts).strip()
|
||||
if not text:
|
||||
_resolve_console().print(Text("Evidence note cannot be empty.", style="bold red"))
|
||||
return 1
|
||||
|
||||
try:
|
||||
criterion_id = _resolve_criterion_id(snapshot, criterion_token)
|
||||
from src.goal import EvidenceInput
|
||||
|
||||
_get_goal_store().append_evidence(
|
||||
session_id=session_id,
|
||||
goal_id=snapshot["goal"]["goal_id"],
|
||||
expected_goal_id=snapshot["goal"]["goal_id"],
|
||||
evidence=EvidenceInput(
|
||||
criterion_id=criterion_id,
|
||||
text=text,
|
||||
source_provider="cli",
|
||||
source_type="manual_note",
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_resolve_console().print(Text(f"/goal evidence failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
updated = _get_goal_store().get_goal_snapshot(snapshot["goal"]["goal_id"])
|
||||
if updated is not None:
|
||||
_render_snapshot(updated, title="/goal evidence added")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_cancel(ctx: Any = None, *args: str) -> int:
|
||||
"""Cancel the current research goal."""
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
|
||||
recap = " ".join(args).strip() or "Cancelled from CLI."
|
||||
try:
|
||||
from src.goal import GoalStatus
|
||||
|
||||
updated = _get_goal_store().update_status(
|
||||
session_id=session_id,
|
||||
goal_id=snapshot["goal"]["goal_id"],
|
||||
expected_goal_id=snapshot["goal"]["goal_id"],
|
||||
status=GoalStatus.CANCELLED,
|
||||
recap=recap,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_resolve_console().print(Text(f"/goal cancel failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
|
||||
if terminal_snapshot is not None:
|
||||
_render_snapshot(terminal_snapshot, title="/goal cancelled")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_complete(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Complete the current research goal after auditing verified evidence."""
|
||||
session_id = _session_id(ctx, create=False)
|
||||
if session_id is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
snapshot = _get_goal_store().get_current_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
|
||||
return 0
|
||||
|
||||
evidence = snapshot.get("evidence") or []
|
||||
audit_rows = []
|
||||
for criterion in snapshot.get("criteria") or []:
|
||||
criterion_evidence = [
|
||||
item
|
||||
for item in evidence
|
||||
if item.get("criterion_id") == criterion["criterion_id"]
|
||||
and item.get("verification_status") == "verified"
|
||||
]
|
||||
if not criterion_evidence:
|
||||
_resolve_console().print(
|
||||
Text(
|
||||
"Cannot complete: every criterion needs verified run/artifact evidence.",
|
||||
style="bold red",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
from src.goal import AuditRow
|
||||
|
||||
audit_rows.append(
|
||||
AuditRow(
|
||||
criterion_id=criterion["criterion_id"],
|
||||
result="satisfied",
|
||||
evidence_ids=[item["evidence_id"] for item in criterion_evidence],
|
||||
notes="Verified evidence attached from CLI audit.",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from src.goal import GoalStatus
|
||||
|
||||
updated = _get_goal_store().update_status(
|
||||
session_id=session_id,
|
||||
goal_id=snapshot["goal"]["goal_id"],
|
||||
expected_goal_id=snapshot["goal"]["goal_id"],
|
||||
status=GoalStatus.COMPLETE,
|
||||
audit=audit_rows,
|
||||
recap=" ".join(args).strip() or "Completed from CLI audit.",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_resolve_console().print(Text(f"/goal complete failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
|
||||
if terminal_snapshot is not None:
|
||||
_render_snapshot(terminal_snapshot, title="/goal completed")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_help() -> int:
|
||||
"""Render /goal usage."""
|
||||
body = Text()
|
||||
body.append("/goal <objective>", style="bold")
|
||||
body.append(" start or replace the current research goal\n", style="dim")
|
||||
body.append("/goal status", style="bold")
|
||||
body.append(" show the current goal snapshot\n", style="dim")
|
||||
body.append("/goal evidence <criterion-index-or-id> <note>", style="bold")
|
||||
body.append(" append manual evidence\n", style="dim")
|
||||
body.append("/goal complete [recap]", style="bold")
|
||||
body.append(" complete after verified evidence audit\n", style="dim")
|
||||
body.append("/goal cancel [recap]", style="bold")
|
||||
body.append(" cancel the current goal\n", style="dim")
|
||||
_resolve_console().print(Panel(body, title="/goal", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Dispatch /goal subcommands."""
|
||||
if not args:
|
||||
return cmd_status(ctx)
|
||||
|
||||
command = args[0].lower()
|
||||
if command in {"help", "-h", "--help"}:
|
||||
return cmd_help()
|
||||
if command in {"status", "show"}:
|
||||
return cmd_status(ctx, *args[1:])
|
||||
if command == "start":
|
||||
return cmd_start(ctx, *args[1:])
|
||||
if command == "evidence":
|
||||
return cmd_evidence(ctx, *args[1:])
|
||||
if command == "complete":
|
||||
return cmd_complete(ctx, *args[1:])
|
||||
if command in {"cancel", "cancelled"}:
|
||||
return cmd_cancel(ctx, *args[1:])
|
||||
return cmd_start(ctx, *args)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"run",
|
||||
"cmd_start",
|
||||
"cmd_status",
|
||||
"cmd_evidence",
|
||||
"cmd_complete",
|
||||
"cmd_cancel",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""``/help`` — Show the slash command list and keyboard shortcuts.
|
||||
|
||||
Renders ``SLASH_COMMANDS`` as a two-column Rich table (name in primary
|
||||
color, description muted) plus a short keyboard-shortcuts panel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .slash_router import SLASH_COMMANDS
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
_SHORTCUTS: tuple[tuple[str, str], ...] = (
|
||||
("⏎", "Send"),
|
||||
("Shift+⏎", "Newline"),
|
||||
("Tab", "Accept completion"),
|
||||
("↑/↓", "Browse history (or navigate completions)"),
|
||||
("Ctrl+C", "Clear input · then exit hint"),
|
||||
("Ctrl+D", "Exit (auto-saves session)"),
|
||||
("/", "Open slash command typeahead"),
|
||||
)
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unused here
|
||||
"""Print the help screen. Always returns 0."""
|
||||
console = _resolve_console()
|
||||
|
||||
commands_table = Table.grid(padding=(0, 2))
|
||||
commands_table.add_column(style="bold #d97706", no_wrap=True)
|
||||
commands_table.add_column(style="dim")
|
||||
for cmd in SLASH_COMMANDS:
|
||||
commands_table.add_row(f"/{cmd.name}", cmd.description)
|
||||
|
||||
console.print()
|
||||
console.print(Text("Commands", style="bold"))
|
||||
console.print(commands_table)
|
||||
console.print()
|
||||
|
||||
shortcuts_table = Table.grid(padding=(0, 2))
|
||||
shortcuts_table.add_column(style="bold", no_wrap=True)
|
||||
shortcuts_table.add_column(style="dim")
|
||||
for key, desc in _SHORTCUTS:
|
||||
shortcuts_table.add_row(key, desc)
|
||||
|
||||
console.print(Text("Keyboard", style="bold"))
|
||||
console.print(shortcuts_table)
|
||||
console.print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""``/memory`` — list / show persistent memory snippets.
|
||||
|
||||
Delegates to the legacy ``cmd_memory_list`` / ``cmd_memory_show`` /
|
||||
``cmd_memory_search`` callables. ``/memory`` with no args lists; with
|
||||
a single arg shows one; with ``search <q>`` runs FTS; with
|
||||
``forget <name>`` deletes one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def cmd_memory(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Dispatch sub-actions:
|
||||
|
||||
* ``/memory`` → list
|
||||
* ``/memory <name>`` → show
|
||||
* ``/memory search <q>`` → full-text search
|
||||
* ``/memory forget <name>``→ delete a snippet
|
||||
"""
|
||||
console = _resolve_console()
|
||||
|
||||
if not args:
|
||||
try:
|
||||
from cli._legacy import cmd_memory_list
|
||||
|
||||
return cmd_memory_list()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/memory list failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
sub = args[0]
|
||||
rest = args[1:]
|
||||
try:
|
||||
if sub == "search" and rest:
|
||||
from cli._legacy import cmd_memory_search
|
||||
|
||||
return cmd_memory_search(" ".join(rest))
|
||||
if sub == "forget" and rest:
|
||||
from cli._legacy import cmd_memory_forget
|
||||
|
||||
return cmd_memory_forget(rest[0], yes=False)
|
||||
from cli._legacy import cmd_memory_show
|
||||
|
||||
return cmd_memory_show(sub)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/memory failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def run(ctx: Any = None, *args: str) -> int:
|
||||
"""Single-entrypoint wrapper for the slash router."""
|
||||
return cmd_memory(ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run", "cmd_memory"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""``/history``, ``/search``, ``/export`` — session lifecycle commands.
|
||||
|
||||
* ``/history`` lists recent sessions (legacy ``cmd_sessions``).
|
||||
* ``/search <query>`` runs FTS5 across the session store via
|
||||
:class:`src.session.search.SessionSearchIndex`.
|
||||
* ``/export`` is a placeholder — the rich export lives in the Web bubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def cmd_history(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""List recent sessions via legacy ``cmd_sessions``."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
from cli._legacy import cmd_sessions
|
||||
|
||||
cmd_sessions()
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/history failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_search(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Full-text search across recorded sessions."""
|
||||
console = _resolve_console()
|
||||
if not args:
|
||||
console.print(Text("Usage: /search <query>", style="bold red"))
|
||||
return 1
|
||||
|
||||
query = " ".join(args)
|
||||
try:
|
||||
from src.session.search import get_shared_index
|
||||
|
||||
index = get_shared_index()
|
||||
matches = index.search(query)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/search failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
if not matches:
|
||||
console.print(Text(f"No matches for '{query}'.", style="dim"))
|
||||
return 0
|
||||
|
||||
for hit in matches:
|
||||
line = Text()
|
||||
line.append(str(hit.session_id), style="bold")
|
||||
line.append(" ")
|
||||
line.append(hit.title, style="dim")
|
||||
line.append(" ")
|
||||
line.append(hit.snippet or "", style="dim")
|
||||
console.print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""Placeholder until the rich export lands."""
|
||||
console = _resolve_console()
|
||||
body = Text()
|
||||
body.append("/export is not yet wired up to the interactive CLI.\n\n", style="dim")
|
||||
body.append("Until then: ", style="dim")
|
||||
body.append("the web UI exports md/json from the message footer.", style="bold")
|
||||
console.print(Panel(body, title="/export", border_style="dim", padding=(1, 2)))
|
||||
return 0
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"history": cmd_history,
|
||||
"search": cmd_search,
|
||||
"export": cmd_export,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx: Any = None, command: str = "history", *args: str) -> int:
|
||||
"""Dispatch ``run("history")`` / ``run("search", ...)`` / ``run("export")``."""
|
||||
handler = _DISPATCH.get(command)
|
||||
if handler is None:
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"Unknown session command: /{command}", style="bold red"))
|
||||
return 1
|
||||
return handler(ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run", "cmd_history", "cmd_search", "cmd_export"]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""``/show``, ``/skill``, ``/pine`` — read-only inspect commands.
|
||||
|
||||
All three already exist as fully-featured ``cmd_*`` callables in
|
||||
``cli._legacy``; this module is a thin shim so the slash router can
|
||||
dispatch by command name without importing the legacy module top-level
|
||||
(which is heavy — it pulls in providers, swarm, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from cli.theme import get_console
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
return get_console()
|
||||
|
||||
|
||||
def cmd_show(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""``/show <run_id>`` — replay a prior run via the legacy handler."""
|
||||
console = _resolve_console()
|
||||
if not args:
|
||||
console.print(Text("Usage: /show <run_id>", style="bold red"))
|
||||
return 1
|
||||
try:
|
||||
from cli._legacy import cmd_show as legacy_show
|
||||
|
||||
legacy_show(args[0])
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/show failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_skill(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""``/skill`` — list bundled + user skills (legacy ``cmd_skills``)."""
|
||||
console = _resolve_console()
|
||||
try:
|
||||
from cli._legacy import cmd_skills
|
||||
|
||||
cmd_skills()
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/skill failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_pine(ctx: Any = None, *args: str) -> int: # noqa: ARG001
|
||||
"""``/pine <run_id>`` — emit Pine Script for a prior backtest."""
|
||||
console = _resolve_console()
|
||||
if not args:
|
||||
console.print(Text("Usage: /pine <run_id>", style="bold red"))
|
||||
return 1
|
||||
try:
|
||||
from cli._legacy import cmd_pine as legacy_pine
|
||||
|
||||
legacy_pine(args[0])
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(Text(f"/pine failed: {exc}", style="bold red"))
|
||||
return 1
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"show": cmd_show,
|
||||
"skill": cmd_skill,
|
||||
"pine": cmd_pine,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx: Any = None, command: str = "show", *args: str) -> int:
|
||||
"""Dispatch ``run("show", run_id)`` / ``run("skill")`` etc."""
|
||||
handler = _DISPATCH.get(command)
|
||||
if handler is None:
|
||||
console = _resolve_console()
|
||||
console.print(Text(f"Unknown show command: /{command}", style="bold red"))
|
||||
return 1
|
||||
return handler(ctx, *args)
|
||||
|
||||
|
||||
__all__ = ["run", "cmd_show", "cmd_skill", "cmd_pine"]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Slash command registry + fuzzy matcher.
|
||||
|
||||
Source of truth for the 15 user-facing slash commands defined in
|
||||
``docs/2026-05-19_session02_uiux_design_proposal.md`` §3.4.
|
||||
|
||||
Each :class:`Command` is a frozen dataclass — registry entries are
|
||||
immutable so callers can cache filtered slices without worrying about
|
||||
mutation. ``match_commands`` powers the typeahead in
|
||||
:mod:`agent.cli.completer`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Command:
|
||||
"""A single slash command entry.
|
||||
|
||||
Attributes:
|
||||
name: The command keyword, without leading ``/``.
|
||||
description: One-line muted description shown in the completion menu.
|
||||
handler_module: Dotted path to the module exposing ``run(ctx, *args)``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
handler_module: str
|
||||
|
||||
|
||||
# Order here = order shown in bare ``/`` typeahead. Group by frequency so the
|
||||
# most-used commands surface first (help/model/memory/history are top).
|
||||
SLASH_COMMANDS: tuple[Command, ...] = (
|
||||
Command("help", "Show keyboard shortcuts and command list", "cli.commands.help"),
|
||||
Command("model", "Switch LLM provider and model", "cli.commands.chat"),
|
||||
Command("memory", "Show / manage persistent memory", "cli.commands.memory"),
|
||||
Command("history", "Browse and resume prior sessions", "cli.commands.session"),
|
||||
Command("goal", "Start / inspect a finance research goal", "cli.commands.goal"),
|
||||
Command("search", "Full-text search across all sessions", "cli.commands.session"),
|
||||
Command("swarm", "Multi-agent presets (committee / quant / risk)", "cli.commands.chat"),
|
||||
Command("skill", "List / load / unload skills", "cli.commands.show"),
|
||||
Command("show", "Show prior run by id", "cli.commands.show"),
|
||||
Command("clear", "Clear current conversation", "cli.commands.chat"),
|
||||
Command("pine", "Export current strategy as Pine Script", "cli.commands.show"),
|
||||
Command("journal", "Analyze trade journal CSV", "cli.commands.chat"),
|
||||
Command("shadow", "Train / view shadow account", "cli.commands.chat"),
|
||||
Command("export", "Export current session (md / json)", "cli.commands.session"),
|
||||
Command("debug", "Toggle debug panel (token usage / latency)", "cli.commands.chat"),
|
||||
Command("quit", "Exit (also: q, exit, :q)", "cli.commands.chat"),
|
||||
)
|
||||
|
||||
|
||||
# Aliases — same handler, different surface keyword. Keep separate from the
|
||||
# main registry so typeahead does not duplicate rows.
|
||||
_ALIASES: dict[str, str] = {
|
||||
"q": "quit",
|
||||
"exit": "quit",
|
||||
":q": "quit",
|
||||
"?": "help",
|
||||
}
|
||||
|
||||
|
||||
def _parse_token(input_text: str) -> str:
|
||||
"""Strip the leading ``/`` and isolate the command token.
|
||||
|
||||
>>> _parse_token("/me arg")
|
||||
'me'
|
||||
>>> _parse_token("/")
|
||||
''
|
||||
>>> _parse_token("not a slash")
|
||||
''
|
||||
"""
|
||||
text = input_text.lstrip()
|
||||
if not text.startswith("/"):
|
||||
return ""
|
||||
# ``/foo bar`` → ``foo``. Use ``split(None, 1)`` to handle any whitespace.
|
||||
parts = text[1:].split(None, 1)
|
||||
return parts[0] if parts else ""
|
||||
|
||||
|
||||
def _fuzzy_score(needle: str, name: str) -> int:
|
||||
"""Return a heuristic score for ``needle`` matching ``name``.
|
||||
|
||||
Higher = better. ``0`` means no match.
|
||||
|
||||
Scoring order (boundary-friendly, like dexter's slash matcher):
|
||||
prefix match → 100 + length bonus
|
||||
substring match → 50 + length bonus
|
||||
subsequence match (chars in order) → 10 + chars matched
|
||||
otherwise → 0
|
||||
"""
|
||||
if not needle:
|
||||
return 1 # bare ``/`` shows everything in registry order
|
||||
needle_l = needle.lower()
|
||||
name_l = name.lower()
|
||||
if name_l.startswith(needle_l):
|
||||
return 100 + len(needle_l)
|
||||
if needle_l in name_l:
|
||||
return 50 + len(needle_l)
|
||||
# subsequence match: every char of needle appears in order inside name
|
||||
j = 0
|
||||
for ch in name_l:
|
||||
if j < len(needle_l) and ch == needle_l[j]:
|
||||
j += 1
|
||||
if j == len(needle_l):
|
||||
return 10 + j
|
||||
return 0
|
||||
|
||||
|
||||
def match_commands(input_text: str) -> list[Command]:
|
||||
"""Return commands that fuzzy-match ``input_text``.
|
||||
|
||||
Only matches when the trimmed input starts with ``/``. The leading slash
|
||||
and any trailing arguments are stripped before scoring; only the command
|
||||
token participates in the match.
|
||||
|
||||
Result is ordered best-match first, ties broken by registry order.
|
||||
|
||||
Args:
|
||||
input_text: Raw input line, possibly empty.
|
||||
|
||||
Returns:
|
||||
A new list — callers may freely mutate without side effects.
|
||||
"""
|
||||
token = _parse_token(input_text)
|
||||
if not input_text.lstrip().startswith("/"):
|
||||
return []
|
||||
|
||||
# Resolve aliases up front so ``/q`` shows the ``quit`` row.
|
||||
if token in _ALIASES:
|
||||
token = _ALIASES[token]
|
||||
|
||||
scored: list[tuple[int, int, Command]] = []
|
||||
for idx, cmd in enumerate(SLASH_COMMANDS):
|
||||
score = _fuzzy_score(token, cmd.name)
|
||||
if score > 0:
|
||||
# Negative idx so ties prefer earlier registry position when
|
||||
# sorted descending by score.
|
||||
scored.append((score, -idx, cmd))
|
||||
scored.sort(reverse=True)
|
||||
return [cmd for _score, _idx, cmd in scored]
|
||||
|
||||
|
||||
def find_exact(name: str) -> Command | None:
|
||||
"""Resolve an exact command name or alias.
|
||||
|
||||
Returns ``None`` if no match — callers handle the "unknown command"
|
||||
response themselves so error UX stays consistent.
|
||||
"""
|
||||
key = name.lstrip("/").strip()
|
||||
key = _ALIASES.get(key, key)
|
||||
for cmd in SLASH_COMMANDS:
|
||||
if cmd.name == key:
|
||||
return cmd
|
||||
return None
|
||||
@@ -0,0 +1,115 @@
|
||||
"""FuzzyCompleter for slash commands.
|
||||
|
||||
Returns completions only when the line starts with ``/`` so normal prose
|
||||
typing is not polluted by suggestions. Each completion advertises the
|
||||
command description in muted style with the command name in the brand
|
||||
primary color (sourced from :mod:`agent.cli.theme` when available; falls
|
||||
back to ``ansibrightyellow`` so the file is importable before Parcel α
|
||||
ships ``theme.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
from .commands.slash_router import SLASH_COMMANDS, Command, match_commands
|
||||
|
||||
|
||||
def _primary_style() -> str:
|
||||
"""Resolve the brand-orange ``style`` string for prompt_toolkit.
|
||||
|
||||
Rich and prompt_toolkit do not share a style grammar, so we read the
|
||||
brand hex from :mod:`cli.theme` (the single source of truth) and
|
||||
rewrite it for prompt_toolkit's ``fg:#xxxxxx`` syntax. NO_COLOR users
|
||||
pick the no-op style so completions remain visible without color.
|
||||
"""
|
||||
try:
|
||||
from cli.theme import Theme
|
||||
|
||||
brand = getattr(Theme, "brand_hex", None)
|
||||
if isinstance(brand, str) and brand:
|
||||
return f"fg:{brand} bold"
|
||||
except Exception: # noqa: BLE001 — never block typeahead on theme import
|
||||
pass
|
||||
# Fallback approximates brand orange in 256-color terminals.
|
||||
return "fg:#d97706 bold"
|
||||
|
||||
|
||||
def _muted_style() -> str:
|
||||
"""Resolve the muted ``style`` string for prompt_toolkit descriptions."""
|
||||
return "fg:#9ca3af"
|
||||
|
||||
|
||||
class SlashCompleter(Completer):
|
||||
"""prompt_toolkit completer that fuzzy-matches the slash registry.
|
||||
|
||||
Behaviour mirrors dexter's slash typeahead: bare ``/`` lists everything,
|
||||
``/m`` filters by prefix/substring/subsequence. Completions are emitted
|
||||
only when the cursor sits on the first token of the line — once the user
|
||||
types ``/help `` (with a trailing space) we get out of the way so they
|
||||
can fill in arguments without the menu re-popping.
|
||||
"""
|
||||
|
||||
def __init__(self, commands: Iterable[Command] = SLASH_COMMANDS) -> None:
|
||||
# Snapshot the registry; commands is a tuple of frozen dataclasses
|
||||
# so the snapshot itself is effectively immutable.
|
||||
self._commands = tuple(commands)
|
||||
|
||||
# ----------------------------------------------------------------- API
|
||||
def get_completions(
|
||||
self,
|
||||
document: Document,
|
||||
complete_event: CompleteEvent,
|
||||
) -> Iterable[Completion]:
|
||||
text = document.text_before_cursor
|
||||
stripped = text.lstrip()
|
||||
|
||||
# Bail unless the line starts with ``/`` AND we are still typing the
|
||||
# command token (no space yet). This means ``/help <enter args>``
|
||||
# stops triggering completions once the user moves past the keyword.
|
||||
if not stripped.startswith("/"):
|
||||
return
|
||||
# Find the slash position (the leading whitespace is irrelevant once
|
||||
# we have confirmed it leads with ``/``).
|
||||
slash_idx = text.index("/")
|
||||
token_zone = text[slash_idx + 1 :]
|
||||
if " " in token_zone or "\t" in token_zone:
|
||||
return
|
||||
|
||||
matches = match_commands(stripped)
|
||||
if not matches:
|
||||
return
|
||||
|
||||
# Compute the replacement window so the chosen completion overwrites
|
||||
# whatever the user typed after ``/``.
|
||||
start_position = -len(token_zone)
|
||||
|
||||
# Right-pad each command name so the description column lines up.
|
||||
name_width = max((len(c.name) for c in matches), default=0) + 2
|
||||
|
||||
primary = _primary_style()
|
||||
muted = _muted_style()
|
||||
|
||||
for cmd in matches:
|
||||
padded_name = cmd.name.ljust(name_width)
|
||||
display: FormattedText = FormattedText(
|
||||
[
|
||||
(primary, f"/{padded_name}"),
|
||||
(muted, cmd.description),
|
||||
]
|
||||
)
|
||||
# ``text`` is the value inserted on accept (no leading slash —
|
||||
# the slash is already in the buffer at ``slash_idx``).
|
||||
yield Completion(
|
||||
text=cmd.name,
|
||||
start_position=start_position,
|
||||
display=display,
|
||||
display_meta=cmd.description,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SlashCompleter"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Reusable CLI render components.
|
||||
|
||||
Each component returns a Rich renderable (Text / Panel / Table) so callers
|
||||
can ``console.print`` or stuff it into a Live group.
|
||||
"""
|
||||
|
||||
from .chat_log import render_history
|
||||
from .hint_bar import render_hint_bar
|
||||
from .tool_event import render_tool_event, render_tool_events
|
||||
from .working_indicator import ThinkingSpinner
|
||||
|
||||
__all__ = [
|
||||
"ThinkingSpinner",
|
||||
"render_history",
|
||||
"render_hint_bar",
|
||||
"render_tool_event",
|
||||
"render_tool_events",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Minimal chat-log renderer — stub for the demo.
|
||||
|
||||
Iterates a list of turn dicts and prints user / assistant lines with the
|
||||
expected meta header. Defer the rich features (collapsible tool
|
||||
timeline, markdown rendering, action footer) to post-demo work — they
|
||||
belong in the Web bubble (Parcel C / D), not in the CLI replay.
|
||||
|
||||
Turn shape:
|
||||
{
|
||||
"role": "user" | "assistant",
|
||||
"content": str,
|
||||
"timestamp": str | None, # ISO or formatted clock
|
||||
"meta": str | None, # "Vibe · 4.1s · 1.2k tokens · $0.003"
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Mapping, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def _resolve_console(console: Optional[Console]) -> Console:
|
||||
"""Return ``console`` if given, else the shared CLI console."""
|
||||
if console is not None:
|
||||
return console
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
def _render_turn(turn: Mapping[str, object]) -> Text:
|
||||
"""Compose the meta header line for one turn."""
|
||||
role = str(turn.get("role", "user"))
|
||||
timestamp = turn.get("timestamp")
|
||||
meta = turn.get("meta")
|
||||
header = Text()
|
||||
if role == "assistant":
|
||||
# "Vibe" is the brand wordmark for assistant turns — primary color
|
||||
header.append("Vibe", style="bold #d97706")
|
||||
else:
|
||||
header.append("you", style="bold")
|
||||
if timestamp:
|
||||
header.append(f" {timestamp}", style="dim")
|
||||
if meta:
|
||||
header.append(f" · {meta}", style="dim")
|
||||
return header
|
||||
|
||||
|
||||
def render_history(
|
||||
turns: Iterable[Mapping[str, object]],
|
||||
*,
|
||||
console: Optional[Console] = None,
|
||||
) -> None:
|
||||
"""Print past turns to ``console``.
|
||||
|
||||
Args:
|
||||
turns: Iterable of turn dicts (see module docstring for shape).
|
||||
console: Override; defaults to the shared CLI console.
|
||||
|
||||
No return value — this is a side-effecting renderer.
|
||||
"""
|
||||
cons = _resolve_console(console)
|
||||
for turn in turns:
|
||||
cons.print(_render_turn(turn))
|
||||
content = str(turn.get("content", ""))
|
||||
if content:
|
||||
# Body intentionally renders as plain text — markdown / katex
|
||||
# / syntax highlighting live in the Web bubble. The CLI keeps
|
||||
# the body monospaced for diff-friendly replay.
|
||||
cons.print(content)
|
||||
cons.print() # blank line between turns
|
||||
|
||||
|
||||
__all__ = ["render_history"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Bottom hint bar — left + right aligned.
|
||||
|
||||
Renders a single-line hint where the right-aligned segment hugs the
|
||||
terminal edge regardless of left-segment width. Mirrors dexter's
|
||||
``hint-bar.ts``.
|
||||
|
||||
Used to surface keyboard guidance ("Tab to complete · /help for
|
||||
commands") and ephemeral state ("input cleared", "cancelled").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def _terminal_width(default: int = 80) -> int:
|
||||
"""Best-effort terminal width detection (falls back gracefully)."""
|
||||
try:
|
||||
size = shutil.get_terminal_size((default, 20))
|
||||
return max(20, size.columns)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def render_hint_bar(left: str, right: str = "", *, width: int | None = None) -> Text:
|
||||
"""Return a Rich :class:`Text` row with ``left`` and right-aligned ``right``.
|
||||
|
||||
The bar always fits the terminal width:
|
||||
* If ``left + right`` fits, pad with spaces between them
|
||||
* Otherwise truncate the left segment with an ellipsis so the
|
||||
right hint remains visible (right-side hint usually carries
|
||||
the *current* Ctrl+C semantics — losing it is worse than
|
||||
losing context)
|
||||
|
||||
Args:
|
||||
left: Left segment (e.g. ``"↑/↓ navigate · Tab/⏎ select"``).
|
||||
right: Right segment (e.g. ``"Esc to cancel"``); may be empty.
|
||||
width: Override terminal width. Defaults to autodetect.
|
||||
"""
|
||||
cols = width if width is not None else _terminal_width()
|
||||
if right == "":
|
||||
# Only a left hint — clip and pad to width, dim style throughout.
|
||||
text = Text(left[:cols], style="dim")
|
||||
return text
|
||||
|
||||
# Reserve at least one space between left and right segments.
|
||||
available_for_left = cols - len(right) - 1
|
||||
if available_for_left < 1:
|
||||
# Right segment alone is wider than the terminal — show right only.
|
||||
return Text(right[: cols].rjust(cols), style="dim")
|
||||
|
||||
if len(left) > available_for_left:
|
||||
left = left[: max(1, available_for_left - 1)].rstrip() + "…"
|
||||
|
||||
padding = cols - len(left) - len(right)
|
||||
if padding < 1:
|
||||
padding = 1
|
||||
bar = Text()
|
||||
bar.append(left, style="dim")
|
||||
bar.append(" " * padding)
|
||||
bar.append(right, style="dim")
|
||||
return bar
|
||||
|
||||
|
||||
__all__ = ["render_hint_bar"]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Single tool invocation render.
|
||||
|
||||
Layout (matches design_proposal §3.5):
|
||||
|
||||
● Get Financials ("AAPL", quarterly, last 8 quarters) 1.4s · 8 quarters
|
||||
|
||||
* ``●`` (U+25CF black circle) marker, color-coded by status:
|
||||
running → ``--warning`` amber (pulse via Rich style ``blink`` when
|
||||
the terminal advertises it, otherwise solid amber)
|
||||
ok → ``--success`` green, solid
|
||||
error → ``--danger`` red, solid
|
||||
* Tool name pretty-printed (``get_financials`` → ``Get Financials``)
|
||||
* Args summary prefers ``query`` then ``prompt``, else the first 2
|
||||
``key=value`` pairs each truncated to 40 chars
|
||||
* Duration formatted via :func:`agent.cli.utils.format.format_duration`
|
||||
when Parcel α has shipped it; otherwise a local fallback is used
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Literal, Mapping
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
Status = Literal["running", "ok", "error"]
|
||||
|
||||
|
||||
# Status → (Rich style, marker glyph). The glyph stays the same; the style
|
||||
# encodes color + decoration (bold/blink). dexter only changes brightness
|
||||
# of the marker rather than swapping glyphs.
|
||||
_STATUS_STYLE: dict[Status, str] = {
|
||||
# ``blink`` is gracefully ignored by Rich when the terminal does not
|
||||
# advertise the capability, so we always include it.
|
||||
"running": "bold blink #f59e0b", # amber, F-002 warning
|
||||
"ok": "bold #16a34a", # green, F-002 success
|
||||
"error": "bold #dc2626", # red, F-002 danger
|
||||
}
|
||||
|
||||
_ARG_VALUE_MAX = 40
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers ----
|
||||
|
||||
|
||||
def _pretty_tool_name(name: str) -> str:
|
||||
"""Convert ``get_financials`` to ``Get Financials``.
|
||||
|
||||
Strips a leading ``get_`` prefix when present (dexter convention).
|
||||
Underscores and hyphens become spaces; each whitespace-separated
|
||||
token is title-cased. Acronyms preserved when fully upper.
|
||||
"""
|
||||
cleaned = name.strip()
|
||||
if cleaned.lower().startswith("get_"):
|
||||
cleaned = cleaned[4:]
|
||||
tokens = cleaned.replace("-", " ").replace("_", " ").split()
|
||||
out: list[str] = []
|
||||
for tok in tokens:
|
||||
if tok.isupper() and len(tok) <= 4:
|
||||
out.append(tok)
|
||||
else:
|
||||
out.append(tok[:1].upper() + tok[1:].lower())
|
||||
return " ".join(out) if out else cleaned
|
||||
|
||||
|
||||
def _truncate(value: str, limit: int = _ARG_VALUE_MAX) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _summarize_args(args: Mapping[str, Any] | None) -> str:
|
||||
"""Produce a one-line args summary.
|
||||
|
||||
Rules:
|
||||
* ``args is None`` or empty → empty string
|
||||
* ``query`` field present → ``"<value>"`` (quoted)
|
||||
* ``prompt`` field present → ``"<value>"`` (quoted)
|
||||
* otherwise → first 2 ``key=value`` pairs, value truncated to 40 chars
|
||||
|
||||
Returns the bare summary string; callers wrap it in parens.
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
|
||||
# Prefer the well-known semantic keys
|
||||
for primary_key in ("query", "prompt"):
|
||||
if primary_key in args:
|
||||
return f'"{_truncate(str(args[primary_key]))}"'
|
||||
|
||||
pairs: list[str] = []
|
||||
for key, value in list(args.items())[:2]:
|
||||
rendered_value = _truncate(_render_value(value))
|
||||
pairs.append(f"{key}={rendered_value}")
|
||||
return ", ".join(pairs)
|
||||
|
||||
|
||||
def _render_value(value: Any) -> str:
|
||||
"""Compact ``repr``-ish formatting suitable for inline arg display."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Show length to avoid blowing up the line on huge arrays.
|
||||
if len(value) <= 3:
|
||||
return "[" + ", ".join(_render_value(v) for v in value) + "]"
|
||||
return f"[{len(value)} items]"
|
||||
if isinstance(value, Mapping):
|
||||
return f"{{{len(value)} keys}}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _format_duration_local(ms: float) -> str:
|
||||
"""Local fallback when ``agent.cli.utils.format.format_duration`` is absent.
|
||||
|
||||
Parcel α owns the canonical implementation; until it lands we keep a
|
||||
minimal version here so the demo build is not blocked on cross-parcel
|
||||
ordering. Numbers match the contract in design_proposal §3.5:
|
||||
|
||||
< 1000ms → "740ms"
|
||||
< 60s → "1.4s"
|
||||
≥ 60s → "1m12s"
|
||||
"""
|
||||
if ms < 1000:
|
||||
return f"{int(round(ms))}ms"
|
||||
seconds = ms / 1000
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
rem = int(seconds - minutes * 60)
|
||||
return f"{minutes}m{rem:02d}s"
|
||||
|
||||
|
||||
def _format_duration(ms: float) -> str:
|
||||
"""Delegate to the shared formatter; fall back locally on import error."""
|
||||
try:
|
||||
from cli.utils.format import format_duration
|
||||
|
||||
return format_duration(ms)
|
||||
except Exception: # noqa: BLE001 — never block render on helper issues
|
||||
return _format_duration_local(ms)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- public ----
|
||||
|
||||
|
||||
def render_tool_event(
|
||||
name: str,
|
||||
args: Mapping[str, Any] | None = None,
|
||||
status: Status = "running",
|
||||
duration_ms: float | None = None,
|
||||
*,
|
||||
result_summary: str | None = None,
|
||||
) -> Text:
|
||||
"""Return a Rich :class:`Text` row describing a single tool call.
|
||||
|
||||
Args:
|
||||
name: Raw tool name (``get_financials``); pretty-printed in output.
|
||||
args: Argument mapping; summarised inline.
|
||||
status: ``"running"`` | ``"ok"`` | ``"error"``.
|
||||
duration_ms: When the call finished, total wall time in milliseconds.
|
||||
result_summary: Optional one-line summary appended after the
|
||||
duration (e.g. ``"8 quarters"``).
|
||||
|
||||
Returns:
|
||||
A Rich ``Text`` object — caller decides whether to ``console.print``
|
||||
it, stuff it into a ``Group``, or stream it as a transient row.
|
||||
"""
|
||||
if status not in _STATUS_STYLE:
|
||||
# Be lenient — unknown statuses degrade to "running" instead of
|
||||
# raising; CLI rendering should never crash the agent loop.
|
||||
status = "running"
|
||||
|
||||
marker = Text("● ", style=_STATUS_STYLE[status])
|
||||
line = Text()
|
||||
line.append(marker)
|
||||
line.append(_pretty_tool_name(name), style="bold")
|
||||
|
||||
summary = _summarize_args(args)
|
||||
if summary:
|
||||
line.append(f" ({summary})", style="dim")
|
||||
|
||||
suffix_parts: list[str] = []
|
||||
if duration_ms is not None:
|
||||
suffix_parts.append(_format_duration(duration_ms))
|
||||
if result_summary:
|
||||
suffix_parts.append(result_summary)
|
||||
if suffix_parts:
|
||||
line.append(" " + " · ".join(suffix_parts), style="dim")
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def render_tool_events(events: Iterable[Mapping[str, Any]]) -> list[Text]:
|
||||
"""Convenience: render a list of event dicts.
|
||||
|
||||
Each event dict needs ``name`` plus any of ``args`` / ``status`` /
|
||||
``duration_ms`` / ``result_summary``. Useful for replaying a stored
|
||||
run via :mod:`agent.cli.commands.show`.
|
||||
"""
|
||||
rendered: list[Text] = []
|
||||
for ev in events:
|
||||
rendered.append(
|
||||
render_tool_event(
|
||||
name=str(ev.get("name", "tool")),
|
||||
args=ev.get("args"),
|
||||
status=ev.get("status", "ok"),
|
||||
duration_ms=ev.get("duration_ms"),
|
||||
result_summary=ev.get("result_summary"),
|
||||
)
|
||||
)
|
||||
return rendered
|
||||
|
||||
|
||||
__all__ = ["render_tool_event", "render_tool_events"]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Thinking spinner — picks a random verb on entry, hides on exit.
|
||||
|
||||
Wraps :class:`rich.live.Live` with ``transient=True`` so the spinner
|
||||
erases itself once the context exits — leaving the answer print
|
||||
uncluttered (the bug called out in the reference StreamRenderer
|
||||
docstring: transient=True avoids the "ghost spinner" duplication).
|
||||
|
||||
If :mod:`agent.cli.utils.thinking_verbs` has not landed yet (Parcel α
|
||||
ships it) we fall back to ``"Pondering…"`` so the demo build still
|
||||
works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from types import TracebackType
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.spinner import Spinner
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
_FALLBACK_VERBS: tuple[str, ...] = (
|
||||
"Pondering…",
|
||||
"Analyzing…",
|
||||
"Reasoning…",
|
||||
"Thinking…",
|
||||
"Investigating…",
|
||||
)
|
||||
|
||||
|
||||
def _pick_verb() -> str:
|
||||
"""Pick a thinking verb via the shared helper, with a local fallback."""
|
||||
try:
|
||||
from cli.utils.thinking_verbs import pick_thinking_verb
|
||||
|
||||
verb = pick_thinking_verb()
|
||||
if isinstance(verb, str) and verb:
|
||||
return verb
|
||||
except Exception: # noqa: BLE001 — never block the spinner on helper issues
|
||||
pass
|
||||
return random.choice(_FALLBACK_VERBS)
|
||||
|
||||
|
||||
def _resolve_console() -> Console:
|
||||
"""Return the shared CLI console."""
|
||||
from cli.theme import get_console
|
||||
|
||||
return get_console()
|
||||
|
||||
|
||||
class ThinkingSpinner:
|
||||
"""Context manager that shows a Rich spinner + thinking verb.
|
||||
|
||||
Usage:
|
||||
with ThinkingSpinner() as spinner:
|
||||
spinner.update_verb("Backtesting…") # optional mid-run swap
|
||||
... do agent work ...
|
||||
|
||||
The spinner is ``transient=True`` so it disappears when the block
|
||||
exits. Use :meth:`pause` to temporarily hide the spinner while
|
||||
printing auxiliary lines (e.g. a tool event row) — borrowed from
|
||||
the reference StreamRenderer pattern.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verb: Optional[str] = None,
|
||||
*,
|
||||
console: Optional[Console] = None,
|
||||
spinner_name: str = "dots",
|
||||
) -> None:
|
||||
self._verb = verb or _pick_verb()
|
||||
self._console = console or _resolve_console()
|
||||
self._spinner_name = spinner_name
|
||||
self._live: Optional[Live] = None
|
||||
|
||||
# --------------------------------------------------------- lifecycle ----
|
||||
def __enter__(self) -> "ThinkingSpinner":
|
||||
self._live = Live(
|
||||
self._renderable(),
|
||||
console=self._console,
|
||||
transient=True,
|
||||
refresh_per_second=12,
|
||||
)
|
||||
self._live.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[type[BaseException]],
|
||||
exc: Optional[BaseException],
|
||||
tb: Optional[TracebackType],
|
||||
) -> None:
|
||||
live, self._live = self._live, None
|
||||
if live is not None:
|
||||
live.__exit__(exc_type, exc, tb)
|
||||
|
||||
# ------------------------------------------------------------- helpers --
|
||||
def _renderable(self) -> Spinner:
|
||||
# ``Spinner.text`` accepts Rich Text for styling — keep the verb in
|
||||
# the dim style so the live region is unobtrusive.
|
||||
return Spinner(self._spinner_name, text=Text(self._verb, style="dim"))
|
||||
|
||||
def update_verb(self, verb: str) -> None:
|
||||
"""Swap the verb mid-run (e.g. ``"Backtesting…"`` once tools fire)."""
|
||||
self._verb = verb
|
||||
if self._live is not None:
|
||||
self._live.update(self._renderable())
|
||||
|
||||
def pause(self) -> "_SpinnerPause":
|
||||
"""Yield a context manager that hides the spinner inside its block.
|
||||
|
||||
Useful when callers need to ``console.print`` a tool-event row
|
||||
without the spinner frame racing with the print to stdout.
|
||||
"""
|
||||
return _SpinnerPause(self)
|
||||
|
||||
|
||||
class _SpinnerPause:
|
||||
"""Inner context manager returned by :meth:`ThinkingSpinner.pause`."""
|
||||
|
||||
def __init__(self, parent: ThinkingSpinner) -> None:
|
||||
self._parent = parent
|
||||
self._saved_live: Optional[Live] = None
|
||||
|
||||
def __enter__(self) -> None:
|
||||
# Stash the live so __exit__ on the outer spinner does nothing
|
||||
# while we are paused; restart it on exit.
|
||||
if self._parent._live is not None:
|
||||
self._parent._live.__exit__(None, None, None)
|
||||
self._saved_live = self._parent._live
|
||||
self._parent._live = None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
if self._saved_live is not None:
|
||||
self._parent._live = self._saved_live
|
||||
self._parent._live.__enter__()
|
||||
self._saved_live = None
|
||||
|
||||
|
||||
__all__ = ["ThinkingSpinner"]
|
||||
@@ -0,0 +1,385 @@
|
||||
"""prompt_toolkit input editor + ``SafeFileHistory``.
|
||||
|
||||
Wraps a :class:`PromptSession` with:
|
||||
* The slash :class:`~cli.completer.SlashCompleter`
|
||||
* Multi-line editing — Enter inserts a newline only when the buffer is
|
||||
mid-bracket; Alt+Enter / Esc-Enter inserts a newline unconditionally;
|
||||
a plain Enter on a balanced buffer submits.
|
||||
* Ctrl+C with three-state semantics (clear buffer → exit hint → exit)
|
||||
* A surrogate-safe :class:`FileHistory` subclass for Windows users
|
||||
* UTF-8 stdout reconfigure on Windows so the brand glyph ``●`` and the
|
||||
Rich box-drawing characters print without ``UnicodeEncodeError``
|
||||
|
||||
Cancel-during-generation lives outside the input loop — that is owned by
|
||||
the agent runner in :mod:`cli.main`. Here we only handle the *input
|
||||
editing* and *idle* states.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.layout.containers import Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.dimension import Dimension
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
|
||||
# Sentinel raised by the Ctrl+C path so the caller can distinguish
|
||||
# "user pressed Ctrl+C on an empty line" from real EOF. We reuse
|
||||
# :class:`EOFError` so prompt_toolkit's existing plumbing keeps working;
|
||||
# the caller decides between "show hint" and "exit" via timing.
|
||||
_EXIT_HINT_GAP_SEC = 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- history ----
|
||||
|
||||
|
||||
class SafeFileHistory(FileHistory):
|
||||
""":class:`FileHistory` that strips invalid surrogate code points.
|
||||
|
||||
Background:
|
||||
Windows terminals occasionally inject lone surrogate halves into
|
||||
pasted Unicode (emoji, mixed-script CJK). prompt_toolkit's default
|
||||
``store_string`` writes the line straight to disk using the
|
||||
system encoding, which raises ``UnicodeEncodeError`` and corrupts
|
||||
the history file. This subclass round-trips the string through
|
||||
``utf-16-le`` with ``surrogatepass`` and then back to a sanitised
|
||||
string before delegating to the parent implementation.
|
||||
|
||||
The cleanup is safe to run on every line — sanitised input that
|
||||
contains only valid code points round-trips unchanged.
|
||||
"""
|
||||
|
||||
def store_string(self, string: str) -> None: # type: ignore[override]
|
||||
super().store_string(_strip_surrogates(string))
|
||||
|
||||
|
||||
def _strip_surrogates(text: str) -> str:
|
||||
"""Drop unpaired surrogate code points from ``text``."""
|
||||
try:
|
||||
round_tripped = text.encode("utf-16-le", "surrogatepass").decode(
|
||||
"utf-16-le", "replace"
|
||||
)
|
||||
except UnicodeError:
|
||||
return "".join(ch for ch in text if not 0xD800 <= ord(ch) <= 0xDFFF)
|
||||
cleaned = round_tripped.encode("utf-8", "ignore").decode("utf-8", "ignore")
|
||||
return cleaned.replace("�", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- session ----
|
||||
|
||||
|
||||
class _VibePromptSession(PromptSession):
|
||||
"""PromptSession with a prompt-height that hugs the edited text."""
|
||||
|
||||
def _create_layout(self): # type: ignore[no-untyped-def]
|
||||
layout = super()._create_layout()
|
||||
# prompt_toolkit's bottom_toolbar is a screen-bottom status bar. Insert
|
||||
# our divider directly after the input container so it hugs the prompt.
|
||||
layout.container.children.insert(1, _prompt_divider_window())
|
||||
return layout
|
||||
|
||||
def _get_default_buffer_control_height(self) -> Dimension: # type: ignore[override]
|
||||
line_count = self.default_buffer.document.line_count
|
||||
return Dimension.exact(max(1, line_count))
|
||||
|
||||
|
||||
def _prompt_divider_window() -> Window:
|
||||
return Window(
|
||||
FormattedTextControl(
|
||||
lambda: FormattedText([("class:prompt-border", _prompt_rule())])
|
||||
),
|
||||
height=1,
|
||||
style="class:prompt-border",
|
||||
dont_extend_height=True,
|
||||
)
|
||||
|
||||
|
||||
def _force_utf8_stdout() -> None:
|
||||
"""Reconfigure stdout to UTF-8 on Windows so brand glyphs render."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
# Best-effort — a redirected pipe may refuse encoding swaps.
|
||||
pass
|
||||
|
||||
|
||||
def _has_unbalanced_brackets(text: str) -> bool:
|
||||
"""Return True if ``text`` contains unbalanced ``()``/``[]``/``{}`` pairs.
|
||||
|
||||
Used to decide whether a plain Enter should submit or insert a
|
||||
newline. Strings inside ``"..."`` / ``'...'`` are skipped so a user
|
||||
typing ``"hello (world)"`` does not get stuck in multi-line mode.
|
||||
"""
|
||||
stack: list[str] = []
|
||||
pairs = {")": "(", "]": "[", "}": "{"}
|
||||
openers = set(pairs.values())
|
||||
in_str: Optional[str] = None
|
||||
escape = False
|
||||
for ch in text:
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if in_str is not None:
|
||||
if ch == in_str:
|
||||
in_str = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
in_str = ch
|
||||
continue
|
||||
if ch in openers:
|
||||
stack.append(ch)
|
||||
elif ch in pairs:
|
||||
if not stack or stack[-1] != pairs[ch]:
|
||||
return True
|
||||
stack.pop()
|
||||
return bool(stack) or in_str is not None
|
||||
|
||||
|
||||
class _CtrlCState:
|
||||
"""Track Ctrl+C presses so the outer loop can implement two-press exit.
|
||||
|
||||
Attributes:
|
||||
previous_press_ts: Monotonic timestamp of the press *before* the
|
||||
most recent one. ``0.0`` means "no prior press".
|
||||
last_window_hit: Set by :meth:`record_press_and_check_window` —
|
||||
``True`` iff the press that just landed was inside the
|
||||
configured window relative to ``previous_press_ts``. Cached
|
||||
so :func:`ctrl_c_within_window` does not re-read the clock
|
||||
and decide independently of the keybinding.
|
||||
|
||||
The two-press semantics live here (not in the outer loop) because
|
||||
the keybinding fires *before* the EOFError propagates, and we want
|
||||
the outer loop's view of "are we inside the window?" to reflect the
|
||||
decision made at the exact press moment — not whatever ``time.monotonic()``
|
||||
reads a few microseconds later.
|
||||
"""
|
||||
|
||||
__slots__ = ("previous_press_ts", "last_window_hit")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.previous_press_ts: float = 0.0
|
||||
self.last_window_hit: bool = False
|
||||
|
||||
def record_press_and_check_window(self, window_sec: float = _EXIT_HINT_GAP_SEC) -> bool:
|
||||
"""Record the current press, return True iff inside the window.
|
||||
|
||||
Args:
|
||||
window_sec: Two-press window in seconds.
|
||||
|
||||
Returns:
|
||||
``False`` for the very first press (``previous_press_ts == 0``).
|
||||
``True`` if the gap between the prior press and this one is
|
||||
below ``window_sec``. Otherwise ``False`` (treated as a fresh
|
||||
first press for the next round).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
prev = self.previous_press_ts
|
||||
self.previous_press_ts = now
|
||||
if prev == 0.0:
|
||||
self.last_window_hit = False
|
||||
return False
|
||||
hit = (now - prev) < window_sec
|
||||
self.last_window_hit = hit
|
||||
return hit
|
||||
|
||||
|
||||
def _build_keybindings(state: _CtrlCState) -> KeyBindings:
|
||||
"""Wire Ctrl+C + multi-line submit semantics.
|
||||
|
||||
State machine (idle / typing):
|
||||
|
||||
Ctrl+C with text → clear the buffer and stay at the prompt
|
||||
Ctrl+C empty (first) → exit with ``EOFError``; caller prints hint
|
||||
and records the press timestamp.
|
||||
Ctrl+C empty (twice) → caller sees the timestamp inside 2 s and
|
||||
actually exits the loop.
|
||||
|
||||
Enter on balanced buffer → submit
|
||||
Enter on unbalanced buffer → insert newline
|
||||
Alt-Enter / Esc-Enter → insert newline unconditionally
|
||||
"""
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("c-c")
|
||||
def _(event) -> None: # noqa: ANN001 — prompt_toolkit event
|
||||
buf = event.app.current_buffer
|
||||
if buf.text:
|
||||
buf.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
# Empty buffer → record the press (this updates the state so the
|
||||
# outer loop's two-press check has the right prior timestamp) and
|
||||
# propagate EOF so the outer loop can decide whether to print the
|
||||
# exit hint or actually exit.
|
||||
state.record_press_and_check_window(_EXIT_HINT_GAP_SEC)
|
||||
event.app.exit(exception=EOFError())
|
||||
|
||||
@kb.add("enter")
|
||||
def _(event) -> None: # noqa: ANN001
|
||||
buf = event.app.current_buffer
|
||||
text = buf.text
|
||||
if _has_unbalanced_brackets(text):
|
||||
buf.insert_text("\n")
|
||||
return
|
||||
buf.validate_and_handle()
|
||||
|
||||
# Alt+Enter / Esc-Enter — unconditional newline.
|
||||
@kb.add("escape", "enter")
|
||||
def _(event) -> None: # noqa: ANN001
|
||||
event.app.current_buffer.insert_text("\n")
|
||||
|
||||
return kb
|
||||
|
||||
|
||||
def _default_history_path() -> Path:
|
||||
"""Where ``~/.vibe-trading/history`` lives by default."""
|
||||
home = Path.home() / ".vibe-trading"
|
||||
return home / "history"
|
||||
|
||||
|
||||
def make_session(history_path: Optional[Path] = None) -> PromptSession:
|
||||
"""Construct a configured :class:`PromptSession`.
|
||||
|
||||
Args:
|
||||
history_path: Override for the persistent history file. ``None``
|
||||
uses ``~/.vibe-trading/history``.
|
||||
|
||||
Returns:
|
||||
A ready-to-use ``PromptSession`` wired to the slash completer,
|
||||
Ctrl+C bindings, multi-line editing, and a surrogate-safe
|
||||
history file. The session exposes ``vibe_ctrl_c_state`` on the
|
||||
returned object so callers can implement the two-press exit
|
||||
confirmation.
|
||||
"""
|
||||
_force_utf8_stdout()
|
||||
|
||||
path = history_path or _default_history_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Touch the file so FileHistory's first read does not fail on a fresh
|
||||
# install.
|
||||
if not path.exists():
|
||||
path.touch(mode=0o600)
|
||||
|
||||
# Lazy import so unit tests can import this module without instantiating
|
||||
# the slash registry side-effects.
|
||||
from cli.completer import SlashCompleter
|
||||
|
||||
ctrl_c_state = _CtrlCState()
|
||||
session = _VibePromptSession(
|
||||
history=SafeFileHistory(str(path)),
|
||||
completer=SlashCompleter(),
|
||||
complete_while_typing=True,
|
||||
key_bindings=_build_keybindings(ctrl_c_state),
|
||||
enable_history_search=True,
|
||||
mouse_support=False,
|
||||
multiline=True,
|
||||
reserve_space_for_menu=0,
|
||||
style=Style.from_dict(
|
||||
{
|
||||
"prompt": "#258bff bold",
|
||||
"prompt-border": "#4b5563",
|
||||
}
|
||||
),
|
||||
)
|
||||
# Expose the state so the outer loop can implement two-press exit.
|
||||
setattr(session, "vibe_ctrl_c_state", ctrl_c_state)
|
||||
return session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers ----
|
||||
|
||||
|
||||
def get_user_input(
|
||||
prompt_message: str = "❯ ",
|
||||
*,
|
||||
session: Optional[PromptSession] = None,
|
||||
) -> str:
|
||||
"""Prompt the user with the configured session and return the input.
|
||||
|
||||
Convenience for one-shot callers. Reuses ``session`` when supplied so
|
||||
the persistent history and completer carry across calls — otherwise
|
||||
a fresh session is built (and torn down).
|
||||
|
||||
Raises:
|
||||
EOFError: When the user hits Ctrl+D, or Ctrl+C on an empty line.
|
||||
"""
|
||||
sess = session or make_session()
|
||||
formatted = FormattedText(
|
||||
[
|
||||
("class:prompt-border", _prompt_rule() + "\n"),
|
||||
("class:prompt", prompt_message),
|
||||
]
|
||||
)
|
||||
return sess.prompt(formatted)
|
||||
|
||||
|
||||
def _prompt_rule() -> str:
|
||||
cols = shutil.get_terminal_size((88, 24)).columns
|
||||
return "─" * max(10, cols)
|
||||
|
||||
|
||||
def ctrl_c_within_window(session: PromptSession, window_sec: float = _EXIT_HINT_GAP_SEC) -> bool:
|
||||
"""Return True if the most recent Ctrl+C press was a "second press".
|
||||
|
||||
A "second press" means the user pressed Ctrl+C twice within
|
||||
``window_sec`` on an empty buffer — that's the signal to actually
|
||||
exit. The decision is made at *press time* by
|
||||
:meth:`_CtrlCState.record_press_and_check_window` and cached on the
|
||||
state object; the outer loop reads the cached flag here.
|
||||
|
||||
Falls back to a timestamp comparison against ``previous_press_ts``
|
||||
for two cases:
|
||||
|
||||
* ``SimpleNamespace`` test doubles that set ``last_press_ts`` directly
|
||||
(legacy test fixtures predate the two-timestamp design).
|
||||
* ``vibe_ctrl_c_state`` being absent entirely (defensive — returns
|
||||
``False`` so the caller treats it as "no exit").
|
||||
|
||||
Args:
|
||||
session: The active prompt_toolkit session (or a duck-typed
|
||||
stand-in exposing ``vibe_ctrl_c_state``).
|
||||
window_sec: Window length in seconds. Only used by the fallback
|
||||
paths described above; the primary path trusts the cached
|
||||
``last_window_hit`` already computed against the configured
|
||||
window.
|
||||
|
||||
Returns:
|
||||
``True`` iff the loop should now exit.
|
||||
"""
|
||||
state = getattr(session, "vibe_ctrl_c_state", None)
|
||||
if state is None:
|
||||
return False
|
||||
# Primary path — the keybinding cached the press-time decision.
|
||||
if hasattr(state, "last_window_hit"):
|
||||
return bool(state.last_window_hit)
|
||||
# Legacy fallback for test doubles that only set ``last_press_ts``.
|
||||
last_ts = getattr(state, "last_press_ts", 0.0)
|
||||
if last_ts <= 0.0:
|
||||
return False
|
||||
return (time.monotonic() - last_ts) < window_sec
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SafeFileHistory",
|
||||
"make_session",
|
||||
"get_user_input",
|
||||
"ctrl_c_within_window",
|
||||
]
|
||||
Loaded 100 of 1460 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user