Compare commits
793 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c70a9273b | |||
| 695fdee4b8 | |||
| 4c6147e2ee | |||
| 818552bc96 | |||
| 534ab42906 | |||
| ba4b77fd74 | |||
| ad76bdb6ae | |||
| 59a0c95aae | |||
| d4e7cdd5b5 | |||
| 984f9d308d | |||
| fbc4233dcd | |||
| 05f1cbbe00 | |||
| ef9cc39882 | |||
| b4c68be29b | |||
| e9c9ecbd48 | |||
| b2eaaa0d26 | |||
| 13c89e8d59 | |||
| 232e9ae154 | |||
| f8955bfb18 | |||
| fb8192a286 | |||
| 7f0c6a008a | |||
| 93de28d369 | |||
| 665653c3fe | |||
| a29c434be9 | |||
| 4c96d0c31f | |||
| 5e11f3da22 | |||
| 8e7ae69b0b | |||
| 81acc54228 | |||
| 600808b171 | |||
| d2f1b64af1 | |||
| 9a54156df9 | |||
| 237ee15a51 | |||
| 996b909608 | |||
| c62ae1206f | |||
| 3a3091a2cf | |||
| d2aa16a2f0 | |||
| 0576cf9b6d | |||
| 636e0dfd05 | |||
| c98bcc3eb2 | |||
| 53af3a6a85 | |||
| 70c6f7e90b | |||
| 0ed6752a73 | |||
| 3e8cc63b9a | |||
| be171683c9 | |||
| 0a6fab9038 | |||
| e8abf43cd4 | |||
| 2286bfa757 | |||
| 93809eea1b | |||
| f7f0ff2951 | |||
| effa0c6456 | |||
| b1ca2dfada | |||
| d15abc18ae | |||
| 9370dfe6e9 | |||
| b19108558f | |||
| fe05384f80 | |||
| 0ff90c4329 | |||
| c85daae608 | |||
| f73513e2d4 | |||
| 4e953b6e98 | |||
| b232847e40 | |||
| e3b3929578 | |||
| 20e73d567b | |||
| cf4498668b | |||
| 3f39943ee4 | |||
| 5c65e65c6f | |||
| f3c70ea566 | |||
| 07b7b181b6 | |||
| fe7243bbc3 | |||
| 875e31f71f | |||
| 61bd14d001 | |||
| 531df39911 | |||
| e912e2a6f5 | |||
| b1f2fcb85c | |||
| 828690a51e | |||
| ffd23b9920 | |||
| 4ac9ee5058 | |||
| 848c1a514a | |||
| 099759ae67 | |||
| d646ca455d | |||
| 3b2a97fa84 | |||
| 2f2258f208 | |||
| 32a34fadd6 | |||
| 7ac87a90c4 | |||
| 5949d4cc69 | |||
| 24cb4805a7 | |||
| 053f880e34 | |||
| 4d3a9e0e5b | |||
| 4edc70c4c6 | |||
| 9804851133 | |||
| 6ffa0346aa | |||
| 29eafc79e2 | |||
| b6859a3360 | |||
| a00dea58b2 | |||
| 2445dc925a | |||
| 1e8d38f2ee | |||
| d2071e4311 | |||
| 0fba16342f | |||
| bd389de57f | |||
| bfbf898027 | |||
| 2b6d031a55 | |||
| 6160a74d2a | |||
| 4acecda79d | |||
| d847cb5c28 | |||
| fa0b316a8b | |||
| 21786187c9 | |||
| e0bed38184 | |||
| c0b78846d6 | |||
| 9d72567b99 | |||
| e47228beff | |||
| 1fc8a3a9e6 | |||
| 98199c02a2 | |||
| 6eaa06c1d6 | |||
| 7e0877b586 | |||
| 36833b7a6a | |||
| 4c0157b66c | |||
| f7d16fb664 | |||
| 97cdfa5972 | |||
| f2e228e0a3 | |||
| 33924b1fba | |||
| d3c36e9478 | |||
| c32807db1d | |||
| 20d723e2a1 | |||
| c2b939e818 | |||
| 6684f48f95 | |||
| adbfadcc07 | |||
| cb6f57be6f | |||
| e75db2d7b0 | |||
| e42dc6938b | |||
| 4f4c1ab7e0 | |||
| e549a23c66 | |||
| 4b21fc4f3f | |||
| e4bac1cf40 | |||
| 1de96494b3 | |||
| d9bac529d6 | |||
| 3e8d652af1 | |||
| 23043ee18b | |||
| c7c2e69b88 | |||
| e6746a7a0e | |||
| 123467d7ae | |||
| b3a3bdbdfd | |||
| 3f9cff1ee5 | |||
| db88abb9e0 | |||
| 75e5e32281 | |||
| 7d05f8f7c7 | |||
| d01f32ca5b | |||
| 3dd7b2bd50 | |||
| c8f6ed102c | |||
| 4664f1a2d3 | |||
| 40481e8825 | |||
| 2473cc04bb | |||
| 3fa1004ec5 | |||
| c4632bacf1 | |||
| 7dcaae594a | |||
| 999bf5bd01 | |||
| b281045df5 | |||
| 35acb1c7e7 | |||
| bbbd9cf3e0 | |||
| 5dc5ef9df5 | |||
| fe8f6c1c26 | |||
| 30e8227099 | |||
| 588da4958f | |||
| bc4754288e | |||
| 6b0cdbcecc | |||
| 5ff05e0d3d | |||
| bab4d47b46 | |||
| fdd26667c9 | |||
| 29522b69fe | |||
| a54489fe92 | |||
| 20c14437c6 | |||
| dce5454ec8 | |||
| 72b64451ac | |||
| d17d67d6ca | |||
| 63c577bd76 | |||
| 8c190597ae | |||
| ad063264cf | |||
| 0108c91bae | |||
| ee2865829d | |||
| e66460c4e9 | |||
| 2811886a52 | |||
| 3b5a7cd318 | |||
| 3fe2da3ee0 | |||
| ee77028907 | |||
| 1bcaa0b3d8 | |||
| 1703a7aa5e | |||
| e3f1028667 | |||
| ea58ab2166 | |||
| 3b1aa2ebcb | |||
| fda2e70456 | |||
| 3cea91467d | |||
| dd6b37be16 | |||
| 078839b0f3 | |||
| 326438aac9 | |||
| 30da6eaead | |||
| 3784246e6b | |||
| fa6f189cdd | |||
| daa9d8d341 | |||
| c85afb59ab | |||
| 7d293f092e | |||
| da27204a71 | |||
| bac721e593 | |||
| bc666c6433 | |||
| 4a71992752 | |||
| 1b78186c4e | |||
| cb97aa193d | |||
| a02ac6e853 | |||
| f3cc790659 | |||
| 5ffbf76565 | |||
| 04b45adf98 | |||
| 9714c051c5 | |||
| fbf5a6d61a | |||
| 94632f42d6 | |||
| 3f6017d98b | |||
| d5af51c02b | |||
| 1ec0bb65f7 | |||
| c934a948c6 | |||
| 950bcaa7aa | |||
| 728ed7998d | |||
| 757b5665ca | |||
| c280bbc1c3 | |||
| 68c186f649 | |||
| f2de8b281a | |||
| 19f725dbaa | |||
| df7a1ee382 | |||
| 4fe1f0d77c | |||
| f45b3a71fa | |||
| 1aa7c28616 | |||
| e542e52bdb | |||
| 0afbdc2210 | |||
| fc6dea12eb | |||
| c3f88ebf12 | |||
| 1d1c93f8e2 | |||
| 929b105173 | |||
| e00777ebc2 | |||
| e4f6cdd025 | |||
| 2a705a361a | |||
| b4ec5da808 | |||
| 4d8761cc3c | |||
| fdd0dd0525 | |||
| df29c8d3ec | |||
| ef24402747 | |||
| 78e872b609 | |||
| 27e523a1dc | |||
| f9d9f06d3f | |||
| d45f39f1bf | |||
| 5fa3d3bf54 | |||
| 7bc832f3c6 | |||
| 46c647d676 | |||
| 24b961ba61 | |||
| d2cf021194 | |||
| 0542a44afe | |||
| 3f14d9de54 | |||
| b8e0d3bf4c | |||
| f1a6a2695d | |||
| 54ccfe7e0a | |||
| ce041e0d16 | |||
| 81f6b7d1a5 | |||
| dd376e4b32 | |||
| f927ef8c0f | |||
| 0d831c4ca6 | |||
| 96d0cc6fc4 | |||
| c7d213bf35 | |||
| b5934085da | |||
| 3657d83833 | |||
| 94a1422a35 | |||
| 5bc9753766 | |||
| a838732e5a | |||
| 4b88defdab | |||
| bc228980af | |||
| e0b4a3f56c | |||
| 840473362d | |||
| 630a7a16c9 | |||
| f5de3a2c29 | |||
| c5767ca272 | |||
| a7f96c030f | |||
| 5a7c49a454 | |||
| cda249e9b4 | |||
| 52c7831bf6 | |||
| 5122c73aa8 | |||
| 5f08978827 | |||
| 2135a5bd03 | |||
| 199f988b2e | |||
| 0f1d49bf63 | |||
| c7f5188354 | |||
| 6a1c60b2e2 | |||
| 2aa330786a | |||
| b65b3ed8f1 | |||
| f9ec86436a | |||
| 42999ffa2c | |||
| e060af380e | |||
| b55ac0bf81 | |||
| 6b5d57675e | |||
| c225d8484f | |||
| 15c8f1f403 | |||
| 52f9ee9d92 | |||
| d7e11be01f | |||
| 866fe32a8c | |||
| d89dd24f0a | |||
| 6a304e2fc2 | |||
| b2da70897a | |||
| 12ad2b163a | |||
| 1e8e738eda | |||
| 7f7fce1fc3 | |||
| a2d9e910ff | |||
| 29faf7932e | |||
| 0af4cd7f68 | |||
| 0e3c2028d0 | |||
| c2e9c11a9a | |||
| 95503d418c | |||
| 0c2681e699 | |||
| ab87e18ba5 | |||
| 33808d9ba9 | |||
| 1f8a0fdc44 | |||
| 229ca8332d | |||
| 1b449287fd | |||
| 05085b961b | |||
| cfb19b5783 | |||
| 6f1e40754d | |||
| d79678d850 | |||
| 224adf70d4 | |||
| f2105399c5 | |||
| eb8b14602a | |||
| df52f90d46 | |||
| 05e67c73df | |||
| 32e4030442 | |||
| be2f624e3c | |||
| 75ab0c06c6 | |||
| 1410aa588a | |||
| 43b15b2098 | |||
| 004a46cb41 | |||
| 76b1d6c38b | |||
| abe4fed452 | |||
| a35137ed1c | |||
| 7f17fa1fb4 | |||
| 5f7321da58 | |||
| e5646ef80c | |||
| 71b3517003 | |||
| 6f81e1940a | |||
| 98a74f7bb4 | |||
| 5b2a03d416 | |||
| e469b0ddf4 | |||
| 1f02e78b24 | |||
| a235f46b6d | |||
| 05953f857a | |||
| 6538742dfa | |||
| 1f10a6bb3d | |||
| 1df0081479 | |||
| db155e7ad0 | |||
| 6d458dbbc6 | |||
| f7708331d3 | |||
| 873cdb9d31 | |||
| 52b07be19b | |||
| 914820cc07 | |||
| 47d1520d72 | |||
| 8ed22897ea | |||
| 3963648f1a | |||
| 2c711871f5 | |||
| 836f56af41 | |||
| e27375a204 | |||
| 92322349d4 | |||
| 1a79dc483c | |||
| a3a077fa87 | |||
| b15b93eec1 | |||
| 1215a04b7d | |||
| d014bb2fbd | |||
| dff791b51f | |||
| ce46e42ff7 | |||
| cf1b60c996 | |||
| d0ca09d4d8 | |||
| c1e66b31a5 | |||
| cb18a3f279 | |||
| b634540047 | |||
| cd116bc3ae | |||
| f839b16dbd | |||
| 70d72d5d02 | |||
| f3a37157fc | |||
| ce5564408d | |||
| 41854fe5bd | |||
| d5265619f3 | |||
| e793a5fcc1 | |||
| 362d85773a | |||
| c55caaf79b | |||
| 257088d7d9 | |||
| ff2844884d | |||
| 56a38accc8 | |||
| de1e65a642 | |||
| 2d516cf1eb | |||
| e261d93ce5 | |||
| c1e7fc91e4 | |||
| 56511f9d15 | |||
| 8691076cef | |||
| 2de84e88eb | |||
| 43184a7afe | |||
| 4f647ef529 | |||
| 59be3b5dd5 | |||
| 5bbb093f47 | |||
| 520409f50f | |||
| e677a73f95 | |||
| 096115bd94 | |||
| cb2319cd22 | |||
| 732c3cfbf5 | |||
| 3d2ab17d72 | |||
| 87c18574a8 | |||
| c46dcf817b | |||
| 3f67ee9134 | |||
| 1a2e247c8d | |||
| 6c47c33cab | |||
| a9511f9a4a | |||
| 85c38b0756 | |||
| dd08358f1c | |||
| b6f4b05844 | |||
| c86a0e2339 | |||
| ed126a3630 | |||
| 45cafa5c96 | |||
| d0639558b3 | |||
| 8dccf8414f | |||
| d84fad721d | |||
| ce4d17c293 | |||
| 97e9a83dd2 | |||
| 9db2a9f79c | |||
| 801a27dace | |||
| 00ec7a6d55 | |||
| 9b00a0777a | |||
| 5a765766e3 | |||
| 6ee1835ca5 | |||
| cdbd8a67de | |||
| 8d7bb60c30 | |||
| 21cfa65af2 | |||
| 01d1e5073d | |||
| 5c300f8181 | |||
| 78c5b72ed3 | |||
| 32b3122457 | |||
| 50690540cd | |||
| 71b4ffcecf | |||
| 7f66bcfff7 | |||
| 422e5f1021 | |||
| 68bff3f661 | |||
| b92a09b5ed | |||
| e867aea3b1 | |||
| 32693dad30 | |||
| 723ce93623 | |||
| b05bd3fc1b | |||
| 48acd66c30 | |||
| 861ed483b5 | |||
| 3926ac2985 | |||
| a72a4518d3 | |||
| 255fc2b878 | |||
| dbaf7b45af | |||
| 8e238f9131 | |||
| 88f5a1a0ae | |||
| bc0f5f6089 | |||
| 0793473210 | |||
| 8303a6477b | |||
| 935dd47e34 | |||
| 8fcf34b3a9 | |||
| c18306aed9 | |||
| 9399a61b46 | |||
| 33370fb694 | |||
| 82604481b6 | |||
| 283bf56e9f | |||
| d3bacffd8b | |||
| 670686681d | |||
| 5929fedf91 | |||
| 8b246a63f0 | |||
| 0867655d85 | |||
| 782bc640ff | |||
| 77622e164c | |||
| 6604afd24f | |||
| bf2e3af4eb | |||
| d071a8a190 | |||
| afef38e45e | |||
| ac30d86b6a | |||
| 01cffb947c | |||
| fac5078dbf | |||
| 68ebe76f24 | |||
| c284cc191e | |||
| 90da7f8a21 | |||
| 2b94d5148f | |||
| 2574b9375d | |||
| e368bad7e4 | |||
| 5b6b3682dd | |||
| 92724c6ab0 | |||
| c9ddff46dd | |||
| 5ff8ddca92 | |||
| 85cfdca14c | |||
| 5482c8a52d | |||
| 2a6bbb82a6 | |||
| 24e4ac9284 | |||
| 81d70f18b3 | |||
| 85c9d03b99 | |||
| a147c495af | |||
| 4cf0224d2d | |||
| 7503dc8517 | |||
| 1c04318ecf | |||
| 4b577517a9 | |||
| c52478708f | |||
| 1d7ac19886 | |||
| 0686532be0 | |||
| 911074ae95 | |||
| 555f2ab9bd | |||
| a79077af17 | |||
| 61a4172bd4 | |||
| 589f94e5b2 | |||
| 8254ebbb47 | |||
| ea79fdae46 | |||
| 2013314e4f | |||
| 869c14e2b0 | |||
| cea079a770 | |||
| 9a7e5c7903 | |||
| 279d6a100f | |||
| 7afb72dca8 | |||
| c8cbc5dc96 | |||
| 6fba34d2e4 | |||
| 375b8b2df1 | |||
| b8776d7169 | |||
| 7a1631efa3 | |||
| fe7e0f397f | |||
| 839ca1fa0d | |||
| d51d324977 | |||
| 6f2c39aaf2 | |||
| 2a4a9d1290 | |||
| 29fcd67bf8 | |||
| faa8ac7c13 | |||
| 40e4c12cdd | |||
| 53c68250e4 | |||
| 2749983267 | |||
| 2c93f4a1ed | |||
| 51acdf1d31 | |||
| d21680ed4f | |||
| ca9732ecb2 | |||
| 4132793219 | |||
| d9ce3eeb5c | |||
| 24528ecfeb | |||
| a77a00c70a | |||
| 117f7be9c8 | |||
| bcb45077fb | |||
| 6c39acd54e | |||
| 518aadb9fd | |||
| 0dca331950 | |||
| 4c0fde7621 | |||
| 755fffda0f | |||
| af4cd25f47 | |||
| 59a500b879 | |||
| 71b5acee30 | |||
| 031e4ee054 | |||
| b6df63c79e | |||
| 6e8eeb984f | |||
| ffc9179cff | |||
| ef491340f6 | |||
| 7f65fda790 | |||
| 5918a03456 | |||
| 0337cc8a22 | |||
| 4d7245d8b0 | |||
| 81ac623fca | |||
| bdd5a41106 | |||
| 60457389a3 | |||
| 8f098e4da1 | |||
| a734cbeaec | |||
| 8f167e7de1 | |||
| 2c1a9be38e | |||
| 01b2ca02ff | |||
| 9d47d06064 | |||
| e7d1c875fd | |||
| d63ac73903 | |||
| 26315e2016 | |||
| 9f85625b83 | |||
| e17c5ad3b0 | |||
| 5f5f0fcf16 | |||
| bacb70e327 | |||
| 0ea5fae69d | |||
| 214872dfbf | |||
| 2b64fb7c7e | |||
| 438757ab54 | |||
| 67f15f12ca | |||
| 992d0e063c | |||
| 428434d98d | |||
| 20b07ddd7d | |||
| c094e78bef | |||
| 57345e8230 | |||
| d5d67102ac | |||
| 30a4071ee0 | |||
| 942ba84ca5 | |||
| ab3c821e59 | |||
| 1953e92b7b | |||
| 9cc625409f | |||
| 558d92fe23 | |||
| 0aef8f9491 | |||
| e275f4632d | |||
| 5166916926 | |||
| 56859dbe32 | |||
| 1272e427a1 | |||
| e396c895dc | |||
| f7d0889bbc | |||
| 9b84f3a1b9 | |||
| b1520fcca0 | |||
| bb51d91b76 | |||
| 2c43feec3e | |||
| 825de1d886 | |||
| 7397a2d69f | |||
| 7951ba67e3 | |||
| 2fcacb4313 | |||
| 5e480cd69b | |||
| bfbbac8952 | |||
| bb04864436 | |||
| b87e1b4a02 | |||
| 185ed49fe2 | |||
| a30face86a | |||
| 188376b75a | |||
| 6a98d9b0bd | |||
| a562a8e2ed | |||
| 215c4046d1 | |||
| d30c93989c | |||
| 4554f92fb4 | |||
| 556ed9b848 | |||
| 344e7e90c2 | |||
| c64bf16c8b | |||
| ce5ef3b77d | |||
| 8f47ee3679 | |||
| f15f229a09 | |||
| da12cdddd4 | |||
| 0bb47812e5 | |||
| 203e17ce87 | |||
| 32a417d6d1 | |||
| 2de7127f02 | |||
| a5a9688599 | |||
| ad49fc89d3 | |||
| 6329a8a0da | |||
| 69b793cbb8 | |||
| f8f01a0987 | |||
| 4ce52a7af6 | |||
| ca0471799b | |||
| 0febd3d644 | |||
| c085730ca5 | |||
| 538edb907d | |||
| a854a4ec02 | |||
| ec098c6d39 | |||
| 17bac07a86 | |||
| 63da6bb1bd | |||
| e847ecd54c | |||
| 51ee4a3f54 | |||
| 8c4156948e | |||
| 89f8bb32d0 | |||
| e0a7eb8972 | |||
| 12da5a4704 | |||
| 749b5e3b9c | |||
| e74e2226d5 | |||
| 5a1534e51e | |||
| 22451300b1 | |||
| a50e59ab1d | |||
| 6d86eab021 | |||
| 6b306eecf2 | |||
| 90857fae0a | |||
| b37ebb81f1 | |||
| e37b8e1265 | |||
| 3ef4029c4a | |||
| cb189e3de4 | |||
| 4fee8e1d07 | |||
| 64298f09c1 | |||
| 60bfa28ef0 | |||
| 8c5a2ef611 | |||
| db1ffb5918 | |||
| fe39a75e6e | |||
| 1d33b29c27 | |||
| 831edfc8c6 | |||
| c3ab279d7d | |||
| 6820b63d56 | |||
| fdc099c960 | |||
| afb531da59 | |||
| 7c867f1788 | |||
| a3415095a7 | |||
| 3e3bebf3e0 | |||
| 99a21324db | |||
| 92d5801f63 | |||
| 551421818b | |||
| de1373deae | |||
| f9f9706900 | |||
| 74105f0fde | |||
| 72c667d790 | |||
| 82ed5b3e0b | |||
| b489abba6b | |||
| 203a20592a | |||
| 7059d3a8d0 | |||
| 289448d196 | |||
| c557656040 | |||
| 7189998699 | |||
| 00c9042c15 | |||
| d3c986e1f2 | |||
| 2f61bd8e57 | |||
| 61ad4119cf | |||
| ab31006d01 | |||
| 584a38c7a7 | |||
| 1d817c7f50 | |||
| b327bb074e | |||
| 8ed2b0df7f | |||
| a21ac83f21 | |||
| 13e15cc7c4 | |||
| ad6e168303 | |||
| 73627b1b2d | |||
| 1219b42046 | |||
| ba2470aefe | |||
| 8543c3ab6d | |||
| 9a16ceefbb | |||
| 549595c1c6 | |||
| 8125ea2e3b | |||
| 3e4270729b | |||
| 9aa156eb2a | |||
| 4e3ae3b378 | |||
| 339f85c27b | |||
| aff7fa309c | |||
| cb02041b3b | |||
| e8ecc037c9 | |||
| 652531ac63 | |||
| a8ffd36150 | |||
| 143a021067 | |||
| 487e1a38aa | |||
| e70d637ade | |||
| 8fa4ab24fb | |||
| 14810708f0 | |||
| 3e907d8e43 | |||
| bf457d94ce | |||
| e045ca5a49 | |||
| 84a50111c0 | |||
| 3fae717912 | |||
| 995dd96117 | |||
| c911e79d88 | |||
| f507c63069 | |||
| 723d2c8354 | |||
| 9ab813e74d | |||
| 69576f7e5b | |||
| 3a66c90f34 | |||
| 5481db63df | |||
| 5f2697540a | |||
| 8e4b3d15a4 | |||
| cbe135ed0d | |||
| 73dd006bb2 | |||
| fea09ce93c | |||
| 9fa588fef8 | |||
| f45a5fd0a7 | |||
| 25001fd3e4 | |||
| 3d98ec35e7 | |||
| 5bbe8959f4 | |||
| 3bb001c374 | |||
| c32926cc34 | |||
| 54fb92a780 | |||
| 27d6740dc5 | |||
| cab7c605dc | |||
| e77baffa10 | |||
| 179c4eaff5 | |||
| 32b6da5aca | |||
| 564a2fb9b2 | |||
| 25cc643f34 | |||
| daa0a9b4e6 | |||
| 97931c3766 | |||
| f796a85617 | |||
| 50120e0f9b | |||
| 8715a6171a | |||
| baa77c68e0 | |||
| b176d2ff6f | |||
| 7db0e8da1d | |||
| 754fc91376 | |||
| fb9d8f3b9a | |||
| cad8877b7a | |||
| eae82128ee | |||
| 9f188f6313 | |||
| 6b1afc975e | |||
| 49e883827e | |||
| 2900a40b38 | |||
| a59b687b62 | |||
| 8ba4b4d2c1 | |||
| eeaf061932 | |||
| c3101aa995 | |||
| ec3ec7a26f | |||
| 19b7a0c00c | |||
| 9e6eac557f | |||
| 1623994287 | |||
| fbc6da1a8f | |||
| 49981f2f3e | |||
| 4009fe0899 | |||
| dd8783c8c1 | |||
| 9562b8a280 | |||
| e83856baa9 | |||
| bb690e12c2 | |||
| 41237dccfd | |||
| 6067adc120 | |||
| 275dde61ae | |||
| ab36e6b613 | |||
| a131c761e5 | |||
| 380c34aa66 | |||
| 7f40cfdab0 | |||
| 6db480b91d | |||
| 02e2a20aff | |||
| 1bcedcee0f | |||
| ad405d130e | |||
| c83526a6a4 |
Generated
+1465
-16
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/fidc-core",
|
||||
"crates/bt-demo",
|
||||
"crates/fidc-signal-client",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -12,10 +12,14 @@ version = "0.1.0"
|
||||
authors = ["OpenAI Codex"]
|
||||
|
||||
[workspace.dependencies]
|
||||
rust_decimal = { version = "=1.39.0", features = ["serde-with-str"] }
|
||||
sha2 = "=0.10.9"
|
||||
ahash = "=0.8.12"
|
||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rayon = "=1.12.0"
|
||||
rhai = { version = "=1.23.6", features = ["sync"] }
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
serde_json = "=1.0.145"
|
||||
serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
|
||||
thiserror = "=2.0.18"
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
|
||||
|
||||
# fidc-backtest-engine
|
||||
|
||||
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
||||
|
||||
## Runtime position exposure schedule
|
||||
|
||||
`strategy_spec.runtimeExpressions.risk.positionExposureSchedule` accepts dated
|
||||
`effectiveDate` plus `targetExposureBps` points. The platform expression strategy
|
||||
uses the latest point whose date is not later than the current execution date and
|
||||
otherwise keeps the strategy's normal `exposureExpr`. This contract is intended for
|
||||
audited runtime controls replayed by paper/live shadow reconciliation; it is not a
|
||||
market-data signal and does not change selection, pricing, fees, or execution-day
|
||||
risk checks.
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 日频、分钟、tick 级策略生命周期与确定性回放。
|
||||
- 日频和分钟执行价策略生命周期与确定性回放。
|
||||
- A 股行情、估值、因子、基准、候选资格、涨跌停触达、停牌和 ST 标记。
|
||||
- 平台策略 DSL 与 `StrategyContext` 数据 API,不暴露非平台脚本语法。
|
||||
- `BacktestConfig` 支持起止日期、初始资金、决策滞后、执行价格字段、基准代码。
|
||||
@@ -14,6 +26,7 @@
|
||||
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
|
||||
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
|
||||
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
|
||||
- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。
|
||||
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
|
||||
|
||||
## Workspace 布局
|
||||
@@ -22,7 +35,6 @@
|
||||
.
|
||||
├── Cargo.toml
|
||||
├── crates
|
||||
│ ├── bt-demo
|
||||
│ └── fidc-core
|
||||
│ └── src
|
||||
│ ├── broker.rs
|
||||
@@ -37,7 +49,6 @@
|
||||
│ ├── scheduler.rs
|
||||
│ ├── strategy.rs
|
||||
│ └── strategy_ai.rs
|
||||
├── data/demo
|
||||
└── docs
|
||||
```
|
||||
|
||||
@@ -51,7 +62,7 @@
|
||||
- `futures`: 期货账户、合约参数、保证金、手续费和多空持仓。
|
||||
- `rules`: 中国市场交易规则和风控校验。
|
||||
- `broker`: 股票撮合、订单簿、滑点、成交量约束、限价和显式订单执行。
|
||||
- `scheduler`: 日、周、月、分钟、tick 调度规则。
|
||||
- `scheduler`: 日、周、月和分钟调度规则。
|
||||
- `platform_expr_strategy`: 平台 DSL 解析后的表达式策略执行模型。
|
||||
- `strategy`: 策略 trait、内置策略和运行时视图。
|
||||
- `strategy_ai`: 策略 AI 手册、提示词生成和数据库字段目录合并。
|
||||
@@ -76,12 +87,22 @@
|
||||
- `selection.market_cap_band(...)` 动态市值带。
|
||||
- `filter.stock_expr(...)` 任意指标、因子和组合选股。
|
||||
- `ordering.rank_by(...)` 与 `ordering.rank_expr(...)` 排序。
|
||||
- `allocation.buy_scale(...)` 动态买入资金比例。
|
||||
- `allocation.buy_scale(...)` 相对等权槽位的个股资金倍率;显式权重可以大于 `1.0`,组合总仓位仍由 `risk.index_exposure(...)` 和严格资金预算控制。
|
||||
- `risk.stop_loss(...)`、`risk.take_profit(...)` 多条件止盈止损。
|
||||
- `order.*`、`cancel.*`、`update_universe(...)`、`subscribe(...)` 显式交易动作。
|
||||
|
||||
任意数据库指标和自定义因子通过 `factor("field")`、`factor_value("field", lookback)`、`rolling_mean("field", n)`、`sma("close", n)` 等函数读取。未预计算的均线窗口可在回测中按已有历史数据实时计算。
|
||||
|
||||
Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。
|
||||
|
||||
盘后固定价格不是策略类型,也不是 `matchingType`。自 2026-07-06 起,只有实际同日提交时间落在 15:00–15:30 的普通委托才由 broker 进入盘后固定价格执行阶段;15:00–15:04 的委托等待到 15:05,15:05–15:30 按官方收盘价和真实盘后成交量撮合,不叠加滑点,未成交余量不跨日。窗口外委托继续沿用连续竞价、当前收盘或下一交易日开盘合同;`next_bar_open` 策略即使在 15:00 生成信号,也不得被改写为同日盘后委托。缺失盘后行情时必须明确不成交,禁止回退全天成交量或 15:00 前分钟行情。
|
||||
|
||||
分钟回放使用行情时间戳与策略定时事件的有序合并时钟。`OnDay`、`Bar` 或 `Minute` 阶段只要声明显式 `physical_time`,就必须在各自真实分钟进入同一时间轴,即使该分钟没有预加载行情也必须触发,并由执行层按需查询该时点或之前最新有效价格;同一时间戳只形成一个事件,scheduler 回调先于 `on_minute`。日线 `current_bar_close` 无显式时间时使用官方日收盘,有显式时间时使用该触发点的 `Last` 行情,禁止读取下一分钟;多个时间点分别执行,禁止压成最后一个时间、把早盘单改成盘后单或依赖已有 BAR 才触发。`next_bar_open` 的 T 日信号时钟继续留在粗粒度决策阶段,不能延迟到 T+1 的同名分钟。
|
||||
|
||||
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
||||
|
||||
`targetPortfolioDaily=true` 时,每只股票的默认目标金额固定为 `target_budget / selection_limit * buy_scale`,候选不足、缺行情或风控拒绝产生的剩余资金保留为现金,不得自动归一到满仓。止盈、止损或最大持有期触发后,标的从活动目标顺序移除;卖出未完成时继续占用仓位槽且不得反向补买,成功释放的槽位只能由同一决策时点已排序且通过策略条件的后续候选补充。只有显式设置 `redistributeTargetWeightsAfterExit=true` 才在可用目标间重新分配权重,只有显式设置 `reenterExitedTargets=true` 才保留退出标的供后续重新入场;两个开关互相独立,默认都为 `false`。
|
||||
|
||||
## 内置微盘策略
|
||||
|
||||
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
||||
@@ -96,45 +117,9 @@
|
||||
|
||||
## 运行方式
|
||||
|
||||
默认运行仓库 demo 数据:
|
||||
`fidc-backtest-engine` 不再维护本地 CSV demo、partitioned snapshot 目录或导出融合表作为运行入口。生产和集成回测由 `fidc-backtest-service` runner 创建 `DataSet`,数据来自 Strategy Factory Source Lake 的 Arrow/Parquet、manifest/data_epoch 缓存和运行时逻辑视图。
|
||||
|
||||
```bash
|
||||
cargo run --bin bt-demo
|
||||
```
|
||||
|
||||
运行平台内置微盘策略:
|
||||
|
||||
```bash
|
||||
FIDC_BT_STRATEGY=omni-microcap \
|
||||
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
|
||||
cargo run --release --bin bt-demo
|
||||
```
|
||||
|
||||
接入真实分区 snapshot 目录:
|
||||
|
||||
```bash
|
||||
FIDC_BT_DATA_LAYOUT=partitioned \
|
||||
FIDC_BT_DATA_DIR=/path/to/snapshots \
|
||||
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
|
||||
cargo run --bin bt-demo
|
||||
```
|
||||
|
||||
约定目录结构:
|
||||
|
||||
```text
|
||||
snapshots/
|
||||
├── instruments.csv
|
||||
├── benchmark/YYYY/MM/*.csv
|
||||
├── market/YYYY/MM/*.csv
|
||||
├── factors/YYYY/MM/*.csv
|
||||
└── candidates/YYYY/MM/*.csv
|
||||
```
|
||||
|
||||
运行后默认生成:
|
||||
|
||||
- `output/demo/equity_curve.csv`
|
||||
- `output/demo/trades.csv`
|
||||
- `output/demo/holdings_summary.csv`
|
||||
本仓库只保留核心库构建和测试入口:
|
||||
|
||||
## 测试与构建
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# FiPanda 品牌资源
|
||||
|
||||
FIDC 系列共用用户提供的两张原始透明图片,不重绘、不裁切、不染色。
|
||||
|
||||
- `fiPandaWithTitle.png`:带标题版,登录页、项目首页、独立报告使用。
|
||||
- `fipanda.png`:无标题版,顶部导航、小尺寸标识和系统图标使用。
|
||||
- `favicon-32.png`:由无标题版等比生成,供浏览器标签页使用。
|
||||
|
||||
导航图标建议 28–42px;带标题版建议 96–160px。始终等比完整显示,保留透明背景,不为 Logo 增加大块容器或改变业务页面主题。项目名称、权限、交易合同与内部 FIDC 标识不因品牌图片变更而重命名。
|
||||
|
||||
原图保存在 `omniquant/assets/brand/source/`;`manifest.json` 记录原图和显示资源的 SHA-256。需要更新时,在完整工作区运行 `node omniquant/scripts/sync-fipanda-branding.mjs`,统一生成八个仓库及可下载策略技能包的资源,禁止分别维护不同版本。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema": "fidc.fipanda-brand/v1",
|
||||
"brand": "FiPanda",
|
||||
"sourceRepository": "omniquant",
|
||||
"sources": [
|
||||
{
|
||||
"file": "fipanda.png",
|
||||
"sha256": "ff7dc0677b7d9c8dc6d008d3391f27fd583e3de35560dab021c1e2f8f2c464e5"
|
||||
},
|
||||
{
|
||||
"file": "fiPandaWithTitle.png",
|
||||
"sha256": "01bfb6acb36830d9b1ce6f7dc8c718977d43af44572ee6a26991d6c6aa2847d6"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"file": "fipanda.png",
|
||||
"source": "fipanda.png",
|
||||
"pixels": 192,
|
||||
"usage": "navigation, compact icons, apple-touch-icon",
|
||||
"sha256": "450aad3506ec3c67624b7705f4d8f851a9102fe950e7671a06fb1203dc1d9cf7"
|
||||
},
|
||||
{
|
||||
"file": "fiPandaWithTitle.png",
|
||||
"source": "fiPandaWithTitle.png",
|
||||
"pixels": 384,
|
||||
"usage": "login, project README, standalone reports",
|
||||
"sha256": "d33846b3687ea69d43b4a1f17ceeb02662bd25f26fabc4c86c25b3475f20dede"
|
||||
},
|
||||
{
|
||||
"file": "favicon-32.png",
|
||||
"source": "fipanda.png",
|
||||
"pixels": 32,
|
||||
"usage": "browser tab icon",
|
||||
"sha256": "ec3d7783457db284d46ae6e27833685203838637a7d68946ff85402725f8e420"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, ChinaAShareCostModel,
|
||||
ChinaEquityRuleHooks, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DailyEquityPoint,
|
||||
DataSet, FillEvent, HoldingSummary, OmniMicroCapConfig, OmniMicroCapStrategy, PortfolioState,
|
||||
PriceField, Strategy, StrategyContext,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let root = workspace_root();
|
||||
let data_dir = std::env::var("FIDC_BT_DATA_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| root.join("data/demo"));
|
||||
let data_layout = std::env::var("FIDC_BT_DATA_LAYOUT").unwrap_or_else(|_| "flat".to_string());
|
||||
let output_dir = std::env::var("FIDC_BT_OUTPUT_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| root.join("output/demo"));
|
||||
let json_output = std::env::var("FIDC_BT_JSON")
|
||||
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let data = if data_layout == "partitioned" {
|
||||
DataSet::from_partitioned_dir(&data_dir)?
|
||||
} else {
|
||||
DataSet::from_csv_dir(&data_dir)?
|
||||
};
|
||||
let strategy_name =
|
||||
std::env::var("FIDC_BT_STRATEGY").unwrap_or_else(|_| "cn-smallcap-rotation".to_string());
|
||||
let debug_date = std::env::var("FIDC_BT_DEBUG_DATE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||
.transpose()?;
|
||||
let decision_lag = std::env::var("FIDC_BT_DECISION_LAG")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok());
|
||||
let execution_price =
|
||||
std::env::var("FIDC_BT_EXECUTION_PRICE")
|
||||
.ok()
|
||||
.map(|value| match value.as_str() {
|
||||
"close" => PriceField::Close,
|
||||
"last" => PriceField::Last,
|
||||
_ => PriceField::Open,
|
||||
});
|
||||
let initial_cash = std::env::var("FIDC_BT_INITIAL_CASH")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<f64>().ok());
|
||||
let start_date = std::env::var("FIDC_BT_START_DATE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||
.transpose()?;
|
||||
let end_date = std::env::var("FIDC_BT_END_DATE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||
.transpose()?;
|
||||
let mut config = BacktestConfig {
|
||||
initial_cash: initial_cash.unwrap_or(1_000_000.0),
|
||||
benchmark_code: data.benchmark_code().to_string(),
|
||||
start_date,
|
||||
end_date,
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
let result = match strategy_name.as_str() {
|
||||
"cn-smallcap-rotation" | "cn-dyn-smallcap-band" => {
|
||||
let mut strategy_cfg = if strategy_name == "cn-dyn-smallcap-band" {
|
||||
CnSmallCapRotationConfig::cn_dyn_smallcap_band()
|
||||
} else {
|
||||
CnSmallCapRotationConfig::demo()
|
||||
};
|
||||
if strategy_cfg.strategy_name == "cn-smallcap-rotation" {
|
||||
strategy_cfg.base_index_level = 3000.0;
|
||||
strategy_cfg.base_cap_floor = 38.0;
|
||||
strategy_cfg.cap_span = 25.0;
|
||||
}
|
||||
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
|
||||
if !signal_symbol.trim().is_empty() {
|
||||
strategy_cfg.signal_symbol = Some(signal_symbol);
|
||||
}
|
||||
}
|
||||
config.decision_lag_trading_days = decision_lag.unwrap_or(1);
|
||||
config.execution_price_field = execution_price.unwrap_or(PriceField::Open);
|
||||
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
config.execution_price_field,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||
engine.run()?
|
||||
}
|
||||
"aiquant-v104" => {
|
||||
let mut strategy_cfg = OmniMicroCapConfig::aiquant_v104();
|
||||
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
|
||||
if !signal_symbol.trim().is_empty() {
|
||||
strategy_cfg.benchmark_signal_symbol = signal_symbol;
|
||||
}
|
||||
}
|
||||
if let Some(date) = debug_date {
|
||||
let eligible = data.eligible_universe_on(date);
|
||||
eprintln!(
|
||||
"DEBUG eligible_universe_on {} count={}",
|
||||
date,
|
||||
eligible.len()
|
||||
);
|
||||
for row in eligible.iter().take(20) {
|
||||
eprintln!(" {} {:.6}", row.symbol, row.market_cap_bn);
|
||||
}
|
||||
let mut debug_strategy = OmniMicroCapStrategy::new(strategy_cfg.clone());
|
||||
let debug_subscriptions = BTreeSet::new();
|
||||
let decision = debug_strategy.on_day(&StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 1,
|
||||
data: &data,
|
||||
portfolio: &PortfolioState::new(20_000.0),
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &debug_subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
})?;
|
||||
eprintln!("DEBUG notes={:?}", decision.notes);
|
||||
eprintln!("DEBUG diagnostics={:?}", decision.diagnostics);
|
||||
return Ok(());
|
||||
}
|
||||
config.decision_lag_trading_days = decision_lag.unwrap_or(1);
|
||||
config.execution_price_field = execution_price.unwrap_or(PriceField::Close);
|
||||
config.initial_cash = initial_cash.unwrap_or(20_000.0);
|
||||
let strategy = OmniMicroCapStrategy::new(strategy_cfg);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
config.execution_price_field,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||
engine.run()?
|
||||
}
|
||||
_ => {
|
||||
let mut strategy_cfg = OmniMicroCapConfig::omni_microcap();
|
||||
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
|
||||
if !signal_symbol.trim().is_empty() {
|
||||
strategy_cfg.benchmark_signal_symbol = signal_symbol;
|
||||
}
|
||||
}
|
||||
if let Some(date) = debug_date {
|
||||
let eligible = data.eligible_universe_on(date);
|
||||
eprintln!(
|
||||
"DEBUG eligible_universe_on {} count={}",
|
||||
date,
|
||||
eligible.len()
|
||||
);
|
||||
for row in eligible.iter().take(20) {
|
||||
eprintln!(" {} {:.6}", row.symbol, row.market_cap_bn);
|
||||
}
|
||||
let mut debug_strategy = OmniMicroCapStrategy::new(strategy_cfg.clone());
|
||||
let debug_subscriptions = BTreeSet::new();
|
||||
let decision = debug_strategy.on_day(&StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 1,
|
||||
data: &data,
|
||||
portfolio: &PortfolioState::new(10_000_000.0),
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &debug_subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
})?;
|
||||
eprintln!("DEBUG notes={:?}", decision.notes);
|
||||
eprintln!("DEBUG diagnostics={:?}", decision.diagnostics);
|
||||
return Ok(());
|
||||
}
|
||||
config.decision_lag_trading_days = decision_lag.unwrap_or(0);
|
||||
config.execution_price_field = execution_price.unwrap_or(PriceField::Last);
|
||||
config.initial_cash = initial_cash.unwrap_or(10_000_000.0);
|
||||
let strategy = OmniMicroCapStrategy::new(strategy_cfg);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
config.execution_price_field,
|
||||
)
|
||||
.with_intraday_execution_start_time(
|
||||
NaiveTime::parse_from_str("10:18:00", "%H:%M:%S").expect("valid 10:18:00"),
|
||||
)
|
||||
.with_volume_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||
engine.run()?
|
||||
}
|
||||
};
|
||||
|
||||
write_equity_curve_csv(&output_dir.join("equity_curve.csv"), &result.equity_curve)?;
|
||||
write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?;
|
||||
write_holdings_csv(
|
||||
&output_dir.join("holdings_summary.csv"),
|
||||
&result.holdings_summary,
|
||||
)?;
|
||||
|
||||
let summary = build_summary(
|
||||
&result.strategy_name,
|
||||
&result.equity_curve,
|
||||
&result.fills,
|
||||
&result.holdings_summary,
|
||||
result.benchmark_series.last(),
|
||||
&output_dir,
|
||||
);
|
||||
|
||||
print_summary(&summary, &result.equity_curve, &result.holdings_summary);
|
||||
println!("Artifacts written under {}", output_dir.display());
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string(&summary)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box<dyn Error>> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"date,cash,market_value,total_equity,benchmark_close,benchmark_prev_close,notes,diagnostics"
|
||||
)?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{}",
|
||||
row.date,
|
||||
row.cash,
|
||||
row.market_value,
|
||||
row.total_equity,
|
||||
row.benchmark_close,
|
||||
row.benchmark_prev_close,
|
||||
sanitize_csv_field(&row.notes),
|
||||
sanitize_csv_field(&row.diagnostics),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_trades_csv(path: &Path, rows: &[FillEvent]) -> Result<(), Box<dyn Error>> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"date,symbol,side,quantity,price,gross_amount,commission,stamp_tax,net_cash_flow,reason"
|
||||
)?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2},{}",
|
||||
row.date,
|
||||
row.symbol,
|
||||
row.side,
|
||||
row.quantity,
|
||||
row.price,
|
||||
row.gross_amount,
|
||||
row.commission,
|
||||
row.stamp_tax,
|
||||
row.net_cash_flow,
|
||||
sanitize_csv_field(&row.reason),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_holdings_csv(path: &Path, rows: &[HoldingSummary]) -> Result<(), Box<dyn Error>> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"date,symbol,quantity,average_cost,last_price,market_value,unrealized_pnl,realized_pnl"
|
||||
)?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2}",
|
||||
row.date,
|
||||
row.symbol,
|
||||
row.quantity,
|
||||
row.average_cost,
|
||||
row.last_price,
|
||||
row.market_value,
|
||||
row.unrealized_pnl,
|
||||
row.realized_pnl,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_csv_field(text: &str) -> String {
|
||||
text.replace(',', ";")
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct RunSummary {
|
||||
strategy: String,
|
||||
start_date: String,
|
||||
end_date: String,
|
||||
start_equity: f64,
|
||||
final_equity: f64,
|
||||
total_return: f64,
|
||||
trade_count: usize,
|
||||
holding_count: usize,
|
||||
benchmark_code: Option<String>,
|
||||
benchmark_last_close: Option<f64>,
|
||||
output_dir: String,
|
||||
diagnostics: serde_json::Value,
|
||||
warnings: Vec<String>,
|
||||
equity_preview: Vec<serde_json::Value>,
|
||||
trades_preview: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn build_summary(
|
||||
strategy_name: &str,
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
holdings: &[HoldingSummary],
|
||||
benchmark_last: Option<&BenchmarkSnapshot>,
|
||||
output_dir: &Path,
|
||||
) -> RunSummary {
|
||||
let first = equity_curve.first();
|
||||
let last = equity_curve.last();
|
||||
let start_equity = first.map(|row| row.total_equity).unwrap_or_default();
|
||||
let final_equity = last.map(|row| row.total_equity).unwrap_or_default();
|
||||
let total_return = if start_equity.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(final_equity / start_equity) - 1.0
|
||||
};
|
||||
|
||||
let diagnostics = extract_diagnostics(equity_curve);
|
||||
let warnings = build_warnings(fills, holdings, &diagnostics);
|
||||
let equity_preview = equity_curve
|
||||
.iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"date": row.date.to_string(),
|
||||
"cash": row.cash,
|
||||
"marketValue": row.market_value,
|
||||
"totalEquity": row.total_equity,
|
||||
"benchmarkClose": row.benchmark_close,
|
||||
"benchmarkPrevClose": row.benchmark_prev_close,
|
||||
"notes": row.notes,
|
||||
"diagnostics": row.diagnostics,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let trades_preview = fills
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"date": row.date.to_string(),
|
||||
"symbol": row.symbol,
|
||||
"side": format!("{:?}", row.side),
|
||||
"quantity": row.quantity,
|
||||
"price": row.price,
|
||||
"grossAmount": row.gross_amount,
|
||||
"netCashFlow": row.net_cash_flow,
|
||||
"reason": row.reason,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
RunSummary {
|
||||
strategy: strategy_name.to_string(),
|
||||
start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
|
||||
end_date: last.map(|row| row.date.to_string()).unwrap_or_default(),
|
||||
start_equity,
|
||||
final_equity,
|
||||
total_return,
|
||||
trade_count: fills.len(),
|
||||
holding_count: holdings.len(),
|
||||
benchmark_code: benchmark_last.map(|row| row.benchmark.clone()),
|
||||
benchmark_last_close: benchmark_last.map(|row| row.close),
|
||||
output_dir: output_dir.display().to_string(),
|
||||
diagnostics,
|
||||
warnings,
|
||||
equity_preview,
|
||||
trades_preview,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
|
||||
let last = equity_curve.last();
|
||||
let text = last.map(|row| row.diagnostics.as_str()).unwrap_or("");
|
||||
let notes = last.map(|row| row.notes.as_str()).unwrap_or("");
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("latestText".to_string(), json!(text));
|
||||
map.insert("latestNotes".to_string(), json!(notes));
|
||||
map.insert("equityPointCount".to_string(), json!(equity_curve.len()));
|
||||
|
||||
for part in text.split(" | ") {
|
||||
let part = part.trim();
|
||||
if let Some(rest) = part.strip_prefix("selection_diag ") {
|
||||
for token in rest.split_whitespace() {
|
||||
if let Some((k, v)) = token.split_once('=') {
|
||||
map.insert(k.to_string(), parse_diag_value(v));
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) = part.strip_prefix("selection_band ") {
|
||||
for token in rest.split_whitespace() {
|
||||
if let Some((k, v)) = token.split_once('=') {
|
||||
map.insert(k.to_string(), parse_diag_value(v));
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) =
|
||||
part.strip_prefix("market_cap_missing likely blocks selection; sample=")
|
||||
{
|
||||
map.insert(
|
||||
"marketCapMissingSample".to_string(),
|
||||
json!(
|
||||
rest.split('|')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
} else if let Some(rest) = part.strip_prefix("selection_rejections sample=") {
|
||||
map.insert(
|
||||
"selectionRejectionsSample".to_string(),
|
||||
json!(
|
||||
rest.split(" | ")
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
} else if let Some(rest) = part.strip_prefix("ma_filter_rejections sample=") {
|
||||
map.insert(
|
||||
"maFilterRejectionsSample".to_string(),
|
||||
json!(
|
||||
rest.split('|')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
} else if let Some(rest) = part.strip_prefix("selected=") {
|
||||
map.insert("selectedLine".to_string(), json!(rest));
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
fn parse_diag_value(value: &str) -> serde_json::Value {
|
||||
if let Ok(v) = value.parse::<i64>() {
|
||||
return json!(v);
|
||||
}
|
||||
if let Ok(v) = value.parse::<f64>() {
|
||||
return json!(v);
|
||||
}
|
||||
json!(value)
|
||||
}
|
||||
|
||||
fn build_warnings(
|
||||
fills: &[FillEvent],
|
||||
holdings: &[HoldingSummary],
|
||||
diagnostics: &serde_json::Value,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if fills.is_empty() {
|
||||
warnings.push("本次回测没有产生任何成交。".to_string());
|
||||
}
|
||||
if holdings.is_empty() {
|
||||
warnings.push("期末没有持仓。".to_string());
|
||||
}
|
||||
let selected_after_ma_is_empty = diagnostics
|
||||
.get("selected_after_ma")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
== 0;
|
||||
if selected_after_ma_is_empty && fills.is_empty() && holdings.is_empty() {
|
||||
warnings
|
||||
.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string());
|
||||
}
|
||||
if diagnostics
|
||||
.get("market_cap_missing_count")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
{
|
||||
warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn print_summary(
|
||||
summary: &RunSummary,
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
holdings: &[HoldingSummary],
|
||||
) {
|
||||
if equity_curve.is_empty() {
|
||||
println!("No equity curve points generated.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Strategy: {}", summary.strategy);
|
||||
println!("Start equity: {:.2}", summary.start_equity);
|
||||
println!("Final equity: {:.2}", summary.final_equity);
|
||||
println!("Total return: {:.2}%", summary.total_return * 100.0);
|
||||
println!("Trades: {}", summary.trade_count);
|
||||
println!("Final holdings: {}", summary.holding_count);
|
||||
|
||||
if let (Some(code), Some(close)) = (&summary.benchmark_code, summary.benchmark_last_close) {
|
||||
println!("Benchmark last close: {} {:.2}", code, close);
|
||||
}
|
||||
|
||||
println!("Recent equity points:");
|
||||
for point in equity_curve
|
||||
.iter()
|
||||
.rev()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
{
|
||||
println!(
|
||||
" {} equity {:.2} cash {:.2} mv {:.2}",
|
||||
point.date, point.total_equity, point.cash, point.market_value
|
||||
);
|
||||
}
|
||||
|
||||
if holdings.is_empty() {
|
||||
println!("No holdings at the end of the demo run.");
|
||||
} else {
|
||||
println!("Ending holdings:");
|
||||
for holding in holdings {
|
||||
println!(
|
||||
" {} qty {} mv {:.2} pnl {:.2}",
|
||||
holding.symbol, holding.quantity, holding.market_value, holding.unrealized_pnl
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,15 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
compact_str = { version = "=0.10.0", features = ["serde"] }
|
||||
rust_decimal.workspace = true
|
||||
ahash.workspace = true
|
||||
chrono.workspace = true
|
||||
indexmap.workspace = true
|
||||
rayon.workspace = true
|
||||
rhai.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use fidc_core::factor_events::{self, Expr, Frame};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, Read};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
expressions: std::collections::BTreeMap<String, Expr>,
|
||||
frame: Frame,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut input = String::new();
|
||||
io::stdin().read_to_string(&mut input)?;
|
||||
let output = if input.trim().is_empty() {
|
||||
factor_events::catalog()
|
||||
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
|
||||
let value:Value=serde_json::from_str(&input)?;
|
||||
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
|
||||
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
|
||||
} else {
|
||||
let request: Request = serde_json::from_str(&input)?;
|
||||
let results = request
|
||||
.expressions
|
||||
.iter()
|
||||
.map(|(id, expr)| {
|
||||
let result = match factor_events::evaluate(expr, &request.frame) {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e}),
|
||||
};
|
||||
(id.clone(), result)
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<String, Value>>();
|
||||
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::io::{self, Read};
|
||||
fn main() {
|
||||
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
|
||||
let request=serde_json::from_str(&input).unwrap();
|
||||
match fidc_core::market_event_context::aggregate(request) {
|
||||
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
|
||||
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::io::Read;
|
||||
fn main() {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
|
||||
let spec: fidc_core::daily_patterns::PatternSpec =
|
||||
serde_json::from_value(value["spec"].clone()).unwrap();
|
||||
let bars: Vec<fidc_core::session_events::MinuteBar> =
|
||||
serde_json::from_value(value["bars"].clone()).unwrap();
|
||||
let result = fidc_core::session_events::evaluate(
|
||||
&spec.validate().unwrap(),
|
||||
value["symbol"].as_str().unwrap(),
|
||||
&bars,
|
||||
serde_json::from_value(value["decision_at"].clone()).unwrap(),
|
||||
);
|
||||
match result {
|
||||
Ok(row) => println!(
|
||||
"{}",
|
||||
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut raw=Vec::new();
|
||||
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
|
||||
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
|
||||
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
|
||||
let version=book.content_sha256()?;
|
||||
let validated=book.validate()?;
|
||||
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
|
||||
"versionSha256":version,"symbols":validated.symbols(),
|
||||
"onlineAllowed":validated.require_observed().is_ok()});
|
||||
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
+7685
-777
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,778 @@
|
||||
use super::*;
|
||||
|
||||
fn time(minute: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
|
||||
}
|
||||
|
||||
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
|
||||
data_with_snapshot(quotes, limit_test_snapshot())
|
||||
}
|
||||
|
||||
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![limit_test_instrument()],
|
||||
vec![snapshot],
|
||||
vec![],
|
||||
vec![limit_test_candidate(true, true)],
|
||||
vec![limit_test_benchmark()],
|
||||
vec![],
|
||||
quotes
|
||||
.iter()
|
||||
.map(|&(minute, price, volume)| {
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.timestamp = quote.date.and_time(time(minute));
|
||||
quote.volume_delta = u64::from(volume);
|
||||
quote.amount_delta = price * f64::from(volume);
|
||||
quote.bid1_volume = u64::from(volume / 100);
|
||||
quote.ask1_volume = u64::from(volume / 100);
|
||||
quote
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BrokerSimulator::new(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0003)
|
||||
.with_minimum_commission(5.),
|
||||
ChinaEquityRuleHooks,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time(0))
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_strict_value_budget(true)
|
||||
}
|
||||
|
||||
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::AlgoValue {
|
||||
symbol: "000001.SZ".into(),
|
||||
value,
|
||||
style,
|
||||
start_time: Some(time(0)),
|
||||
end_time: Some(time(10)),
|
||||
reason: "clock-algorithm".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn step(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
portfolio,
|
||||
data,
|
||||
decision,
|
||||
Some(time(minute)),
|
||||
Some(time(minute)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10.1, 4_000),
|
||||
(5, 10.2, 4_000),
|
||||
(10, 10.3, 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 5, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
assert!(
|
||||
batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
let canonical = |rows: &[crate::events::FillEvent]| {
|
||||
rows.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.commission.to_bits(),
|
||||
fill.stamp_tax.to_bits(),
|
||||
fill.transfer_fee.to_bits(),
|
||||
fill.execution_timestamp,
|
||||
fill.order_id,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
|
||||
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
|
||||
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(5, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
|
||||
let partial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
partial
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
let working = broker.open_order_views();
|
||||
assert_eq!(working[0].order_id, 1);
|
||||
assert_eq!(working[0].filled_quantity, 100);
|
||||
assert_eq!(
|
||||
working[0].reserved_cash,
|
||||
Some(10_000. + partial.fill_events[0].net_cash_flow)
|
||||
);
|
||||
let cancel = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
3,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "explicit-user-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
broker.next_day_order_expiry(limit_test_snapshot().date),
|
||||
Some(time(10))
|
||||
);
|
||||
let terminal = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(terminal.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
terminal.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(
|
||||
terminal
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.detail.contains("Expired")),
|
||||
"{:?}",
|
||||
terminal.process_events
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(1, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(11_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let other = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
1,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 1_000,
|
||||
reason: "separate-buy".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
other.fill_events.is_empty(),
|
||||
"cash reserved for order 1 was spent: {:?}",
|
||||
other.fill_events
|
||||
);
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.order_id == Some(1))
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
|
||||
assert!(account.cash() >= 1_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
|
||||
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
|
||||
let mut changed = limit_test_snapshot();
|
||||
changed.close = 100.;
|
||||
changed.last_price = 100.;
|
||||
let run = |data: DataSet| {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let initial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert!(initial.fill_events.is_empty());
|
||||
let quantity = broker.open_order_views()[0].requested_quantity;
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
(
|
||||
quantity,
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.net_cash_flow.to_bits(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
run(data("es)),
|
||||
run(data_with_snapshot("es, changed))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
|
||||
let data = data(&[
|
||||
(0, 10., 400),
|
||||
(2, 10., 800),
|
||||
(5, 10., 1_200),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let empty = StrategyDecision::default();
|
||||
let mut filled = 0;
|
||||
let mut commission = 0.;
|
||||
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
filled += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>();
|
||||
commission += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>();
|
||||
assert_eq!(filled, expected);
|
||||
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
|
||||
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
|
||||
}
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(
|
||||
commission,
|
||||
reference
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
|
||||
let broker = broker().with_matching_type(MatchingType::Vwap);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 900,
|
||||
reason: "configured-vwap".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"{:?}",
|
||||
first.order_events
|
||||
);
|
||||
let second = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(second.fill_events[0].quantity, 100);
|
||||
assert_eq!(second.fill_events[0].order_id, Some(1));
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(final_batch.fill_events[0].quantity, 700);
|
||||
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
|
||||
let date = limit_test_snapshot().date;
|
||||
for acquired_today in [false, true] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
if acquired_today {
|
||||
date
|
||||
} else {
|
||||
date.pred_opt().unwrap()
|
||||
},
|
||||
1_000,
|
||||
10.,
|
||||
);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
assert_eq!(
|
||||
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
|
||||
if acquired_today { 0 } else { 1_000 }
|
||||
);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
if !acquired_today {
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
|
||||
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
|
||||
}
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
|
||||
for tif in [
|
||||
OrderTimeInForce::Ioc,
|
||||
OrderTimeInForce::Fok,
|
||||
OrderTimeInForce::Day,
|
||||
OrderTimeInForce::Gtc,
|
||||
] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
if !decision.order_intents[0].supports_time_in_force(tif) {
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let error = broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&decision,
|
||||
Some(time(0)),
|
||||
Some(time(0)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("is not supported for this order intent")
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
continue;
|
||||
}
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let first = step(&broker, &mut account, &data, 0, &decision);
|
||||
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
|
||||
assert_eq!(
|
||||
!broker.open_order_views().is_empty(),
|
||||
persists,
|
||||
"{tif:?}: {:?}",
|
||||
first.order_events
|
||||
);
|
||||
if !persists {
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
|
||||
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(15_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
decision
|
||||
.order_intents
|
||||
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
|
||||
step(&broker, &mut account, &data, 0, &decision);
|
||||
assert_eq!(
|
||||
broker
|
||||
.open_order_views()
|
||||
.iter()
|
||||
.map(|order| order.reserved_cash.unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![10_000., 5_000.]
|
||||
);
|
||||
let report = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
report
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| (fill.order_id, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some(1), 900), (Some(2), 500)]
|
||||
);
|
||||
assert!(account.cash() >= 0.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
|
||||
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
|
||||
let broker = broker()
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_matching_controls_amend_or_cancel_without_filling_a_crossing_quote() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 9.4, 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
limit_price: 9.5,
|
||||
reason: "resting".into(),
|
||||
}
|
||||
.with_time_in_force(OrderTimeInForce::Gtc),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(broker.open_order_views().len(), 1);
|
||||
let modify = broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::ModifyOrder {
|
||||
order_id: 1,
|
||||
new_total_quantity: Some(200),
|
||||
new_limit_price: Some(9.3),
|
||||
reason: "pre-open-amend".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Some(time(2)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(modify.fill_events.is_empty());
|
||||
assert_eq!(broker.open_order_views()[0].limit_price, 9.3);
|
||||
assert_eq!(broker.open_order_views()[0].requested_quantity, 200);
|
||||
let cancel = broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "pre-open-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Some(time(2)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_only_phase_cannot_be_used_to_submit_an_order_or_leave_matching_disabled() {
|
||||
let data = data(&[(0, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let submit = StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "normal-order".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&submit,
|
||||
Some(time(0))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert_eq!(
|
||||
step(&broker, &mut account, &data, 0, &submit).fill_events[0].quantity,
|
||||
100
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
// Kept inside broker::tests to inspect internal accepted-order identity as
|
||||
// well as the public report. These are simulator states, never GT requests.
|
||||
fn recovery_test_data(missing_previous: Option<usize>, intraday: bool) -> DataSet {
|
||||
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let mut instruments = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
let mut quotes = Vec::new();
|
||||
for index in 1..=2 {
|
||||
let symbol = format!("{index:06}.SZ");
|
||||
let mut instrument = limit_test_instrument();
|
||||
instrument.symbol = symbol.clone();
|
||||
instruments.push(instrument);
|
||||
for day in [previous, date] {
|
||||
if day == previous && missing_previous == Some(index) {
|
||||
continue;
|
||||
}
|
||||
let mut row = dated_limit_test_snapshot(day);
|
||||
row.symbol = symbol.clone().into();
|
||||
rows.push(row);
|
||||
let mut candidate = dated_limit_test_candidate(day, false, false, true, true);
|
||||
candidate.symbol = symbol.clone().into();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
if intraday {
|
||||
let mut quote = limit_test_quote(10., 10., 10.);
|
||||
quote.symbol = symbol;
|
||||
quote.date = date;
|
||||
quote.timestamp = date.and_hms_opt(9, 33, 0).unwrap();
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
instruments,
|
||||
rows,
|
||||
vec![],
|
||||
candidates,
|
||||
vec![
|
||||
dated_limit_test_benchmark(previous),
|
||||
dated_limit_test_benchmark(date),
|
||||
],
|
||||
vec![],
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn recovery_test_broker(
|
||||
intraday: bool,
|
||||
first_side: OrderSide,
|
||||
) -> (
|
||||
BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
PortfolioState,
|
||||
) {
|
||||
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(if intraday {
|
||||
MatchingType::MinuteLast
|
||||
} else {
|
||||
MatchingType::CurrentBarClose
|
||||
})
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false)
|
||||
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(
|
||||
super::DynamicSlippageConfig::new(0., 0., 0.1),
|
||||
));
|
||||
if intraday {
|
||||
broker =
|
||||
broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 33, 0).unwrap());
|
||||
}
|
||||
let mut first = test_open_order(1);
|
||||
first.filled_quantity = 100;
|
||||
first.remaining_quantity = 100;
|
||||
first.commission_remaining = Some(0.);
|
||||
first.side = first_side;
|
||||
let mut second = test_open_order(2);
|
||||
second.symbol = "000002.SZ".into();
|
||||
broker.upsert_open_order(first);
|
||||
broker.upsert_open_order(second);
|
||||
broker.next_order_id.set(3);
|
||||
let mut account = PortfolioState::new(9000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
if first_side == OrderSide::Buy {
|
||||
100
|
||||
} else {
|
||||
200
|
||||
},
|
||||
10.,
|
||||
);
|
||||
account.begin_trading_day();
|
||||
(broker, account)
|
||||
}
|
||||
|
||||
fn recovery_report_value(report: &BrokerExecutionReport) -> serde_json::Value {
|
||||
serde_json::json!({"orders":report.order_events,"fills":report.fill_events,
|
||||
"positions":report.position_events,"accounts":report.account_events,
|
||||
"events":report.process_events,"diagnostics":report.diagnostics})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_resting_order_batch_keeps_accepted_orders_and_unpublished_financial_state() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
for intraday in [false, true] {
|
||||
for first_side in [OrderSide::Buy, OrderSide::Sell] {
|
||||
for missing in [1, 2] {
|
||||
let (broker, mut account) = recovery_test_broker(intraday, first_side);
|
||||
let orders = format!("{:?}", broker.open_orders.borrow());
|
||||
let ledger = account.financial_replay_identity();
|
||||
let error = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&recovery_test_data(Some(missing), intraday),
|
||||
&StrategyDecision::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("historical_slippage_calibration_missing")
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", broker.open_orders.borrow()),
|
||||
orders,
|
||||
"intraday={intraday} first={first_side:?} missing={missing}"
|
||||
);
|
||||
assert_eq!(account.financial_replay_identity(), ledger);
|
||||
assert!(broker.same_day_sold_symbols.borrow().is_empty());
|
||||
let recovered = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&recovery_test_data(None, intraday),
|
||||
&StrategyDecision::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let (clean, mut clean_account) = recovery_test_broker(intraday, first_side);
|
||||
let reference = clean
|
||||
.execute(
|
||||
date,
|
||||
&mut clean_account,
|
||||
&recovery_test_data(None, intraday),
|
||||
&StrategyDecision::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
recovery_report_value(&recovered),
|
||||
recovery_report_value(&reference)
|
||||
);
|
||||
assert_eq!(
|
||||
account.financial_replay_identity(),
|
||||
clean_account.financial_replay_identity()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert_eq!(recovered.fill_events.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_new_batch_does_not_erase_prior_success_or_double_charge_on_retry() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let (broker, mut account) = recovery_test_broker(true, OrderSide::Buy);
|
||||
let good = recovery_test_data(None, true);
|
||||
let prior = broker
|
||||
.execute(date, &mut account, &good, &StrategyDecision::default())
|
||||
.unwrap();
|
||||
assert_eq!(prior.fill_events.len(), 2);
|
||||
let initial = account.financial_replay_identity();
|
||||
let id = broker.next_order_id.get();
|
||||
let decision = StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "next-batch-a".into(),
|
||||
},
|
||||
OrderIntent::Shares {
|
||||
symbol: "000002.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "next-batch-b".into(),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
// A later quote lets this batch execute independently of the prior fills.
|
||||
let mut parts = good.snapshot_components();
|
||||
for quote in &mut parts.execution_quotes {
|
||||
quote.timestamp += chrono::Duration::minutes(1);
|
||||
}
|
||||
broker
|
||||
.runtime_execution_clock
|
||||
.set(Some(NaiveTime::from_hms_opt(9, 34, 0).unwrap()));
|
||||
let restored = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments.clone(),
|
||||
parts.market.clone(),
|
||||
parts.factors.clone(),
|
||||
parts.candidates.clone(),
|
||||
parts.benchmarks.clone(),
|
||||
vec![],
|
||||
parts.execution_quotes.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
parts
|
||||
.market
|
||||
.retain(|row| !(row.symbol.as_str() == "000002.SZ" && row.date < date));
|
||||
let broken = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
vec![],
|
||||
parts.execution_quotes,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
broker
|
||||
.execute(date, &mut account, &broken, &decision)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.financial_replay_identity(), initial);
|
||||
assert_eq!(broker.next_order_id.get(), id);
|
||||
assert!(broker.open_orders.borrow().is_empty());
|
||||
let result = broker
|
||||
.execute(date, &mut account, &restored, &decision)
|
||||
.unwrap();
|
||||
assert_eq!(result.fill_events.len(), 2);
|
||||
assert_eq!(result.fill_events[0].order_id, Some(id));
|
||||
assert_eq!(result.fill_events[1].order_id, Some(id + 1));
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 300);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 300);
|
||||
assert_eq!(
|
||||
prior.fill_events.len(),
|
||||
2,
|
||||
"previously returned report remains intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwinding_an_unpublished_simulator_transaction_restores_its_state() {
|
||||
let (broker, mut account) = recovery_test_broker(false, OrderSide::Sell);
|
||||
let initial = account.financial_replay_identity();
|
||||
let orders = format!("{:?}", broker.open_orders.borrow());
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = broker.execution_transaction(&mut account, true, None, |account| {
|
||||
account.apply_cash_delta(500.).unwrap();
|
||||
broker.open_orders.borrow_mut().clear();
|
||||
panic!("isolated simulator callback unwind");
|
||||
});
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
assert_eq!(account.financial_replay_identity(), initial);
|
||||
assert_eq!(format!("{:?}", broker.open_orders.borrow()), orders);
|
||||
assert_eq!(broker.execution_transaction_depth.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_batch_failure_keeps_both_targets_and_prior_generation_progress() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut parts = recovery_test_data(None, false).snapshot_components();
|
||||
for instrument in &mut parts.instruments {
|
||||
instrument.board = "ETF".into();
|
||||
}
|
||||
let good = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments.clone(),
|
||||
parts.market.clone(),
|
||||
parts.factors.clone(),
|
||||
parts.candidates.clone(),
|
||||
parts.benchmarks.clone(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
parts
|
||||
.market
|
||||
.retain(|row| !(row.date == date && row.symbol.as_str() == "000002.SZ"));
|
||||
let bad = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let members = std::sync::Arc::new(
|
||||
(1..=2)
|
||||
.map(|index| crate::stock_pool_execution::StockPoolMemberSpec {
|
||||
symbol: format!("{index:06}.SZ"),
|
||||
requested_order: index,
|
||||
recommendation_reason: String::new(),
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
broker
|
||||
.deferred_etf_targets
|
||||
.borrow_mut()
|
||||
.replace_generation("pool", "latest");
|
||||
for index in 1..=2 {
|
||||
broker
|
||||
.deferred_etf_targets
|
||||
.borrow_mut()
|
||||
.upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id: "pool".into(),
|
||||
generation: "latest".into(),
|
||||
symbol: format!("{index:06}.SZ"),
|
||||
signal_date: previous,
|
||||
signal_at: previous.and_hms_opt(13, 0, 0).unwrap(),
|
||||
execute_on: Some(date),
|
||||
target_value: 1000.into(),
|
||||
target_weight_bps: 5000,
|
||||
side: crate::stock_pool_execution::OrderSide::Buy,
|
||||
max_positions: 2,
|
||||
rule: Default::default(),
|
||||
members: std::sync::Arc::clone(&members),
|
||||
reason: "deferred recovery fixture".into(),
|
||||
});
|
||||
}
|
||||
let queue = format!("{:?}", broker.deferred_etf_targets.borrow());
|
||||
let mut account = PortfolioState::new(10000.);
|
||||
let state = account.stock_pool_execution_state("pool");
|
||||
assert!(
|
||||
broker
|
||||
.execute_deferred_etf_targets(date, &mut account, &bad)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 10000.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(account.stock_pool_execution_state("pool"), state);
|
||||
assert_eq!(format!("{:?}", broker.deferred_etf_targets.borrow()), queue);
|
||||
assert_eq!(broker.next_order_id.get(), 1);
|
||||
assert_eq!(broker.execution_transaction_depth.get(), 0);
|
||||
let result = broker
|
||||
.execute_deferred_etf_targets(date, &mut account, &good)
|
||||
.unwrap();
|
||||
assert_eq!(result.fill_events.len(), 2, "{result:?}");
|
||||
assert_eq!(broker.pending_etf_target_count(), 0);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_callback_unwind_does_not_leak_order_context_or_authoritative_prior_state() {
|
||||
struct PanicRules;
|
||||
impl crate::rules::EquityRuleHooks for PanicRules {
|
||||
fn can_buy(
|
||||
&self,
|
||||
_: chrono::NaiveDate,
|
||||
_: &DailyMarketSnapshot,
|
||||
_: &CandidateEligibility,
|
||||
_: PriceField,
|
||||
) -> crate::rules::RuleCheck {
|
||||
panic!("isolated rule callback panic")
|
||||
}
|
||||
fn can_sell(
|
||||
&self,
|
||||
_: chrono::NaiveDate,
|
||||
_: &DailyMarketSnapshot,
|
||||
_: &CandidateEligibility,
|
||||
_: &crate::portfolio::Position,
|
||||
_: PriceField,
|
||||
) -> crate::rules::RuleCheck {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let prior = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), PanicRules)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
broker.runtime_decision_date.set(Some(prior));
|
||||
broker
|
||||
.runtime_buy_denials
|
||||
.borrow_mut()
|
||||
.insert("unrelated".into(), "prior".into());
|
||||
let mut account = PortfolioState::new(10000.);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: BTreeMap::from([("another".into(), "temporary".into())]),
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
limit_price: 10.,
|
||||
reason: "panic fixture".into(),
|
||||
}
|
||||
.with_time_in_force(OrderTimeInForce::Gtc),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = broker.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&recovery_test_data(None, false),
|
||||
&decision,
|
||||
);
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 10000.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert_eq!(broker.runtime_decision_date.get(), Some(prior));
|
||||
assert_eq!(
|
||||
*broker.runtime_buy_denials.borrow(),
|
||||
BTreeMap::from([("unrelated".into(), "prior".into())])
|
||||
);
|
||||
assert_eq!(broker.runtime_time_in_force.get(), None);
|
||||
assert_eq!(broker.runtime_target_position_limit.get(), None);
|
||||
assert_eq!(broker.execution_transaction_depth.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulator_transaction_profile_preserves_successful_output() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut instruments = Vec::new();
|
||||
let mut market = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for index in 1..=30 {
|
||||
let symbol = format!("{index:06}.SZ");
|
||||
let mut instrument = limit_test_instrument();
|
||||
instrument.symbol = symbol.clone();
|
||||
instruments.push(instrument);
|
||||
for day in [previous, date] {
|
||||
let mut row = dated_limit_test_snapshot(day);
|
||||
row.symbol = symbol.clone().into();
|
||||
market.push(row);
|
||||
let mut row = dated_limit_test_candidate(day, false, false, true, true);
|
||||
row.symbol = symbol.clone().into();
|
||||
candidates.push(row);
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components(
|
||||
instruments,
|
||||
market,
|
||||
vec![],
|
||||
candidates,
|
||||
vec![
|
||||
dated_limit_test_benchmark(previous),
|
||||
dated_limit_test_benchmark(date),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let mut reference = None;
|
||||
let mut samples = Vec::new();
|
||||
for protected in [false, true, true, false] {
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
// Private comparison only: no runtime option can disable protection.
|
||||
if !protected {
|
||||
broker.execution_transaction_depth.set(1);
|
||||
}
|
||||
let mut account = PortfolioState::new(10_000_000.);
|
||||
for index in 1..=30 {
|
||||
for _ in 0..20 {
|
||||
account
|
||||
.position_mut(&format!("{index:06}.SZ"))
|
||||
.buy(previous, 100, 10.);
|
||||
}
|
||||
}
|
||||
account.begin_trading_day();
|
||||
let mut orders = Vec::new();
|
||||
let mut fills = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
for index in 0..500 {
|
||||
let report = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: format!("{:06}.SZ", index % 30 + 1),
|
||||
quantity: 100,
|
||||
reason: "transaction profile".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
orders.extend(report.order_events);
|
||||
fills.extend(report.fill_events);
|
||||
}
|
||||
samples.push(
|
||||
serde_json::json!({"protected":protected,"microseconds":start.elapsed().as_micros()}),
|
||||
);
|
||||
assert_eq!(fills.len(), 500);
|
||||
let outcome = serde_json::json!({"orders":orders,"fills":fills,"ledger":account.financial_replay_identity()});
|
||||
if let Some(reference) = &reference {
|
||||
assert_eq!(&outcome, reference);
|
||||
} else {
|
||||
reference = Some(outcome);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"simulator_transaction_profile={}",
|
||||
serde_json::json!({"securities":30,"initial_lots_per_security":20,"calls":500,"samples":samples,
|
||||
"scope":"isolated broker only; not Source or full backtest throughput"})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
//! Executes one frozen pool intent against real broker-simulator state.
|
||||
use super::*;
|
||||
use crate::holding_policy::HoldingLifecycleEvidence;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||
use chrono::Timelike;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct DeferredStockPoolExecution {
|
||||
date: NaiveDate,
|
||||
contract: Box<pool::FrozenStockPoolIntent>,
|
||||
buy_only: bool,
|
||||
symbols: BTreeSet<String>,
|
||||
initial_holdings: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R> {
|
||||
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
|
||||
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
|
||||
!self.deferred_stock_pools.borrow().is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
|
||||
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
|
||||
if pending.date <= date {
|
||||
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
|
||||
if !value.is_finite() {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_nonfinite_{label}"
|
||||
)));
|
||||
}
|
||||
value
|
||||
.to_string()
|
||||
.parse()
|
||||
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
|
||||
}
|
||||
|
||||
fn etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
|
||||
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
|
||||
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
|
||||
}
|
||||
|
||||
fn pool_positions(
|
||||
portfolio: &PortfolioState,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<pool::Position>, BacktestError> {
|
||||
portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.filter(|p| p.quantity > 0)
|
||||
.map(|p| {
|
||||
Ok(pool::Position {
|
||||
symbol: p.symbol.clone(),
|
||||
quantity: Decimal::from(p.quantity),
|
||||
closable_quantity: Decimal::from(p.sellable_qty(date)),
|
||||
average_cost: decimal(p.average_cost, "position_cost")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod successor_protection_tests {
|
||||
use super::*;
|
||||
use crate::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||
fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, n).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_rechecks_inherited_locks_and_cooldown_before_any_order() {
|
||||
let old = "159915.SZ";
|
||||
let new = "159999.SZ";
|
||||
let data = DataSet::from_components(
|
||||
[old, new].into_iter().map(|symbol| crate::Instrument {
|
||||
symbol: symbol.into(), name: "isolated ETF fixture".into(), board: "ETF".into(), round_lot: 100,
|
||||
listed_at: Some(day(1)), delisted_at: None, status: "active".into(),
|
||||
}).collect(), vec![crate::DailyMarketSnapshot {
|
||||
date: day(15), symbol: new.into(), timestamp: None, day_open: 5., open: 5., high: 5., low: 5.,
|
||||
close: 5., last_price: 5., bid1: 5., ask1: 5., prev_close: 5., volume: 100000,
|
||||
minute_volume: 0, bid1_volume: 100000, ask1_volume: 100000, trading_phase: None,
|
||||
paused: false, upper_limit: 5.5, lower_limit: 4.5, price_tick: 0.001,
|
||||
}], vec![], vec![crate::CandidateEligibility {
|
||||
date: day(15), symbol: new.into(), is_st: false, is_star_st: false, is_new_listing: false,
|
||||
is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}], [11,14,15].into_iter().map(|n| crate::BenchmarkSnapshot {
|
||||
date: day(n), benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 10000,
|
||||
}).collect()).unwrap();
|
||||
for mode in ["lock", "cooldown", "expired"] {
|
||||
let broker = BrokerSimulator::new(crate::ChinaAShareCostModel::default(), crate::ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false).with_liquidity_limit(false);
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
portfolio.position_mut(old).buy(day(11), 200, 10.);
|
||||
portfolio.position_mut(old).sell(100, 10.).unwrap();
|
||||
broker.mark_same_day_sold(day(11), old);
|
||||
portfolio.apply_successor_conversion(old, new, 2., 0.).unwrap();
|
||||
let policy = AutomaticTradeProtection {
|
||||
sell_cooldown_days: if mode == "cooldown" { 3 } else { 0 },
|
||||
locks: if mode != "cooldown" { vec![AutomaticTradeLock {
|
||||
symbol: old.into(), start_date: day(11), end_date: Some(day(if mode == "expired" {14} else {15})),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let rule = pool::StockPoolExecutionRule { automatic_trade_protection: policy, ..Default::default() };
|
||||
broker.deferred_etf_targets.borrow_mut().replace_generation("pool", "latest");
|
||||
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
|
||||
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
|
||||
target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1,
|
||||
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
|
||||
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
||||
target_weight_bps: None, stop_loss: None, take_profit: None,
|
||||
}]), reason: "isolated deferred ETF target".into(),
|
||||
});
|
||||
let report = broker.execute_deferred_etf_targets(day(15), &mut portfolio, &data).unwrap();
|
||||
if mode == "expired" {
|
||||
assert_eq!(report.fill_events.len(), 1, "{report:?}");
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 1000);
|
||||
} else {
|
||||
assert!(report.order_events.is_empty(), "{mode}: {report:?}");
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 200);
|
||||
assert!(report.diagnostics.iter().any(|text| text.contains(if mode == "lock" {"automatic_trade_locked"} else {"sell_fill_cooldown"})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fn pool_automatic_permission(&self, symbol: &str, date: NaiveDate,
|
||||
policy: &crate::holding_policy::AutomaticTradeProtection,
|
||||
portfolio: &PortfolioState, data: &DataSet)
|
||||
-> Result<crate::holding_policy::AutomaticTradePermission, BacktestError> {
|
||||
let position = portfolio.position(symbol).filter(|position| position.quantity > 0);
|
||||
let sold = self.same_day_sold_symbols.borrow().iter().rev()
|
||||
.find(|(day, symbols)| **day <= date && (symbols.contains(symbol)
|
||||
|| portfolio.corporate_predecessors(symbol).any(|previous| symbols.contains(previous))))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: position.and_then(|position| position.last_buy_date()), last_sell_date: sold,
|
||||
};
|
||||
policy.evaluate_with_predecessors(symbol, date, &evidence, data.calendar(),
|
||||
portfolio.corporate_predecessors(symbol)).map_err(BacktestError::Execution)
|
||||
}
|
||||
|
||||
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
|
||||
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
|
||||
let mut expired = Vec::new();
|
||||
for (id, pending) in self.deferred_stock_pools.borrow().iter() {
|
||||
let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M")
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
|
||||
if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); }
|
||||
}
|
||||
for id in expired {
|
||||
if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) {
|
||||
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
|
||||
}
|
||||
}
|
||||
if self.has_open_orders() || clock.is_none() { return Ok(()); }
|
||||
let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut());
|
||||
for (id, pending) in pending {
|
||||
let now = clock.expect("clock checked above");
|
||||
let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M")
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
|
||||
if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) {
|
||||
self.deferred_stock_pools.borrow_mut().insert(id, pending);
|
||||
continue;
|
||||
}
|
||||
let prior_followup = self.runtime_stock_pool_followup.replace(true);
|
||||
let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date));
|
||||
let prior_created = self.runtime_order_created_date.replace(Some(date));
|
||||
let order_start = report.order_events.len();
|
||||
let fill_start = report.fill_events.len();
|
||||
report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash()));
|
||||
let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract,
|
||||
&mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor,
|
||||
&mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings));
|
||||
self.runtime_stock_pool_followup.set(prior_followup);
|
||||
self.runtime_decision_date.set(prior_decision);
|
||||
self.runtime_order_created_date.set(prior_created);
|
||||
result?;
|
||||
Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pool_quote_inputs(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
symbols: &BTreeSet<String>,
|
||||
execution_clock: Option<NaiveDateTime>,
|
||||
cumulative_conditions: bool,
|
||||
) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
|
||||
let mut unavailable = Vec::new();
|
||||
let quotes = symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let snapshot = data.market(date, symbol).ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_execution_snapshot_missing:{symbol}:{date}"
|
||||
))
|
||||
})?;
|
||||
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
||||
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
|
||||
})?;
|
||||
let fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
|
||||
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
|
||||
let calibration = self.slippage_calibration(data, snapshot)?;
|
||||
(reference.price, snapshot.prev_close, None, None, None, None,
|
||||
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
|
||||
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
|
||||
} else if self
|
||||
.matching_type_uses_intraday_quotes()
|
||||
{
|
||||
let time = self
|
||||
.runtime_intraday_start_time
|
||||
.get()
|
||||
.or(self.intraday_execution_start_time)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"stock_pool_intraday_execution_clock_required".into(),
|
||||
)
|
||||
})?;
|
||||
let clock = execution_clock
|
||||
.unwrap_or(date.and_time(time))
|
||||
.max(date.and_time(time));
|
||||
let quote = data
|
||||
.execution_quotes_on(date, symbol)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|quote| quote.timestamp <= clock)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_execution_quote_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
if !quote.last_price.is_finite() || quote.last_price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_execution_quote_invalid:{symbol}:{clock}"
|
||||
)));
|
||||
}
|
||||
let raw_buy = self
|
||||
.select_quote_reference_price(
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Buy,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_buy_reference_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
let raw_sell = self
|
||||
.select_quote_reference_price(
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Sell,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_sell_reference_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
let calibration = self.slippage_calibration(data, snapshot)?;
|
||||
let buy = self.quote_execution_price(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
raw_buy,
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
let sell = self.quote_execution_price(
|
||||
snapshot,
|
||||
OrderSide::Sell,
|
||||
raw_sell,
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
let totals = if cumulative_conditions {
|
||||
match data.execution_session_totals(symbol, clock) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
(
|
||||
quote.last_price,
|
||||
snapshot.prev_close,
|
||||
totals.map(|total| total.0),
|
||||
totals.map(|total| total.1),
|
||||
Some(quote.bid1),
|
||||
Some(quote.ask1),
|
||||
buy,
|
||||
sell,
|
||||
)
|
||||
} else {
|
||||
let price = snapshot.price(self.effective_execution_price_field(date));
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_execution_price_missing:{symbol}:{date}"
|
||||
)));
|
||||
}
|
||||
// A daily open does not reveal the session's volume/turnover.
|
||||
let completed = self.effective_execution_price_field(date) == PriceField::Close;
|
||||
let totals = if cumulative_conditions && !completed {
|
||||
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
|
||||
match data.execution_session_totals(symbol, at) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
let amount = if completed && cumulative_conditions {
|
||||
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
|
||||
.map(|value| decimal(value, "amount")).transpose()?
|
||||
} else { totals.map(|total| total.1) };
|
||||
(
|
||||
price,
|
||||
snapshot.prev_close,
|
||||
if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
|
||||
amount,
|
||||
None,
|
||||
None,
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
|
||||
)
|
||||
};
|
||||
Ok(pool::MarketSnapshot {
|
||||
symbol: symbol.clone(),
|
||||
last_price: decimal(price, "price")?,
|
||||
prev_close: Some(decimal(prev, "prev_close")?),
|
||||
volume,
|
||||
turnover: amount,
|
||||
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
|
||||
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
|
||||
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
|
||||
instrument_rules: Some(pool::StockPoolInstrumentRules {
|
||||
price_tick: decimal(snapshot.price_tick, "price_tick")?,
|
||||
quantity_step: instrument.order_step_size().into(),
|
||||
minimum_buy_quantity: instrument.minimum_order_quantity().into(),
|
||||
}),
|
||||
buy_sizing_price: Some(decimal(buy_price, "buy_price")?),
|
||||
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, BacktestError>>()?;
|
||||
Ok((quotes, unavailable))
|
||||
}
|
||||
|
||||
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
|
||||
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
|
||||
return Ok(None);
|
||||
}
|
||||
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
|
||||
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
|
||||
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
|
||||
crate::etf_execution::reference(data, symbol, at).map(Some)
|
||||
}
|
||||
|
||||
pub(super) fn process_stock_pool_contract(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
contract: &pool::FrozenStockPoolIntent,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut IntradayExecutionLedger,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover,
|
||||
execution_cursors, global_execution_cursor, commission_state, report, false, None)
|
||||
}
|
||||
|
||||
fn process_stock_pool_contract_phase(
|
||||
&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>, report: &mut BrokerExecutionReport, buy_only: bool,
|
||||
initial_holdings: Option<&BTreeSet<String>>,
|
||||
) -> Result<(), BacktestError> {
|
||||
if contract.signal_date > date
|
||||
|| contract.frozen_equity < Decimal::ZERO
|
||||
|| contract.generation.is_empty()
|
||||
|| contract.pool_id.trim().is_empty()
|
||||
{
|
||||
return Err(BacktestError::Execution(
|
||||
"stock_pool_frozen_intent_invalid".into(),
|
||||
));
|
||||
}
|
||||
if self.matching_type == MatchingType::NextBarOpen && contract.signal_date >= date {
|
||||
return Err(BacktestError::Execution(
|
||||
"stock_pool_next_open_requires_prior_signal".into(),
|
||||
));
|
||||
}
|
||||
let mut selection = contract.selection.clone();
|
||||
let mut members = contract.members.clone();
|
||||
for symbol in &contract.selection.requested_symbols {
|
||||
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
||||
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
|
||||
})?;
|
||||
if portfolio.position(symbol).is_none()
|
||||
&& let Some(reason) = instrument.dated_market_absence_reason(date)
|
||||
{
|
||||
selection.requested_symbols.retain(|v| v != symbol);
|
||||
selection.normal_trading_symbols.retain(|v| v != symbol);
|
||||
selection.risk_eligible_symbols.retain(|v| v != symbol);
|
||||
selection.final_symbols.retain(|v| v != symbol);
|
||||
members.retain(|v| &v.symbol != symbol);
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_market_absence symbol={symbol} date={date} reason={reason}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut scope = selection
|
||||
.requested_symbols
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
scope.extend(portfolio.positions().keys().cloned());
|
||||
let before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect());
|
||||
let official_dates = data.calendar().iter().collect::<Vec<_>>();
|
||||
let initial_positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
.observe(
|
||||
contract.signal_date,
|
||||
date,
|
||||
&official_dates,
|
||||
&members,
|
||||
&initial_positions,
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio
|
||||
.set_stock_pool_execution_state(&contract.pool_id, state)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
|
||||
if self.has_open_orders() {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
|
||||
});
|
||||
report
|
||||
.diagnostics
|
||||
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
|
||||
return Ok(());
|
||||
}
|
||||
let mut constraints = contract.constraints.clone();
|
||||
constraints.execution_date = Some(date);
|
||||
constraints.frozen_positions.clear();
|
||||
let mut quote_scope = scope.clone();
|
||||
for symbol in &scope {
|
||||
let paused = data.market(date, symbol).is_some_and(|row| row.paused)
|
||||
|| data
|
||||
.candidate(date, symbol)
|
||||
.is_some_and(|row| row.is_paused);
|
||||
if !paused {
|
||||
continue;
|
||||
}
|
||||
quote_scope.remove(symbol);
|
||||
if let Some(position) = portfolio
|
||||
.position(symbol)
|
||||
.filter(|position| position.quantity > 0)
|
||||
{
|
||||
constraints.frozen_positions.insert(
|
||||
symbol.clone(),
|
||||
pool::FrozenStockPoolPosition {
|
||||
trade_date: date,
|
||||
reason: "paused".into(),
|
||||
valuation_price: decimal(position.last_price, "paused_holding_valuation")?,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
selection
|
||||
.normal_trading_symbols
|
||||
.retain(|item| item != symbol);
|
||||
selection
|
||||
.risk_eligible_symbols
|
||||
.retain(|item| item != symbol);
|
||||
selection.final_symbols.retain(|item| item != symbol);
|
||||
selection
|
||||
.exclusion_reasons
|
||||
.entry(symbol.clone())
|
||||
.or_default()
|
||||
.push("paused".into());
|
||||
}
|
||||
}
|
||||
// All delayed symbols in a generation share immutable configuration.
|
||||
// Do not duplicate an N-member pool N times in a large mixed pool.
|
||||
let mut deferred_configuration = None;
|
||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||
if buy_only && side == pool::OrderSide::Sell { continue; }
|
||||
if side == pool::OrderSide::Buy && self.has_open_orders()
|
||||
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
|
||||
});
|
||||
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
|
||||
break;
|
||||
}
|
||||
let mut fallback_references = BTreeMap::new();
|
||||
for symbol in "e_scope {
|
||||
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
|
||||
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
|
||||
if !condition.trim().is_empty() {
|
||||
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
|
||||
}
|
||||
fallback_references.insert(symbol.clone(), reference);
|
||||
}
|
||||
}
|
||||
let (quotes, unavailable) = self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor,
|
||||
crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let execution_state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
.observe(
|
||||
contract.signal_date,
|
||||
date,
|
||||
&official_dates,
|
||||
&members,
|
||||
&positions,
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
||||
constraints.prior_target_weights = execution_state.last_target_weights.clone();
|
||||
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
|
||||
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
||||
let account = pool::AccountSnapshot {
|
||||
total_equity: contract.frozen_equity,
|
||||
cash: decimal(portfolio.cash(), "cash")?,
|
||||
frozen_cash: Decimal::ZERO,
|
||||
};
|
||||
constraints.automatic_permissions.clear();
|
||||
if contract.rule.automatic_trade_protection.enabled() {
|
||||
for symbol in &scope {
|
||||
let permission = self.pool_automatic_permission(symbol, date,
|
||||
&contract.rule.automatic_trade_protection, portfolio, data)?;
|
||||
constraints
|
||||
.automatic_permissions
|
||||
.insert(symbol.clone(), permission);
|
||||
}
|
||||
}
|
||||
if side == pool::OrderSide::Buy {
|
||||
for (symbol, reference) in &fallback_references {
|
||||
if !reference.immediate {
|
||||
// The sell leg was queued, not filled. Keep its real
|
||||
// holdings/slots and do not finance buys with proceeds
|
||||
// from the following session.
|
||||
constraints.automatic_permissions.entry(symbol.clone()).or_default()
|
||||
.sell_denial.get_or_insert("etf_daily_open_deferred");
|
||||
}
|
||||
}
|
||||
}
|
||||
if self
|
||||
.risk_config
|
||||
.static_rules
|
||||
.forbid_same_day_rebuy_after_sell
|
||||
{
|
||||
constraints.same_day_sold_symbols.extend(
|
||||
self.same_day_sold_symbols
|
||||
.borrow()
|
||||
.get(&date)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
constraints.same_day_sold_symbols.extend(
|
||||
before_positions
|
||||
.iter()
|
||||
.filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0))
|
||||
.cloned(),
|
||||
);
|
||||
let fee =
|
||||
|symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result<Decimal, String> {
|
||||
let amount = gross
|
||||
.to_f64()
|
||||
.ok_or("stock_pool_cost_amount_out_of_range")?;
|
||||
decimal(
|
||||
self.cost_model
|
||||
.calculate_for_instrument(
|
||||
date,
|
||||
if side == pool::OrderSide::Buy {
|
||||
OrderSide::Buy
|
||||
} else {
|
||||
OrderSide::Sell
|
||||
},
|
||||
amount,
|
||||
data.instruments().get(symbol),
|
||||
)
|
||||
.total(),
|
||||
"fee",
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
let plan = pool::build_stock_pool_target_plan_with_fee_model(
|
||||
&selection,
|
||||
&members,
|
||||
&contract.rule,
|
||||
&account,
|
||||
&positions,
|
||||
"es,
|
||||
contract.invest_ratio_bps,
|
||||
contract.reserve_cash,
|
||||
&contract.out_of_pool_policy,
|
||||
"full_rebalance",
|
||||
&constraints,
|
||||
&contract.generation,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Some(&fee),
|
||||
)
|
||||
.map_err(|error| BacktestError::Execution(if !unavailable.is_empty()
|
||||
&& (error.contains("requires volume") || error.contains("requires amount")) {
|
||||
format!("{error}; {}", unavailable.join("; "))
|
||||
} else { error }))?;
|
||||
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
|
||||
let mut updated = execution_state
|
||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
for (symbol, reference) in &fallback_references {
|
||||
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
|
||||
// The signal only fixes money, not shares at a stale close.
|
||||
entry.completion_quantity = None;
|
||||
}
|
||||
}
|
||||
portfolio
|
||||
.set_stock_pool_execution_state(&contract.pool_id, updated)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget));
|
||||
let max_positions = constraints
|
||||
.target_holding_count
|
||||
.unwrap_or(selection.final_symbols.len());
|
||||
for row in plan.rows {
|
||||
if side == pool::OrderSide::Buy && row.side.is_none() {
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_decision symbol={} status={} current={} target={} reason={}",
|
||||
row.symbol,
|
||||
row.status,
|
||||
row.current_quantity,
|
||||
row.target_quantity,
|
||||
row.reason
|
||||
));
|
||||
}
|
||||
if row.side != Some(side) {
|
||||
continue;
|
||||
}
|
||||
if let Some(reference) = fallback_references.get(&row.symbol) {
|
||||
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
|
||||
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
|
||||
if !reference.immediate {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
|
||||
let deferred = deferred_configuration.get_or_insert_with(|| (
|
||||
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
|
||||
));
|
||||
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
|
||||
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
|
||||
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
|
||||
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
|
||||
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
|
||||
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
|
||||
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if side == pool::OrderSide::Buy
|
||||
&& portfolio
|
||||
.position(&row.symbol)
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
&& Self::positive_position_count(portfolio) >= max_positions
|
||||
{
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_buy_deferred symbol={} reason=occupied_position_slots",
|
||||
row.symbol
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = row.target_quantity.to_i32().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
|
||||
})?;
|
||||
let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
|
||||
let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
|
||||
let first_fill = report.fill_events.len();
|
||||
if fallback_references.contains_key(&row.symbol) {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
|
||||
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
|
||||
}
|
||||
let mut execute = || if let Some(price) = row.limit_price {
|
||||
self.process_limit_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
&row.symbol,
|
||||
target,
|
||||
price.to_f64().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_limit_price_out_of_range".into())
|
||||
})?,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)
|
||||
} else {
|
||||
self.process_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
&row.symbol,
|
||||
target,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)
|
||||
};
|
||||
if fallback_references.contains_key(&row.symbol) {
|
||||
self.with_etf_daily_open(execute)?;
|
||||
for fill in &mut report.fill_events[first_fill..] {
|
||||
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
}
|
||||
} else { execute()?; }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pending_etf_target_count(&self) -> usize {
|
||||
self.deferred_etf_targets.borrow().len()
|
||||
}
|
||||
|
||||
/// Called at the opening clock, after settlement/corporate actions and
|
||||
/// auction callbacks. It never sends a stock order or replays a strategy.
|
||||
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
self.execution_transaction(portfolio, !self.has_open_orders() && self.pending_etf_target_count() > 0, None,
|
||||
|portfolio| self.execute_deferred_etf_targets_inner(date, portfolio, data))
|
||||
}
|
||||
|
||||
fn execute_deferred_etf_targets_inner(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
if self.has_open_orders() {
|
||||
if self.pending_etf_target_count() > 0 {
|
||||
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
|
||||
}
|
||||
return Ok(report);
|
||||
}
|
||||
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
|
||||
let dates = data.calendar().iter().collect::<Vec<_>>();
|
||||
for target in due {
|
||||
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
|
||||
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
|
||||
continue;
|
||||
}
|
||||
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
|
||||
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
|
||||
}
|
||||
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
|
||||
let before_quantity = position.map_or(0, |p| p.quantity);
|
||||
let permission = self.pool_automatic_permission(&target.symbol, date,
|
||||
&target.rule.automatic_trade_protection, portfolio, data)?;
|
||||
let denial = if target.side == pool::OrderSide::Buy {
|
||||
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
|
||||
} else { permission.sell_denial };
|
||||
if let Some(denial) = denial {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
|
||||
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
|
||||
continue;
|
||||
}
|
||||
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
|
||||
continue;
|
||||
}
|
||||
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
|
||||
let current_value = snapshot.open * f64::from(before_quantity);
|
||||
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|
||||
|| (target.side == pool::OrderSide::Sell && value >= current_value);
|
||||
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
|
||||
let mut sub = BrokerExecutionReport::default();
|
||||
if !satisfied {
|
||||
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
|
||||
let intent = match limit {
|
||||
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
|
||||
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
|
||||
};
|
||||
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
|
||||
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
|
||||
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
|
||||
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
|
||||
}));
|
||||
self.runtime_intraday_start_time.set(old_time);
|
||||
self.runtime_resting_order_origin.set(old_origin);
|
||||
sub = outcome?;
|
||||
}
|
||||
// The actual open determines the full requested shares. A clipped
|
||||
// or rejected execution must not be recorded as completed entry.
|
||||
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
|
||||
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
|
||||
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
|
||||
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
|
||||
});
|
||||
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio.stock_pool_execution_state(&target.pool_id)
|
||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
|
||||
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
|
||||
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
|
||||
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
|
||||
}]).map_err(BacktestError::Execution)?
|
||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
|
||||
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
|
||||
for fill in &mut sub.fill_events {
|
||||
fill.decision_date.get_or_insert(target.signal_date);
|
||||
fill.order_created_date.get_or_insert(target.signal_at.date());
|
||||
fill.execution_date.get_or_insert(date);
|
||||
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
}
|
||||
for order in &mut sub.order_events {
|
||||
order.decision_date.get_or_insert(target.signal_date);
|
||||
order.order_created_date.get_or_insert(target.signal_at.date());
|
||||
order.execution_date.get_or_insert(date);
|
||||
}
|
||||
report.diagnostics.push(reason);
|
||||
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity} → {};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
|
||||
report.order_events.extend(sub.order_events);
|
||||
report.fill_events.extend(sub.fill_events);
|
||||
report.position_events.extend(sub.position_events);
|
||||
report.account_events.extend(sub.account_events);
|
||||
report.process_events.extend(sub.process_events);
|
||||
report.diagnostics.extend(sub.diagnostics);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
fn pool_batch_data() -> DataSet {
|
||||
pool_batch_data_with(|_| true)
|
||||
}
|
||||
|
||||
fn pool_batch_data_with(change: impl Fn(&mut IntradayExecutionQuote) -> bool) -> DataSet {
|
||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let instruments = symbols
|
||||
.iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_instrument()
|
||||
})
|
||||
.collect();
|
||||
let snapshots = symbols
|
||||
.iter()
|
||||
.map(|symbol| DailyMarketSnapshot {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_snapshot()
|
||||
})
|
||||
.collect();
|
||||
let candidates = symbols
|
||||
.iter()
|
||||
.map(|symbol| CandidateEligibility {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_candidate(true, true)
|
||||
})
|
||||
.collect();
|
||||
let mut quotes = Vec::new();
|
||||
for minute in [30, 31, 32, 33, 34, 36] {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == "000001.SZ" && minute > 30 {
|
||||
10.5
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.symbol = symbol.into();
|
||||
quote.timestamp = quote.date.and_hms_opt(9, minute, 0).unwrap();
|
||||
quote.volume_delta = 200;
|
||||
quote.bid1_volume = 200;
|
||||
quote.ask1_volume = 200;
|
||||
quote.amount_delta = price * 200.0;
|
||||
if change(&mut quote) {
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
instruments,
|
||||
snapshots,
|
||||
Vec::new(),
|
||||
candidates,
|
||||
vec![limit_test_benchmark()],
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()])
|
||||
}
|
||||
|
||||
fn pool_batch_decision(symbol: &str, generation: &str, end: &str) -> StrategyDecision {
|
||||
use crate::stock_pool_execution as pool;
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let symbols = vec![symbol.to_owned()];
|
||||
let rule = pool::StockPoolExecutionRule {
|
||||
pricing_mode: pool::POOL_PRICE_FIXED_LIMIT.into(),
|
||||
fixed_prices: [
|
||||
("000001.SZ".into(), rust_decimal::Decimal::new(104, 1)),
|
||||
("000002.SZ".into(), 10.into()),
|
||||
("000003.SZ".into(), 10.into()),
|
||||
]
|
||||
.into(),
|
||||
window_end: end.into(),
|
||||
..Default::default()
|
||||
};
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::StockPool {
|
||||
contract: Box::new(pool::FrozenStockPoolIntent {
|
||||
pool_id: "batch-test".into(),
|
||||
signal_date: signal,
|
||||
frozen_equity: 2000.into(),
|
||||
selection: pool::StockPoolSelection {
|
||||
trade_date: signal,
|
||||
requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(),
|
||||
risk_eligible_symbols: symbols.clone(),
|
||||
final_symbols: symbols,
|
||||
exclusion_reasons: Default::default(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: false,
|
||||
generation: Some(generation.into()),
|
||||
},
|
||||
members: vec![pool::StockPoolMemberSpec {
|
||||
symbol: symbol.into(),
|
||||
recommendation_reason: String::new(),
|
||||
requested_order: 0,
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
}],
|
||||
rule,
|
||||
constraints: pool::StockPoolDecisionConstraints {
|
||||
target_holding_count: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
invest_ratio_bps: 10000,
|
||||
reserve_cash: 0.into(),
|
||||
out_of_pool_policy: "reduce_to_zero_when_sellable".into(),
|
||||
generation: generation.into(),
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_batch_broker(partial: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
let cost = ChinaAShareCostModel::from_trading_constraints(
|
||||
crate::risk_control::TradingConstraintConfig {
|
||||
commission_rate: 0.0,
|
||||
minimum_commission: 0.0,
|
||||
stamp_tax_rate_before_change: 0.0,
|
||||
stamp_tax_rate_after_change: 0.0,
|
||||
transfer_fee_rate: 0.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let broker =
|
||||
BrokerSimulator::new_with_execution_price(cost, ChinaEquityRuleHooks, PriceField::Open)
|
||||
.with_matching_type(if partial {
|
||||
MatchingType::MinuteLast
|
||||
} else {
|
||||
MatchingType::NextBarOpen
|
||||
})
|
||||
.with_volume_limit(partial)
|
||||
.with_volume_percent(0.5)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false);
|
||||
if partial {
|
||||
broker
|
||||
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
||||
} else {
|
||||
broker
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_batch_account() -> PortfolioState {
|
||||
let mut account = PortfolioState::new(0.0);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
|
||||
200,
|
||||
10.0,
|
||||
);
|
||||
account
|
||||
}
|
||||
|
||||
fn pool_batch_tick(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
account: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
broker
|
||||
.runtime_intraday_start_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
|
||||
broker
|
||||
.runtime_intraday_end_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
|
||||
broker.execute(date, account, data, decision).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_pending_sell_continues_buy_after_actual_fill_without_strategy_rerun() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let initial = broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "first", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(initial.fill_events.is_empty());
|
||||
assert_eq!(broker.open_order_views().len(), 1);
|
||||
assert_eq!(broker.open_order_views()[0].side, OrderSide::Sell);
|
||||
let done = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
done.fill_events
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell)
|
||||
);
|
||||
assert_eq!(
|
||||
account.position("000002.SZ").map(|p| p.quantity),
|
||||
Some(200),
|
||||
"sell proceeds must trigger the retained buy phase: {:?}",
|
||||
done.diagnostics
|
||||
);
|
||||
assert!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
);
|
||||
let repeated = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(repeated.order_events.is_empty() && repeated.fill_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_partial_sell_waits_for_the_whole_batch_and_never_reissues_buys() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "partial", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
let first = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert!(
|
||||
first
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| event.side == OrderSide::Sell)
|
||||
);
|
||||
let second = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let third = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
33,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
|
||||
let ids = second
|
||||
.order_events
|
||||
.iter()
|
||||
.chain(&third.order_events)
|
||||
.filter(|event| event.side == OrderSide::Buy)
|
||||
.filter_map(|event| event.order_id)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
1,
|
||||
"one buy intention; partial reports must keep its ID"
|
||||
);
|
||||
assert!(
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
34,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.order_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_delayed_sell_does_not_start_buys_after_the_configured_window() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "expired", "09:32"),
|
||||
)
|
||||
.unwrap();
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert!(
|
||||
last.order_events
|
||||
.iter()
|
||||
.all(|event| event.side == OrderSide::Sell)
|
||||
);
|
||||
assert!(
|
||||
last.diagnostics
|
||||
.iter()
|
||||
.any(|event| event.contains("unsubmitted_phase_expired"))
|
||||
);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_new_signal_supersedes_the_unsubmitted_buy_phase() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "old", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&pool_batch_decision("000003.SZ", "new", "09:35"),
|
||||
);
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
33,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert_eq!(account.position("000003.SZ").unwrap().quantity, 200);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_after_sell_uses_fresh_quotes_and_actual_submission_clock() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data_with(|quote| {
|
||||
if quote.symbol == "000002.SZ" {
|
||||
quote.last_price = 10.2;
|
||||
quote.bid1 = 10.2;
|
||||
quote.ask1 = 10.2;
|
||||
quote.amount_delta = 2040.0;
|
||||
}
|
||||
true
|
||||
});
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let mut decision = pool_batch_decision("000002.SZ", "fresh", "09:35");
|
||||
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
|
||||
contract.rule.pricing_mode = crate::stock_pool_execution::POOL_PRICE_FORMULA_LIMIT.into();
|
||||
contract.rule.sell_offset_bps = 400;
|
||||
}
|
||||
broker
|
||||
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
|
||||
.unwrap();
|
||||
let result = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
account.position("000002.SZ").unwrap().quantity,
|
||||
100,
|
||||
"2000/10.2 rounds to one 100-share lot, not 200 at stale open"
|
||||
);
|
||||
let fill = result
|
||||
.fill_events
|
||||
.iter()
|
||||
.find(|fill| fill.symbol == "000002.SZ")
|
||||
.unwrap();
|
||||
assert_eq!(fill.price, 10.2);
|
||||
assert_eq!(
|
||||
fill.execution_start_timestamp,
|
||||
Some(date.and_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
let event = result
|
||||
.order_events
|
||||
.iter()
|
||||
.find(|event| event.side == OrderSide::Buy)
|
||||
.unwrap();
|
||||
assert_eq!(event.decision_date, Some(signal));
|
||||
assert_eq!(event.order_created_date, Some(date));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_after_sell_rejects_missing_quote_instead_of_reusing_daily_open() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data_with(|quote| quote.symbol != "000002.SZ");
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "missing", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
broker
|
||||
.runtime_intraday_start_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
|
||||
broker
|
||||
.runtime_intraday_end_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
|
||||
let error = broker
|
||||
.execute(date, &mut account, &data, &StrategyDecision::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("stock_pool_execution_quote_missing:000002.SZ"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_delayed_take_profit_does_not_rebuy_the_same_generation_exit() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = PortfolioState::new(0.0);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
|
||||
200,
|
||||
9.0,
|
||||
);
|
||||
let mut decision = pool_batch_decision("000002.SZ", "take-profit", "09:35");
|
||||
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
|
||||
let symbols = vec!["000001.SZ".to_owned(), "000002.SZ".to_owned()];
|
||||
contract.selection.requested_symbols = symbols.clone();
|
||||
contract.selection.normal_trading_symbols = symbols.clone();
|
||||
contract.selection.risk_eligible_symbols = symbols.clone();
|
||||
contract.selection.final_symbols = symbols;
|
||||
contract.constraints.target_holding_count = Some(2);
|
||||
contract.members.insert(
|
||||
0,
|
||||
crate::stock_pool_execution::StockPoolMemberSpec {
|
||||
symbol: "000001.SZ".into(),
|
||||
recommendation_reason: String::new(),
|
||||
requested_order: 0,
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: Some(rust_decimal::Decimal::new(5, 2)),
|
||||
},
|
||||
);
|
||||
contract.members[1].requested_order = 1;
|
||||
}
|
||||
broker
|
||||
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
|
||||
.unwrap();
|
||||
let result = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
|
||||
assert!(
|
||||
!result
|
||||
.order_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == "000001.SZ" && event.side == OrderSide::Buy)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_pending_phase_cannot_cross_the_execution_session() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let mut report = broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "end", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(broker.has_pending_stock_pool_execution());
|
||||
broker.finish_stock_pool_session(date, &mut report);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
assert!(
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|event| event.contains("unsubmitted_phase_expired"))
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"session cleanup preserves broker order history and remainder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_engine_drives_the_pending_buy_without_a_minute_strategy_callback() {
|
||||
use crate::{BacktestConfig, BacktestEngine, BacktestError, Strategy, StrategyContext};
|
||||
struct DailyPool;
|
||||
impl Strategy for DailyPool {
|
||||
fn name(&self) -> &str {
|
||||
"daily-pool-batch"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<crate::ScheduleRule> {
|
||||
vec![
|
||||
crate::ScheduleRule::daily("open", crate::ScheduleStage::OnDay)
|
||||
.with_time_rule(crate::ScheduleTimeRule::physical_time(9, 30)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &crate::ScheduleRule,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
if ctx.execution_date.day() == 2 {
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::LimitTargetShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
target_quantity: 200,
|
||||
limit_price: 10.0,
|
||||
reason: "initial-entry".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Ok(pool_batch_decision("000002.SZ", "rotation", "09:35"))
|
||||
}
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
_: &StrategyContext<'_>,
|
||||
_: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
panic!("this daily strategy must not be rerun to continue a pending batch")
|
||||
}
|
||||
}
|
||||
use chrono::Datelike;
|
||||
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let last = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let base = pool_batch_data();
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
let mut benchmarks = Vec::new();
|
||||
let mut quotes = Vec::new();
|
||||
for date in [first, last] {
|
||||
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
|
||||
let mut row = base.market(first, symbol).unwrap().clone();
|
||||
row.date = date;
|
||||
market.push(row);
|
||||
let mut row = base.candidate(first, symbol).unwrap().clone();
|
||||
row.date = date;
|
||||
candidates.push(row);
|
||||
factors.push(crate::data::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
for original in base.execution_quotes_on(first, symbol) {
|
||||
let mut quote = original.clone();
|
||||
quote.date = date;
|
||||
quote.timestamp = date.and_time(original.timestamp.time());
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
let mut row = limit_test_benchmark();
|
||||
row.date = date;
|
||||
benchmarks.push(row);
|
||||
}
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
base.instruments().values().cloned().collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
benchmarks,
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()]);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 2000.0,
|
||||
benchmark_code: "000852.SH".into(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(last),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
let result = BacktestEngine::new(data, DailyPool, pool_batch_broker(false), config)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.fills.len(),
|
||||
3,
|
||||
"initial buy, delayed sell, resumed buy: orders={:?} equity={:?}",
|
||||
result.order_events,
|
||||
result.equity_curve
|
||||
);
|
||||
assert_eq!(result.fills[2].symbol, "000002.SZ");
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(
|
||||
result.fills[2].execution_timestamp,
|
||||
Some(last.and_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
assert_eq!(result.holdings_summary.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
use crate::{
|
||||
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
|
||||
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
|
||||
};
|
||||
use chrono::{NaiveDate, TimeZone};
|
||||
|
||||
pub(crate) fn validate_action<'a>(
|
||||
action: &'a crate::CorporateAction,
|
||||
data: &DataSet,
|
||||
) -> Result<Option<(&'a str, f64, f64)>, String> {
|
||||
let terms = action.validated_successor_terms()?;
|
||||
crate::finite_serialization::validate(action).map_err(|error| {
|
||||
format!(
|
||||
"corporate_action_invalid_number: symbol={} action_date={} detail={error}",
|
||||
action.symbol, action.date
|
||||
)
|
||||
})?;
|
||||
if let Some((successor, _, _)) = terms {
|
||||
for (symbol, role) in [(&*action.symbol, "source"), (successor, "successor")] {
|
||||
if data.instrument(symbol).is_none() {
|
||||
return Err(format!(
|
||||
"corporate_action_{role}_instrument_missing: symbol={symbol} action_date={} source_symbol={}",
|
||||
action.date, action.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// One corporate-action calculation for normal processing and audited replay.
|
||||
pub(crate) fn apply(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let actions = data.corporate_actions_on(date);
|
||||
for action in actions {
|
||||
validate_action(action, data).map_err(BacktestError::Execution)?;
|
||||
}
|
||||
if !actions.iter().any(|action| {
|
||||
action.has_effect()
|
||||
&& portfolio
|
||||
.position(&action.symbol)
|
||||
.is_some_and(|position| position.quantity > 0)
|
||||
}) {
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
// An entire settlement batch is a single ledger update. A later invalid
|
||||
// cash leg must not leave an earlier split, receivable, target unit, or
|
||||
// note applied to the observed account.
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let report = apply_inner(
|
||||
date,
|
||||
data,
|
||||
&mut next,
|
||||
&mut recorded,
|
||||
cash_dividends_enabled,
|
||||
cash_dividend_adjusts_cost_basis,
|
||||
)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn apply_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for action in data.corporate_actions_on(date) {
|
||||
if !action.has_effect() {
|
||||
continue;
|
||||
}
|
||||
let Some(existing_position) = portfolio.position(&action.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if existing_position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
let (cash_delta, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for dividend action");
|
||||
let cash_delta = if cash_dividend_adjusts_cost_basis {
|
||||
position.apply_cash_dividend(action.share_cash)
|
||||
} else {
|
||||
position.apply_cash_dividend_preserve_cost_basis(action.share_cash)
|
||||
};
|
||||
(cash_delta, position.quantity, position.average_cost)
|
||||
};
|
||||
if cash_delta.abs() > f64::EPSILON {
|
||||
let payable_date = action.payable_date.unwrap_or(date);
|
||||
portfolio.add_cash_receivable(CashReceivable {
|
||||
symbol: action.symbol.clone(),
|
||||
ex_date: date,
|
||||
payable_date,
|
||||
amount: cash_delta,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
let note = format!(
|
||||
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
||||
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
|
||||
);
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity: 0,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for split")
|
||||
.quantity,
|
||||
split_ratio,
|
||||
0,
|
||||
)?;
|
||||
portfolio
|
||||
.adjust_stock_pool_split(&action.symbol, split_ratio)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let (delta_quantity, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for split action");
|
||||
let delta_quantity = position.apply_split_ratio(split_ratio);
|
||||
(delta_quantity, position.quantity, position.average_cost)
|
||||
};
|
||||
if delta_quantity != 0 {
|
||||
let note = format!(
|
||||
"stock_split {} ratio={:.6} delta_qty={}",
|
||||
action.symbol, split_ratio, delta_quantity
|
||||
);
|
||||
notes.push(note);
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("stock_split {:.6}", split_ratio),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((successor_symbol, ratio, cash_per_share)) = action
|
||||
.validated_successor_terms()
|
||||
.map_err(BacktestError::Execution)?
|
||||
{
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for conversion")
|
||||
.quantity,
|
||||
ratio,
|
||||
portfolio
|
||||
.position(successor_symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
)?;
|
||||
let Some(outcome) = portfolio.apply_successor_conversion(
|
||||
&action.symbol,
|
||||
successor_symbol,
|
||||
ratio,
|
||||
cash_per_share,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let reason = format!(
|
||||
"successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}",
|
||||
outcome.old_symbol, outcome.new_symbol, ratio, cash_per_share
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: outcome.old_symbol.clone(),
|
||||
delta_quantity: -(outcome.old_quantity as i32),
|
||||
quantity_after: 0,
|
||||
average_cost: 0.0,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: outcome.new_symbol.clone(),
|
||||
delta_quantity: outcome.new_quantity_delta,
|
||||
quantity_after: outcome.new_quantity_after,
|
||||
average_cost: outcome.new_average_cost_after,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
if outcome.cash_delta.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.apply_cash_delta(outcome.cash_delta)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note: format!("{} cash={:.2}", reason, outcome.cash_delta),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
portfolio.prune_flat_positions();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn checked_quantity(
|
||||
symbol: &str,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
ratio: f64,
|
||||
merged: u32,
|
||||
) -> Result<(), BacktestError> {
|
||||
let scaled = (f64::from(quantity) * ratio).round();
|
||||
if !scaled.is_finite()
|
||||
|| scaled < 0.
|
||||
|| scaled > f64::from(i32::MAX)
|
||||
|| scaled + f64::from(merged) > f64::from(u32::MAX)
|
||||
{
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"corporate_action_quantity_overflow: symbol={symbol} action_date={date}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Preserve the declared fee-free accounting allocation model; this does not
|
||||
/// submit a market order or use a later opening quote as an earlier fact.
|
||||
pub(crate) fn settle_receivables(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
if !portfolio
|
||||
.cash_receivables()
|
||||
.iter()
|
||||
.any(|row| row.payable_date <= date)
|
||||
{
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let control = if reinvest_enabled {
|
||||
manual_reinvestment_control(date, runtime_input)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let report = settle_receivables_inner(
|
||||
date,
|
||||
data,
|
||||
&mut next,
|
||||
&mut recorded,
|
||||
reinvest_enabled,
|
||||
control,
|
||||
)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The accounting stage precedes the market session. A later same-day setting
|
||||
/// must not retroactively change an allocation already observed at settlement.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ManualReinvestmentControl<'a> {
|
||||
Event(&'a crate::position_exposure::PositionExposureEvent),
|
||||
LegacyZero(NaiveDate),
|
||||
}
|
||||
|
||||
impl ManualReinvestmentControl<'_> {
|
||||
fn suppression(self, symbol: &str) -> Option<String> {
|
||||
use crate::position_exposure::PositionExposureAction as Action;
|
||||
match self {
|
||||
Self::LegacyZero(date) => Some(format!(
|
||||
"runtime_zero_exposure legacy_effective_date={date}"
|
||||
)),
|
||||
Self::Event(event) => {
|
||||
if matches!(event.action, Action::Restore) {
|
||||
return None;
|
||||
}
|
||||
let reason = if matches!(
|
||||
event.action,
|
||||
Action::Scale { requested_bps: 0 }
|
||||
| Action::Set {
|
||||
target_exposure_bps: 0
|
||||
}
|
||||
) {
|
||||
"runtime_zero_exposure"
|
||||
} else if event
|
||||
.allocation_weights_bps
|
||||
.as_ref()
|
||||
.is_some_and(|weights| weights.get(symbol).copied().unwrap_or(0) == 0)
|
||||
{
|
||||
"runtime_zero_allocation"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(format!(
|
||||
"{reason} event_sequence={} effective_at={}",
|
||||
event.sequence, event.effective_at
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_reinvestment_control(
|
||||
date: NaiveDate,
|
||||
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
|
||||
) -> Result<Option<ManualReinvestmentControl<'_>>, BacktestError> {
|
||||
let Some(input) = runtime_input else {
|
||||
return Ok(None);
|
||||
};
|
||||
let at = chrono::FixedOffset::east_opt(8 * 3600)
|
||||
.unwrap()
|
||||
.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: accounting stage clock is out of range".into(),
|
||||
)
|
||||
})?
|
||||
.with_timezone(&chrono::Utc);
|
||||
// The input has already been validated and bound to the runtime audit.
|
||||
// Keep the same (time, sequence) precedence as PositionExposureTimeline.
|
||||
if let Some(event) = input
|
||||
.position_exposure_events
|
||||
.iter()
|
||||
.filter(|event| event.effective_at <= at)
|
||||
.max_by_key(|event| (event.effective_at, event.sequence))
|
||||
{
|
||||
return Ok(Some(ManualReinvestmentControl::Event(event)));
|
||||
}
|
||||
Ok(input
|
||||
.legacy_position_exposure_bps
|
||||
.range(..=date)
|
||||
.next_back()
|
||||
.filter(|(_, bps)| **bps == 0)
|
||||
.map(|(day, _)| ManualReinvestmentControl::LegacyZero(*day)))
|
||||
}
|
||||
|
||||
fn settle_receivables_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
control: Option<ManualReinvestmentControl<'_>>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let due = portfolio.take_due_cash_receivables(date);
|
||||
for receivable in due {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.settle_cash_receivable(&receivable)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut note = format!(
|
||||
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
||||
);
|
||||
if let Some(suppression) =
|
||||
control.and_then(|control| control.suppression(&receivable.symbol))
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.
|
||||
{
|
||||
note.push_str(&format!(
|
||||
" dividend_reinvestment_not_applied reason={suppression} cash_retained=true"
|
||||
));
|
||||
} else if reinvest_enabled
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.0
|
||||
{
|
||||
let instrument = data.instrument(&receivable.symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: instrument metadata missing symbol={} payable_date={date}", receivable.symbol)))?;
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||
note.push_str(&format!(
|
||||
" dividend_reinvestment_not_applied reason={reason} cash_retained=true"
|
||||
));
|
||||
} else {
|
||||
let (reinvest_price, reference_basis) = if let Some(position) = portfolio
|
||||
.position(&receivable.symbol)
|
||||
.filter(|position| position.quantity > 0)
|
||||
{
|
||||
(Some(position.last_price), "adjusted_carried_mark")
|
||||
} else {
|
||||
(
|
||||
data.calendar().previous_day(date).and_then(|prev_date| {
|
||||
data.price_on_or_before(
|
||||
prev_date,
|
||||
&receivable.symbol,
|
||||
PriceField::Close,
|
||||
)
|
||||
}),
|
||||
"previous_completed_close",
|
||||
)
|
||||
};
|
||||
let price = reinvest_price.filter(|price| price.is_finite() && *price > 0.).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: accounting reference missing or invalid symbol={} payable_date={date} basis={reference_basis}", receivable.symbol)))?;
|
||||
let round_lot = instrument.round_lot;
|
||||
if round_lot == 0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: invalid quantity unit symbol={}",
|
||||
receivable.symbol
|
||||
)));
|
||||
}
|
||||
{
|
||||
let raw = (receivable.amount / price).floor();
|
||||
if !raw.is_finite() || raw > i32::MAX as f64 {
|
||||
return Err(BacktestError::Execution("dividend_reinvestment: accounting allocation quantity exceeds the ledger contract".into()));
|
||||
}
|
||||
let raw_quantity = raw as u32;
|
||||
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
|
||||
if reinvest_quantity > 0 {
|
||||
// Report the same micro-unit amount actually posted to
|
||||
// the ledger, not a floating multiplication residue.
|
||||
let reinvest_money =
|
||||
crate::FixedMoney::from_f64(reinvest_quantity as f64 * price)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: allocation amount out of range"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let cash_delta = reinvest_money.checked_neg().ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: cash amount out of range".into(),
|
||||
)
|
||||
})?;
|
||||
let residual_cash = crate::FixedMoney::from_f64(receivable.amount)
|
||||
.and_then(|cash| cash.checked_sub(reinvest_money))
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: residual amount out of range".into(),
|
||||
)
|
||||
})?
|
||||
.to_f64();
|
||||
let reinvest_cash = reinvest_money.to_f64();
|
||||
portfolio
|
||||
.apply_cash_delta_fixed(cash_delta)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.position_mut(&receivable.symbol).buy(
|
||||
date,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
);
|
||||
|
||||
note = format!(
|
||||
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
|
||||
receivable.symbol,
|
||||
receivable.ex_date,
|
||||
receivable.payable_date,
|
||||
receivable.amount,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
residual_cash
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::DividendReinvestment,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
execution_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: reinvest_quantity,
|
||||
price,
|
||||
gross_amount: reinvest_cash,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: cash_delta.to_f64(),
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: receivable.symbol.clone(),
|
||||
delta_quantity: reinvest_quantity as i32,
|
||||
quantity_after: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.average_cost)
|
||||
.unwrap_or(0.0),
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.process_events.push(ProcessEvent {
|
||||
date,
|
||||
kind: ProcessEventKind::Trade,
|
||||
order_id: None,
|
||||
symbol: Some(receivable.symbol.clone()),
|
||||
side: Some(OrderSide::Buy),
|
||||
detail: format!("dividend_reinvestment model=fee_free_accounting booked_at={} quantity={} price={} reference_basis={} ex_date={} payable_date={} residual_cash={}",
|
||||
date.and_hms_opt(0,0,0).unwrap(), reinvest_quantity, price, reference_basis,
|
||||
receivable.ex_date, receivable.payable_date, residual_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn date() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
|
||||
}
|
||||
fn data(delisted: bool) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: delisted.then_some(date()),
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(10.);
|
||||
book.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".into(),
|
||||
ex_date: date().pred_opt().unwrap(),
|
||||
payable_date: date(),
|
||||
amount: 100.,
|
||||
reason: "cash_dividend 1".into(),
|
||||
});
|
||||
book
|
||||
}
|
||||
#[test]
|
||||
fn missing_accounting_reference_is_atomic_not_a_silent_cash_only_success() {
|
||||
let mut book = book();
|
||||
let before = book.financial_replay_identity();
|
||||
let error =
|
||||
settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true, None)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("accounting reference missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
}
|
||||
#[test]
|
||||
fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() {
|
||||
let mut book = book();
|
||||
let mut notes = Vec::new();
|
||||
let report =
|
||||
settle_receivables(date(), &data(true), &mut book, &mut notes, true, None).unwrap();
|
||||
assert_eq!(book.cash(), 110.);
|
||||
assert!(book.positions().is_empty());
|
||||
assert!(book.cash_receivables().is_empty());
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_zero_skips_only_unused_allocation_facts_not_invalid_cash_evidence() {
|
||||
let mut input = crate::manual_execution::ManualExecutionReplay {
|
||||
schema: crate::manual_execution::MANUAL_REPLAY_SCHEMA.into(),
|
||||
runtime_id: "runtime".into(),
|
||||
account_id: "account".into(),
|
||||
source_contract_sha256: "a".repeat(64),
|
||||
content_sha256: String::new(),
|
||||
observation_cutoff: "2026-09-14T08:00:00Z".parse().unwrap(),
|
||||
actions: vec![],
|
||||
position_exposure_events: vec![],
|
||||
legacy_position_exposure_bps: std::collections::BTreeMap::from([(date(), 0)]),
|
||||
};
|
||||
input.content_sha256 = input.content_digest().unwrap();
|
||||
input.validate().unwrap();
|
||||
let mut account = book();
|
||||
let mut notes = Vec::new();
|
||||
// No historical price is present, but no optional purchase is wanted.
|
||||
let report = settle_receivables(
|
||||
date(),
|
||||
&data(false),
|
||||
&mut account,
|
||||
&mut notes,
|
||||
true,
|
||||
Some(&input),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(account.cash(), 110.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("runtime_zero_exposure"));
|
||||
let mut account = book();
|
||||
account.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".into(),
|
||||
ex_date: date(),
|
||||
payable_date: date(),
|
||||
amount: f64::NAN,
|
||||
reason: "cash_dividend invalid fixture".into(),
|
||||
});
|
||||
let mut notes = vec!["prior".into()];
|
||||
assert!(
|
||||
settle_receivables(
|
||||
date(),
|
||||
&data(false),
|
||||
&mut account,
|
||||
&mut notes,
|
||||
true,
|
||||
Some(&input)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 10.);
|
||||
assert_eq!(account.cash_receivables().len(), 2);
|
||||
assert!(account.cash_receivables()[1].amount.is_nan());
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
fn conversion() -> crate::CorporateAction {
|
||||
crate::CorporateAction {
|
||||
date: date(),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some("000002.SZ".into()),
|
||||
successor_ratio: Some(1.5),
|
||||
successor_cash: Some(0.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_data(actions: Vec<crate::CorporateAction>, include_successor: bool) -> DataSet {
|
||||
let mut instruments = data(false)
|
||||
.instruments()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if include_successor {
|
||||
let mut successor = instruments[0].clone();
|
||||
successor.symbol = "000002.SZ".into();
|
||||
instruments.push(successor);
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
instruments,
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
actions,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn conversion_book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(1000.);
|
||||
book.position_mut("000001.SZ")
|
||||
.buy(date().pred_opt().unwrap(), 100, 10.);
|
||||
book
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security() {
|
||||
let mut action = conversion();
|
||||
action.share_cash = 1.;
|
||||
action.share_bonus = 1.;
|
||||
let data = conversion_data(vec![action], false);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash() {
|
||||
let base = conversion();
|
||||
let mut cases = Vec::new();
|
||||
for ratio in [
|
||||
None,
|
||||
Some(0.),
|
||||
Some(-1.),
|
||||
Some(f64::NAN),
|
||||
Some(f64::INFINITY),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_ratio = ratio;
|
||||
cases.push(row);
|
||||
}
|
||||
for symbol in [
|
||||
None,
|
||||
Some(""),
|
||||
Some(" "),
|
||||
Some("000001.SZ"),
|
||||
Some(" 000002.SZ"),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_symbol = symbol.map(str::to_owned);
|
||||
cases.push(row);
|
||||
}
|
||||
for cash in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut row = base.clone();
|
||||
row.successor_cash = Some(cash);
|
||||
cases.push(row);
|
||||
}
|
||||
for action in cases {
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let data = conversion_data(vec![action.clone()], true);
|
||||
let mut notes = Vec::new();
|
||||
assert!(
|
||||
apply(date(), &data, &mut book, &mut notes, true, true).is_err(),
|
||||
"accepted {action:?}"
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes() {
|
||||
let mut dividend = conversion();
|
||||
dividend.successor_symbol = None;
|
||||
dividend.successor_ratio = None;
|
||||
dividend.successor_cash = None;
|
||||
dividend.share_cash = 1.;
|
||||
dividend.share_bonus = 1.;
|
||||
let mut failure = conversion();
|
||||
failure.successor_cash = Some(1e100);
|
||||
let data = conversion_data(vec![dividend, failure], true);
|
||||
let mut book = conversion_book();
|
||||
let mut state = crate::stock_pool_state::StockPoolExecutionState {
|
||||
last_execution_date: date().pred_opt(),
|
||||
..Default::default()
|
||||
};
|
||||
state.position_action_bases.insert(
|
||||
"000001.SZ".into(),
|
||||
crate::stock_pool_state::StockPoolPositionActionBasis {
|
||||
generation: "original".into(),
|
||||
first_execution_date: date().pred_opt().unwrap(),
|
||||
quantity: rust_decimal::Decimal::from(100),
|
||||
},
|
||||
);
|
||||
state.last_target_weights.insert("000001.SZ".into(), 10000);
|
||||
book.set_stock_pool_execution_state("pool", state.clone())
|
||||
.unwrap();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
assert!(apply(date(), &data, &mut book, &mut notes, true, true).is_err());
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(book.stock_pool_execution_state("pool"), state);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_quantity_overflow_fails_without_saturation_or_a_negative_delta() {
|
||||
for split in [false, true] {
|
||||
let mut action = conversion();
|
||||
if split {
|
||||
action.share_bonus = 1e100;
|
||||
} else {
|
||||
action.successor_ratio = Some(1e100);
|
||||
}
|
||||
let data = conversion_data(vec![action], true);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = Vec::new();
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("corporate_action_quantity_overflow")
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_conversion_preserves_lots_without_creating_orders_or_fills() {
|
||||
let data = conversion_data(vec![conversion()], true);
|
||||
let mut book = conversion_book();
|
||||
book.position_mut("000002.SZ").buy(date(), 50, 20.);
|
||||
let mut notes = Vec::new();
|
||||
let report = apply(date(), &data, &mut book, &mut notes, true, true).unwrap();
|
||||
assert!(book.position("000001.SZ").is_none());
|
||||
let successor = book.position("000002.SZ").unwrap();
|
||||
assert_eq!(successor.quantity, 200);
|
||||
assert_eq!(successor.opened_date(), date().pred_opt());
|
||||
assert_eq!(successor.last_buy_date(), Some(date()));
|
||||
assert_eq!(book.cash(), 1050.);
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(report.order_events.is_empty());
|
||||
assert_eq!(report.position_events.len(), 2);
|
||||
assert!(notes[0].contains("ratio=1.500000"));
|
||||
}
|
||||
}
|
||||
+265
-63
@@ -3,24 +3,50 @@ use std::collections::BTreeMap;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::events::OrderSide;
|
||||
|
||||
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
|
||||
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
|
||||
use crate::risk_control::TradingConstraintConfig;
|
||||
use crate::Instrument;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TradingCost {
|
||||
pub commission: f64,
|
||||
pub stamp_tax: f64,
|
||||
pub transfer_fee: f64,
|
||||
}
|
||||
|
||||
impl TradingCost {
|
||||
pub fn total(self) -> f64 {
|
||||
self.commission + self.stamp_tax
|
||||
self.fixed_total().to_f64()
|
||||
}
|
||||
|
||||
pub fn fixed_total(self) -> FixedMoney {
|
||||
FixedMoney::checked_sum_f64([self.commission, self.stamp_tax, self.transfer_fee])
|
||||
.expect("trading costs must be finite fixed-point money")
|
||||
}
|
||||
|
||||
fn from_fixed(value: FixedTradingCost) -> Self {
|
||||
Self {
|
||||
commission: value.commission.to_f64(),
|
||||
stamp_tax: value.stamp_tax.to_f64(),
|
||||
transfer_fee: value.transfer_fee.to_f64(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CostModel {
|
||||
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost;
|
||||
|
||||
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, _instrument: Option<&Instrument>) -> TradingCost {
|
||||
self.calculate(date, side, gross_amount)
|
||||
}
|
||||
|
||||
fn calculate_with_order_state_for_instrument(
|
||||
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
|
||||
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, _instrument: Option<&Instrument>,
|
||||
) -> TradingCost {
|
||||
self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state)
|
||||
}
|
||||
|
||||
fn calculate_with_order_state(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -35,58 +61,133 @@ pub trait CostModel {
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ChinaAShareCostModel {
|
||||
pub commission_rate: f64,
|
||||
pub stamp_tax_rate_before_change: f64,
|
||||
pub stamp_tax_rate_after_change: f64,
|
||||
pub minimum_commission: f64,
|
||||
fixed: FixedChinaAShareCostModel,
|
||||
}
|
||||
|
||||
impl Default for ChinaAShareCostModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
commission_rate: 0.0008,
|
||||
stamp_tax_rate_before_change: 0.001,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
minimum_commission: 5.0,
|
||||
}
|
||||
Self::from_trading_constraints(TradingConstraintConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl ChinaAShareCostModel {
|
||||
pub fn aiquant_rqalpha_default() -> Self {
|
||||
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
|
||||
Self {
|
||||
stamp_tax_rate_before_change: 0.0005,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
..Self::default()
|
||||
fixed: FixedChinaAShareCostModel {
|
||||
commission_rate: Self::fixed_money(config.commission_rate, "commission rate"),
|
||||
stamp_tax_rate_before_change: Self::fixed_money(
|
||||
config.stamp_tax_rate_before_change,
|
||||
"stamp tax rate before change",
|
||||
),
|
||||
stamp_tax_rate_after_change: Self::fixed_money(
|
||||
config.stamp_tax_rate_after_change,
|
||||
"stamp tax rate after change",
|
||||
),
|
||||
stamp_tax_change_date: config.stamp_tax_change_date,
|
||||
minimum_commission: Self::fixed_money(
|
||||
config.minimum_commission,
|
||||
"minimum commission",
|
||||
),
|
||||
transfer_fee_rate: Self::fixed_money(config.transfer_fee_rate, "transfer fee rate"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_commission_rate(&mut self, value: f64) {
|
||||
self.fixed.commission_rate = Self::fixed_money(value, "commission rate");
|
||||
}
|
||||
|
||||
pub fn set_minimum_commission(&mut self, value: f64) {
|
||||
self.fixed.minimum_commission = Self::fixed_money(value, "minimum commission");
|
||||
}
|
||||
|
||||
pub fn set_transfer_fee_rate(&mut self, value: f64) {
|
||||
self.fixed.transfer_fee_rate = Self::fixed_money(value, "transfer fee rate");
|
||||
}
|
||||
|
||||
pub fn set_stamp_tax_rate_before_change(&mut self, value: f64) {
|
||||
self.fixed.stamp_tax_rate_before_change =
|
||||
Self::fixed_money(value, "stamp tax rate before change");
|
||||
}
|
||||
|
||||
pub fn set_stamp_tax_rate_after_change(&mut self, value: f64) {
|
||||
self.fixed.stamp_tax_rate_after_change =
|
||||
Self::fixed_money(value, "stamp tax rate after change");
|
||||
}
|
||||
|
||||
pub fn set_stamp_tax_change_date(&mut self, value: NaiveDate) {
|
||||
self.fixed.stamp_tax_change_date = value;
|
||||
}
|
||||
|
||||
pub fn commission_rate(&self) -> f64 {
|
||||
self.fixed.commission_rate.to_f64()
|
||||
}
|
||||
|
||||
pub fn minimum_commission(&self) -> f64 {
|
||||
self.fixed.minimum_commission.to_f64()
|
||||
}
|
||||
|
||||
pub fn transfer_fee_rate(&self) -> f64 {
|
||||
self.fixed.transfer_fee_rate.to_f64()
|
||||
}
|
||||
|
||||
pub fn stamp_tax_rate_before_change(&self) -> f64 {
|
||||
self.fixed.stamp_tax_rate_before_change.to_f64()
|
||||
}
|
||||
|
||||
pub fn stamp_tax_rate_after_change(&self) -> f64 {
|
||||
self.fixed.stamp_tax_rate_after_change.to_f64()
|
||||
}
|
||||
|
||||
pub fn stamp_tax_change_date(&self) -> NaiveDate {
|
||||
self.fixed.stamp_tax_change_date
|
||||
}
|
||||
|
||||
pub fn with_commission_rate(mut self, value: f64) -> Self {
|
||||
self.set_commission_rate(value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_minimum_commission(mut self, value: f64) -> Self {
|
||||
self.set_minimum_commission(value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_stamp_tax_rates(mut self, before: f64, after: f64) -> Self {
|
||||
self.set_stamp_tax_rate_before_change(before);
|
||||
self.set_stamp_tax_rate_after_change(after);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn commission_for(&self, gross_amount: f64) -> f64 {
|
||||
if gross_amount <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(gross_amount * self.commission_rate).max(self.minimum_commission)
|
||||
self.fixed_model()
|
||||
.commission_for(Self::fixed_money(gross_amount, "gross amount"))
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
|
||||
let change_date = NaiveDate::from_ymd_opt(
|
||||
STOCK_PIT_TAX_CHANGE_DATE.0,
|
||||
STOCK_PIT_TAX_CHANGE_DATE.1,
|
||||
STOCK_PIT_TAX_CHANGE_DATE.2,
|
||||
)
|
||||
.expect("valid pit tax change date");
|
||||
if date < change_date {
|
||||
self.stamp_tax_rate_before_change
|
||||
} else {
|
||||
self.stamp_tax_rate_after_change
|
||||
}
|
||||
self.fixed.stamp_tax_rate_for(date).to_f64()
|
||||
}
|
||||
|
||||
pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 {
|
||||
if gross_amount <= 0.0 || side == OrderSide::Buy {
|
||||
return 0.0;
|
||||
}
|
||||
gross_amount * self.stamp_tax_rate_for(date)
|
||||
self.fixed_model()
|
||||
.stamp_tax_for(date, side, Self::fixed_money(gross_amount, "gross amount"))
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
|
||||
if gross_amount <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.fixed_model()
|
||||
.transfer_fee_for(Self::fixed_money(gross_amount, "gross amount"))
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
pub fn commission_for_order_fill(
|
||||
@@ -99,50 +200,68 @@ impl ChinaAShareCostModel {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let raw_commission = gross_amount * self.commission_rate;
|
||||
let Some(order_id) = order_id else {
|
||||
return raw_commission.max(self.minimum_commission);
|
||||
return self.commission_for(gross_amount);
|
||||
};
|
||||
|
||||
let remaining_minimum = commission_state
|
||||
.entry(order_id)
|
||||
.or_insert(self.minimum_commission);
|
||||
if raw_commission > *remaining_minimum {
|
||||
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
|
||||
raw_commission
|
||||
} else {
|
||||
raw_commission - *remaining_minimum
|
||||
};
|
||||
*remaining_minimum = 0.0;
|
||||
charged
|
||||
} else {
|
||||
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
|
||||
self.minimum_commission
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*remaining_minimum -= raw_commission;
|
||||
charged
|
||||
}
|
||||
.or_insert(self.fixed.minimum_commission.to_f64());
|
||||
let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission");
|
||||
let charged = self.fixed_model().commission_for_order_fill_remaining(
|
||||
Self::fixed_money(gross_amount, "gross amount"),
|
||||
&mut fixed_remaining,
|
||||
);
|
||||
*remaining_minimum = fixed_remaining.to_f64();
|
||||
charged.to_f64()
|
||||
}
|
||||
|
||||
fn fixed_money(value: f64, label: &str) -> FixedMoney {
|
||||
FixedMoney::from_f64(value)
|
||||
.unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}"))
|
||||
}
|
||||
|
||||
fn fixed_model(&self) -> FixedChinaAShareCostModel {
|
||||
self.fixed
|
||||
}
|
||||
}
|
||||
|
||||
impl CostModel for ChinaAShareCostModel {
|
||||
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, instrument: Option<&Instrument>) -> TradingCost {
|
||||
let mut cost = self.calculate(date, side, gross_amount);
|
||||
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
|
||||
cost.stamp_tax = 0.0;
|
||||
cost.transfer_fee = 0.0;
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
fn calculate_with_order_state_for_instrument(
|
||||
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
|
||||
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, instrument: Option<&Instrument>,
|
||||
) -> TradingCost {
|
||||
let mut cost = self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state);
|
||||
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
|
||||
cost.stamp_tax = 0.0;
|
||||
cost.transfer_fee = 0.0;
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
|
||||
if gross_amount <= 0.0 {
|
||||
return TradingCost {
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let commission = self.commission_for(gross_amount);
|
||||
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
||||
|
||||
TradingCost {
|
||||
commission,
|
||||
stamp_tax,
|
||||
}
|
||||
TradingCost::from_fixed(self.fixed_model().calculate(
|
||||
date,
|
||||
side,
|
||||
Self::fixed_money(gross_amount, "gross amount"),
|
||||
))
|
||||
}
|
||||
|
||||
fn calculate_with_order_state(
|
||||
@@ -157,15 +276,98 @@ impl CostModel for ChinaAShareCostModel {
|
||||
return TradingCost {
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
|
||||
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
||||
|
||||
TradingCost {
|
||||
let fixed_model = self.fixed_model();
|
||||
let fixed_gross = Self::fixed_money(gross_amount, "gross amount");
|
||||
let commission = if let Some(order_id) = order_id {
|
||||
let remaining = commission_state
|
||||
.entry(order_id)
|
||||
.or_insert(self.fixed.minimum_commission.to_f64());
|
||||
let mut fixed_remaining = Self::fixed_money(*remaining, "remaining commission");
|
||||
let commission =
|
||||
fixed_model.commission_for_order_fill_remaining(fixed_gross, &mut fixed_remaining);
|
||||
*remaining = fixed_remaining.to_f64();
|
||||
commission
|
||||
} else {
|
||||
fixed_model.commission_for(fixed_gross)
|
||||
};
|
||||
TradingCost::from_fixed(FixedTradingCost {
|
||||
commission,
|
||||
stamp_tax,
|
||||
}
|
||||
stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross),
|
||||
transfer_fee: fixed_model.transfer_fee_for(fixed_gross),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fund_fees_use_admitted_instrument_type_and_share_the_order_commission_budget() {
|
||||
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
|
||||
let model=ChinaAShareCostModel::from_trading_constraints(TradingConstraintConfig{commission_rate:0.0003,minimum_commission:5.,transfer_fee_rate:0.00001,..Default::default()});
|
||||
let mut instrument=Instrument{symbol:"510300.SH".into(),name:"fixture".into(),board:"ETF".into(),round_lot:100,listed_at:Some(day),delisted_at:None,status:"active".into()};
|
||||
for side in [OrderSide::Buy,OrderSide::Sell] {
|
||||
let cost=model.calculate_for_instrument(day,side,10_000.,Some(&instrument));
|
||||
assert_eq!(cost.commission,5.);assert_eq!(cost.stamp_tax,0.);assert_eq!(cost.transfer_fee,0.);
|
||||
let mut state=BTreeMap::new();
|
||||
let one=model.calculate_with_order_state_for_instrument(day,side,1_000.,Some(1),&mut state,Some(&instrument));
|
||||
let two=model.calculate_with_order_state_for_instrument(day,side,9_000.,Some(1),&mut state,Some(&instrument));
|
||||
assert_eq!(one.total()+two.total(),cost.total());
|
||||
}
|
||||
instrument.board="SH".into();
|
||||
let stock=model.calculate_for_instrument(day,OrderSide::Sell,10_000.,Some(&instrument));
|
||||
assert_eq!(stock.stamp_tax,5.);assert_eq!(stock.transfer_fee,0.1);
|
||||
assert_eq!(stock.total(),model.calculate(day,OrderSide::Sell,10_000.).total());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_quantizes_fees_to_micro_yuan() {
|
||||
let model = ChinaAShareCostModel::default();
|
||||
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
|
||||
|
||||
assert!((model.commission_for(248_059.812) - 74.417944).abs() < 1e-12);
|
||||
assert!(
|
||||
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.873504).abs() < 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_model_can_use_configurable_stamp_tax_change_date() {
|
||||
let config = TradingConstraintConfig {
|
||||
commission_rate: 0.0003,
|
||||
minimum_commission: 5.0,
|
||||
transfer_fee_rate: 0.00001,
|
||||
stamp_tax_rate_before_change: 0.002,
|
||||
stamp_tax_rate_after_change: 0.001,
|
||||
stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
||||
..TradingConstraintConfig::default()
|
||||
};
|
||||
let model = ChinaAShareCostModel::from_trading_constraints(config);
|
||||
|
||||
assert!((model.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12);
|
||||
|
||||
assert!(
|
||||
(model.stamp_tax_for(
|
||||
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
|
||||
OrderSide::Sell,
|
||||
10_000.0
|
||||
) - 20.0)
|
||||
.abs()
|
||||
< 1e-9
|
||||
);
|
||||
assert!(
|
||||
(model.stamp_tax_for(
|
||||
NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
||||
OrderSide::Sell,
|
||||
10_000.0
|
||||
) - 10.0)
|
||||
.abs()
|
||||
< 1e-9
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+4694
-1199
File diff suppressed because it is too large
Load Diff
+7246
-1154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
//! Historical ETF execution fallback. Never manufactures an intraday bar.
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::{BacktestError, DataSet};
|
||||
|
||||
pub(crate) fn opening_time() -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct EtfFallbackReference {
|
||||
pub price: f64,
|
||||
pub reference_date: NaiveDate,
|
||||
/// None means the next official session is outside the loaded calendar.
|
||||
/// No natural-day guess or price from beyond the requested run is used.
|
||||
pub execute_on: Option<NaiveDate>,
|
||||
pub immediate: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
|
||||
let fail = |reason: &str| BacktestError::Execution(format!(
|
||||
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
|
||||
));
|
||||
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
|
||||
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
|
||||
return Err(fail("verified_etf_identity_required"));
|
||||
}
|
||||
if instrument.dated_market_absence_reason(at.date()).is_some() {
|
||||
return Err(fail("outside_instrument_lifecycle"));
|
||||
}
|
||||
if at.time() == opening_time() {
|
||||
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
|
||||
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
|
||||
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
|
||||
}
|
||||
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
|
||||
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
|
||||
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
|
||||
Ok(EtfFallbackReference {
|
||||
price: close, reference_date: previous, immediate: false,
|
||||
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeferredEtfTarget {
|
||||
pub pool_id: String,
|
||||
pub generation: String,
|
||||
pub symbol: String,
|
||||
pub signal_date: NaiveDate,
|
||||
pub signal_at: NaiveDateTime,
|
||||
pub execute_on: Option<NaiveDate>,
|
||||
pub target_value: Decimal,
|
||||
pub target_weight_bps: i32,
|
||||
pub side: crate::stock_pool_execution::OrderSide,
|
||||
pub max_positions: usize,
|
||||
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
|
||||
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Owned by one broker/run. Replacing a full pool generation supersedes older
|
||||
/// queued targets; order of the latest candidate list is retained.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct DeferredEtfTargets {
|
||||
generations: std::collections::BTreeMap<String, String>,
|
||||
rows: Vec<DeferredEtfTarget>,
|
||||
}
|
||||
|
||||
impl DeferredEtfTargets {
|
||||
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
|
||||
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
|
||||
self.generations.insert(pool_id.into(), generation.into());
|
||||
let before = self.rows.len();
|
||||
self.rows.retain(|row| row.pool_id != pool_id);
|
||||
before - self.rows.len()
|
||||
}
|
||||
pub fn upsert(&mut self, row: DeferredEtfTarget) {
|
||||
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
|
||||
*existing = row;
|
||||
} else { self.rows.push(row); }
|
||||
}
|
||||
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
|
||||
let mut due = Vec::new();
|
||||
self.rows.retain(|row| {
|
||||
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
|
||||
});
|
||||
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
|
||||
due
|
||||
}
|
||||
pub fn len(&self) -> usize { self.rows.len() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
|
||||
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
|
||||
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
|
||||
}
|
||||
#[test]
|
||||
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
|
||||
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
|
||||
let mut queue=DeferredEtfTargets::default();
|
||||
queue.replace_generation("pool","v1");
|
||||
queue.upsert(target("510300.SH",Buy,"v1"));
|
||||
queue.upsert(target("159915.SZ",Buy,"v1"));
|
||||
assert_eq!(queue.replace_generation("pool","v1"),0);
|
||||
assert_eq!(queue.replace_generation("pool","v2"),2);
|
||||
queue.upsert(target("560450.SH",Buy,"v2"));
|
||||
queue.upsert(target("159915.SZ",Sell,"v2"));
|
||||
queue.upsert(target("510300.SH",Buy,"v2"));
|
||||
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
|
||||
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
|
||||
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
|
||||
assert!(due.iter().all(|v|v.generation=="v2"));
|
||||
assert_eq!(queue.len(),0);
|
||||
}
|
||||
#[test]
|
||||
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
|
||||
let mut queue=DeferredEtfTargets::default();
|
||||
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
|
||||
item.execute_on=None;
|
||||
queue.upsert(item);
|
||||
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
|
||||
assert_eq!(queue.len(),1);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,15 @@ impl ProcessEventBus {
|
||||
loader.install_enabled(self, enabled_names)
|
||||
}
|
||||
|
||||
pub fn has_listeners_for(&self, kinds: &[ProcessEventKind]) -> bool {
|
||||
!self.any_listeners.is_empty()
|
||||
|| kinds.iter().any(|kind| {
|
||||
self.listeners
|
||||
.get(kind)
|
||||
.is_some_and(|listeners| !listeners.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn publish(&mut self, event: &ProcessEvent) {
|
||||
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
|
||||
for listener in listeners {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod date_format {
|
||||
@@ -23,6 +23,62 @@ mod date_format {
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d";
|
||||
|
||||
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match date {
|
||||
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
value
|
||||
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_datetime_format {
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
|
||||
|
||||
pub fn serialize<S>(datetime: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match datetime {
|
||||
Some(datetime) => serializer.serialize_some(&datetime.format(FORMAT).to_string()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDateTime>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
value
|
||||
.map(|text| {
|
||||
NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum OrderSide {
|
||||
Buy,
|
||||
@@ -45,6 +101,7 @@ pub enum OrderStatus {
|
||||
PartiallyFilled,
|
||||
Canceled,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl OrderStatus {
|
||||
@@ -55,6 +112,7 @@ impl OrderStatus {
|
||||
Self::PartiallyFilled => "partially_filled",
|
||||
Self::Canceled => "canceled",
|
||||
Self::Rejected => "rejected",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +121,12 @@ impl OrderStatus {
|
||||
pub struct OrderEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -73,10 +137,86 @@ pub struct OrderEvent {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl OrderEvent {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.symbol.trim().is_empty() || self.requested_quantity == 0 {
|
||||
return Err(format!(
|
||||
"invalid order identity/quantity order_id={:?} symbol={} requested={}",
|
||||
self.order_id, self.symbol, self.requested_quantity
|
||||
));
|
||||
}
|
||||
if self.filled_quantity > self.requested_quantity {
|
||||
return Err(format!(
|
||||
"order overfill order_id={:?} requested={} filled={}",
|
||||
self.order_id, self.requested_quantity, self.filled_quantity
|
||||
));
|
||||
}
|
||||
let quantity_valid = match self.status {
|
||||
OrderStatus::Pending => self.filled_quantity < self.requested_quantity,
|
||||
OrderStatus::Filled => self.filled_quantity == self.requested_quantity,
|
||||
OrderStatus::PartiallyFilled => {
|
||||
self.filled_quantity > 0 && self.filled_quantity < self.requested_quantity
|
||||
}
|
||||
OrderStatus::Canceled => self.filled_quantity < self.requested_quantity,
|
||||
OrderStatus::Rejected => self.filled_quantity == 0,
|
||||
OrderStatus::Expired => self.filled_quantity < self.requested_quantity,
|
||||
};
|
||||
if !quantity_valid {
|
||||
return Err(format!(
|
||||
"order status/quantity mismatch order_id={:?} status={} requested={} filled={}",
|
||||
self.order_id,
|
||||
self.status.as_str(),
|
||||
self.requested_quantity,
|
||||
self.filled_quantity
|
||||
));
|
||||
}
|
||||
if self.reason.trim().is_empty() {
|
||||
return Err(format!(
|
||||
"order reason is empty order_id={:?} status={}",
|
||||
self.order_id,
|
||||
self.status.as_str()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FillOrigin {
|
||||
#[default]
|
||||
MarketExecution,
|
||||
DividendReinvestment,
|
||||
}
|
||||
|
||||
impl FillOrigin {
|
||||
pub fn is_market_execution(&self) -> bool { *self == Self::MarketExecution }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FillEvent {
|
||||
#[serde(default, skip_serializing_if = "FillOrigin::is_market_execution")]
|
||||
pub origin: FillOrigin,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(
|
||||
default,
|
||||
with = "optional_datetime_format",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub execution_start_timestamp: Option<NaiveDateTime>,
|
||||
#[serde(
|
||||
default,
|
||||
with = "optional_datetime_format",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub execution_timestamp: Option<NaiveDateTime>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -86,10 +226,55 @@ pub struct FillEvent {
|
||||
pub gross_amount: f64,
|
||||
pub commission: f64,
|
||||
pub stamp_tax: f64,
|
||||
pub transfer_fee: f64,
|
||||
pub net_cash_flow: f64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl FillEvent {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.origin == FillOrigin::DividendReinvestment && (
|
||||
self.order_id.is_some() || self.side != OrderSide::Buy
|
||||
|| self.commission != 0. || self.stamp_tax != 0. || self.transfer_fee != 0.
|
||||
|| self.execution_timestamp != self.date.and_hms_opt(0, 0, 0)
|
||||
|| self.execution_start_timestamp != self.execution_timestamp
|
||||
) {
|
||||
return Err("dividend accounting allocation cannot carry an exchange order, fees, or a market clock".into());
|
||||
}
|
||||
if self.symbol.trim().is_empty()
|
||||
|| self.quantity == 0
|
||||
|| !self.price.is_finite()
|
||||
|| self.price <= 0.0
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid fill identity/quantity/price order_id={:?} symbol={} quantity={} price={}",
|
||||
self.order_id, self.symbol, self.quantity, self.price
|
||||
));
|
||||
}
|
||||
if let (Some(start), Some(end)) = (self.execution_start_timestamp, self.execution_timestamp)
|
||||
{
|
||||
if start > end {
|
||||
return Err(format!(
|
||||
"fill execution timestamp order is invalid order_id={:?} start={} end={}",
|
||||
self.order_id, start, end
|
||||
));
|
||||
}
|
||||
if start.date() != self.date || end.date() != self.date {
|
||||
return Err(format!(
|
||||
"fill execution timestamp date mismatch order_id={:?} fill_date={} start={} end={}",
|
||||
self.order_id, self.date, start, end
|
||||
));
|
||||
}
|
||||
} else if self.execution_start_timestamp.is_some() || self.execution_timestamp.is_some() {
|
||||
return Err(format!(
|
||||
"fill execution timestamp range is incomplete order_id={:?}",
|
||||
self.order_id
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PositionEvent {
|
||||
#[serde(with = "date_format")]
|
||||
@@ -123,9 +308,9 @@ pub enum ProcessEventKind {
|
||||
PreBar,
|
||||
Bar,
|
||||
PostBar,
|
||||
PreTick,
|
||||
Tick,
|
||||
PostTick,
|
||||
PreMinute,
|
||||
Minute,
|
||||
PostMinute,
|
||||
PreScheduled,
|
||||
PostScheduled,
|
||||
PreOnDay,
|
||||
@@ -143,14 +328,20 @@ pub enum ProcessEventKind {
|
||||
OrderPendingCancel,
|
||||
OrderCancellationPass,
|
||||
OrderCancellationReject,
|
||||
OrderPendingUpdate,
|
||||
OrderUpdatePass,
|
||||
OrderUpdateReject,
|
||||
OrderUnsolicitedUpdate,
|
||||
Trade,
|
||||
ManualExecutionObserved,
|
||||
UniverseUpdated,
|
||||
UniverseSubscribed,
|
||||
UniverseUnsubscribed,
|
||||
AccountDepositWithdraw,
|
||||
AccountFinanceRepay,
|
||||
AccountManagementFee,
|
||||
SessionCapacityAudit,
|
||||
EtfExecutionFallback,
|
||||
}
|
||||
|
||||
impl ProcessEventKind {
|
||||
@@ -165,9 +356,9 @@ impl ProcessEventKind {
|
||||
Self::PreBar => "pre_bar",
|
||||
Self::Bar => "bar",
|
||||
Self::PostBar => "post_bar",
|
||||
Self::PreTick => "pre_tick",
|
||||
Self::Tick => "tick",
|
||||
Self::PostTick => "post_tick",
|
||||
Self::PreMinute => "pre_minute",
|
||||
Self::Minute => "minute",
|
||||
Self::PostMinute => "post_minute",
|
||||
Self::PreScheduled => "pre_scheduled",
|
||||
Self::PostScheduled => "post_scheduled",
|
||||
Self::PreOnDay => "pre_on_day",
|
||||
@@ -185,16 +376,57 @@ impl ProcessEventKind {
|
||||
Self::OrderPendingCancel => "order_pending_cancel",
|
||||
Self::OrderCancellationPass => "order_cancellation_pass",
|
||||
Self::OrderCancellationReject => "order_cancellation_reject",
|
||||
Self::OrderPendingUpdate => "order_pending_update",
|
||||
Self::OrderUpdatePass => "order_update_pass",
|
||||
Self::OrderUpdateReject => "order_update_reject",
|
||||
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
|
||||
Self::Trade => "trade",
|
||||
Self::ManualExecutionObserved => "manual_execution_observed",
|
||||
Self::UniverseUpdated => "universe_updated",
|
||||
Self::UniverseSubscribed => "universe_subscribed",
|
||||
Self::UniverseUnsubscribed => "universe_unsubscribed",
|
||||
Self::AccountDepositWithdraw => "account_deposit_withdraw",
|
||||
Self::AccountFinanceRepay => "account_finance_repay",
|
||||
Self::AccountManagementFee => "account_management_fee",
|
||||
Self::SessionCapacityAudit => "session_capacity_audit",
|
||||
Self::EtfExecutionFallback => "etf_execution_fallback",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the event is part of the durable business lifecycle
|
||||
/// audit. Phase boundary events are useful during interactive debugging,
|
||||
/// but retaining every minute phase marker for a long run is unnecessary.
|
||||
pub fn is_business_lifecycle(&self) -> bool {
|
||||
matches!(
|
||||
*self,
|
||||
Self::PreScheduled
|
||||
| Self::PostScheduled
|
||||
| Self::PreOnDay
|
||||
| Self::OnDay
|
||||
| Self::PostOnDay
|
||||
| Self::OrderPendingNew
|
||||
| Self::OrderCreationPass
|
||||
| Self::OrderCreationReject
|
||||
| Self::OrderPendingCancel
|
||||
| Self::OrderCancellationPass
|
||||
| Self::OrderCancellationReject
|
||||
| Self::OrderPendingUpdate
|
||||
| Self::OrderUpdatePass
|
||||
| Self::OrderUpdateReject
|
||||
| Self::OrderUnsolicitedUpdate
|
||||
| Self::Trade
|
||||
| Self::ManualExecutionObserved
|
||||
| Self::UniverseUpdated
|
||||
| Self::UniverseSubscribed
|
||||
| Self::UniverseUnsubscribed
|
||||
| Self::AccountDepositWithdraw
|
||||
| Self::AccountFinanceRepay
|
||||
| Self::AccountManagementFee
|
||||
| Self::SessionCapacityAudit
|
||||
| Self::EtfExecutionFallback
|
||||
| Self::Settlement
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -210,3 +442,138 @@ pub struct ProcessEvent {
|
||||
pub side: Option<OrderSide>,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
use super::{FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
|
||||
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
|
||||
OrderEvent {
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(1),
|
||||
symbol: "600000.SH".to_string(),
|
||||
side: OrderSide::Buy,
|
||||
requested_quantity: 100,
|
||||
filled_quantity,
|
||||
status,
|
||||
reason: "test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_event_status_quantity_contract_is_explicit() {
|
||||
assert!(order_event(OrderStatus::Pending, 0).validate().is_ok());
|
||||
assert!(
|
||||
order_event(OrderStatus::PartiallyFilled, 40)
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(order_event(OrderStatus::Filled, 100).validate().is_ok());
|
||||
assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok());
|
||||
assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok());
|
||||
assert!(order_event(OrderStatus::Expired, 40).validate().is_ok());
|
||||
|
||||
assert!(
|
||||
order_event(OrderStatus::PartiallyFilled, 0)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(order_event(OrderStatus::Filled, 99).validate().is_err());
|
||||
assert!(order_event(OrderStatus::Canceled, 100).validate().is_err());
|
||||
assert!(order_event(OrderStatus::Rejected, 1).validate().is_err());
|
||||
assert!(order_event(OrderStatus::Expired, 100).validate().is_err());
|
||||
}
|
||||
|
||||
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
|
||||
FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: start,
|
||||
execution_timestamp: end,
|
||||
order_id: Some(1),
|
||||
symbol: "600000.SH".to_string(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: 100,
|
||||
price: 10.0,
|
||||
gross_amount: 1_000.0,
|
||||
commission: 5.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: -1_005.0,
|
||||
reason: "test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_origin_cannot_disguise_an_exchange_order_or_fee() {
|
||||
let at = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||
let mut fill = fill_event(Some(at), Some(at));
|
||||
fill.origin = FillOrigin::DividendReinvestment;
|
||||
fill.order_id = None;
|
||||
fill.commission = 0.;
|
||||
fill.net_cash_flow = -1000.;
|
||||
assert!(fill.validate().is_ok());
|
||||
for kind in 0..3 {
|
||||
let mut invalid = fill.clone();
|
||||
match kind {
|
||||
0 => invalid.order_id = Some(1),
|
||||
1 => invalid.commission = 1.,
|
||||
_ => { invalid.execution_timestamp = Some(at + chrono::Duration::hours(9)); invalid.execution_start_timestamp = invalid.execution_timestamp; }
|
||||
}
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
assert!(serde_json::to_value(fill_event(None, None)).unwrap().get("origin").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
|
||||
.unwrap()
|
||||
.and_hms_opt(10, 18, 0)
|
||||
.unwrap();
|
||||
let end = start + chrono::Duration::seconds(3);
|
||||
assert!(fill_event(Some(start), Some(end)).validate().is_ok());
|
||||
assert!(fill_event(Some(end), Some(start)).validate().is_err());
|
||||
assert!(fill_event(Some(start), None).validate().is_err());
|
||||
|
||||
let next_day = start + chrono::Duration::days(1);
|
||||
assert!(
|
||||
fill_event(Some(next_day), Some(next_day))
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let legacy = fill_event(None, None);
|
||||
let legacy_json = serde_json::to_value(&legacy).unwrap();
|
||||
assert!(legacy_json.get("execution_start_timestamp").is_none());
|
||||
assert!(legacy_json.get("execution_timestamp").is_none());
|
||||
let decoded: FillEvent = serde_json::from_value(legacy_json).unwrap();
|
||||
assert_eq!(decoded.execution_start_timestamp, None);
|
||||
assert_eq!(decoded.execution_timestamp, None);
|
||||
|
||||
let timestamped_json = serde_json::to_value(fill_event(Some(start), Some(end))).unwrap();
|
||||
assert_eq!(
|
||||
timestamped_json["execution_start_timestamp"],
|
||||
"2025-01-02 10:18:00"
|
||||
);
|
||||
assert_eq!(
|
||||
timestamped_json["execution_timestamp"],
|
||||
"2025-01-02 10:18:03"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn process_event_business_lifecycle_filter_keeps_audit_events_only() {
|
||||
assert!(ProcessEventKind::OrderUpdateReject.is_business_lifecycle());
|
||||
assert!(ProcessEventKind::Settlement.is_business_lifecycle());
|
||||
assert!(!ProcessEventKind::PreMinute.is_business_lifecycle());
|
||||
assert!(!ProcessEventKind::PostBar.is_business_lifecycle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VolumeCapacityMode {
|
||||
#[default]
|
||||
ExecutionObservation,
|
||||
CompletedBar,
|
||||
SessionCapacityAudit,
|
||||
}
|
||||
|
||||
impl VolumeCapacityMode {
|
||||
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
|
||||
if !enabled { return Ok(()); }
|
||||
match self {
|
||||
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
|
||||
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum CapacityError {
|
||||
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
||||
InvalidRatio,
|
||||
#[error("execution capacity decimal cannot be represented exactly")]
|
||||
InvalidDecimal,
|
||||
#[error("execution capacity observation has invalid time bounds")]
|
||||
InvalidWindow,
|
||||
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
|
||||
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
|
||||
#[error("execution capacity observation belongs to another session")]
|
||||
WrongSession,
|
||||
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
||||
MissingObservation,
|
||||
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
|
||||
MissingCompletedBar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapacityAuditSummary {
|
||||
pub mode: VolumeCapacityMode,
|
||||
pub enabled: bool,
|
||||
pub participation_rate: f64,
|
||||
pub audited_symbol_sessions: usize,
|
||||
pub failed_symbol_sessions: usize,
|
||||
pub audit_passed: Option<bool>,
|
||||
pub execution_time_capacity_proven: bool,
|
||||
}
|
||||
|
||||
impl CapacityAuditSummary {
|
||||
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
|
||||
self.audited_symbol_sessions += 1;
|
||||
self.failed_symbol_sessions += usize::from(!audit.passed);
|
||||
self.audit_passed = Some(self.failed_symbol_sessions == 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ParticipationRate {
|
||||
numerator: u128,
|
||||
denominator: u128,
|
||||
}
|
||||
|
||||
impl ParticipationRate {
|
||||
pub fn new(rate: f64) -> Result<Self, CapacityError> {
|
||||
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
|
||||
return Err(CapacityError::InvalidRatio);
|
||||
}
|
||||
if rate < 1e-20 {
|
||||
// Even u64::MAX shares at this rate cannot admit a single share.
|
||||
return Ok(Self { numerator: 0, denominator: 1 });
|
||||
}
|
||||
if rate == 1.0 {
|
||||
return Ok(Self { numerator: 1, denominator: 1 });
|
||||
}
|
||||
let text = rate.to_string();
|
||||
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
|
||||
let digits = digits.trim_end_matches('0');
|
||||
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
|
||||
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
|
||||
if numerator > u128::MAX / u128::from(u64::MAX) {
|
||||
return Err(CapacityError::InvalidDecimal);
|
||||
}
|
||||
Ok(Self { numerator, denominator })
|
||||
}
|
||||
|
||||
pub fn total_shares(self, market_shares: u64) -> u64 {
|
||||
let total = u128::from(market_shares) * self.numerator / self.denominator;
|
||||
u64::try_from(total).expect("participation rate cannot exceed the market shares")
|
||||
}
|
||||
|
||||
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
|
||||
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VolumeObservationKind {
|
||||
TradeIncrement,
|
||||
CompletedBar,
|
||||
CumulativeSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct VolumeObservation {
|
||||
pub kind: VolumeObservationKind,
|
||||
pub start: NaiveDateTime,
|
||||
pub end: NaiveDateTime,
|
||||
pub available_at: NaiveDateTime,
|
||||
pub shares: u64,
|
||||
}
|
||||
|
||||
impl VolumeObservation {
|
||||
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
|
||||
if self.start > self.end || self.available_at < self.end {
|
||||
return Err(CapacityError::InvalidWindow);
|
||||
}
|
||||
if self.available_at > execution_at {
|
||||
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
|
||||
}
|
||||
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
|
||||
return Err(CapacityError::WrongSession);
|
||||
}
|
||||
Ok(self.shares)
|
||||
}
|
||||
|
||||
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
|
||||
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionCapacityAudit {
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub filled_shares: u64,
|
||||
pub session_shares: u64,
|
||||
pub allowed_shares: u64,
|
||||
pub passed: bool,
|
||||
}
|
||||
|
||||
impl SessionCapacityAudit {
|
||||
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
|
||||
let allowed_shares = rate.total_shares(session_shares);
|
||||
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
|
||||
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
|
||||
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
|
||||
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
|
||||
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
|
||||
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
|
||||
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
|
||||
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
|
||||
assert!(ParticipationRate::new(rate).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_volume_cannot_be_used_for_an_earlier_open() {
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||
let opening = day.and_hms_opt(9,30,0).unwrap();
|
||||
let closing = day.and_hms_opt(15,0,0).unwrap();
|
||||
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
|
||||
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
|
||||
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
|
||||
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
|
||||
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
|
||||
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
|
||||
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
|
||||
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
|
||||
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_audit_changes_verdict_not_executed_quantity() {
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||
let rate = ParticipationRate::new(0.25).unwrap();
|
||||
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
|
||||
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
|
||||
assert!(!a.passed); assert!(b.passed);
|
||||
assert_eq!(a.filled_shares,b.filled_shares);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::engine::BacktestError;
|
||||
|
||||
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
|
||||
pub(crate) struct TwapSchedule {
|
||||
start: NaiveDateTime,
|
||||
end: NaiveDateTime,
|
||||
quantity: u32,
|
||||
}
|
||||
|
||||
impl TwapSchedule {
|
||||
pub(crate) fn new(
|
||||
start: Option<NaiveDateTime>,
|
||||
end: Option<NaiveDateTime>,
|
||||
quantity: u32,
|
||||
) -> Result<Self, BacktestError> {
|
||||
let (Some(start), Some(end)) = (start, end) else {
|
||||
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
|
||||
};
|
||||
if end < start || start.date() != end.date() {
|
||||
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
|
||||
}
|
||||
Ok(Self { start, end, quantity })
|
||||
}
|
||||
|
||||
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
|
||||
if at < self.start {
|
||||
return 0;
|
||||
}
|
||||
let entitlement = if at >= self.end {
|
||||
self.quantity
|
||||
} else {
|
||||
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||
(u128::from(self.quantity) * elapsed / duration) as u32
|
||||
};
|
||||
entitlement.saturating_sub(filled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, NaiveDate};
|
||||
|
||||
#[test]
|
||||
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
|
||||
let end = start + Duration::minutes(10);
|
||||
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
|
||||
assert_eq!(schedule.due_quantity(start, 0), 0);
|
||||
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
|
||||
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
|
||||
assert_eq!(schedule.due_quantity(end, 100), 900);
|
||||
assert_eq!(schedule.due_quantity(end, 1_000), 0);
|
||||
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
|
||||
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
|
||||
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Cross-sectional operators require an explicit complete universe, never a UI page.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const OPERATORS: &[&str] = &[
|
||||
"RANK",
|
||||
"PERCENTILE",
|
||||
"TOP",
|
||||
"BOTTOM",
|
||||
"TOP_PERCENT",
|
||||
"BOTTOM_PERCENT",
|
||||
"WINSORIZE",
|
||||
"INDUSTRY_NEUTRALIZE",
|
||||
"SIZE_NEUTRALIZE",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub value: f64,
|
||||
pub industry: Option<String>,
|
||||
pub market_cap: Option<f64>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Output {
|
||||
pub symbol: String,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
|
||||
pub fn rank_history(
|
||||
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|
||||
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|
||||
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|
||||
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|
||||
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
|
||||
return Err("research_rank_history_incomplete_or_invalid_universe".into());
|
||||
}
|
||||
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
|
||||
let mut percentile = rank.clone();
|
||||
let mut unknown_dates = Vec::new();
|
||||
for (i, date) in dates.iter().enumerate() {
|
||||
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
|
||||
continue;
|
||||
}
|
||||
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
|
||||
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||
}
|
||||
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
|
||||
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
|
||||
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
|
||||
}
|
||||
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
let base = values[0];
|
||||
base + values
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|v| (v - base) / values.len() as f64)
|
||||
.sum::<f64>()
|
||||
}
|
||||
fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||
let x = p * (sorted.len() - 1) as f64;
|
||||
let l = x.floor() as usize;
|
||||
let r = x.ceil() as usize;
|
||||
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
name: &str,
|
||||
universe: &[String],
|
||||
rows: &[Observation],
|
||||
threshold: f64,
|
||||
) -> Result<Vec<Output>, String> {
|
||||
let expected = universe.iter().collect::<BTreeSet<_>>();
|
||||
if rows.is_empty()
|
||||
|| rows.len() > 20_000
|
||||
|| expected.len() != universe.len()
|
||||
|| rows.len() != universe.len()
|
||||
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|
||||
|| rows.iter().any(|r| !r.value.is_finite())
|
||||
{
|
||||
return Err("cross_section_incomplete_or_invalid_universe".into());
|
||||
}
|
||||
if !OPERATORS.contains(&name) || !threshold.is_finite() {
|
||||
return Err("cross_section_operator_invalid".into());
|
||||
}
|
||||
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|
||||
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|
||||
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
|
||||
{
|
||||
return Err("cross_section_threshold_invalid".into());
|
||||
}
|
||||
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||
if name == "INDUSTRY_NEUTRALIZE" {
|
||||
for row in rows {
|
||||
let industry = row
|
||||
.industry
|
||||
.as_deref()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or("cross_section_pit_industry_missing")?;
|
||||
industry_values.entry(industry).or_default().push(row.value);
|
||||
}
|
||||
}
|
||||
let size = if name == "SIZE_NEUTRALIZE" {
|
||||
let x = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.market_cap
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(f64::ln)
|
||||
.ok_or("cross_section_market_cap_missing")
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let xm = mean(&x);
|
||||
let ym = mean(&sorted);
|
||||
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
|
||||
if variance == 0.0 || rows.len() < 3 {
|
||||
return Err("cross_section_size_regression_unidentified".into());
|
||||
}
|
||||
let beta = x
|
||||
.iter()
|
||||
.zip(rows)
|
||||
.map(|(x, y)| (x - xm) * (y.value - ym))
|
||||
.sum::<f64>()
|
||||
/ variance;
|
||||
Some((x, xm, ym, beta))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let low = sorted.partition_point(|v| *v < row.value);
|
||||
let high = sorted.partition_point(|v| *v <= row.value);
|
||||
let rank = (low + 1 + high) as f64 / 2.0;
|
||||
let descending = (rows.len() + 1) as f64 - rank;
|
||||
let percentile = if rows.len() == 1 {
|
||||
0.5
|
||||
} else {
|
||||
(rank - 1.0) / (rows.len() - 1) as f64
|
||||
};
|
||||
let value = match name {
|
||||
"RANK" => descending,
|
||||
"PERCENTILE" => percentile,
|
||||
"TOP" => f64::from(descending <= threshold),
|
||||
"BOTTOM" => f64::from(rank <= threshold),
|
||||
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
|
||||
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
|
||||
"WINSORIZE" => row.value.clamp(
|
||||
quantile(&sorted, threshold),
|
||||
quantile(&sorted, 1.0 - threshold),
|
||||
),
|
||||
"INDUSTRY_NEUTRALIZE" => {
|
||||
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
|
||||
}
|
||||
"SIZE_NEUTRALIZE" => {
|
||||
let (x, xm, ym, beta) = size.as_ref().unwrap();
|
||||
row.value - (ym + beta * (x[index] - xm))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !value.is_finite() {
|
||||
return Err("cross_section_result_nonfinite".into());
|
||||
}
|
||||
Ok(Output {
|
||||
symbol: row.symbol.clone(),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
|
||||
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
|
||||
let universe=vec!["A".into(),"B".into(),"C".into()];
|
||||
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
|
||||
let out=rank_history(&dates,&universe,&values).unwrap();
|
||||
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
|
||||
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
|
||||
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
|
||||
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
|
||||
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
|
||||
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
|
||||
assert!(rank_history(&dates,&universe[..2],&values).is_err());
|
||||
}
|
||||
fn rows() -> Vec<Observation> {
|
||||
[1.0, 3.0, 3.0, 4.0]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| Observation {
|
||||
symbol: format!("S{i}"),
|
||||
value,
|
||||
industry: Some(if i < 2 { "A" } else { "B" }.into()),
|
||||
market_cap: Some(10.0 + i as f64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn ties_keep_equal_rank_and_missing_universe_rejects() {
|
||||
let r = rows();
|
||||
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||
vec![4.0, 2.5, 2.5, 1.0]
|
||||
);
|
||||
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn neutralization_preserves_input_order() {
|
||||
let r = rows();
|
||||
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||
vec![-1.0, 1.0, -0.5, 0.5]
|
||||
);
|
||||
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Indicator metadata is versioned independently from the numerical kernel.
|
||||
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
|
||||
use serde_json::{Value, json};
|
||||
use ta_lib::abstract_api::{self, OptInputType};
|
||||
|
||||
pub fn catalog() -> Value {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut implementation = Sha256::new();
|
||||
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
|
||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||
})).collect();
|
||||
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
||||
}
|
||||
|
||||
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
|
||||
match kind {
|
||||
OptInputType::IntegerRange { min, max, default, .. } => json!({
|
||||
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
|
||||
}),
|
||||
OptInputType::RealRange { min, max, default, precision, .. } => json!({
|
||||
"value_type":"number", "minimum":min, "maximum":max,
|
||||
"default":default, "display_precision":precision,
|
||||
}),
|
||||
OptInputType::IntegerList { values, default } => json!({
|
||||
"value_type":"integer", "default":default,
|
||||
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||
}),
|
||||
OptInputType::RealList { values, default } => json!({
|
||||
"value_type":"number", "default":default,
|
||||
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
//! Causal, typed indicator/event expressions shared by research and trading.
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use ta_lib::{
|
||||
Core,
|
||||
abstract_api::{self, InputType, OptInputType, OutputType},
|
||||
};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_factor_event_expression_v1";
|
||||
pub const TA_REV: &str = "dd5a90259a3f9e04e2da9f38bf0719a841b40108";
|
||||
|
||||
pub fn field_dependencies(expr: &Expr) -> std::collections::BTreeSet<String> {
|
||||
let mut fields = std::collections::BTreeSet::new();
|
||||
match expr {
|
||||
Expr::Field { name } => {
|
||||
fields.insert(name.clone());
|
||||
}
|
||||
Expr::Indicator { inputs, .. } => {
|
||||
for e in inputs {
|
||||
fields.extend(field_dependencies(e));
|
||||
}
|
||||
}
|
||||
Expr::Operator { args, .. } => {
|
||||
for e in args {
|
||||
fields.extend(field_dependencies(e));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Expr {
|
||||
Number {
|
||||
value: f64,
|
||||
},
|
||||
Field {
|
||||
name: String,
|
||||
},
|
||||
Indicator {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
inputs: Vec<Expr>,
|
||||
#[serde(default)]
|
||||
parameters: BTreeMap<String, Value>,
|
||||
#[serde(default)]
|
||||
output: usize,
|
||||
},
|
||||
Operator {
|
||||
name: String,
|
||||
args: Vec<Expr>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
window: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frame {
|
||||
pub symbol: String,
|
||||
pub frequency: String,
|
||||
pub decision_at: DateTime<FixedOffset>,
|
||||
pub timestamps: Vec<DateTime<FixedOffset>>,
|
||||
pub available_at: Vec<DateTime<FixedOffset>>,
|
||||
pub fields: BTreeMap<String, Vec<Option<f64>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValueType {
|
||||
Number,
|
||||
Boolean,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Series {
|
||||
pub value_type: ValueType,
|
||||
pub values: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
pub(crate) const OPERATORS: &[&str] = &[
|
||||
"GT",
|
||||
"GTE",
|
||||
"LT",
|
||||
"LTE",
|
||||
"EQ",
|
||||
"NEQ",
|
||||
"BETWEEN",
|
||||
"OUTSIDE",
|
||||
"CROSS_ABOVE",
|
||||
"CROSS_BELOW",
|
||||
"BREAK_ABOVE",
|
||||
"BREAK_BELOW",
|
||||
"BREAK_HIGH",
|
||||
"BREAK_LOW",
|
||||
"CHANGE",
|
||||
"DIFF",
|
||||
"DELTA",
|
||||
"PCT_CHANGE",
|
||||
"LOG_RETURN",
|
||||
"RISING",
|
||||
"FALLING",
|
||||
"NON_DECREASING",
|
||||
"NON_INCREASING",
|
||||
"TURN_UP",
|
||||
"TURN_DOWN",
|
||||
"BOTTOM_REVERSAL",
|
||||
"TOP_REVERSAL",
|
||||
"SLOPE",
|
||||
"SLOPE_CHANGE",
|
||||
"ACCELERATION",
|
||||
"HHV",
|
||||
"LLV",
|
||||
"ARGMAX",
|
||||
"ARGMIN",
|
||||
"DISTANCE_TO_HIGH",
|
||||
"DISTANCE_TO_LOW",
|
||||
"NEW_HIGH",
|
||||
"NEW_LOW",
|
||||
"NEAR_HIGH",
|
||||
"NEAR_LOW",
|
||||
"BULLISH_DIVERGENCE",
|
||||
"BEARISH_DIVERGENCE",
|
||||
"ZSCORE",
|
||||
"MINMAX",
|
||||
"STANDARDIZE",
|
||||
"NORMALIZE",
|
||||
"COUNT",
|
||||
"COUNT_TRUE",
|
||||
"CONSECUTIVE",
|
||||
"BARS_SINCE",
|
||||
"DURATION",
|
||||
"DAYS_SINCE",
|
||||
"TIME_SINCE",
|
||||
"REF",
|
||||
"LAG",
|
||||
"PREV",
|
||||
"SHIFT",
|
||||
"ROLLING_MEAN",
|
||||
"ROLLING_SUM",
|
||||
"ROLLING_STD",
|
||||
"ROLLING_MAX",
|
||||
"ROLLING_MIN",
|
||||
"ROLLING_MEDIAN",
|
||||
"ROLLING_CORR",
|
||||
"ROLLING_COV",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"XOR",
|
||||
"ADD",
|
||||
"SUB",
|
||||
"MUL",
|
||||
"DIV",
|
||||
"ABS",
|
||||
"MAX",
|
||||
"MIN",
|
||||
"LOG",
|
||||
"SQRT",
|
||||
"POWER",
|
||||
"CUMMAX",
|
||||
"CUMMIN",
|
||||
"SIGN",
|
||||
"IF",
|
||||
];
|
||||
|
||||
pub use crate::factor_event_catalog::catalog;
|
||||
|
||||
impl Frame {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let n = self.timestamps.len();
|
||||
if self.symbol.is_empty()
|
||||
|| n == 0
|
||||
|| n > 200_000
|
||||
|| self.available_at.len() != n
|
||||
|| self.fields.len() > 100
|
||||
|| n.saturating_mul(self.fields.len()) > 1_000_000
|
||||
{
|
||||
return Err("factor_frame_invalid: identity/shape/limit".into());
|
||||
}
|
||||
if !["1d", "1w", "1m", "5m", "15m", "30m", "60m"].contains(&self.frequency.as_str()) {
|
||||
return Err("factor_frame_invalid: unsupported_frequency".into());
|
||||
}
|
||||
for i in 0..n {
|
||||
if (i > 0 && self.timestamps[i] <= self.timestamps[i - 1])
|
||||
|| self.available_at[i] < self.timestamps[i]
|
||||
|| self.available_at[i] > self.decision_at
|
||||
{
|
||||
return Err(format!(
|
||||
"factor_input_not_visible: {} index={i}",
|
||||
self.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
for (field, values) in &self.fields {
|
||||
if values.len() != n || values.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(format!("factor_field_invalid: {} {field}", self.symbol));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate(expr: &Expr, frame: &Frame) -> Result<Series, String> {
|
||||
frame.validate()?;
|
||||
fn cost(expr: &Expr, depth: usize, nodes: &mut usize) -> Result<usize, String> {
|
||||
*nodes += 1;
|
||||
if depth > 24 || *nodes > 256 {
|
||||
return Err("factor_expression_size_exceeded".into());
|
||||
}
|
||||
let (children, own) = match expr {
|
||||
Expr::Indicator {
|
||||
inputs, parameters, ..
|
||||
} => (
|
||||
inputs.as_slice(),
|
||||
parameters
|
||||
.values()
|
||||
.filter_map(Value::as_u64)
|
||||
.max()
|
||||
.unwrap_or(30)
|
||||
.min(1_000_000) as usize,
|
||||
),
|
||||
Expr::Operator { args, window, .. } => (args.as_slice(), window.unwrap_or(1)),
|
||||
_ => (&[][..], 1),
|
||||
};
|
||||
children.iter().try_fold(own, |total, child| {
|
||||
Ok(total.saturating_add(cost(child, depth + 1, nodes)?))
|
||||
})
|
||||
}
|
||||
if frame
|
||||
.timestamps
|
||||
.len()
|
||||
.saturating_mul(cost(expr, 0, &mut 0)?)
|
||||
> 20_000_000
|
||||
{
|
||||
return Err("factor_expression_compute_budget_exceeded".into());
|
||||
}
|
||||
evaluate_inner(expr, frame, 0)
|
||||
}
|
||||
|
||||
fn evaluate_inner(expr: &Expr, frame: &Frame, depth: usize) -> Result<Series, String> {
|
||||
if depth > 24 {
|
||||
return Err("factor_expression_too_deep".into());
|
||||
}
|
||||
match expr {
|
||||
Expr::Number { value } if value.is_finite() => Ok(Series {
|
||||
value_type: ValueType::Number,
|
||||
values: vec![Some(*value); frame.timestamps.len()],
|
||||
}),
|
||||
Expr::Number { .. } => Err("factor_constant_nonfinite".into()),
|
||||
Expr::Field { name } => Ok(Series {
|
||||
value_type: ValueType::Number,
|
||||
values: frame
|
||||
.fields
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("factor_source_field_missing: {} {name}", frame.symbol))?
|
||||
.clone(),
|
||||
}),
|
||||
Expr::Indicator {
|
||||
name,
|
||||
inputs,
|
||||
parameters,
|
||||
output,
|
||||
} => indicator(name, inputs, parameters, *output, frame, depth),
|
||||
Expr::Operator { name, args, window } => {
|
||||
if args.len() > 16 {
|
||||
return Err("factor_operator_arity_exceeded".into());
|
||||
}
|
||||
let args = args
|
||||
.iter()
|
||||
.map(|a| evaluate_inner(a, frame, depth + 1))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
operator(name, &args, *window, frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn indicator(
|
||||
name: &str,
|
||||
inputs: &[Expr],
|
||||
parameters: &BTreeMap<String, Value>,
|
||||
output: usize,
|
||||
frame: &Frame,
|
||||
depth: usize,
|
||||
) -> Result<Series, String> {
|
||||
let id =
|
||||
abstract_api::get_func_handle(name).ok_or_else(|| format!("indicator_unknown: {name}"))?;
|
||||
let info = id.info();
|
||||
if output >= info.outputs.len() {
|
||||
return Err("indicator_output_invalid".into());
|
||||
}
|
||||
let real_count = info
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|i| i.kind == InputType::Real)
|
||||
.count();
|
||||
if inputs.len() != real_count || info.inputs.iter().any(|i| i.kind == InputType::Integer) {
|
||||
return Err(format!(
|
||||
"indicator_inputs_invalid: {name} expects {real_count} real series"
|
||||
));
|
||||
}
|
||||
let mut data = inputs
|
||||
.iter()
|
||||
.map(|a| evaluate_inner(a, frame, depth + 1))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if data.iter().any(|s| s.value_type != ValueType::Number) {
|
||||
return Err("indicator_requires_numeric_input".into());
|
||||
}
|
||||
let price_names = ["open", "high", "low", "close", "volume", "open_interest"];
|
||||
let flags = info
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|i| i.kind == InputType::Price)
|
||||
.fold(0, |v, i| v | i.flags.0);
|
||||
let mut price_indices = [None; 6];
|
||||
for (i, field) in price_names.iter().enumerate() {
|
||||
if flags & (1 << i) != 0 {
|
||||
price_indices[i] = Some(data.len());
|
||||
data.push(evaluate_inner(
|
||||
&Expr::Field {
|
||||
name: (*field).into(),
|
||||
},
|
||||
frame,
|
||||
depth + 1,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
let core = Core::new();
|
||||
let mut validation = id.new_call(&core);
|
||||
for (key, v) in parameters {
|
||||
let slot = info
|
||||
.opt_inputs
|
||||
.iter()
|
||||
.position(|p| p.param_name == key)
|
||||
.ok_or_else(|| format!("indicator_parameter_unknown: {name}.{key}"))?;
|
||||
match info.opt_inputs[slot].kind {
|
||||
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
|
||||
let v = v
|
||||
.as_i64()
|
||||
.and_then(|v| i32::try_from(v).ok())
|
||||
.ok_or("indicator_parameter_requires_integer")?;
|
||||
validation.set_opt(slot, v).map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
_ => {
|
||||
validation
|
||||
.set_opt(
|
||||
slot,
|
||||
v.as_f64()
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or("indicator_parameter_requires_finite_number")?,
|
||||
)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lookback = validation
|
||||
.lookback()
|
||||
.map_err(|e| format!("indicator_parameter_invalid: {name} {e:?}"))?;
|
||||
let n = frame.timestamps.len();
|
||||
let mut result = vec![None; n];
|
||||
let mut start = 0;
|
||||
// Never bridge missing source observations. Recursive indicators rewarm after a gap.
|
||||
while start < n {
|
||||
if data.iter().any(|s| s.values[start].is_none()) {
|
||||
start += 1;
|
||||
continue;
|
||||
}
|
||||
let mut end = start + 1;
|
||||
while end < n && data.iter().all(|s| s.values[end].is_some()) {
|
||||
end += 1;
|
||||
}
|
||||
if end - start <= lookback {
|
||||
start = end;
|
||||
continue;
|
||||
}
|
||||
let arrays = data
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.values[start..end]
|
||||
.iter()
|
||||
.map(|v| v.unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut float_out = (0..info.outputs.len())
|
||||
.map(|_| vec![0.0; end - start])
|
||||
.collect::<Vec<_>>();
|
||||
let mut int_out = (0..info.outputs.len())
|
||||
.map(|_| vec![0i32; end - start])
|
||||
.collect::<Vec<_>>();
|
||||
let mut call = id.new_call(&core);
|
||||
for (key, v) in parameters {
|
||||
let slot = info
|
||||
.opt_inputs
|
||||
.iter()
|
||||
.position(|p| p.param_name == key)
|
||||
.unwrap();
|
||||
match info.opt_inputs[slot].kind {
|
||||
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
|
||||
call.set_opt(slot, v.as_i64().unwrap() as i32)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
_ => {
|
||||
call.set_opt(slot, v.as_f64().unwrap())
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut real_slot = 0;
|
||||
for (slot, i) in info.inputs.iter().enumerate() {
|
||||
if i.kind == InputType::Real {
|
||||
call.set_input(slot, &arrays[real_slot])
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
real_slot += 1;
|
||||
} else {
|
||||
let p = price_indices.map(|i| i.map(|i| arrays[i].as_slice()));
|
||||
call.set_price_input(slot, p[0], p[1], p[2], p[3], p[4], p[5])
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
}
|
||||
for (slot, (floats, ints)) in float_out.iter_mut().zip(int_out.iter_mut()).enumerate() {
|
||||
if info.outputs[slot].kind == OutputType::Real {
|
||||
call.set_output(slot, floats)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
} else {
|
||||
call.set_int_output(slot, ints)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
}
|
||||
let range = call
|
||||
.call(0, end - start - 1)
|
||||
.map_err(|e| format!("indicator_failed: {name} {e:?}"))?;
|
||||
drop(call);
|
||||
for j in 0..range.count {
|
||||
let value = if info.outputs[output].kind == OutputType::Real {
|
||||
float_out[output][j]
|
||||
} else {
|
||||
int_out[output][j] as f64
|
||||
};
|
||||
if !value.is_finite() {
|
||||
return Err(format!(
|
||||
"indicator_nonfinite: {name} index={}",
|
||||
start + range.beg_idx + j
|
||||
));
|
||||
}
|
||||
result[start + range.beg_idx + j] = Some(value);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
Ok(Series {
|
||||
value_type: ValueType::Number,
|
||||
values: result,
|
||||
})
|
||||
}
|
||||
|
||||
fn average(v: &[f64]) -> f64 {
|
||||
v[0] + v
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|x| (x - v[0]) / v.len() as f64)
|
||||
.sum::<f64>()
|
||||
}
|
||||
fn slope(v: &[f64]) -> f64 {
|
||||
let x = (v.len() - 1) as f64 / 2.0;
|
||||
let y = average(v);
|
||||
let num = v
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| (i as f64 - x) * (v - y))
|
||||
.sum::<f64>();
|
||||
let den = (0..v.len()).map(|i| (i as f64 - x).powi(2)).sum::<f64>();
|
||||
num / den
|
||||
}
|
||||
fn boolean(v: bool) -> Option<f64> {
|
||||
Some(if v { 1.0 } else { 0.0 })
|
||||
}
|
||||
|
||||
fn operator(
|
||||
name: &str,
|
||||
args: &[Series],
|
||||
window: Option<usize>,
|
||||
frame: &Frame,
|
||||
) -> Result<Series, String> {
|
||||
if !OPERATORS.contains(&name) {
|
||||
return Err(format!("operator_not_registered: {name}"));
|
||||
}
|
||||
let bool_input = matches!(
|
||||
name,
|
||||
"AND"
|
||||
| "OR"
|
||||
| "NOT"
|
||||
| "XOR"
|
||||
| "COUNT"
|
||||
| "COUNT_TRUE"
|
||||
| "CONSECUTIVE"
|
||||
| "BARS_SINCE"
|
||||
| "DURATION"
|
||||
| "DAYS_SINCE"
|
||||
| "TIME_SINCE"
|
||||
);
|
||||
let lag = matches!(name, "REF" | "LAG" | "PREV" | "SHIFT");
|
||||
if args.is_empty()
|
||||
|| (name == "IF"
|
||||
&& (args.len() != 3
|
||||
|| args[0].value_type != ValueType::Boolean
|
||||
|| args[1].value_type != args[2].value_type))
|
||||
|| (!lag
|
||||
&& name != "IF"
|
||||
&& args
|
||||
.iter()
|
||||
.any(|a| (a.value_type == ValueType::Boolean) != bool_input))
|
||||
{
|
||||
return Err(format!("operator_input_type_invalid: {name}"));
|
||||
}
|
||||
let arity = match name {
|
||||
"BETWEEN" | "OUTSIDE" | "IF" => 3,
|
||||
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" | "CROSS_ABOVE" | "CROSS_BELOW"
|
||||
| "BREAK_ABOVE" | "BREAK_BELOW" | "ADD" | "SUB" | "MUL" | "DIV" | "MAX" | "MIN"
|
||||
| "POWER" | "XOR" | "ROLLING_CORR" | "ROLLING_COV" | "NEAR_HIGH" | "NEAR_LOW"
|
||||
| "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => 2,
|
||||
"AND" | "OR" => args.len(),
|
||||
_ => 1,
|
||||
};
|
||||
if args.len() != arity {
|
||||
return Err(format!("operator_arity_invalid: {name}"));
|
||||
}
|
||||
let windowed = matches!(
|
||||
name,
|
||||
"BREAK_HIGH"
|
||||
| "BREAK_LOW"
|
||||
| "RISING"
|
||||
| "FALLING"
|
||||
| "NON_DECREASING"
|
||||
| "NON_INCREASING"
|
||||
| "SLOPE"
|
||||
| "SLOPE_CHANGE"
|
||||
| "HHV"
|
||||
| "LLV"
|
||||
| "ARGMAX"
|
||||
| "ARGMIN"
|
||||
| "DISTANCE_TO_HIGH"
|
||||
| "DISTANCE_TO_LOW"
|
||||
| "NEW_HIGH"
|
||||
| "NEW_LOW"
|
||||
| "NEAR_HIGH"
|
||||
| "NEAR_LOW"
|
||||
| "BULLISH_DIVERGENCE"
|
||||
| "BEARISH_DIVERGENCE"
|
||||
| "ZSCORE"
|
||||
| "STANDARDIZE"
|
||||
| "MINMAX"
|
||||
| "NORMALIZE"
|
||||
| "COUNT"
|
||||
| "COUNT_TRUE"
|
||||
) || name.starts_with("ROLLING_");
|
||||
let n = window.unwrap_or(1);
|
||||
if n == 0
|
||||
|| n > 10_000
|
||||
|| (windowed && window.is_none())
|
||||
|| (matches!(
|
||||
name,
|
||||
"SLOPE"
|
||||
| "SLOPE_CHANGE"
|
||||
| "ZSCORE"
|
||||
| "STANDARDIZE"
|
||||
| "ROLLING_STD"
|
||||
| "ROLLING_CORR"
|
||||
| "ROLLING_COV"
|
||||
) && n < 2)
|
||||
{
|
||||
return Err(format!("operator_window_invalid: {name}"));
|
||||
}
|
||||
let returns_bool = matches!(
|
||||
name,
|
||||
"GT" | "GTE"
|
||||
| "LT"
|
||||
| "LTE"
|
||||
| "EQ"
|
||||
| "NEQ"
|
||||
| "BETWEEN"
|
||||
| "OUTSIDE"
|
||||
| "CROSS_ABOVE"
|
||||
| "CROSS_BELOW"
|
||||
| "BREAK_ABOVE"
|
||||
| "BREAK_BELOW"
|
||||
| "BREAK_HIGH"
|
||||
| "BREAK_LOW"
|
||||
| "RISING"
|
||||
| "FALLING"
|
||||
| "NON_DECREASING"
|
||||
| "NON_INCREASING"
|
||||
| "TURN_UP"
|
||||
| "TURN_DOWN"
|
||||
| "BOTTOM_REVERSAL"
|
||||
| "TOP_REVERSAL"
|
||||
| "NEW_HIGH"
|
||||
| "NEW_LOW"
|
||||
| "NEAR_HIGH"
|
||||
| "NEAR_LOW"
|
||||
| "BULLISH_DIVERGENCE"
|
||||
| "BEARISH_DIVERGENCE"
|
||||
| "AND"
|
||||
| "OR"
|
||||
| "NOT"
|
||||
| "XOR"
|
||||
);
|
||||
let len = frame.timestamps.len();
|
||||
let mut out = vec![None; len];
|
||||
let mut last_true = None;
|
||||
let mut consecutive = Some(0usize);
|
||||
let mut extreme: Option<f64> = None;
|
||||
let mut cumulative_complete = true;
|
||||
for i in 0..len {
|
||||
let a = args[0].values[i];
|
||||
let b = args.get(1).and_then(|a| a.values[i]);
|
||||
let at = |j: usize| args[0].values.get(j).copied().flatten();
|
||||
let history = |end: usize, count: usize| -> Option<Vec<f64>> {
|
||||
if end < count {
|
||||
None
|
||||
} else {
|
||||
args[0].values[end - count..end].iter().copied().collect()
|
||||
}
|
||||
};
|
||||
out[i] = match name {
|
||||
"IF" => a.and_then(|a| {
|
||||
if a == 1.0 {
|
||||
args[1].values[i]
|
||||
} else {
|
||||
args[2].values[i]
|
||||
}
|
||||
}),
|
||||
"SIGN" => a.map(|v| {
|
||||
if v == 0.0 {
|
||||
0.0
|
||||
} else if v > 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}),
|
||||
"CUMMAX" | "CUMMIN" => {
|
||||
cumulative_complete &= a.is_some();
|
||||
extreme = a.filter(|_| cumulative_complete).map(|v| {
|
||||
extreme.map_or(v, |p| if name == "CUMMAX" { p.max(v) } else { p.min(v) })
|
||||
});
|
||||
extreme
|
||||
}
|
||||
"AND" => {
|
||||
if args.iter().any(|a| a.values[i] == Some(0.0)) {
|
||||
Some(0.0)
|
||||
} else if args.iter().any(|a| a.values[i].is_none()) {
|
||||
None
|
||||
} else {
|
||||
Some(1.0)
|
||||
}
|
||||
}
|
||||
"OR" => {
|
||||
if args.iter().any(|a| a.values[i] == Some(1.0)) {
|
||||
Some(1.0)
|
||||
} else if args.iter().any(|a| a.values[i].is_none()) {
|
||||
None
|
||||
} else {
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
"NOT" => a.map(|v| 1.0 - v),
|
||||
"XOR" => a.zip(b).and_then(|(a, b)| boolean(a != b)),
|
||||
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" => a.zip(b).and_then(|(a, b)| {
|
||||
boolean(match name {
|
||||
"GT" => a > b,
|
||||
"GTE" => a >= b,
|
||||
"LT" => a < b,
|
||||
"LTE" => a <= b,
|
||||
"EQ" => a == b,
|
||||
_ => a != b,
|
||||
})
|
||||
}),
|
||||
"BETWEEN" | "OUTSIDE" => a.zip(b).zip(args[2].values[i]).and_then(|((a, b), c)| {
|
||||
if b > c {
|
||||
None
|
||||
} else {
|
||||
boolean((a >= b && a <= c) == (name == "BETWEEN"))
|
||||
}
|
||||
}),
|
||||
"CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" => {
|
||||
if i == 0 {
|
||||
None
|
||||
} else {
|
||||
a.zip(b).zip(at(i - 1).zip(args[1].values[i - 1])).and_then(
|
||||
|((a, b), (p, q))| {
|
||||
boolean(if name.ends_with("ABOVE") {
|
||||
p <= q && a > b
|
||||
} else {
|
||||
p >= q && a < b
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
"REF" | "LAG" | "PREV" | "SHIFT" => i.checked_sub(n).and_then(at),
|
||||
"CHANGE" | "DIFF" | "DELTA" | "PCT_CHANGE" | "LOG_RETURN" => a
|
||||
.zip(i.checked_sub(n).and_then(at))
|
||||
.and_then(|(a, p)| match name {
|
||||
"PCT_CHANGE" => {
|
||||
if p == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(a / p - 1.0)
|
||||
}
|
||||
}
|
||||
"LOG_RETURN" => {
|
||||
if a <= 0.0 || p <= 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some((a / p).ln())
|
||||
}
|
||||
}
|
||||
_ => Some(a - p),
|
||||
}),
|
||||
"ACCELERATION" => a
|
||||
.zip(i.checked_sub(n).and_then(at))
|
||||
.zip(i.checked_sub(n * 2).and_then(at))
|
||||
.map(|((a, p), q)| a - 2.0 * p + q),
|
||||
"BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => {
|
||||
if i < n || n < 4 {
|
||||
None
|
||||
} else {
|
||||
let price: Option<Vec<f64>> =
|
||||
args[0].values[i - n..=i].iter().copied().collect();
|
||||
let indicator: Option<Vec<f64>> =
|
||||
args[1].values[i - n..=i].iter().copied().collect();
|
||||
price.zip(indicator).and_then(|(price, indicator)| {
|
||||
let low = name == "BULLISH_DIVERGENCE";
|
||||
let pivots = (1..n)
|
||||
.filter(|&j| {
|
||||
if low {
|
||||
price[j] < price[j - 1] && price[j] < price[j + 1]
|
||||
} else {
|
||||
price[j] > price[j - 1] && price[j] > price[j + 1]
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if pivots.last() != Some(&(n - 1)) || pivots.len() < 2 {
|
||||
return boolean(false);
|
||||
}
|
||||
let a = pivots[pivots.len() - 2];
|
||||
let b = n - 1;
|
||||
boolean(if low {
|
||||
price[b] < price[a] && indicator[b] > indicator[a]
|
||||
} else {
|
||||
price[b] > price[a] && indicator[b] < indicator[a]
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
"TURN_UP" | "TURN_DOWN" | "BOTTOM_REVERSAL" | "TOP_REVERSAL" => {
|
||||
if i < 2 {
|
||||
None
|
||||
} else {
|
||||
a.zip(at(i - 1)).zip(at(i - 2)).and_then(|((a, p), q)| {
|
||||
if name == "ACCELERATION" {
|
||||
Some(a - 2.0 * p + q)
|
||||
} else {
|
||||
boolean(if matches!(name, "TURN_UP" | "BOTTOM_REVERSAL") {
|
||||
p < q && a > p
|
||||
} else {
|
||||
p > q && a < p
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
"ABS" => a.map(f64::abs),
|
||||
"LOG" => a.filter(|v| *v > 0.0).map(f64::ln),
|
||||
"SQRT" => a.filter(|v| *v >= 0.0).map(f64::sqrt),
|
||||
"ADD" => a.zip(b).map(|(a, b)| a + b),
|
||||
"SUB" => a.zip(b).map(|(a, b)| a - b),
|
||||
"MUL" => a.zip(b).map(|(a, b)| a * b),
|
||||
"DIV" => a.zip(b).filter(|(_, b)| *b != 0.0).map(|(a, b)| a / b),
|
||||
"MAX" => a.zip(b).map(|(a, b)| a.max(b)),
|
||||
"MIN" => a.zip(b).map(|(a, b)| a.min(b)),
|
||||
"POWER" => a.zip(b).map(|(a, b)| a.powf(b)),
|
||||
"BARS_SINCE" | "DAYS_SINCE" | "TIME_SINCE" => {
|
||||
if a == Some(1.0) {
|
||||
last_true = Some(i);
|
||||
}
|
||||
if a.is_none() {
|
||||
last_true = None;
|
||||
}
|
||||
last_true.map(|t| {
|
||||
if name == "BARS_SINCE" {
|
||||
(i - t) as f64
|
||||
} else {
|
||||
let secs = (frame.timestamps[i] - frame.timestamps[t]).num_seconds() as f64;
|
||||
if name == "DAYS_SINCE" {
|
||||
secs / 86400.0
|
||||
} else {
|
||||
secs
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
"CONSECUTIVE" | "DURATION" => {
|
||||
consecutive = match a {
|
||||
Some(1.0) => consecutive.map(|v| v + 1),
|
||||
Some(_) => Some(0),
|
||||
None => None,
|
||||
};
|
||||
consecutive.map(|v| v as f64)
|
||||
}
|
||||
"BREAK_HIGH" | "NEW_HIGH" | "BREAK_LOW" | "NEW_LOW" => {
|
||||
a.zip(history(i, n)).and_then(|(a, v)| {
|
||||
boolean(if matches!(name, "BREAK_HIGH" | "NEW_HIGH") {
|
||||
a > v.into_iter().fold(f64::NEG_INFINITY, f64::max)
|
||||
} else {
|
||||
a < v.into_iter().fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
})
|
||||
}
|
||||
"RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" => history(i + 1, n + 1)
|
||||
.and_then(|v| {
|
||||
boolean(v.windows(2).all(|p| match name {
|
||||
"RISING" => p[1] > p[0],
|
||||
"FALLING" => p[1] < p[0],
|
||||
"NON_DECREASING" => p[1] >= p[0],
|
||||
_ => p[1] <= p[0],
|
||||
}))
|
||||
}),
|
||||
"SLOPE_CHANGE" => history(i + 1, n)
|
||||
.zip(history(i, n))
|
||||
.map(|(a, b)| slope(&a) - slope(&b)),
|
||||
_ => history(i + 1, n).and_then(|mut v| {
|
||||
let mean = average(&v);
|
||||
let lo = v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let variance = v.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
|
||||
match name {
|
||||
"HHV" | "ROLLING_MAX" => Some(hi),
|
||||
"LLV" | "ROLLING_MIN" => Some(lo),
|
||||
"ARGMAX" => v.iter().rposition(|x| *x == hi).map(|p| (n - 1 - p) as f64),
|
||||
"ARGMIN" => v.iter().rposition(|x| *x == lo).map(|p| (n - 1 - p) as f64),
|
||||
"DISTANCE_TO_HIGH" => {
|
||||
if hi == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(v[n - 1] / hi - 1.0)
|
||||
}
|
||||
}
|
||||
"DISTANCE_TO_LOW" => {
|
||||
if lo == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(v[n - 1] / lo - 1.0)
|
||||
}
|
||||
}
|
||||
"NEAR_HIGH" | "NEAR_LOW" => b.filter(|b| *b >= 0.0).and_then(|b| {
|
||||
let base = if name == "NEAR_HIGH" { hi } else { lo };
|
||||
if base == 0.0 {
|
||||
None
|
||||
} else {
|
||||
boolean((v[n - 1] / base - 1.0).abs() <= b)
|
||||
}
|
||||
}),
|
||||
"ZSCORE" | "STANDARDIZE" => {
|
||||
if variance == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some((v[n - 1] - mean) / variance.sqrt())
|
||||
}
|
||||
}
|
||||
"MINMAX" | "NORMALIZE" => {
|
||||
if hi == lo {
|
||||
None
|
||||
} else {
|
||||
Some((v[n - 1] - lo) / (hi - lo))
|
||||
}
|
||||
}
|
||||
"ROLLING_MEAN" => Some(mean),
|
||||
"ROLLING_SUM" | "COUNT" | "COUNT_TRUE" => Some(v.iter().sum()),
|
||||
"ROLLING_STD" => Some(variance.sqrt()),
|
||||
"ROLLING_MEDIAN" => {
|
||||
v.sort_by(f64::total_cmp);
|
||||
Some(if n % 2 == 1 {
|
||||
v[n / 2]
|
||||
} else {
|
||||
(v[n / 2 - 1] + v[n / 2]) / 2.0
|
||||
})
|
||||
}
|
||||
"SLOPE" => Some(slope(&v)),
|
||||
"ROLLING_CORR" | "ROLLING_COV" => {
|
||||
let b: Option<Vec<f64>> =
|
||||
args[1].values[i + 1 - n..=i].iter().copied().collect();
|
||||
b.and_then(|b| {
|
||||
let bm = average(&b);
|
||||
let cov = v
|
||||
.iter()
|
||||
.zip(&b)
|
||||
.map(|(a, b)| (a - mean) * (b - bm))
|
||||
.sum::<f64>()
|
||||
/ n as f64;
|
||||
if name == "ROLLING_COV" {
|
||||
Some(cov)
|
||||
} else {
|
||||
let bv = b.iter().map(|b| (b - bm).powi(2)).sum::<f64>() / n as f64;
|
||||
let d = (variance * bv).sqrt();
|
||||
if d == 0.0 { None } else { Some(cov / d) }
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}),
|
||||
}
|
||||
.filter(|v| v.is_finite());
|
||||
}
|
||||
Ok(Series {
|
||||
value_type: if name == "IF" {
|
||||
args[1].value_type
|
||||
} else if lag {
|
||||
args[0].value_type
|
||||
} else if returns_bool {
|
||||
ValueType::Boolean
|
||||
} else {
|
||||
ValueType::Number
|
||||
},
|
||||
values: out,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "factor_events_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use crate::factor_event_catalog::parameter_domain;
|
||||
|
||||
#[test]
|
||||
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
|
||||
for function in abstract_api::funcs() {
|
||||
let handle = abstract_api::get_func_handle(function.name).unwrap();
|
||||
let core = Core::new();
|
||||
let mut call = handle.new_call(&core);
|
||||
for (index, parameter) in function.opt_inputs.iter().enumerate() {
|
||||
let domain = parameter_domain(parameter.kind);
|
||||
let default = domain["default"].as_f64().unwrap();
|
||||
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
|
||||
if let Some(choices) = domain.get("choices") {
|
||||
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
|
||||
} else {
|
||||
assert!(default >= domain["minimum"].as_f64().unwrap());
|
||||
assert!(default <= domain["maximum"].as_f64().unwrap());
|
||||
}
|
||||
if domain["value_type"] == "integer" {
|
||||
assert_eq!(default.fract(), 0.0);
|
||||
call.set_opt(index, default as i32).unwrap();
|
||||
} else {
|
||||
call.set_opt(index, default).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(call.lookback().is_ok(), "{}", function.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
|
||||
let catalog = catalog();
|
||||
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
|
||||
let indicators = catalog["indicators"].as_array().unwrap();
|
||||
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
|
||||
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
|
||||
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
|
||||
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
|
||||
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
|
||||
}
|
||||
|
||||
fn frame(values: Vec<Option<f64>>) -> Frame {
|
||||
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
|
||||
let times = (0..values.len())
|
||||
.map(|i| start + chrono::Duration::days(i as i64))
|
||||
.collect::<Vec<_>>();
|
||||
Frame {
|
||||
symbol: "TEST".into(),
|
||||
frequency: "1d".into(),
|
||||
decision_at: *times.last().unwrap(),
|
||||
available_at: times.clone(),
|
||||
timestamps: times,
|
||||
fields: BTreeMap::from([("close".into(), values)]),
|
||||
}
|
||||
}
|
||||
fn expr(v: Value) -> Expr {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn ta_sma_real_values_and_parameter_validation() {
|
||||
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &frame).unwrap().values,
|
||||
vec![None, None, Some(2.0), Some(3.0)]
|
||||
);
|
||||
let bad = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
|
||||
);
|
||||
assert!(
|
||||
evaluate(&bad, &frame)
|
||||
.unwrap_err()
|
||||
.contains("parameter_unknown")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cross_is_event_not_state_and_never_uses_future() {
|
||||
let f = frame(vec![
|
||||
Some(9.0),
|
||||
Some(10.0),
|
||||
Some(11.0),
|
||||
Some(12.0),
|
||||
Some(8.0),
|
||||
]);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
|
||||
);
|
||||
let mut invalid = f.clone();
|
||||
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
|
||||
assert!(evaluate(&e, &invalid).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn missing_is_not_zero_and_breakout_excludes_current() {
|
||||
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, None, Some(1.0), None, None]
|
||||
);
|
||||
let zero = expr(
|
||||
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
|
||||
);
|
||||
assert!(
|
||||
evaluate(&zero, &f)
|
||||
.unwrap()
|
||||
.values
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
|
||||
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, Some(1.0), None, None, Some(2.0)]
|
||||
);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||
);
|
||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||
}
|
||||
#[test]
|
||||
fn no_event_has_no_bars_since_and_type_errors_reject() {
|
||||
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
|
||||
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
|
||||
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
|
||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||
assert!(
|
||||
evaluate(
|
||||
&expr(
|
||||
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
|
||||
),
|
||||
&f
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
|
||||
assert!(
|
||||
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
|
||||
.is_err()
|
||||
);
|
||||
let c = catalog();
|
||||
assert!(c["indicators"].as_array().unwrap().len() > 190);
|
||||
assert_eq!(c["live_routing"], false);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
|
||||
//! This traverses the original Serialize representation without materializing it.
|
||||
use serde::{Serialize, Serializer, ser};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Finite;
|
||||
|
||||
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
|
||||
value.serialize(Finite)
|
||||
}
|
||||
|
||||
macro_rules! scalar {
|
||||
($($method:ident: $ty:ty),* $(,)?) => {$(
|
||||
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
|
||||
)*};
|
||||
}
|
||||
|
||||
impl Serializer for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
type SerializeSeq = Self;
|
||||
type SerializeTuple = Self;
|
||||
type SerializeTupleStruct = Self;
|
||||
type SerializeTupleVariant = Self;
|
||||
type SerializeMap = Self;
|
||||
type SerializeStruct = Self;
|
||||
type SerializeStructVariant = Self;
|
||||
|
||||
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
|
||||
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
|
||||
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
|
||||
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
|
||||
serialize_str: &str, serialize_bytes: &[u8]);
|
||||
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
|
||||
self.serialize_f64(f64::from(value))
|
||||
}
|
||||
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
|
||||
if value.is_finite() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ser::Error::custom(
|
||||
"pending strategy intent contains a non-finite number",
|
||||
))
|
||||
}
|
||||
}
|
||||
fn serialize_none(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_unit(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_newtype_struct<T: ?Sized + Serialize>(
|
||||
self,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_newtype_variant<T: ?Sized + Serialize>(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
_: usize,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
_: usize,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! sequence {
|
||||
($trait:ident, $method:ident) => {
|
||||
impl ser::$trait for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
sequence!(SerializeSeq, serialize_element);
|
||||
sequence!(SerializeTuple, serialize_element);
|
||||
sequence!(SerializeTupleStruct, serialize_field);
|
||||
sequence!(SerializeTupleVariant, serialize_field);
|
||||
|
||||
impl ser::SerializeMap for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! structure {
|
||||
($trait:ident) => {
|
||||
impl ser::$trait for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn serialize_field<T: ?Sized + Serialize>(
|
||||
&mut self,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
structure!(SerializeStruct);
|
||||
structure!(SerializeStructVariant);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::strategy::{OrderIntent, StrategyDecision};
|
||||
|
||||
#[test]
|
||||
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let decision = StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitTargetPercent {
|
||||
symbol: "000001.SZ".into(),
|
||||
target_percent: 0.5,
|
||||
limit_price: value,
|
||||
reason: "test".into(),
|
||||
}
|
||||
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate(&decision).is_err());
|
||||
assert!(validate(&vec![Some(value)]).is_err());
|
||||
}
|
||||
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Fixed-point execution primitives for money and fee arithmetic.
|
||||
//!
|
||||
//! Market data and analytics remain floating point at their API boundaries.
|
||||
//! The execution kernel quantizes monetary values to micro-yuan before fee,
|
||||
//! budget and cash-ledger arithmetic so repeated fills and external cash flows
|
||||
//! do not accumulate binary floating-point drift.
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::events::OrderSide;
|
||||
|
||||
pub const MONEY_SCALE: i128 = 1_000_000;
|
||||
const MONEY_SCALE_F64: f64 = MONEY_SCALE as f64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||
pub struct FixedMoney(i128);
|
||||
|
||||
impl FixedMoney {
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
pub const fn from_raw(raw: i128) -> Self {
|
||||
Self(raw)
|
||||
}
|
||||
|
||||
pub const fn raw(self) -> i128 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn to_decimal_string(self) -> String {
|
||||
let magnitude = self.0.unsigned_abs();
|
||||
let scale = MONEY_SCALE as u128;
|
||||
let sign = if self.0 < 0 { "-" } else { "" };
|
||||
let width = MONEY_SCALE.ilog10() as usize;
|
||||
format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err("fixed money value is empty".to_string());
|
||||
}
|
||||
let (negative, unsigned) = match value.as_bytes()[0] {
|
||||
b'-' => (true, &value[1..]),
|
||||
b'+' => (false, &value[1..]),
|
||||
_ => (false, value),
|
||||
};
|
||||
let mut parts = unsigned.split('.');
|
||||
let whole = parts.next().unwrap_or_default();
|
||||
let fractional = parts.next().unwrap_or_default();
|
||||
if parts.next().is_some()
|
||||
|| whole.is_empty()
|
||||
|| !whole.bytes().all(|byte| byte.is_ascii_digit())
|
||||
|| !fractional.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
return Err(format!("invalid fixed money decimal: {value}"));
|
||||
}
|
||||
let whole = whole
|
||||
.parse::<i128>()
|
||||
.map_err(|_| format!("fixed money whole part is out of range: {value}"))?;
|
||||
let mut fractional_digits = fractional.as_bytes().to_vec();
|
||||
let round_up = fractional_digits.len() > 6 && fractional_digits[6] >= b'5';
|
||||
fractional_digits.truncate(6);
|
||||
while fractional_digits.len() < 6 {
|
||||
fractional_digits.push(b'0');
|
||||
}
|
||||
let fractional = if fractional_digits.is_empty() {
|
||||
0
|
||||
} else {
|
||||
std::str::from_utf8(&fractional_digits)
|
||||
.expect("fractional digits are ASCII")
|
||||
.parse::<i128>()
|
||||
.map_err(|_| format!("fixed money fractional part is invalid: {value}"))?
|
||||
};
|
||||
let mut raw = whole
|
||||
.checked_mul(MONEY_SCALE)
|
||||
.and_then(|raw| raw.checked_add(fractional))
|
||||
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
|
||||
if round_up {
|
||||
raw = raw
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
|
||||
}
|
||||
Ok(Self(if negative { -raw } else { raw }))
|
||||
}
|
||||
|
||||
pub fn from_f64(value: f64) -> Option<Self> {
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let raw = (value * MONEY_SCALE_F64).round();
|
||||
if !raw.is_finite() || raw < i128::MIN as f64 || raw > i128::MAX as f64 {
|
||||
return None;
|
||||
}
|
||||
Some(Self(raw as i128))
|
||||
}
|
||||
|
||||
pub fn to_f64(self) -> f64 {
|
||||
self.0 as f64 / MONEY_SCALE_F64
|
||||
}
|
||||
|
||||
pub fn checked_add(self, other: Self) -> Option<Self> {
|
||||
self.0.checked_add(other.0).map(Self)
|
||||
}
|
||||
|
||||
pub fn checked_sub(self, other: Self) -> Option<Self> {
|
||||
self.0.checked_sub(other.0).map(Self)
|
||||
}
|
||||
|
||||
pub fn checked_mul_quantity(self, quantity: u64) -> Option<Self> {
|
||||
self.0.checked_mul(i128::from(quantity)).map(Self)
|
||||
}
|
||||
|
||||
pub fn checked_neg(self) -> Option<Self> {
|
||||
self.0.checked_neg().map(Self)
|
||||
}
|
||||
|
||||
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
|
||||
let product = self.0.checked_mul(rate.0)?;
|
||||
let half = MONEY_SCALE / 2;
|
||||
let rounded = if product >= 0 {
|
||||
product.checked_add(half)? / MONEY_SCALE
|
||||
} else {
|
||||
product.checked_sub(half)? / MONEY_SCALE
|
||||
};
|
||||
Some(Self(rounded))
|
||||
}
|
||||
|
||||
pub fn checked_sum_f64(values: impl IntoIterator<Item = f64>) -> Option<Self> {
|
||||
values.into_iter().try_fold(Self::ZERO, |total, value| {
|
||||
total.checked_add(Self::from_f64(value)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn f64_fits_within(value: f64, limit: f64) -> Option<bool> {
|
||||
let value = Self::from_f64(value)?;
|
||||
if limit == f64::INFINITY {
|
||||
return Some(true);
|
||||
}
|
||||
Some(value <= Self::from_f64(limit)?)
|
||||
}
|
||||
|
||||
pub fn abs(self) -> Self {
|
||||
Self(self.0.abs())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FixedTradingCost {
|
||||
pub commission: FixedMoney,
|
||||
pub stamp_tax: FixedMoney,
|
||||
pub transfer_fee: FixedMoney,
|
||||
}
|
||||
|
||||
impl FixedTradingCost {
|
||||
pub fn total(self) -> FixedMoney {
|
||||
FixedMoney::from_raw(self.commission.raw() + self.stamp_tax.raw() + self.transfer_fee.raw())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FixedChinaAShareCostModel {
|
||||
pub commission_rate: FixedMoney,
|
||||
pub stamp_tax_rate_before_change: FixedMoney,
|
||||
pub stamp_tax_rate_after_change: FixedMoney,
|
||||
pub stamp_tax_change_date: NaiveDate,
|
||||
pub minimum_commission: FixedMoney,
|
||||
pub transfer_fee_rate: FixedMoney,
|
||||
}
|
||||
|
||||
impl FixedChinaAShareCostModel {
|
||||
pub fn commission_for(self, gross_amount: FixedMoney) -> FixedMoney {
|
||||
if gross_amount.raw() <= 0 {
|
||||
return FixedMoney::ZERO;
|
||||
}
|
||||
let raw = gross_amount
|
||||
.checked_mul_rate(self.commission_rate)
|
||||
.expect("fixed commission multiplication overflow");
|
||||
raw.max(self.minimum_commission)
|
||||
}
|
||||
|
||||
pub fn stamp_tax_rate_for(self, date: NaiveDate) -> FixedMoney {
|
||||
if date < self.stamp_tax_change_date {
|
||||
self.stamp_tax_rate_before_change
|
||||
} else {
|
||||
self.stamp_tax_rate_after_change
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stamp_tax_for(
|
||||
self,
|
||||
date: NaiveDate,
|
||||
side: OrderSide,
|
||||
gross_amount: FixedMoney,
|
||||
) -> FixedMoney {
|
||||
if gross_amount.raw() <= 0 || side == OrderSide::Buy {
|
||||
return FixedMoney::ZERO;
|
||||
}
|
||||
gross_amount
|
||||
.checked_mul_rate(self.stamp_tax_rate_for(date))
|
||||
.expect("fixed stamp tax multiplication overflow")
|
||||
}
|
||||
|
||||
pub fn transfer_fee_for(self, gross_amount: FixedMoney) -> FixedMoney {
|
||||
if gross_amount.raw() <= 0 {
|
||||
return FixedMoney::ZERO;
|
||||
}
|
||||
gross_amount
|
||||
.checked_mul_rate(self.transfer_fee_rate)
|
||||
.expect("fixed transfer fee multiplication overflow")
|
||||
}
|
||||
|
||||
pub fn calculate(
|
||||
self,
|
||||
date: NaiveDate,
|
||||
side: OrderSide,
|
||||
gross_amount: FixedMoney,
|
||||
) -> FixedTradingCost {
|
||||
FixedTradingCost {
|
||||
commission: self.commission_for(gross_amount),
|
||||
stamp_tax: self.stamp_tax_for(date, side, gross_amount),
|
||||
transfer_fee: self.transfer_fee_for(gross_amount),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commission_for_order_fill(
|
||||
self,
|
||||
gross_amount: FixedMoney,
|
||||
order_id: Option<u64>,
|
||||
commission_state: &mut BTreeMap<u64, FixedMoney>,
|
||||
) -> FixedMoney {
|
||||
if gross_amount.raw() <= 0 {
|
||||
return FixedMoney::ZERO;
|
||||
}
|
||||
let raw = gross_amount
|
||||
.checked_mul_rate(self.commission_rate)
|
||||
.expect("fixed commission multiplication overflow");
|
||||
let Some(order_id) = order_id else {
|
||||
return raw.max(self.minimum_commission);
|
||||
};
|
||||
let remaining = commission_state
|
||||
.entry(order_id)
|
||||
.or_insert(self.minimum_commission);
|
||||
self.commission_for_order_fill_remaining(gross_amount, remaining)
|
||||
}
|
||||
|
||||
pub fn commission_for_order_fill_remaining(
|
||||
self,
|
||||
gross_amount: FixedMoney,
|
||||
remaining: &mut FixedMoney,
|
||||
) -> FixedMoney {
|
||||
if gross_amount.raw() <= 0 {
|
||||
return FixedMoney::ZERO;
|
||||
}
|
||||
let raw = gross_amount
|
||||
.checked_mul_rate(self.commission_rate)
|
||||
.expect("fixed commission multiplication overflow");
|
||||
if raw > *remaining {
|
||||
let charged = if *remaining == self.minimum_commission {
|
||||
raw
|
||||
} else {
|
||||
raw.checked_sub(*remaining)
|
||||
.expect("fixed remaining commission underflow")
|
||||
};
|
||||
*remaining = FixedMoney::ZERO;
|
||||
charged
|
||||
} else {
|
||||
let charged = if *remaining == self.minimum_commission {
|
||||
self.minimum_commission
|
||||
} else {
|
||||
FixedMoney::ZERO
|
||||
};
|
||||
*remaining = remaining
|
||||
.checked_sub(raw)
|
||||
.expect("fixed remaining commission underflow");
|
||||
charged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FixedLot {
|
||||
pub acquired_date: NaiveDate,
|
||||
pub quantity: u64,
|
||||
pub entry_price: FixedMoney,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FixedLotBook {
|
||||
lots: VecDeque<FixedLot>,
|
||||
pub realized_pnl: FixedMoney,
|
||||
pub quantity: u64,
|
||||
}
|
||||
|
||||
impl FixedLotBook {
|
||||
pub fn buy(&mut self, date: NaiveDate, quantity: u64, price: FixedMoney) {
|
||||
if quantity == 0 {
|
||||
return;
|
||||
}
|
||||
self.lots.push_back(FixedLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
entry_price: price,
|
||||
});
|
||||
self.quantity = self.quantity.saturating_add(quantity);
|
||||
}
|
||||
|
||||
pub fn sell(&mut self, quantity: u64, price: FixedMoney) -> Result<FixedMoney, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!(
|
||||
"fixed sell quantity {} exceeds current quantity {}",
|
||||
quantity, self.quantity
|
||||
));
|
||||
}
|
||||
let mut remaining = quantity;
|
||||
let mut realized = FixedMoney::ZERO;
|
||||
while remaining > 0 {
|
||||
let Some(mut lot) = self.lots.pop_front() else {
|
||||
return Err("fixed lot book is empty while selling".to_string());
|
||||
};
|
||||
let sold = remaining.min(lot.quantity);
|
||||
let price_delta = price
|
||||
.checked_sub(lot.entry_price)
|
||||
.and_then(|delta| delta.checked_mul_quantity(sold))
|
||||
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
|
||||
realized = realized
|
||||
.checked_add(price_delta)
|
||||
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
|
||||
lot.quantity -= sold;
|
||||
remaining -= sold;
|
||||
if lot.quantity > 0 {
|
||||
self.lots.push_front(lot);
|
||||
}
|
||||
}
|
||||
self.quantity -= quantity;
|
||||
self.realized_pnl = self
|
||||
.realized_pnl
|
||||
.checked_add(realized)
|
||||
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
|
||||
Ok(realized)
|
||||
}
|
||||
|
||||
pub fn market_value(&self, mark_price: FixedMoney) -> FixedMoney {
|
||||
mark_price
|
||||
.checked_mul_quantity(self.quantity)
|
||||
.expect("fixed market value overflow")
|
||||
}
|
||||
|
||||
pub fn unrealized_pnl(&self, mark_price: FixedMoney) -> FixedMoney {
|
||||
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
|
||||
let delta = mark_price
|
||||
.checked_sub(lot.entry_price)
|
||||
.and_then(|value| value.checked_mul_quantity(lot.quantity))
|
||||
.expect("fixed unrealized PnL overflow");
|
||||
total
|
||||
.checked_add(delta)
|
||||
.expect("fixed unrealized PnL overflow")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FixedAccount {
|
||||
pub cash: FixedMoney,
|
||||
pub units: FixedMoney,
|
||||
pub external_cash_flow_total: FixedMoney,
|
||||
}
|
||||
|
||||
impl FixedAccount {
|
||||
pub fn new(initial_cash: FixedMoney) -> Self {
|
||||
Self {
|
||||
cash: initial_cash,
|
||||
units: initial_cash,
|
||||
external_cash_flow_total: FixedMoney::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_external_cash_flow(
|
||||
&mut self,
|
||||
amount: FixedMoney,
|
||||
unit_nav: FixedMoney,
|
||||
) -> Result<(), String> {
|
||||
if unit_nav.raw() <= 0 {
|
||||
return Err("fixed unit NAV must be positive".to_string());
|
||||
}
|
||||
let exact_units_raw = amount
|
||||
.raw()
|
||||
.checked_mul(MONEY_SCALE)
|
||||
.and_then(|value| value.checked_div(unit_nav.raw()))
|
||||
.ok_or_else(|| "fixed external flow unit conversion overflow".to_string())?;
|
||||
self.cash = self
|
||||
.cash
|
||||
.checked_add(amount)
|
||||
.ok_or_else(|| "fixed cash overflow".to_string())?;
|
||||
self.units = self
|
||||
.units
|
||||
.checked_add(FixedMoney::from_raw(exact_units_raw))
|
||||
.ok_or_else(|| "fixed units overflow".to_string())?;
|
||||
self.external_cash_flow_total = self
|
||||
.external_cash_flow_total
|
||||
.checked_add(amount)
|
||||
.ok_or_else(|| "fixed external flow overflow".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unit_nav(&self, total_equity: FixedMoney) -> Result<FixedMoney, String> {
|
||||
if self.units.raw() <= 0 {
|
||||
return Err("fixed account has no units".to_string());
|
||||
}
|
||||
let raw = total_equity
|
||||
.raw()
|
||||
.checked_mul(MONEY_SCALE)
|
||||
.and_then(|value| value.checked_div(self.units.raw()))
|
||||
.ok_or_else(|| "fixed unit NAV overflow".to_string())?;
|
||||
Ok(FixedMoney::from_raw(raw))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cost::{ChinaAShareCostModel, CostModel};
|
||||
use crate::risk_control::TradingConstraintConfig;
|
||||
|
||||
fn fixed_model() -> FixedChinaAShareCostModel {
|
||||
let config = TradingConstraintConfig::default();
|
||||
FixedChinaAShareCostModel {
|
||||
commission_rate: FixedMoney::from_f64(config.commission_rate).unwrap(),
|
||||
stamp_tax_rate_before_change: FixedMoney::from_f64(config.stamp_tax_rate_before_change)
|
||||
.unwrap(),
|
||||
stamp_tax_rate_after_change: FixedMoney::from_f64(config.stamp_tax_rate_after_change)
|
||||
.unwrap(),
|
||||
stamp_tax_change_date: config.stamp_tax_change_date,
|
||||
minimum_commission: FixedMoney::from_f64(config.minimum_commission).unwrap(),
|
||||
transfer_fee_rate: FixedMoney::from_f64(config.transfer_fee_rate).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_parser_rounds_only_beyond_money_scale() {
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str("1.234567").unwrap().raw(),
|
||||
1_234_567
|
||||
);
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str("1.2345675").unwrap().raw(),
|
||||
1_234_568
|
||||
);
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str("-0.0000014").unwrap().raw(),
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_cost_model_matches_fixed_execution_primitive() {
|
||||
let fixed = fixed_model();
|
||||
let float = ChinaAShareCostModel::default();
|
||||
let dates = [
|
||||
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
];
|
||||
for gross in [0.01, 10.0, 16_666.67, 248_059.812, 1_000_000.01] {
|
||||
let fixed_gross = FixedMoney::from_f64(gross).unwrap();
|
||||
for date in dates {
|
||||
for side in [OrderSide::Buy, OrderSide::Sell] {
|
||||
let expected = float.calculate(date, side, gross);
|
||||
let actual = fixed.calculate(date, side, fixed_gross);
|
||||
for (actual, expected) in [
|
||||
(actual.commission, expected.commission),
|
||||
(actual.stamp_tax, expected.stamp_tax),
|
||||
(actual.transfer_fee, expected.transfer_fee),
|
||||
] {
|
||||
assert_eq!(actual.to_f64(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_split_commission_matches_fixed_execution_primitive() {
|
||||
let fixed = fixed_model();
|
||||
let float = ChinaAShareCostModel::default();
|
||||
let mut fixed_state = BTreeMap::new();
|
||||
let mut float_state = BTreeMap::new();
|
||||
let mut fixed_total = FixedMoney::ZERO;
|
||||
let mut float_total = 0.0;
|
||||
for gross in [1000.0, 2000.0, 4000.0, 40_000.0] {
|
||||
let fixed_fee = fixed.commission_for_order_fill(
|
||||
FixedMoney::from_f64(gross).unwrap(),
|
||||
Some(42),
|
||||
&mut fixed_state,
|
||||
);
|
||||
let float_fee = float.commission_for_order_fill(gross, Some(42), &mut float_state);
|
||||
fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
|
||||
float_total += float_fee;
|
||||
}
|
||||
assert_eq!(fixed_total.to_f64(), float_total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_budget_never_exceeds_cash_after_cost() {
|
||||
let model = fixed_model();
|
||||
let date = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
|
||||
let cash = FixedMoney::from_decimal_str("99880.00").unwrap();
|
||||
let price = FixedMoney::from_decimal_str("19.9731").unwrap();
|
||||
let mut quantity = 5_000u64;
|
||||
while quantity > 0 {
|
||||
let gross = price.checked_mul_quantity(quantity).unwrap();
|
||||
if gross
|
||||
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
|
||||
.unwrap()
|
||||
<= cash
|
||||
{
|
||||
break;
|
||||
}
|
||||
quantity -= 100;
|
||||
}
|
||||
let gross = price.checked_mul_quantity(quantity).unwrap();
|
||||
let total = gross
|
||||
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
|
||||
.unwrap();
|
||||
assert!(total <= cash);
|
||||
assert!(quantity < 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_budget_comparison_rejects_one_micro_yuan_overrun() {
|
||||
assert_eq!(FixedMoney::f64_fits_within(100.0, 100.0), Some(true));
|
||||
assert_eq!(FixedMoney::f64_fits_within(100.000001, 100.0), Some(false));
|
||||
assert_eq!(
|
||||
FixedMoney::f64_fits_within(100.000001, f64::INFINITY),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
|
||||
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let day_two = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let mut book = FixedLotBook::default();
|
||||
book.buy(day_one, 100, FixedMoney::from_decimal_str("10.01").unwrap());
|
||||
book.buy(day_two, 100, FixedMoney::from_decimal_str("10.03").unwrap());
|
||||
let realized = book
|
||||
.sell(150, FixedMoney::from_decimal_str("10.11").unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(realized.raw(), 14_000_000);
|
||||
assert_eq!(book.quantity, 50);
|
||||
assert_eq!(
|
||||
book.unrealized_pnl(FixedMoney::from_decimal_str("10.20").unwrap())
|
||||
.raw(),
|
||||
8_500_000
|
||||
);
|
||||
|
||||
let mut account = FixedAccount::new(FixedMoney::from_decimal_str("100.00").unwrap());
|
||||
account
|
||||
.apply_external_cash_flow(
|
||||
FixedMoney::from_decimal_str("50.00").unwrap(),
|
||||
FixedMoney::from_decimal_str("1.00").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(account.units.raw(), 150 * MONEY_SCALE);
|
||||
assert_eq!(
|
||||
account
|
||||
.unit_nav(FixedMoney::from_decimal_str("150.00").unwrap())
|
||||
.unwrap()
|
||||
.raw(),
|
||||
MONEY_SCALE
|
||||
);
|
||||
assert_eq!(account.external_cash_flow_total.raw(), 50 * MONEY_SCALE);
|
||||
}
|
||||
}
|
||||
+326
-79
@@ -7,8 +7,26 @@ use crate::events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
use crate::fixed_point::FixedMoney;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
fn futures_money(value: f64, label: &str) -> Result<FixedMoney, String> {
|
||||
FixedMoney::from_f64(value)
|
||||
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
|
||||
}
|
||||
|
||||
fn futures_money_or_panic(value: f64, label: &str) -> FixedMoney {
|
||||
futures_money(value, label).unwrap_or_else(|error| panic!("{error}"))
|
||||
}
|
||||
|
||||
fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str) -> FixedMoney {
|
||||
values.into_iter().fold(FixedMoney::ZERO, |total, value| {
|
||||
total
|
||||
.checked_add(value)
|
||||
.unwrap_or_else(|| panic!("fixed-point {label} overflow"))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub enum FuturesDirection {
|
||||
Long,
|
||||
Short,
|
||||
@@ -44,7 +62,7 @@ impl FuturesDirection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum FuturesPositionEffect {
|
||||
Open,
|
||||
Close,
|
||||
@@ -63,7 +81,7 @@ impl FuturesPositionEffect {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct FuturesContractSpec {
|
||||
pub contract_multiplier: f64,
|
||||
pub long_margin_rate: f64,
|
||||
@@ -172,7 +190,7 @@ impl FuturesTransactionCostModel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FuturesOrderIntent {
|
||||
pub symbol: String,
|
||||
pub direction: FuturesDirection,
|
||||
@@ -345,6 +363,14 @@ pub struct FuturesExecutionReport {
|
||||
}
|
||||
|
||||
impl FuturesContractSpec {
|
||||
pub fn unresolved() -> Self {
|
||||
Self {
|
||||
contract_multiplier: f64::NAN,
|
||||
long_margin_rate: f64::NAN,
|
||||
short_margin_rate: f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self {
|
||||
Self {
|
||||
contract_multiplier: contract_multiplier.max(1.0),
|
||||
@@ -359,6 +385,15 @@ impl FuturesContractSpec {
|
||||
FuturesDirection::Short => self.short_margin_rate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_resolved(&self) -> bool {
|
||||
self.contract_multiplier.is_finite()
|
||||
&& self.contract_multiplier > 0.0
|
||||
&& self.long_margin_rate.is_finite()
|
||||
&& self.long_margin_rate >= 0.0
|
||||
&& self.short_margin_rate.is_finite()
|
||||
&& self.short_margin_rate >= 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -366,15 +401,16 @@ pub struct FuturesPosition {
|
||||
pub symbol: String,
|
||||
pub direction: FuturesDirection,
|
||||
pub old_quantity: u32,
|
||||
day_start_quantity: u32,
|
||||
pub quantity: u32,
|
||||
pub avg_price: f64,
|
||||
pub last_price: f64,
|
||||
pub prev_close: f64,
|
||||
pub contract_multiplier: f64,
|
||||
pub margin_rate: f64,
|
||||
pub transaction_cost: f64,
|
||||
transaction_cost: FixedMoney,
|
||||
trade_quantity_delta: i32,
|
||||
trade_cost: f64,
|
||||
trade_value: FixedMoney,
|
||||
}
|
||||
|
||||
impl FuturesPosition {
|
||||
@@ -390,15 +426,16 @@ impl FuturesPosition {
|
||||
symbol: symbol.into(),
|
||||
direction,
|
||||
old_quantity: init_quantity,
|
||||
day_start_quantity: init_quantity,
|
||||
quantity: init_quantity,
|
||||
avg_price: init_price.max(0.0),
|
||||
last_price: init_price.max(0.0),
|
||||
prev_close: init_price.max(0.0),
|
||||
contract_multiplier: spec.contract_multiplier,
|
||||
margin_rate,
|
||||
transaction_cost: 0.0,
|
||||
transaction_cost: FixedMoney::ZERO,
|
||||
trade_quantity_delta: 0,
|
||||
trade_cost: 0.0,
|
||||
trade_value: FixedMoney::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,18 +444,39 @@ impl FuturesPosition {
|
||||
}
|
||||
|
||||
pub fn market_value(&self) -> f64 {
|
||||
self.quantity as f64 * self.last_price * self.contract_multiplier
|
||||
self.market_value_money().to_f64()
|
||||
}
|
||||
|
||||
fn market_value_money(&self) -> FixedMoney {
|
||||
futures_money_or_panic(
|
||||
self.quantity as f64 * self.last_price * self.contract_multiplier,
|
||||
"futures position market value",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn margin(&self) -> f64 {
|
||||
self.market_value() * self.margin_rate
|
||||
self.margin_money().to_f64()
|
||||
}
|
||||
|
||||
fn margin_money(&self) -> FixedMoney {
|
||||
futures_money_or_panic(
|
||||
self.market_value_money().to_f64() * self.margin_rate,
|
||||
"futures position margin",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn equity(&self) -> f64 {
|
||||
(self.last_price - self.avg_price)
|
||||
* self.quantity as f64
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor()
|
||||
self.equity_money().to_f64()
|
||||
}
|
||||
|
||||
fn equity_money(&self) -> FixedMoney {
|
||||
futures_money_or_panic(
|
||||
(self.last_price - self.avg_price)
|
||||
* self.quantity as f64
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor(),
|
||||
"futures position equity",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pnl(&self) -> f64 {
|
||||
@@ -426,22 +484,47 @@ impl FuturesPosition {
|
||||
}
|
||||
|
||||
pub fn trading_pnl(&self) -> f64 {
|
||||
(self.trade_quantity_delta as f64 * self.last_price - self.trade_cost)
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor()
|
||||
self.trading_pnl_money().to_f64()
|
||||
}
|
||||
|
||||
fn trading_pnl_money(&self) -> FixedMoney {
|
||||
let marked_trade_value = futures_money_or_panic(
|
||||
self.trade_quantity_delta as f64 * self.last_price * self.contract_multiplier,
|
||||
"futures marked trade value",
|
||||
);
|
||||
let pnl = marked_trade_value
|
||||
.checked_sub(self.trade_value)
|
||||
.expect("fixed-point futures trading PnL overflow");
|
||||
if self.direction == FuturesDirection::Short {
|
||||
pnl.checked_neg()
|
||||
.expect("fixed-point futures short trading PnL overflow")
|
||||
} else {
|
||||
pnl
|
||||
}
|
||||
}
|
||||
|
||||
pub fn position_pnl(&self) -> f64 {
|
||||
if self.old_quantity == 0 {
|
||||
0.0
|
||||
self.position_pnl_money().to_f64()
|
||||
}
|
||||
|
||||
fn position_pnl_money(&self) -> FixedMoney {
|
||||
if self.day_start_quantity == 0 {
|
||||
FixedMoney::ZERO
|
||||
} else {
|
||||
self.old_quantity as f64
|
||||
* (self.last_price - self.prev_close)
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor()
|
||||
futures_money_or_panic(
|
||||
self.day_start_quantity as f64
|
||||
* (self.last_price - self.prev_close)
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor(),
|
||||
"futures position daily PnL",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transaction_cost(&self) -> f64 {
|
||||
self.transaction_cost.to_f64()
|
||||
}
|
||||
|
||||
pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) {
|
||||
if quantity == 0 {
|
||||
return;
|
||||
@@ -450,9 +533,20 @@ impl FuturesPosition {
|
||||
self.quantity += quantity;
|
||||
self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64;
|
||||
self.last_price = price;
|
||||
self.transaction_cost += transaction_cost.max(0.0);
|
||||
let transaction_cost =
|
||||
futures_money_or_panic(transaction_cost.max(0.0), "futures open transaction cost");
|
||||
self.transaction_cost = self
|
||||
.transaction_cost
|
||||
.checked_add(transaction_cost)
|
||||
.expect("fixed-point futures transaction cost overflow");
|
||||
self.trade_quantity_delta += quantity as i32;
|
||||
self.trade_cost += price * quantity as f64;
|
||||
self.trade_value = self
|
||||
.trade_value
|
||||
.checked_add(futures_money_or_panic(
|
||||
price * quantity as f64 * self.contract_multiplier,
|
||||
"futures open trade value",
|
||||
))
|
||||
.expect("fixed-point futures trade value overflow");
|
||||
}
|
||||
|
||||
pub fn close(
|
||||
@@ -476,6 +570,17 @@ impl FuturesPosition {
|
||||
transaction_cost: f64,
|
||||
effect: FuturesPositionEffect,
|
||||
) -> Result<f64, String> {
|
||||
self.close_with_effect_money(quantity, price, transaction_cost, effect)
|
||||
.map(FixedMoney::to_f64)
|
||||
}
|
||||
|
||||
fn close_with_effect_money(
|
||||
&mut self,
|
||||
quantity: u32,
|
||||
price: f64,
|
||||
transaction_cost: f64,
|
||||
effect: FuturesPositionEffect,
|
||||
) -> Result<FixedMoney, String> {
|
||||
if effect == FuturesPositionEffect::Open {
|
||||
return Err("close_with_effect does not accept open effect".to_string());
|
||||
}
|
||||
@@ -489,7 +594,7 @@ impl FuturesPosition {
|
||||
));
|
||||
}
|
||||
if quantity == 0 {
|
||||
return Ok(0.0);
|
||||
return Ok(FixedMoney::ZERO);
|
||||
}
|
||||
match effect {
|
||||
FuturesPositionEffect::Open => unreachable!(),
|
||||
@@ -523,19 +628,34 @@ impl FuturesPosition {
|
||||
}
|
||||
}
|
||||
|
||||
let realized = (price - self.avg_price)
|
||||
* quantity as f64
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor()
|
||||
- transaction_cost.max(0.0);
|
||||
let transaction_cost =
|
||||
futures_money(transaction_cost.max(0.0), "futures close transaction cost")?;
|
||||
let realized = futures_money(
|
||||
(price - self.avg_price)
|
||||
* quantity as f64
|
||||
* self.contract_multiplier
|
||||
* self.direction.factor(),
|
||||
"futures realized PnL",
|
||||
)?
|
||||
.checked_sub(transaction_cost)
|
||||
.ok_or_else(|| "fixed-point futures realized PnL overflow".to_string())?;
|
||||
self.quantity -= quantity;
|
||||
if self.quantity == 0 {
|
||||
self.avg_price = 0.0;
|
||||
}
|
||||
self.last_price = price;
|
||||
self.transaction_cost += transaction_cost.max(0.0);
|
||||
self.transaction_cost = self
|
||||
.transaction_cost
|
||||
.checked_add(transaction_cost)
|
||||
.ok_or_else(|| "fixed-point futures transaction cost overflow".to_string())?;
|
||||
self.trade_quantity_delta -= quantity as i32;
|
||||
self.trade_cost -= price * quantity as f64;
|
||||
self.trade_value = self
|
||||
.trade_value
|
||||
.checked_sub(futures_money(
|
||||
price * quantity as f64 * self.contract_multiplier,
|
||||
"futures close trade value",
|
||||
)?)
|
||||
.ok_or_else(|| "fixed-point futures trade value overflow".to_string())?;
|
||||
Ok(realized)
|
||||
}
|
||||
|
||||
@@ -547,98 +667,163 @@ impl FuturesPosition {
|
||||
|
||||
pub fn begin_trading_day(&mut self) {
|
||||
self.old_quantity = self.quantity;
|
||||
self.day_start_quantity = self.quantity;
|
||||
self.prev_close = self.last_price;
|
||||
self.transaction_cost = 0.0;
|
||||
self.transaction_cost = FixedMoney::ZERO;
|
||||
self.trade_quantity_delta = 0;
|
||||
self.trade_cost = 0.0;
|
||||
self.trade_value = FixedMoney::ZERO;
|
||||
}
|
||||
|
||||
pub fn settlement(&mut self, settlement_price: f64) -> f64 {
|
||||
self.settlement_money(settlement_price).to_f64()
|
||||
}
|
||||
|
||||
fn settlement_money(&mut self, settlement_price: f64) -> FixedMoney {
|
||||
self.mark_price(settlement_price);
|
||||
let cash_delta = self.equity();
|
||||
let cash_delta = self.equity_money();
|
||||
self.avg_price = self.last_price;
|
||||
self.prev_close = self.last_price;
|
||||
self.old_quantity = self.quantity;
|
||||
cash_delta
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FuturesAccountState {
|
||||
starting_cash: f64,
|
||||
total_cash: f64,
|
||||
frozen_cash: f64,
|
||||
starting_cash: FixedMoney,
|
||||
total_cash: FixedMoney,
|
||||
frozen_cash: FixedMoney,
|
||||
closed_day_trading_pnl: FixedMoney,
|
||||
closed_day_position_pnl: FixedMoney,
|
||||
closed_day_transaction_cost: FixedMoney,
|
||||
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
|
||||
}
|
||||
|
||||
impl FuturesAccountState {
|
||||
pub fn new(total_cash: f64) -> Self {
|
||||
let total_cash = futures_money_or_panic(total_cash, "futures starting cash");
|
||||
Self {
|
||||
starting_cash: total_cash,
|
||||
total_cash,
|
||||
frozen_cash: 0.0,
|
||||
frozen_cash: FixedMoney::ZERO,
|
||||
closed_day_trading_pnl: FixedMoney::ZERO,
|
||||
closed_day_position_pnl: FixedMoney::ZERO,
|
||||
closed_day_transaction_cost: FixedMoney::ZERO,
|
||||
positions: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn starting_cash(&self) -> f64 {
|
||||
self.starting_cash
|
||||
self.starting_cash.to_f64()
|
||||
}
|
||||
|
||||
pub fn total_cash(&self) -> f64 {
|
||||
self.total_cash
|
||||
self.total_cash.to_f64()
|
||||
}
|
||||
|
||||
pub fn frozen_cash(&self) -> f64 {
|
||||
self.frozen_cash
|
||||
self.frozen_cash.to_f64()
|
||||
}
|
||||
|
||||
pub fn cash(&self) -> f64 {
|
||||
self.total_cash - self.margin() - self.frozen_cash
|
||||
self.cash_money().to_f64()
|
||||
}
|
||||
|
||||
fn cash_money(&self) -> FixedMoney {
|
||||
self.total_cash
|
||||
.checked_sub(self.margin_money())
|
||||
.and_then(|cash| cash.checked_sub(self.frozen_cash))
|
||||
.expect("fixed-point futures available cash overflow")
|
||||
}
|
||||
|
||||
pub fn margin(&self) -> f64 {
|
||||
self.positions.values().map(FuturesPosition::margin).sum()
|
||||
self.margin_money().to_f64()
|
||||
}
|
||||
|
||||
fn margin_money(&self) -> FixedMoney {
|
||||
sum_futures_money(
|
||||
self.positions.values().map(FuturesPosition::margin_money),
|
||||
"futures account margin",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn market_value(&self) -> f64 {
|
||||
self.positions
|
||||
.values()
|
||||
.map(FuturesPosition::market_value)
|
||||
.sum()
|
||||
sum_futures_money(
|
||||
self.positions
|
||||
.values()
|
||||
.map(FuturesPosition::market_value_money),
|
||||
"futures account market value",
|
||||
)
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
pub fn position_equity(&self) -> f64 {
|
||||
self.positions.values().map(FuturesPosition::equity).sum()
|
||||
self.position_equity_money().to_f64()
|
||||
}
|
||||
|
||||
fn position_equity_money(&self) -> FixedMoney {
|
||||
sum_futures_money(
|
||||
self.positions.values().map(FuturesPosition::equity_money),
|
||||
"futures account position equity",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn total_value(&self) -> f64 {
|
||||
self.total_cash + self.position_equity()
|
||||
self.total_cash
|
||||
.checked_add(self.position_equity_money())
|
||||
.expect("fixed-point futures total value overflow")
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
pub fn daily_pnl(&self) -> f64 {
|
||||
self.trading_pnl() + self.position_pnl() - self.transaction_cost()
|
||||
self.trading_pnl_money()
|
||||
.checked_add(self.position_pnl_money())
|
||||
.and_then(|pnl| pnl.checked_sub(self.transaction_cost_money()))
|
||||
.expect("fixed-point futures daily PnL overflow")
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
pub fn trading_pnl(&self) -> f64 {
|
||||
self.positions
|
||||
.values()
|
||||
.map(FuturesPosition::trading_pnl)
|
||||
.sum()
|
||||
self.trading_pnl_money().to_f64()
|
||||
}
|
||||
|
||||
fn trading_pnl_money(&self) -> FixedMoney {
|
||||
sum_futures_money(
|
||||
std::iter::once(self.closed_day_trading_pnl).chain(
|
||||
self.positions
|
||||
.values()
|
||||
.map(FuturesPosition::trading_pnl_money),
|
||||
),
|
||||
"futures account trading PnL",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn position_pnl(&self) -> f64 {
|
||||
self.positions
|
||||
.values()
|
||||
.map(FuturesPosition::position_pnl)
|
||||
.sum()
|
||||
self.position_pnl_money().to_f64()
|
||||
}
|
||||
|
||||
fn position_pnl_money(&self) -> FixedMoney {
|
||||
sum_futures_money(
|
||||
std::iter::once(self.closed_day_position_pnl).chain(
|
||||
self.positions
|
||||
.values()
|
||||
.map(FuturesPosition::position_pnl_money),
|
||||
),
|
||||
"futures account position PnL",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transaction_cost(&self) -> f64 {
|
||||
self.positions
|
||||
.values()
|
||||
.map(|position| position.transaction_cost)
|
||||
.sum()
|
||||
self.transaction_cost_money().to_f64()
|
||||
}
|
||||
|
||||
fn transaction_cost_money(&self) -> FixedMoney {
|
||||
sum_futures_money(
|
||||
std::iter::once(self.closed_day_transaction_cost).chain(
|
||||
self.positions
|
||||
.values()
|
||||
.map(|position| position.transaction_cost),
|
||||
),
|
||||
"futures account transaction cost",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> {
|
||||
@@ -667,7 +852,13 @@ impl FuturesAccountState {
|
||||
.entry((symbol.clone(), direction))
|
||||
.or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price));
|
||||
position.open(quantity, price, transaction_cost);
|
||||
self.total_cash -= transaction_cost.max(0.0);
|
||||
self.total_cash = self
|
||||
.total_cash
|
||||
.checked_sub(futures_money_or_panic(
|
||||
transaction_cost.max(0.0),
|
||||
"futures open transaction cost",
|
||||
))
|
||||
.expect("fixed-point futures cash overflow");
|
||||
}
|
||||
|
||||
pub fn close(
|
||||
@@ -702,12 +893,30 @@ impl FuturesAccountState {
|
||||
.positions
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?;
|
||||
let cash_delta = position.close_with_effect(quantity, price, transaction_cost, effect)?;
|
||||
self.total_cash += cash_delta;
|
||||
let cash_delta =
|
||||
position.close_with_effect_money(quantity, price, transaction_cost, effect)?;
|
||||
self.total_cash = self
|
||||
.total_cash
|
||||
.checked_add(cash_delta)
|
||||
.ok_or_else(|| "fixed-point futures cash overflow".to_string())?;
|
||||
if position.quantity == 0 {
|
||||
self.closed_day_trading_pnl = self
|
||||
.closed_day_trading_pnl
|
||||
.checked_add(position.trading_pnl_money())
|
||||
.ok_or_else(|| "fixed-point closed futures trading PnL overflow".to_string())?;
|
||||
self.closed_day_position_pnl = self
|
||||
.closed_day_position_pnl
|
||||
.checked_add(position.position_pnl_money())
|
||||
.ok_or_else(|| "fixed-point closed futures position PnL overflow".to_string())?;
|
||||
self.closed_day_transaction_cost = self
|
||||
.closed_day_transaction_cost
|
||||
.checked_add(position.transaction_cost)
|
||||
.ok_or_else(|| {
|
||||
"fixed-point closed futures transaction cost overflow".to_string()
|
||||
})?;
|
||||
self.positions.remove(&key);
|
||||
}
|
||||
Ok(cash_delta)
|
||||
Ok(cash_delta.to_f64())
|
||||
}
|
||||
|
||||
pub fn execute_order(
|
||||
@@ -746,6 +955,9 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -779,7 +991,7 @@ impl FuturesAccountState {
|
||||
intent.price,
|
||||
intent.transaction_cost,
|
||||
);
|
||||
if projected.cash() < -1e-8 {
|
||||
if projected.cash_money().raw() < 0 {
|
||||
Err(format!(
|
||||
"insufficient futures margin available_cash={:.2} required_margin_after={:.2}",
|
||||
self.cash(),
|
||||
@@ -794,7 +1006,13 @@ impl FuturesAccountState {
|
||||
intent.price,
|
||||
intent.transaction_cost,
|
||||
);
|
||||
Ok(-intent.transaction_cost.max(0.0))
|
||||
Ok(futures_money_or_panic(
|
||||
intent.transaction_cost.max(0.0),
|
||||
"futures open transaction cost",
|
||||
)
|
||||
.checked_neg()
|
||||
.expect("fixed-point futures open cash delta overflow")
|
||||
.to_f64())
|
||||
}
|
||||
}
|
||||
FuturesPositionEffect::Close
|
||||
@@ -819,18 +1037,33 @@ impl FuturesAccountState {
|
||||
.position(&intent.symbol, intent.direction)
|
||||
.map(|position| position.avg_price)
|
||||
.unwrap_or(0.0);
|
||||
let notional =
|
||||
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
|
||||
let notional = futures_money_or_panic(
|
||||
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier,
|
||||
"futures fill notional",
|
||||
)
|
||||
.to_f64();
|
||||
let transaction_cost = futures_money_or_panic(
|
||||
intent.transaction_cost.max(0.0),
|
||||
"futures fill transaction cost",
|
||||
)
|
||||
.to_f64();
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: None,
|
||||
execution_timestamp: None,
|
||||
order_id,
|
||||
symbol: intent.symbol.clone(),
|
||||
side,
|
||||
quantity: intent.quantity,
|
||||
price: intent.price,
|
||||
gross_amount: notional,
|
||||
commission: intent.transaction_cost.max(0.0),
|
||||
commission: transaction_cost,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: cash_delta,
|
||||
reason: format!(
|
||||
"{} direction={} effect={}",
|
||||
@@ -889,6 +1122,9 @@ impl FuturesAccountState {
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -915,6 +1151,9 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -997,22 +1236,30 @@ impl FuturesAccountState {
|
||||
}
|
||||
|
||||
pub fn begin_trading_day(&mut self) {
|
||||
self.closed_day_trading_pnl = FixedMoney::ZERO;
|
||||
self.closed_day_position_pnl = FixedMoney::ZERO;
|
||||
self.closed_day_transaction_cost = FixedMoney::ZERO;
|
||||
for position in self.positions.values_mut() {
|
||||
position.begin_trading_day();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settle(&mut self, settlement_prices: &BTreeMap<String, f64>) -> f64 {
|
||||
let mut cash_delta = 0.0;
|
||||
let mut cash_delta = FixedMoney::ZERO;
|
||||
for position in self.positions.values_mut() {
|
||||
let price = settlement_prices
|
||||
.get(&position.symbol)
|
||||
.copied()
|
||||
.unwrap_or(position.last_price);
|
||||
cash_delta += position.settlement(price);
|
||||
cash_delta = cash_delta
|
||||
.checked_add(position.settlement_money(price))
|
||||
.expect("fixed-point futures settlement overflow");
|
||||
}
|
||||
self.total_cash += cash_delta;
|
||||
cash_delta
|
||||
self.total_cash = self
|
||||
.total_cash
|
||||
.checked_add(cash_delta)
|
||||
.expect("fixed-point futures cash settlement overflow");
|
||||
cash_delta.to_f64()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::TradingCalendar;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TradingActionOrigin {
|
||||
Strategy,
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomaticTradeProtection {
|
||||
#[serde(default, deserialize_with = "optional_days")]
|
||||
pub buy_protection_days: u32,
|
||||
#[serde(default, deserialize_with = "optional_days")]
|
||||
pub sell_cooldown_days: u32,
|
||||
#[serde(default, deserialize_with = "optional_days")]
|
||||
pub max_holding_days: u32,
|
||||
#[serde(default, deserialize_with = "optional_locks")]
|
||||
pub locks: Vec<AutomaticTradeLock>,
|
||||
}
|
||||
|
||||
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<AutomaticTradeProtection, D::Error> {
|
||||
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||
let raw = serde_json::Value::deserialize(deserializer)?;
|
||||
if raw.is_null() {
|
||||
return Ok(0);
|
||||
}
|
||||
raw.as_f64()
|
||||
.filter(|value| {
|
||||
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
|
||||
})
|
||||
.map(|value| value as u32)
|
||||
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
|
||||
}
|
||||
|
||||
fn optional_locks<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
|
||||
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomaticTradeLock {
|
||||
pub symbol: String,
|
||||
pub start_date: NaiveDate,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct HoldingLifecycleEvidence {
|
||||
pub has_position: bool,
|
||||
pub opened_date: Option<NaiveDate>,
|
||||
pub last_buy_date: Option<NaiveDate>,
|
||||
pub last_sell_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct AutomaticTradePermission {
|
||||
pub buy_denial: Option<&'static str>,
|
||||
pub sell_denial: Option<&'static str>,
|
||||
pub max_holding_exit: bool,
|
||||
}
|
||||
|
||||
impl AutomaticTradeProtection {
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.buy_protection_days > 0
|
||||
|| self.sell_cooldown_days > 0
|
||||
|| self.max_holding_days > 0
|
||||
|| !self.locks.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if [
|
||||
self.buy_protection_days,
|
||||
self.sell_cooldown_days,
|
||||
self.max_holding_days,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|days| days > 3650)
|
||||
{
|
||||
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
|
||||
}
|
||||
if self.locks.len() > 2000 {
|
||||
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
|
||||
}
|
||||
for lock in &self.locks {
|
||||
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|ch| ch.is_ascii_digit())
|
||||
&& matches!(venue, "SH" | "SZ" | "BJ")
|
||||
});
|
||||
if !valid_symbol {
|
||||
return Err(format!(
|
||||
"automatic_trade_lock_invalid_symbol: {}",
|
||||
lock.symbol
|
||||
));
|
||||
}
|
||||
if lock.end_date.is_some_and(|end| end < lock.start_date) {
|
||||
return Err(format!(
|
||||
"automatic_trade_lock_invalid_interval: {}",
|
||||
lock.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.evaluate_with_predecessors(symbol, execution_date, evidence, calendar, std::iter::empty())
|
||||
}
|
||||
|
||||
/// Only accept predecessors from validated, actually applied holding
|
||||
/// conversions. Catalog aliases or requested strategy symbols are not
|
||||
/// evidence that a configured lock covers another security.
|
||||
pub fn evaluate_with_predecessors<'a>(
|
||||
&self,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
verified_predecessors: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
let predecessors = verified_predecessors.into_iter().collect::<std::collections::BTreeSet<_>>();
|
||||
if self.locks.iter().any(|lock| {
|
||||
(lock.symbol == symbol || predecessors.contains(lock.symbol.as_str()))
|
||||
&& lock.start_date <= execution_date
|
||||
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||
}) {
|
||||
return Ok(AutomaticTradePermission {
|
||||
buy_denial: Some("automatic_trade_locked"),
|
||||
sell_denial: Some("automatic_trade_locked"),
|
||||
max_holding_exit: false,
|
||||
});
|
||||
}
|
||||
let elapsed = |date: NaiveDate| -> Result<usize, String> {
|
||||
let start = calendar.index_of(date).ok_or_else(|| {
|
||||
format!(
|
||||
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
|
||||
)
|
||||
})?;
|
||||
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
|
||||
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
|
||||
};
|
||||
let mut decision = AutomaticTradePermission::default();
|
||||
if self.buy_protection_days > 0
|
||||
&& evidence.has_position
|
||||
&& let Some(date) = evidence.last_buy_date
|
||||
&& elapsed(date)? <= self.buy_protection_days as usize
|
||||
{
|
||||
decision.sell_denial = Some("buy_fill_protection");
|
||||
}
|
||||
if self.sell_cooldown_days > 0
|
||||
&& let Some(date) = evidence.last_sell_date
|
||||
&& elapsed(date)? <= self.sell_cooldown_days as usize
|
||||
{
|
||||
decision.buy_denial = Some("sell_fill_cooldown");
|
||||
}
|
||||
if self.max_holding_days > 0 && evidence.has_position {
|
||||
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
|
||||
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
|
||||
&& decision.sell_denial.is_none();
|
||||
if decision.max_holding_exit {
|
||||
decision.buy_denial = Some("maximum_holding_exit");
|
||||
}
|
||||
}
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
/// The caller supplies origin from its authenticated execution path, never
|
||||
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
|
||||
pub fn evaluate_for_origin(
|
||||
&self,
|
||||
origin: TradingActionOrigin,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
match origin {
|
||||
TradingActionOrigin::Strategy => {
|
||||
self.evaluate(symbol, execution_date, evidence, calendar)
|
||||
}
|
||||
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn d(value: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
fn calendar() -> TradingCalendar {
|
||||
TradingCalendar::new(
|
||||
[
|
||||
"2026-09-11",
|
||||
"2026-09-14",
|
||||
"2026-09-15",
|
||||
"2026-09-16",
|
||||
"2026-09-17",
|
||||
]
|
||||
.into_iter()
|
||||
.map(d)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
|
||||
let policy = AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
last_buy_date: Some(d("2026-09-11")),
|
||||
last_sell_date: Some(d("2026-09-11")),
|
||||
..Default::default()
|
||||
};
|
||||
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
|
||||
let decision = policy
|
||||
.evaluate("000001.SZ", d(day), &evidence, &calendar())
|
||||
.unwrap();
|
||||
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
|
||||
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
|
||||
}
|
||||
assert_eq!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap(),
|
||||
AutomaticTradePermission::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
|
||||
let policy = AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d("2026-09-11"),
|
||||
end_date: Some(d("2026-09-16")),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
opened_date: Some(d("2026-09-11")),
|
||||
..Default::default()
|
||||
};
|
||||
let locked = policy
|
||||
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
|
||||
.unwrap();
|
||||
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
|
||||
assert!(!locked.max_holding_exit);
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
AutomaticTradeProtection::default()
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
d("2026-09-17"),
|
||||
&evidence,
|
||||
&TradingCalendar::new(vec![])
|
||||
)
|
||||
.unwrap(),
|
||||
AutomaticTradePermission::default()
|
||||
);
|
||||
let policy = AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap_err()
|
||||
.contains("opened_date_missing")
|
||||
);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
opened_date: Some(d("2026-09-10")),
|
||||
..evidence
|
||||
};
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap_err()
|
||||
.contains("calendar_missing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
|
||||
let policy = AutomaticTradeProtection {
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d("2026-09-11"),
|
||||
end_date: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
policy
|
||||
.evaluate_for_origin(
|
||||
TradingActionOrigin::Manual,
|
||||
"000001.SZ",
|
||||
d("2026-09-14"),
|
||||
&HoldingLifecycleEvidence::default(),
|
||||
&calendar()
|
||||
)
|
||||
.unwrap(),
|
||||
AutomaticTradePermission::default()
|
||||
);
|
||||
assert_eq!(
|
||||
policy
|
||||
.evaluate_for_origin(
|
||||
TradingActionOrigin::Strategy,
|
||||
"000001.SZ",
|
||||
d("2026-09-14"),
|
||||
&HoldingLifecycleEvidence::default(),
|
||||
&calendar()
|
||||
)
|
||||
.unwrap()
|
||||
.buy_denial,
|
||||
Some("automatic_trade_locked")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
|
||||
let mut portfolio = crate::PortfolioState::new(100_000.0);
|
||||
let position = portfolio.position_mut("000001.SZ");
|
||||
position.buy(d("2026-09-11"), 100, 10.0);
|
||||
position.buy(d("2026-09-14"), 200, 10.0);
|
||||
position.sell(100, 10.0).unwrap();
|
||||
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
|
||||
portfolio
|
||||
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
|
||||
.unwrap();
|
||||
let successor = portfolio.position_mut("000002.SZ");
|
||||
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
|
||||
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
|
||||
successor.sell(400, 5.0).unwrap();
|
||||
assert_eq!(successor.opened_date(), None);
|
||||
successor.buy(d("2026-09-17"), 100, 5.0);
|
||||
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true),
|
||||
"主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板"
|
||||
| "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层"
|
||||
| "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE"
|
||||
| "BSE" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Instrument {
|
||||
pub symbol: String,
|
||||
@@ -16,22 +27,40 @@ pub struct Instrument {
|
||||
}
|
||||
|
||||
impl Instrument {
|
||||
/// Classification from the admitted security master, never a code prefix
|
||||
/// or a name substring. This does not grant T+0 settlement eligibility.
|
||||
pub fn is_exchange_traded_fund(&self) -> bool {
|
||||
matches!(self.board.trim().to_ascii_uppercase().as_str(), "ETF" | "EXCHANGE_TRADED_FUND")
|
||||
}
|
||||
|
||||
pub fn effective_round_lot(&self) -> u32 {
|
||||
self.round_lot.max(1)
|
||||
}
|
||||
|
||||
pub fn minimum_order_quantity(&self) -> u32 {
|
||||
match self.board.trim().to_ascii_uppercase().as_str() {
|
||||
"KSH" => 200,
|
||||
"BJS" | "BJ" | "BJSE" => 100,
|
||||
_ => self.effective_round_lot(),
|
||||
let board = self.board.trim();
|
||||
if board.eq_ignore_ascii_case("KSH") {
|
||||
200
|
||||
} else if board.eq_ignore_ascii_case("BJS")
|
||||
|| board.eq_ignore_ascii_case("BJ")
|
||||
|| board.eq_ignore_ascii_case("BJSE")
|
||||
{
|
||||
100
|
||||
} else {
|
||||
self.effective_round_lot()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order_step_size(&self) -> u32 {
|
||||
match self.board.trim().to_ascii_uppercase().as_str() {
|
||||
"KSH" | "BJS" | "BJ" | "BJSE" => 1,
|
||||
_ => self.effective_round_lot(),
|
||||
let board = self.board.trim();
|
||||
if board.eq_ignore_ascii_case("KSH")
|
||||
|| board.eq_ignore_ascii_case("BJS")
|
||||
|| board.eq_ignore_ascii_case("BJ")
|
||||
|| board.eq_ignore_ascii_case("BJSE")
|
||||
{
|
||||
1
|
||||
} else {
|
||||
self.effective_round_lot()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +69,24 @@ impl Instrument {
|
||||
.is_some_and(|delisted_at| delisted_at < date)
|
||||
}
|
||||
|
||||
pub fn is_delisted_on_or_before(&self, date: NaiveDate) -> bool {
|
||||
self.delisted_at
|
||||
.is_some_and(|delisted_at| delisted_at <= date)
|
||||
}
|
||||
|
||||
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
||||
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
||||
&& !self.is_delisted_before(date)
|
||||
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
|
||||
&& !self.is_delisted_on_or_before(date)
|
||||
}
|
||||
|
||||
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
|
||||
if self.listed_at.is_some_and(|listed| date < listed) {
|
||||
Some("not_yet_listed")
|
||||
} else if self.is_delisted_on_or_before(date) {
|
||||
Some("delisted")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +94,69 @@ fn default_status() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Instrument, listed_sector_is_kcb};
|
||||
|
||||
#[test]
|
||||
fn listing_sector_is_explicit_and_unknown_stays_unknown() {
|
||||
assert_eq!(listed_sector_is_kcb("科创板"), Some(true));
|
||||
assert_eq!(listed_sector_is_kcb(" star "), Some(true));
|
||||
assert_eq!(listed_sector_is_kcb("主板"), Some(false));
|
||||
assert_eq!(listed_sector_is_kcb("创业板"), Some(false));
|
||||
assert_eq!(listed_sector_is_kcb("北证"), Some(false));
|
||||
for value in ["", "-", "SH", "688001.SH", "半导体"] {
|
||||
assert_eq!(listed_sector_is_kcb(value), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn instrument(board: &str, round_lot: u32) -> Instrument {
|
||||
Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "test".to_string(),
|
||||
board: board.to_string(),
|
||||
round_lot,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
|
||||
let mut item = instrument("BJS", 100);
|
||||
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
|
||||
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||
item.listed_at = Some(listing);
|
||||
item.delisted_at = Some(removal);
|
||||
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
|
||||
assert!(item.is_active_on(listing));
|
||||
assert!(!item.is_active_on(removal));
|
||||
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
|
||||
item.delisted_at = None;
|
||||
for status in ["delisting", "delisted", "inactive", "terminated"] {
|
||||
item.status = status.into();
|
||||
assert!(item.is_active_on(listing));
|
||||
assert_eq!(item.dated_market_absence_reason(listing), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
||||
let kcb = instrument(" kSh ", 100);
|
||||
assert_eq!(kcb.minimum_order_quantity(), 200);
|
||||
assert_eq!(kcb.order_step_size(), 1);
|
||||
|
||||
let bjse = instrument("bjse", 100);
|
||||
assert_eq!(bjse.minimum_order_quantity(), 100);
|
||||
assert_eq!(bjse.order_step_size(), 1);
|
||||
|
||||
let main_board = instrument("SZSE", 50);
|
||||
assert_eq!(main_board.minimum_order_quantity(), 50);
|
||||
assert_eq!(main_board.order_step_size(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
+60
-15
@@ -1,26 +1,55 @@
|
||||
pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
mod corporate_book;
|
||||
pub mod data;
|
||||
mod numeric_factors;
|
||||
pub mod daily_patterns;
|
||||
pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
pub mod execution_capacity;
|
||||
mod etf_execution;
|
||||
mod execution_schedule;
|
||||
mod factor_event_catalog;
|
||||
pub mod factor_cross_section;
|
||||
pub mod market_event_context;
|
||||
pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
pub mod fixed_point;
|
||||
mod finite_serialization;
|
||||
pub mod futures;
|
||||
pub mod instrument;
|
||||
pub mod metrics;
|
||||
pub mod manual_execution;
|
||||
mod manual_corporate_replay;
|
||||
mod numeric_expr_vm;
|
||||
pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod portfolio_loss;
|
||||
pub mod position_exposure;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod holding_policy;
|
||||
pub mod stock_pool_candidates;
|
||||
pub mod stock_pool_indicators;
|
||||
pub mod stock_pool_execution;
|
||||
pub mod stock_pool_index_policy;
|
||||
pub mod stock_pool_market_cap;
|
||||
pub mod stock_pool_state;
|
||||
pub mod stock_pool_quote_facts;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
pub use broker::{
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
|
||||
SlippageModel,
|
||||
};
|
||||
pub use calendar::TradingCalendar;
|
||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||
@@ -28,30 +57,39 @@ pub use data::{
|
||||
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
||||
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
||||
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
||||
IntradayOrderBookDepthLevel, PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord,
|
||||
YieldCurvePoint,
|
||||
IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
|
||||
PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
||||
};
|
||||
pub use engine::{
|
||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||
BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
|
||||
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||
ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
AccountEvent, FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
pub use fixed_point::{
|
||||
FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost,
|
||||
MONEY_SCALE,
|
||||
};
|
||||
pub use futures::{
|
||||
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
||||
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
|
||||
FuturesTradingParameter, FuturesTransactionCostModel,
|
||||
};
|
||||
pub use instrument::Instrument;
|
||||
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use metrics::{
|
||||
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics,
|
||||
};
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||
PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode,
|
||||
PlatformTradeAction, PlatformUniverseActionKind,
|
||||
};
|
||||
pub use platform_runtime_schema::{
|
||||
@@ -65,25 +103,32 @@ pub use platform_strategy_spec::{
|
||||
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
|
||||
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
|
||||
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
|
||||
StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
|
||||
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig, StrategyRebalanceSpec,
|
||||
StrategyRiskPolicySpec, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
|
||||
StrategyRuntimeSpec, StrategyUniverseSpec, platform_expr_config_from_spec,
|
||||
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
|
||||
};
|
||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||
pub use risk_control::ChinaAShareRiskControl;
|
||||
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
|
||||
pub use risk_control::{
|
||||
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||
StaticRiskRuleConfig, TradingConstraintConfig,
|
||||
};
|
||||
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
|
||||
pub use scheduler::{
|
||||
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
||||
};
|
||||
pub use strategy::{
|
||||
AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, OmniMicroCapConfig,
|
||||
OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, PortfolioRuntimeView,
|
||||
Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
|
||||
OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, OrderTimeInForce,
|
||||
PortfolioRuntimeView, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
|
||||
};
|
||||
pub use strategy_ai::{
|
||||
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiManual,
|
||||
StrategyAiOptimizeRequest, build_generation_prompt, build_optimization_prompt,
|
||||
built_in_strategy_manual, merge_catalog_into_manual, render_manual_markdown,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
|
||||
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
|
||||
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
|
||||
render_manual_markdown,
|
||||
};
|
||||
pub use universe::{
|
||||
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::manual_execution::{
|
||||
AppliedManualFill, ManualCorporateActionReference, ManualCorporateAdjustment,
|
||||
ManualCorporatePositionChange, ManualExecutionReplay, ManualFillObservation,
|
||||
};
|
||||
use crate::{DataSet, FillEvent, FixedMoney, MatchingType, OrderSide, PortfolioState, PriceField};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CashEffect {
|
||||
at: NaiveDateTime,
|
||||
amount: FixedMoney,
|
||||
}
|
||||
|
||||
/// Replays issued financial facts, never strategy callbacks or hypothetical orders.
|
||||
/// The ordinary book remains observable until an actual receipt is delivered.
|
||||
pub(crate) struct ManualCorporateReplay {
|
||||
first_date: NaiveDate,
|
||||
sessions: RefCell<BTreeSet<NaiveDate>>,
|
||||
closed: RefCell<BTreeSet<NaiveDate>>,
|
||||
cash: RefCell<Vec<CashEffect>>,
|
||||
reconciled_count: Cell<usize>,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
}
|
||||
|
||||
impl ManualCorporateReplay {
|
||||
pub(crate) fn new(
|
||||
first_date: NaiveDate,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
first_date,
|
||||
sessions: RefCell::new(BTreeSet::new()),
|
||||
closed: RefCell::new(BTreeSet::new()),
|
||||
cash: RefCell::new(Vec::new()),
|
||||
reconciled_count: Cell::new(0),
|
||||
cash_dividends,
|
||||
adjust_cost,
|
||||
reinvest,
|
||||
matching,
|
||||
daily_price,
|
||||
same_day_mark_at_fill,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_session(&self, date: NaiveDate) {
|
||||
self.sessions.borrow_mut().insert(date);
|
||||
}
|
||||
pub(crate) fn record_close(&self, date: NaiveDate) {
|
||||
self.closed.borrow_mut().insert(date);
|
||||
}
|
||||
pub(crate) fn committed(&self, count: usize) {
|
||||
self.reconciled_count.set(count);
|
||||
}
|
||||
|
||||
pub(crate) fn record_cash(
|
||||
&self,
|
||||
at: Option<NaiveDateTime>,
|
||||
before: FixedMoney,
|
||||
after: FixedMoney,
|
||||
) -> Result<(), String> {
|
||||
let amount = after
|
||||
.checked_sub(before)
|
||||
.ok_or("manual corporate cash observation overflow")?;
|
||||
if amount != FixedMoney::ZERO {
|
||||
self.cash.borrow_mut().push(CashEffect {
|
||||
at: at.ok_or("manual corporate cash observation has no execution clock")?,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn action_references(
|
||||
&self,
|
||||
observation: ManualFillObservation<'_>,
|
||||
data: &DataSet,
|
||||
) -> Result<Vec<ManualCorporateActionReference>, String> {
|
||||
let mut symbols = BTreeSet::from([observation.order.symbol.clone()]);
|
||||
let observed_date = local(observation.fill.observed_at).date();
|
||||
let mut actions = Vec::new();
|
||||
for date in self.sessions.borrow().range((
|
||||
std::ops::Bound::Excluded(observation.fill.trade_date),
|
||||
std::ops::Bound::Included(observed_date),
|
||||
)) {
|
||||
for action in data.corporate_actions_on(*date) {
|
||||
if !symbols.contains(&action.symbol) {
|
||||
continue;
|
||||
}
|
||||
let successor_terms = crate::corporate_book::validate_action(action, data)?;
|
||||
let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON
|
||||
|| action.has_successor_conversion()
|
||||
|| (self.cash_dividends && action.share_cash.abs() > f64::EPSILON);
|
||||
if !effective {
|
||||
continue;
|
||||
}
|
||||
if let Some((successor, _, _)) = successor_terms {
|
||||
symbols.insert(successor.to_owned());
|
||||
}
|
||||
actions.push(ManualCorporateActionReference {
|
||||
date: *date,
|
||||
symbol: action.symbol.clone(),
|
||||
successor_symbol: action.successor_symbol.clone(),
|
||||
share_cash: action.share_cash.to_string(),
|
||||
split_ratio: action.split_ratio().to_string(),
|
||||
successor_ratio: action.successor_ratio.map(|value| value.to_string()),
|
||||
successor_cash: action.successor_cash.map(|value| value.to_string()),
|
||||
sha256: digest(
|
||||
&serde_json::to_value(action).map_err(|error| error.to_string())?,
|
||||
)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
pub(crate) fn required(
|
||||
&self,
|
||||
observation: ManualFillObservation<'_>,
|
||||
data: &DataSet,
|
||||
) -> Result<bool, String> {
|
||||
Ok(!self.action_references(observation, data)?.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn project(
|
||||
&self,
|
||||
source: &ManualExecutionReplay,
|
||||
applied_count: usize,
|
||||
observation: ManualFillObservation<'_>,
|
||||
current: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
fills: &[FillEvent],
|
||||
has_pending: bool,
|
||||
) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
|
||||
if has_pending {
|
||||
return Err("manual observation conflicts with pending shadow orders".into());
|
||||
}
|
||||
let all = source.observations()?;
|
||||
if all.get(applied_count).is_none_or(|next| {
|
||||
next.fill.observation_event_id != observation.fill.observation_event_id
|
||||
}) {
|
||||
return Err(
|
||||
"manual corporate observation prefix differs from the immutable trace".into(),
|
||||
);
|
||||
}
|
||||
let actions = self.action_references(observation, data)?;
|
||||
if actions.is_empty() {
|
||||
return Err("manual corporate projection has no processed corporate action".into());
|
||||
}
|
||||
let at = local(observation.fill.observed_at);
|
||||
let reference = self.replay(
|
||||
source,
|
||||
current.initial_cash_fixed(),
|
||||
&all[..applied_count],
|
||||
self.reconciled_count.get(),
|
||||
data,
|
||||
fills,
|
||||
at,
|
||||
)?;
|
||||
let expected = current.financial_replay_identity();
|
||||
let reference_identity = reference.financial_replay_identity();
|
||||
if reference_identity != expected {
|
||||
return Err(format!(
|
||||
"manual corporate ledger coverage mismatch: symbol={} observed_at={} expected={} replayed={}",
|
||||
observation.order.symbol,
|
||||
observation.fill.observed_at,
|
||||
digest(&expected)?,
|
||||
digest(&reference_identity)?
|
||||
));
|
||||
}
|
||||
let replayed = self.replay(
|
||||
source,
|
||||
current.initial_cash_fixed(),
|
||||
&all[..=applied_count],
|
||||
applied_count + 1,
|
||||
data,
|
||||
fills,
|
||||
at,
|
||||
)?;
|
||||
let replayed_identity = replayed.financial_replay_identity();
|
||||
let gross = FixedMoney::from_decimal_str(&observation.fill.gross_amount()?.to_string())?;
|
||||
let fees = FixedMoney::from_decimal_str(&observation.fill.total_fees()?.to_string())?;
|
||||
let cash_delta = match observation.order.side {
|
||||
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
|
||||
OrderSide::Sell => gross.checked_sub(fees),
|
||||
}
|
||||
.ok_or("manual corporate trade cash overflow")?;
|
||||
let before = current.cash_fixed();
|
||||
let after = replayed.cash_fixed();
|
||||
let corporate_cash = after
|
||||
.checked_sub(before)
|
||||
.and_then(|delta| delta.checked_sub(cash_delta))
|
||||
.ok_or("manual corporate adjustment overflow")?;
|
||||
let symbols = current
|
||||
.positions()
|
||||
.keys()
|
||||
.chain(replayed.positions().keys())
|
||||
.cloned()
|
||||
.chain(std::iter::once(observation.order.symbol.clone()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut positions = BTreeMap::new();
|
||||
for symbol in symbols {
|
||||
let change = ManualCorporatePositionChange {
|
||||
quantity_before: current
|
||||
.position(&symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
quantity_after: replayed
|
||||
.position(&symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
cost_basis_before: current
|
||||
.financial_position_basis(&symbol)
|
||||
.to_decimal_string(),
|
||||
cost_basis_after: replayed
|
||||
.financial_position_basis(&symbol)
|
||||
.to_decimal_string(),
|
||||
};
|
||||
if change.quantity_before != change.quantity_after
|
||||
|| change.cost_basis_before != change.cost_basis_after
|
||||
{
|
||||
positions.insert(symbol, change);
|
||||
}
|
||||
}
|
||||
let quantity_after = replayed
|
||||
.position(&observation.order.symbol)
|
||||
.map_or(0, |position| position.quantity);
|
||||
let adjustment = ManualCorporateAdjustment {
|
||||
schema: "fidc.manual-corporate-adjustment/v1".into(),
|
||||
observed_at: observation.fill.observed_at,
|
||||
cash_dividends_enabled: self.cash_dividends,
|
||||
dividend_cost_basis_adjustment: self.adjust_cost,
|
||||
dividend_reinvestment: self.reinvest,
|
||||
actions,
|
||||
cash_before: before.to_decimal_string(),
|
||||
cash_after: after.to_decimal_string(),
|
||||
corporate_cash_delta: corporate_cash.to_decimal_string(),
|
||||
positions,
|
||||
reference_sha256: digest(&reference_identity)?,
|
||||
replayed_sha256: digest(&replayed_identity)?,
|
||||
};
|
||||
current.replace_replayed_financial_book(replayed)?;
|
||||
Ok((
|
||||
AppliedManualFill {
|
||||
gross,
|
||||
fees,
|
||||
cash_delta,
|
||||
quantity_after,
|
||||
},
|
||||
adjustment,
|
||||
))
|
||||
}
|
||||
|
||||
fn replay(
|
||||
&self,
|
||||
runtime_input: &ManualExecutionReplay,
|
||||
initial_cash: FixedMoney,
|
||||
manual: &[ManualFillObservation<'_>],
|
||||
economic_count: usize,
|
||||
data: &DataSet,
|
||||
fills: &[FillEvent],
|
||||
at: NaiveDateTime,
|
||||
) -> Result<PortfolioState, String> {
|
||||
enum Event<'a> {
|
||||
Session,
|
||||
Cash(&'a CashEffect),
|
||||
Corporate(NaiveDate),
|
||||
Settle(NaiveDate),
|
||||
Manual(ManualFillObservation<'a>),
|
||||
Simulated(&'a FillEvent),
|
||||
Close(NaiveDate),
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let sessions = self.sessions.borrow();
|
||||
let closed = self.closed.borrow();
|
||||
let cash = self.cash.borrow();
|
||||
for date in &*sessions {
|
||||
let clock = date.and_hms_opt(0, 0, 0).unwrap();
|
||||
events.push((clock, 0, 0, Event::Session));
|
||||
events.push((clock, 2, 0, Event::Corporate(*date)));
|
||||
events.push((clock, 3, 0, Event::Settle(*date)));
|
||||
}
|
||||
for (index, effect) in cash.iter().enumerate() {
|
||||
events.push((effect.at, 1, index, Event::Cash(effect)));
|
||||
}
|
||||
for (index, observation) in manual.iter().enumerate() {
|
||||
let clock = if index < economic_count {
|
||||
local(observation.fill.executed_at)
|
||||
} else {
|
||||
local(observation.fill.observed_at)
|
||||
};
|
||||
if clock.date() < self.first_date {
|
||||
return Err("manual corporate execution precedes the represented initial ledger; opening facts are required".into());
|
||||
}
|
||||
events.push((clock, 4, fills.len() + index, Event::Manual(*observation)));
|
||||
}
|
||||
for (index, fill) in fills.iter().enumerate() {
|
||||
fill.validate()?;
|
||||
if fill.origin == crate::events::FillOrigin::DividendReinvestment {
|
||||
// The declared accounting model is recalculated from the
|
||||
// corrected entitlements; this was never a submitted order.
|
||||
continue;
|
||||
}
|
||||
let date = fill.execution_date.unwrap_or(fill.date);
|
||||
// This is the frozen daily matching model, not a broker timestamp.
|
||||
// Intraday contracts must supply their actual execution clock.
|
||||
let clock = match fill.execution_timestamp {
|
||||
Some(clock) => clock,
|
||||
None if matches!(
|
||||
self.matching,
|
||||
MatchingType::OpenAuction | MatchingType::NextBarOpen
|
||||
) =>
|
||||
{
|
||||
date.and_hms_opt(9, 30, 0).unwrap()
|
||||
}
|
||||
None if self.matching == MatchingType::CurrentBarClose
|
||||
&& self.daily_price == PriceField::Close =>
|
||||
{
|
||||
date.and_hms_opt(15, 0, 0).unwrap()
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"manual corporate replay lacks an intraday fill execution clock".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
events.push((clock, 4, index, Event::Simulated(fill)));
|
||||
}
|
||||
for date in &*closed {
|
||||
events.push((
|
||||
date.and_hms_nano_opt(23, 59, 59, 999_999_999)
|
||||
.unwrap()
|
||||
.min(at),
|
||||
5,
|
||||
0,
|
||||
Event::Close(*date),
|
||||
));
|
||||
}
|
||||
events.sort_by_key(|(clock, priority, sequence, _)| (*clock, *priority, *sequence));
|
||||
let mut book = PortfolioState::from_fixed_initial_cash(initial_cash);
|
||||
for (clock, _, _, event) in events {
|
||||
if clock > at {
|
||||
return Err("manual corporate replay contains a future financial fact".into());
|
||||
}
|
||||
match event {
|
||||
Event::Session => book.begin_trading_day(),
|
||||
Event::Cash(effect) => {
|
||||
book.apply_cash_delta_fixed(effect.amount)?;
|
||||
if book.cash_fixed() < FixedMoney::ZERO {
|
||||
return Err(
|
||||
"manual corporate replay conflicts with prior cash facts".into()
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Corporate(date) => {
|
||||
crate::corporate_book::apply(
|
||||
date,
|
||||
data,
|
||||
&mut book,
|
||||
&mut Vec::new(),
|
||||
self.cash_dividends,
|
||||
self.adjust_cost,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Settle(date) => {
|
||||
crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest, Some(runtime_input))
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Manual(observation) => {
|
||||
observation.apply(&mut book, data, false)?;
|
||||
}
|
||||
Event::Simulated(fill) => {
|
||||
let gross = FixedMoney::from_f64(fill.gross_amount)
|
||||
.ok_or("invalid simulated gross amount")?;
|
||||
let fees = FixedMoney::checked_sum_f64([
|
||||
fill.commission,
|
||||
fill.stamp_tax,
|
||||
fill.transfer_fee,
|
||||
])
|
||||
.ok_or("invalid simulated fee amount")?;
|
||||
book.apply_observed_manual_fill(
|
||||
fill.execution_date.unwrap_or(fill.date),
|
||||
&fill.symbol,
|
||||
fill.side,
|
||||
fill.quantity,
|
||||
fill.price,
|
||||
fill.price,
|
||||
gross,
|
||||
fees,
|
||||
)?;
|
||||
book.prune_flat_positions();
|
||||
}
|
||||
Event::Close(date) => {
|
||||
book.update_prices_with_options(
|
||||
date,
|
||||
data,
|
||||
PriceField::Close,
|
||||
self.same_day_mark_at_fill,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
if book.cash_fixed() < FixedMoney::ZERO {
|
||||
return Err("manual corporate replay would borrow unobserved cash".into());
|
||||
}
|
||||
}
|
||||
Ok(book)
|
||||
}
|
||||
}
|
||||
|
||||
fn local(value: DateTime<Utc>) -> NaiveDateTime {
|
||||
value
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.naive_local()
|
||||
}
|
||||
fn digest(value: &serde_json::Value) -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
//! Confirmed manual fills are external observations, not simulated broker fills.
|
||||
//! The producer must bind these records to the runtime's durable order/audit facts.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::events::OrderSide;
|
||||
use crate::{DataSet, FixedMoney, PortfolioState};
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionReplay {
|
||||
pub schema: String,
|
||||
pub runtime_id: String,
|
||||
pub account_id: String,
|
||||
pub source_contract_sha256: String,
|
||||
pub content_sha256: String,
|
||||
pub observation_cutoff: DateTime<Utc>,
|
||||
pub actions: Vec<ManualExecutionAction>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionAction {
|
||||
pub action_id: String,
|
||||
pub source: ManualExecutionSource,
|
||||
pub audit_event_ids: Vec<String>,
|
||||
pub confirmed_at: DateTime<Utc>,
|
||||
pub confirmation_observed_at: DateTime<Utc>,
|
||||
pub outcome: ManualActionOutcome,
|
||||
pub orders: Vec<ManualExecutionOrder>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualActionOutcome {
|
||||
NoOrdersNeeded,
|
||||
NotExecuted,
|
||||
OrdersTerminal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualExecutionSource {
|
||||
ManualSecurityTrade,
|
||||
ManualPositionAction,
|
||||
ManualRebalance,
|
||||
StockPoolAllocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionOrder {
|
||||
pub order_id: String,
|
||||
pub broker_order_id: Option<String>,
|
||||
pub source_adapter: Option<String>,
|
||||
pub symbol: String,
|
||||
pub side: OrderSide,
|
||||
pub quantity: u32,
|
||||
pub order_created_at: DateTime<Utc>,
|
||||
pub terminal_observed_at: DateTime<Utc>,
|
||||
pub terminal_status: ManualOrderTerminalStatus,
|
||||
pub fills: Vec<ManualExecutionFill>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualOrderTerminalStatus {
|
||||
Filled,
|
||||
Cancelled,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionFill {
|
||||
pub trade_id: String,
|
||||
pub observation_event_id: String,
|
||||
pub observation_sequence: u64,
|
||||
pub fee_observation_event_id: String,
|
||||
pub fee_observation_sequence: u64,
|
||||
pub fee_observed_at: DateTime<Utc>,
|
||||
pub trade_date: NaiveDate,
|
||||
pub executed_at: DateTime<Utc>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub timestamp_precision: ManualTimestampPrecision,
|
||||
pub quantity: u32,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub commission: Option<Decimal>,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub stamp_tax: Option<Decimal>,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub transfer_fee: Option<Decimal>,
|
||||
/// Full observed charge, including any venue fees not itemized above.
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub total_fee: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualTimestampPrecision {
|
||||
Second,
|
||||
Millisecond,
|
||||
Microsecond,
|
||||
Nanosecond,
|
||||
}
|
||||
|
||||
impl ManualTimestampPrecision {
|
||||
fn nanoseconds(self) -> i64 {
|
||||
match self {
|
||||
Self::Second => 1_000_000_000,
|
||||
Self::Millisecond => 1_000_000,
|
||||
Self::Microsecond => 1_000,
|
||||
Self::Nanosecond => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualExecutionFill {
|
||||
pub fn gross_amount(&self) -> Result<Decimal, String> {
|
||||
self.price
|
||||
.checked_mul(Decimal::from(self.quantity))
|
||||
.ok_or_else(|| "manual fill gross amount overflow".into())
|
||||
}
|
||||
|
||||
pub fn total_fees(&self) -> Result<Decimal, String> {
|
||||
let known = [self.commission, self.stamp_tax, self.transfer_fee]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.try_fold(Decimal::ZERO, |sum, fee| {
|
||||
if fee < Decimal::ZERO {
|
||||
return Err("manual fill fee component is negative");
|
||||
}
|
||||
sum.checked_add(fee).ok_or("manual fill fees overflow")
|
||||
})?;
|
||||
if self.total_fee < known {
|
||||
return Err("manual total fee is below its known components".into());
|
||||
}
|
||||
Ok(self.total_fee)
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(value: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.trim() != value
|
||||
|| value.len() > 256
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err("manual execution identity is empty, untrimmed or invalid".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ManualExecutionReplay {
|
||||
/// Market/indicator data is needed for securities whose observed fills
|
||||
/// change the portfolio. A rejected, never-filled order is not data demand.
|
||||
pub fn required_data_symbols(&self) -> Result<BTreeSet<String>, String> {
|
||||
self.validate()?;
|
||||
Ok(self
|
||||
.actions
|
||||
.iter()
|
||||
.flat_map(|action| &action.orders)
|
||||
.filter(|order| !order.fills.is_empty())
|
||||
.map(|order| order.symbol.clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
|
||||
self.validate()?;
|
||||
let mut observations = Vec::new();
|
||||
for action in &self.actions {
|
||||
for order in &action.orders {
|
||||
for fill in &order.fills {
|
||||
observations.push(ManualFillObservation {
|
||||
action,
|
||||
order,
|
||||
fill,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence));
|
||||
Ok(observations)
|
||||
}
|
||||
pub fn content_digest(&self) -> Result<String, String> {
|
||||
let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?;
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or("manual replay is not an object")?
|
||||
.remove("contentSha256");
|
||||
let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?;
|
||||
Ok(format!("{:x}", Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema != MANUAL_REPLAY_SCHEMA
|
||||
&& self.schema != "fidc.observed-manual-executions/v2"
|
||||
{
|
||||
return Err("unsupported manual replay schema".into());
|
||||
}
|
||||
if self.schema == "fidc.observed-manual-executions/v2"
|
||||
&& (!self.position_exposure_events.is_empty()
|
||||
|| !self.legacy_position_exposure_bps.is_empty())
|
||||
{
|
||||
return Err("runtime configuration requires manual replay v3".into());
|
||||
}
|
||||
crate::position_exposure::PositionExposureTimeline::from_events(
|
||||
&self.position_exposure_events,
|
||||
)?;
|
||||
if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) {
|
||||
return Err("observed runtime position event is after the evidence cutoff".into());
|
||||
}
|
||||
if self
|
||||
.legacy_position_exposure_bps
|
||||
.values()
|
||||
.any(|value| !(0..=10000).contains(value))
|
||||
{
|
||||
return Err("legacy manual exposure is outside 0..10000 bps".into());
|
||||
}
|
||||
identifier(&self.runtime_id)?;
|
||||
identifier(&self.account_id)?;
|
||||
if self.source_contract_sha256.len() != 64
|
||||
|| !self
|
||||
.source_contract_sha256
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("manual replay source contract hash is invalid".into());
|
||||
}
|
||||
if self.content_digest()? != self.content_sha256 {
|
||||
return Err("manual replay content digest mismatch".into());
|
||||
}
|
||||
if self.actions.len() > 100_000 {
|
||||
return Err("manual replay action limit exceeded; trace was not truncated".into());
|
||||
}
|
||||
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
let mut actions = BTreeSet::new();
|
||||
let mut audits = BTreeSet::new();
|
||||
let mut orders = BTreeSet::new();
|
||||
let mut broker_orders = BTreeSet::new();
|
||||
let mut trades = BTreeSet::new();
|
||||
let mut observation_events = BTreeSet::new();
|
||||
let mut observation_sequences = BTreeSet::new();
|
||||
let mut fee_observations = BTreeSet::new();
|
||||
let mut receipt_ids = BTreeMap::new();
|
||||
let mut receipt_sequences = BTreeMap::new();
|
||||
for action in &self.actions {
|
||||
identifier(&action.action_id)?;
|
||||
if !actions.insert(action.action_id.as_str())
|
||||
|| action.confirmed_at > self.observation_cutoff
|
||||
|| action.confirmation_observed_at < action.confirmed_at
|
||||
|| action.confirmation_observed_at > self.observation_cutoff
|
||||
{
|
||||
return Err("duplicate manual action or confirmation after cutoff".into());
|
||||
}
|
||||
if action.audit_event_ids.is_empty() {
|
||||
return Err("manual action has no immutable audit binding".into());
|
||||
}
|
||||
if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
|
||||
return Err("manual action outcome does not prove its order coverage".into());
|
||||
}
|
||||
for id in &action.audit_event_ids {
|
||||
identifier(id)?;
|
||||
if !audits.insert(id.as_str()) {
|
||||
return Err("manual audit event is bound more than once".into());
|
||||
}
|
||||
}
|
||||
for order in &action.orders {
|
||||
identifier(&order.order_id)?;
|
||||
if let Some(adapter) = &order.source_adapter {
|
||||
identifier(adapter)?;
|
||||
}
|
||||
identifier(&order.symbol)?;
|
||||
if let Some(id) = &order.broker_order_id {
|
||||
identifier(id)?;
|
||||
if !broker_orders.insert((
|
||||
order
|
||||
.source_adapter
|
||||
.as_deref()
|
||||
.ok_or("broker identity requires its source adapter")?,
|
||||
order.order_created_at.with_timezone(&shanghai).date_naive(),
|
||||
id.as_str(),
|
||||
)) {
|
||||
return Err("manual local orders share one broker order identity".into());
|
||||
}
|
||||
}
|
||||
if !order.fills.is_empty() && order.source_adapter.is_none() {
|
||||
return Err("manual fills require a known source adapter".into());
|
||||
}
|
||||
if !order.fills.is_empty()
|
||||
&& order.source_adapter.as_deref() != Some("paper")
|
||||
&& order.broker_order_id.is_none()
|
||||
{
|
||||
return Err(
|
||||
"manual broker fills require their original broker order identity".into(),
|
||||
);
|
||||
}
|
||||
if !orders.insert(order.order_id.as_str())
|
||||
|| order.quantity == 0
|
||||
|| order.quantity > i32::MAX as u32
|
||||
{
|
||||
return Err("duplicate manual order or invalid quantity".into());
|
||||
}
|
||||
if order.order_created_at < action.confirmed_at
|
||||
|| order.terminal_observed_at < order.order_created_at
|
||||
|| order.terminal_observed_at > self.observation_cutoff
|
||||
{
|
||||
return Err(
|
||||
"manual order confirmation/submission/terminal time is inconsistent".into(),
|
||||
);
|
||||
}
|
||||
let mut filled = 0_u32;
|
||||
for fill in &order.fills {
|
||||
identifier(&fill.trade_id)?;
|
||||
identifier(&fill.observation_event_id)?;
|
||||
identifier(&fill.fee_observation_event_id)?;
|
||||
if fill.observation_sequence == 0
|
||||
|| fill.observation_sequence > i64::MAX as u64
|
||||
|| !observation_events.insert(fill.observation_event_id.as_str())
|
||||
|| !observation_sequences.insert(fill.observation_sequence)
|
||||
{
|
||||
return Err(
|
||||
"manual fill requires a unique durable observation event and sequence"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if fill.fee_observation_sequence == 0
|
||||
|| fill.fee_observation_sequence > i64::MAX as u64
|
||||
|| fill.fee_observed_at < fill.observed_at
|
||||
|| fill.fee_observed_at > self.observation_cutoff
|
||||
|| !fee_observations.insert((
|
||||
fill.fee_observation_event_id.as_str(),
|
||||
fill.fee_observation_sequence,
|
||||
))
|
||||
{
|
||||
return Err("manual finalized fees require their own unique observation within the cutoff".into());
|
||||
}
|
||||
if (fill.fee_observation_event_id == fill.observation_event_id)
|
||||
!= (fill.fee_observation_sequence == fill.observation_sequence)
|
||||
|| (fill.fee_observation_event_id == fill.observation_event_id
|
||||
&& fill.fee_observed_at != fill.observed_at)
|
||||
{
|
||||
return Err("manual fill and fee observation identities disagree".into());
|
||||
}
|
||||
if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|
||||
|| fill.quantity == 0
|
||||
{
|
||||
return Err("duplicate manual trade or zero fill quantity".into());
|
||||
}
|
||||
for (id, sequence) in [
|
||||
(&fill.observation_event_id, fill.observation_sequence),
|
||||
(
|
||||
&fill.fee_observation_event_id,
|
||||
fill.fee_observation_sequence,
|
||||
),
|
||||
] {
|
||||
if receipt_ids
|
||||
.insert(id, (&fill.trade_id, sequence))
|
||||
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|
||||
|| receipt_sequences
|
||||
.insert(sequence, (&fill.trade_id, id))
|
||||
.is_some_and(|owner| owner != (&fill.trade_id, id))
|
||||
{
|
||||
return Err("manual observation identity is reused by a different trade or sequence".into());
|
||||
}
|
||||
}
|
||||
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|
||||
|| fill.observed_at > self.observation_cutoff
|
||||
|| fill.observed_at < order.order_created_at
|
||||
|| fill.observed_at < action.confirmation_observed_at
|
||||
|| fill.observed_at < fill.executed_at
|
||||
|| fill.executed_at > order.terminal_observed_at
|
||||
{
|
||||
return Err("manual fill execution/observation time is inconsistent".into());
|
||||
}
|
||||
if i64::from(fill.executed_at.nanosecond())
|
||||
% fill.timestamp_precision.nanoseconds()
|
||||
!= 0
|
||||
{
|
||||
return Err(
|
||||
"broker timestamp contains digits finer than its declared precision"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let upper = fill
|
||||
.executed_at
|
||||
.checked_add_signed(chrono::Duration::nanoseconds(
|
||||
fill.timestamp_precision.nanoseconds(),
|
||||
))
|
||||
.ok_or("manual execution timestamp overflow")?;
|
||||
let earliest = order.order_created_at.max(action.confirmation_observed_at);
|
||||
if fill.executed_at < earliest && earliest >= upper {
|
||||
return Err("manual fill predates its order or durable confirmation".into());
|
||||
}
|
||||
if fill.price <= Decimal::ZERO {
|
||||
return Err("manual fill requires a positive price".into());
|
||||
}
|
||||
fill.gross_amount()?
|
||||
.checked_add(fill.total_fees()?)
|
||||
.ok_or("manual fill cash amount overflow")?;
|
||||
filled = filled
|
||||
.checked_add(fill.quantity)
|
||||
.ok_or("manual cumulative fill quantity overflow")?;
|
||||
}
|
||||
if filled > order.quantity
|
||||
|| (order.terminal_status == ManualOrderTerminalStatus::Filled
|
||||
&& filled != order.quantity)
|
||||
|| (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0)
|
||||
|| (matches!(
|
||||
order.terminal_status,
|
||||
ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired
|
||||
) && filled == order.quantity)
|
||||
{
|
||||
return Err("manual terminal status disagrees with cumulative fills".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ManualFillObservation<'a> {
|
||||
pub action: &'a ManualExecutionAction,
|
||||
pub order: &'a ManualExecutionOrder,
|
||||
pub fill: &'a ManualExecutionFill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AppliedManualFill {
|
||||
pub gross: FixedMoney,
|
||||
pub fees: FixedMoney,
|
||||
pub cash_delta: FixedMoney,
|
||||
pub quantity_after: u32,
|
||||
}
|
||||
|
||||
/// One replay owns its immutable trace and progress. Advancing is atomic even
|
||||
/// if a later receipt in the same step disagrees with the shadow account.
|
||||
pub struct ManualReplayCursor {
|
||||
replay: std::sync::Arc<ManualExecutionReplay>,
|
||||
indices: Vec<(usize, usize, usize)>,
|
||||
cursor: usize,
|
||||
clock: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManualReplayApplication {
|
||||
pub action_id: String,
|
||||
pub order_id: String,
|
||||
pub trade_id: String,
|
||||
pub observation_event_id: String,
|
||||
pub observation_sequence: u64,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub fee_observation_event_id: String,
|
||||
pub fee_observed_at: DateTime<Utc>,
|
||||
pub executed_at: DateTime<Utc>,
|
||||
pub symbol: String,
|
||||
pub side: OrderSide,
|
||||
pub quantity: u32,
|
||||
pub quantity_after: u32,
|
||||
pub price: String,
|
||||
pub commission: Option<String>,
|
||||
pub stamp_tax: Option<String>,
|
||||
pub transfer_fee: Option<String>,
|
||||
pub source_total_fee: String,
|
||||
pub source_gross_amount: String,
|
||||
pub ledger_gross_amount: String,
|
||||
pub ledger_fees: String,
|
||||
pub cash_delta: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corporate_adjustment: Option<ManualCorporateAdjustment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateAdjustment {
|
||||
pub schema: String,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub cash_dividends_enabled: bool,
|
||||
pub dividend_cost_basis_adjustment: bool,
|
||||
#[serde(default, skip_serializing_if = "disabled_flag")]
|
||||
pub dividend_reinvestment: bool,
|
||||
pub actions: Vec<ManualCorporateActionReference>,
|
||||
pub cash_before: String,
|
||||
pub cash_after: String,
|
||||
pub corporate_cash_delta: String,
|
||||
pub positions: BTreeMap<String, ManualCorporatePositionChange>,
|
||||
pub reference_sha256: String,
|
||||
pub replayed_sha256: String,
|
||||
}
|
||||
|
||||
fn disabled_flag(value: &bool) -> bool { !value }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateActionReference {
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub successor_symbol: Option<String>,
|
||||
pub share_cash: String,
|
||||
pub split_ratio: String,
|
||||
pub successor_ratio: Option<String>,
|
||||
pub successor_cash: Option<String>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporatePositionChange {
|
||||
pub quantity_before: u32,
|
||||
pub quantity_after: u32,
|
||||
pub cost_basis_before: String,
|
||||
pub cost_basis_after: String,
|
||||
}
|
||||
|
||||
impl ManualReplayCursor {
|
||||
pub(crate) fn frozen_source(&self) -> std::sync::Arc<ManualExecutionReplay> {
|
||||
self.replay.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn next_observation(&self) -> Option<ManualFillObservation<'_>> {
|
||||
self.indices.get(self.cursor).map(|&(a, o, f)| ManualFillObservation {
|
||||
action: &self.replay.actions[a], order: &self.replay.actions[a].orders[o],
|
||||
fill: &self.replay.actions[a].orders[o].fills[f],
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn advance_next_projected<F>(
|
||||
&mut self, portfolio: &mut PortfolioState, project: F,
|
||||
) -> Result<Option<ManualReplayApplication>, String>
|
||||
where F: FnOnce(ManualFillObservation<'_>, &mut PortfolioState) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
|
||||
let Some(observation) = self.next_observation() else { return Ok(None); };
|
||||
let at = observation.fill.observed_at;
|
||||
if at > self.replay.observation_cutoff || self.clock.is_some_and(|clock| at < clock) {
|
||||
return Err("manual projected observation clock violates the frozen trace".into());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let (applied, adjustment) = project(observation, &mut next)?;
|
||||
let mut application = observation.application(applied)?;
|
||||
application.corporate_adjustment = Some(adjustment);
|
||||
crate::finite_serialization::validate(&application).map_err(|error| error.to_string())?;
|
||||
*portfolio = next;
|
||||
self.cursor += 1;
|
||||
self.clock = Some(at);
|
||||
Ok(Some(application))
|
||||
}
|
||||
|
||||
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
|
||||
Self::from_shared(std::sync::Arc::new(replay))
|
||||
}
|
||||
|
||||
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
|
||||
replay.validate()?;
|
||||
let mut indices = Vec::new();
|
||||
for (a, action) in replay.actions.iter().enumerate() {
|
||||
for (o, order) in action.orders.iter().enumerate() {
|
||||
for f in 0..order.fills.len() {
|
||||
indices.push((a, o, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
indices.sort_by_key(|&(a, o, f)| {
|
||||
let fill = &replay.actions[a].orders[o].fills[f];
|
||||
(fill.observed_at, fill.observation_sequence)
|
||||
});
|
||||
Ok(Self {
|
||||
replay,
|
||||
indices,
|
||||
cursor: 0,
|
||||
clock: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn next_observation_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.indices
|
||||
.get(self.cursor)
|
||||
.map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at)
|
||||
}
|
||||
|
||||
pub fn applied_count(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn advance(
|
||||
&mut self,
|
||||
at: DateTime<Utc>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
has_pending_orders: bool,
|
||||
) -> Result<Vec<ManualReplayApplication>, String> {
|
||||
let end = self.cursor
|
||||
+ self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| {
|
||||
self.replay.actions[a].orders[o].fills[f].observed_at <= at
|
||||
}).count();
|
||||
self.advance_through(at, end, portfolio, data, has_pending_orders)
|
||||
}
|
||||
|
||||
/// One receipt at a time lets callbacks observe the intermediate state
|
||||
/// when multiple fills share a timestamp but have distinct durable sequences.
|
||||
pub fn advance_next(
|
||||
&mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool,
|
||||
) -> Result<Option<ManualReplayApplication>, String> {
|
||||
let Some(at) = self.next_observation_at() else { return Ok(None); };
|
||||
let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?;
|
||||
Ok(applications.pop())
|
||||
}
|
||||
|
||||
fn advance_through(
|
||||
&mut self, at: DateTime<Utc>, end: usize, portfolio: &mut PortfolioState,
|
||||
data: &DataSet, has_pending_orders: bool,
|
||||
) -> Result<Vec<ManualReplayApplication>, String> {
|
||||
if at > self.replay.observation_cutoff {
|
||||
return Err("manual observation clock exceeds the frozen evidence cutoff".into());
|
||||
}
|
||||
if self.clock.is_some_and(|clock| at < clock) {
|
||||
return Err("manual observation clock moved backwards".into());
|
||||
}
|
||||
if end == self.cursor {
|
||||
self.clock = Some(at);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut applications = Vec::with_capacity(end - self.cursor);
|
||||
for &(a, o, f) in &self.indices[self.cursor..end] {
|
||||
let action = &self.replay.actions[a];
|
||||
let order = &action.orders[o];
|
||||
let fill = &order.fills[f];
|
||||
let applied = ManualFillObservation {
|
||||
action,
|
||||
order,
|
||||
fill,
|
||||
}
|
||||
.apply(&mut next, data, has_pending_orders)?;
|
||||
applications.push(ManualReplayApplication {
|
||||
action_id: action.action_id.clone(),
|
||||
order_id: order.order_id.clone(),
|
||||
trade_id: fill.trade_id.clone(),
|
||||
observation_event_id: fill.observation_event_id.clone(),
|
||||
observation_sequence: fill.observation_sequence,
|
||||
observed_at: fill.observed_at,
|
||||
fee_observation_event_id: fill.fee_observation_event_id.clone(),
|
||||
fee_observed_at: fill.fee_observed_at,
|
||||
executed_at: fill.executed_at,
|
||||
symbol: order.symbol.clone(),
|
||||
side: order.side,
|
||||
quantity: fill.quantity,
|
||||
quantity_after: applied.quantity_after,
|
||||
price: fill.price.to_string(),
|
||||
commission: fill.commission.map(|fee| fee.to_string()),
|
||||
stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
|
||||
transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
|
||||
source_total_fee: fill.total_fee.to_string(),
|
||||
source_gross_amount: fill.gross_amount()?.to_string(),
|
||||
ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||
ledger_fees: applied.fees.to_decimal_string(),
|
||||
cash_delta: applied.cash_delta.to_decimal_string(),
|
||||
corporate_adjustment: None,
|
||||
});
|
||||
}
|
||||
*portfolio = next;
|
||||
self.cursor = end;
|
||||
self.clock = Some(at);
|
||||
Ok(applications)
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualFillObservation<'_> {
|
||||
fn application(&self, applied: AppliedManualFill) -> Result<ManualReplayApplication, String> {
|
||||
Ok(ManualReplayApplication {
|
||||
action_id: self.action.action_id.clone(), order_id: self.order.order_id.clone(),
|
||||
trade_id: self.fill.trade_id.clone(), observation_event_id: self.fill.observation_event_id.clone(),
|
||||
observation_sequence: self.fill.observation_sequence, observed_at: self.fill.observed_at,
|
||||
fee_observation_event_id: self.fill.fee_observation_event_id.clone(), fee_observed_at: self.fill.fee_observed_at,
|
||||
executed_at: self.fill.executed_at, symbol: self.order.symbol.clone(), side: self.order.side,
|
||||
quantity: self.fill.quantity, quantity_after: applied.quantity_after, price: self.fill.price.to_string(),
|
||||
commission: self.fill.commission.map(|fee| fee.to_string()), stamp_tax: self.fill.stamp_tax.map(|fee| fee.to_string()),
|
||||
transfer_fee: self.fill.transfer_fee.map(|fee| fee.to_string()), source_total_fee: self.fill.total_fee.to_string(),
|
||||
source_gross_amount: self.fill.gross_amount()?.to_string(), ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||
ledger_fees: applied.fees.to_decimal_string(), cash_delta: applied.cash_delta.to_decimal_string(), corporate_adjustment: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn apply(
|
||||
&self,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
has_pending_orders: bool,
|
||||
) -> Result<AppliedManualFill, String> {
|
||||
if has_pending_orders {
|
||||
return Err("manual observation conflicts with pending shadow orders".into());
|
||||
}
|
||||
let instrument = data
|
||||
.instrument(&self.order.symbol)
|
||||
.ok_or("manual observation instrument is absent from frozen source data")?;
|
||||
if instrument
|
||||
.dated_market_absence_reason(self.fill.trade_date)
|
||||
.is_some()
|
||||
{
|
||||
return Err("manual execution contradicts the frozen instrument lifecycle".into());
|
||||
}
|
||||
let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?;
|
||||
let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?;
|
||||
let price = self
|
||||
.fill
|
||||
.price
|
||||
.to_f64()
|
||||
.filter(|price| price.is_finite() && *price > 0.)
|
||||
.ok_or("manual execution price cannot be represented for valuation")?;
|
||||
// This is the real observed trade price, not a fabricated quote. The
|
||||
// normal market clock remains responsible for subsequent marks.
|
||||
let cash_delta = portfolio.apply_observed_manual_fill(
|
||||
self.fill.trade_date,
|
||||
&self.order.symbol,
|
||||
self.order.side,
|
||||
self.fill.quantity,
|
||||
price,
|
||||
price,
|
||||
gross,
|
||||
fees,
|
||||
)?;
|
||||
Ok(AppliedManualFill {
|
||||
gross,
|
||||
fees,
|
||||
cash_delta,
|
||||
quantity_after: portfolio
|
||||
.position(&self.order.symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,761 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn sample() -> ManualExecutionReplay {
|
||||
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
|
||||
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
|
||||
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
|
||||
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
|
||||
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
|
||||
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
|
||||
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
|
||||
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
|
||||
"fills":[fill]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
reseal(&mut input);
|
||||
input
|
||||
}
|
||||
|
||||
fn reseal(input: &mut ManualExecutionReplay) {
|
||||
input.content_sha256 = input.content_digest().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_corporate_projection_does_not_change_the_book_or_receipt_cursor() {
|
||||
let mut cursor = ManualReplayCursor::new(sample()).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let before = portfolio.financial_replay_identity();
|
||||
let error = cursor.advance_next_projected(&mut portfolio, |_, next| {
|
||||
next.apply_cash_delta(-50.)?;
|
||||
next.position_mut("000001.SZ").buy(NaiveDate::from_ymd_opt(2026, 9, 11).unwrap(), 100, 10.);
|
||||
Err("financial coverage mismatch".into())
|
||||
}).unwrap_err();
|
||||
assert_eq!(error, "financial coverage mismatch");
|
||||
assert_eq!(portfolio.financial_replay_identity(), before);
|
||||
assert_eq!(cursor.applied_count(), 0);
|
||||
}
|
||||
|
||||
fn delayed_buy_replay() -> ManualExecutionReplay {
|
||||
let mut input = sample();
|
||||
let template = input.actions[0].clone();
|
||||
input.actions.clear();
|
||||
for (index, side, executed, observed, price, fee) in [
|
||||
(
|
||||
0,
|
||||
OrderSide::Buy,
|
||||
"2026-09-14T01:30:00Z",
|
||||
"2026-09-14T01:30:01Z",
|
||||
"20",
|
||||
"0.25",
|
||||
),
|
||||
(
|
||||
1,
|
||||
OrderSide::Buy,
|
||||
"2026-09-11T06:00:00Z",
|
||||
"2026-09-14T01:30:02Z",
|
||||
"10",
|
||||
"0.75",
|
||||
),
|
||||
(
|
||||
2,
|
||||
OrderSide::Sell,
|
||||
"2026-09-14T01:31:00Z",
|
||||
"2026-09-14T01:31:01Z",
|
||||
"10",
|
||||
"0.5",
|
||||
),
|
||||
(
|
||||
3,
|
||||
OrderSide::Sell,
|
||||
"2026-09-14T01:32:00Z",
|
||||
"2026-09-14T01:32:01Z",
|
||||
"10",
|
||||
"0.5",
|
||||
),
|
||||
] {
|
||||
let executed: DateTime<Utc> = executed.parse().unwrap();
|
||||
let observed: DateTime<Utc> = observed.parse().unwrap();
|
||||
let mut action = template.clone();
|
||||
action.action_id = format!("action-{index}");
|
||||
action.audit_event_ids = vec![format!("audit-{index}")];
|
||||
action.confirmed_at = executed - chrono::Duration::seconds(2);
|
||||
action.confirmation_observed_at = action.confirmed_at;
|
||||
let order = &mut action.orders[0];
|
||||
order.order_id = format!("order-{index}");
|
||||
order.broker_order_id = Some(format!("broker-{index}"));
|
||||
order.side = side;
|
||||
order.order_created_at = executed - chrono::Duration::seconds(1);
|
||||
order.terminal_observed_at = observed;
|
||||
let fill = &mut order.fills[0];
|
||||
fill.trade_id = format!("trade-{index}");
|
||||
fill.observation_event_id = format!("receipt-{index}");
|
||||
fill.observation_sequence = index + 1;
|
||||
fill.fee_observation_event_id = fill.observation_event_id.clone();
|
||||
fill.fee_observation_sequence = fill.observation_sequence;
|
||||
fill.trade_date = executed
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.date_naive();
|
||||
fill.executed_at = executed;
|
||||
fill.observed_at = observed;
|
||||
fill.fee_observed_at = observed;
|
||||
fill.price = price.parse().unwrap();
|
||||
fill.commission = None;
|
||||
fill.stamp_tax = None;
|
||||
fill.transfer_fee = None;
|
||||
fill.total_fee = fee.parse().unwrap();
|
||||
input.actions.push(action);
|
||||
}
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
|
||||
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let applications = cursor
|
||||
.advance(
|
||||
"2026-09-14T01:30:02Z".parse().unwrap(),
|
||||
&mut portfolio,
|
||||
&data,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
applications
|
||||
.iter()
|
||||
.map(|row| row.trade_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["trade-0", "trade-1"]
|
||||
);
|
||||
let position = portfolio.position("000001.SZ").unwrap();
|
||||
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
|
||||
assert_eq!(
|
||||
position.last_buy_date(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14)
|
||||
);
|
||||
assert_eq!(position.quantity, 200);
|
||||
let calendar = crate::TradingCalendar::new(
|
||||
[11, 14, 15, 16, 17, 18]
|
||||
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
|
||||
.into(),
|
||||
);
|
||||
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
opened_date: position.opened_date(),
|
||||
last_buy_date: position.last_buy_date(),
|
||||
last_sell_date: None,
|
||||
};
|
||||
let mut policy = crate::holding_policy::AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
policy
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
|
||||
&evidence,
|
||||
&calendar
|
||||
)
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
policy.buy_protection_days = 3;
|
||||
for day in [14, 15, 16, 17] {
|
||||
let permission = policy
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
|
||||
&evidence,
|
||||
&calendar,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
|
||||
assert!(!permission.max_holding_exit);
|
||||
}
|
||||
assert!(
|
||||
policy
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
|
||||
&evidence,
|
||||
&calendar
|
||||
)
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
|
||||
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let applications = cursor
|
||||
.advance(
|
||||
"2026-09-14T01:31:01Z".parse().unwrap(),
|
||||
&mut portfolio,
|
||||
&data,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(applications.len(), 3);
|
||||
let position = portfolio.position("000001.SZ").unwrap();
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert_eq!(position.unrealized_pnl(), -1000.25);
|
||||
assert_eq!(
|
||||
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
|
||||
0
|
||||
);
|
||||
assert_eq!(position.realized_pnl(), -0.75);
|
||||
assert_eq!(portfolio.cash(), 7998.5);
|
||||
assert_eq!(portfolio.external_cash_flow_total(), 0.);
|
||||
assert!(
|
||||
cursor
|
||||
.advance(
|
||||
"2026-09-14T01:32:01Z".parse().unwrap(),
|
||||
&mut portfolio,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("T+1")
|
||||
);
|
||||
assert_eq!(cursor.applied_count(), 3);
|
||||
assert_eq!(portfolio.cash(), 7998.5);
|
||||
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
|
||||
let mut input = input.clone();
|
||||
reseal(&mut input);
|
||||
input.validate()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
|
||||
let input = sample();
|
||||
input.validate().unwrap();
|
||||
let fill = &input.actions[0].orders[0].fills[0];
|
||||
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
|
||||
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
|
||||
"10.1234567891"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() {
|
||||
let mut input = sample();
|
||||
let mut rejected = input.actions[0].orders[0].clone();
|
||||
rejected.order_id = "rejected-order".into();
|
||||
rejected.broker_order_id = None;
|
||||
rejected.source_adapter = None;
|
||||
rejected.symbol = "510300.SH".into();
|
||||
rejected.terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
rejected.fills.clear();
|
||||
input.actions[0].orders.push(rejected);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input.required_data_symbols().unwrap(),
|
||||
BTreeSet::from(["000001.SZ".into()])
|
||||
);
|
||||
input.actions[0].orders[0].symbol = "600000.SH".into();
|
||||
assert!(input.required_data_symbols().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() {
|
||||
let mut input = sample();
|
||||
input.schema = "fidc.observed-manual-executions/v2".into();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let old = serde_json::to_value(&input).unwrap();
|
||||
assert!(old.get("positionExposureEvents").is_none());
|
||||
assert!(old.get("legacyPositionExposureBps").is_none());
|
||||
input
|
||||
.legacy_position_exposure_bps
|
||||
.insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000);
|
||||
reseal(&mut input);
|
||||
assert!(input.validate().is_err());
|
||||
input.schema = MANUAL_REPLAY_SCHEMA.into();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() {
|
||||
let mut input = sample();
|
||||
input.position_exposure_events.push(serde_json::from_value(json!({
|
||||
"eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff,
|
||||
"action": "scale", "requestedBps": 5000
|
||||
})).unwrap());
|
||||
semantic_result(&input).unwrap();
|
||||
input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1);
|
||||
assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
|
||||
let original = serde_json::to_value(sample()).unwrap();
|
||||
for field in ["price", "totalFee"] {
|
||||
let mut missing = original.clone();
|
||||
missing["actions"][0]["orders"][0]["fills"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
let mut numeric = original.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
|
||||
"numeric {field}"
|
||||
);
|
||||
}
|
||||
for mutate in [
|
||||
("schema", json!("unknown")),
|
||||
("sourceContractSha256", json!("broken")),
|
||||
("accountId", json!(" ")),
|
||||
] {
|
||||
let mut value = original.clone();
|
||||
value[mutate.0] = mutate.1;
|
||||
assert!(
|
||||
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
|
||||
let original = sample();
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].quantity = 200;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].audit_event_ids.clear();
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions.push(invalid.actions[0].clone());
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
|
||||
invalid.actions[0].orders[0].fills.push(duplicate);
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].broker_order_id = None;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
|
||||
reseal(&mut invalid);
|
||||
invalid.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
|
||||
input.actions[0].orders[0].terminal_observed_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].fee_observed_at =
|
||||
input.actions[0].orders[0].fills[0].observed_at;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:01Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].fills[0].executed_at =
|
||||
"2026-09-14T01:30:00.800001Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders.clear();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let mut value = serde_json::to_value(input).unwrap();
|
||||
value["actions"][0]["outcome"] = json!("result_unknown");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_timezone_and_cutoff_are_required() {
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
let mut input = sample();
|
||||
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
|
||||
let mut input = sample();
|
||||
let fill = &mut input.actions[0].orders[0].fills[0];
|
||||
fill.commission = None;
|
||||
fill.stamp_tax = None;
|
||||
fill.transfer_fee = None;
|
||||
assert_eq!(
|
||||
fill.total_fees().unwrap(),
|
||||
"0.1200001".parse::<Decimal>().unwrap()
|
||||
);
|
||||
assert!(semantic_result(&input).is_ok());
|
||||
let value = serde_json::to_value(&input).unwrap();
|
||||
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
|
||||
assert_eq!(
|
||||
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
|
||||
"0.1200001"
|
||||
);
|
||||
for field in ["commission", "stampTax", "transferFee"] {
|
||||
let mut numeric = value.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_ok());
|
||||
assert_eq!(
|
||||
input.actions[0].orders[0].fills[0]
|
||||
.total_fees()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
"0.15"
|
||||
);
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut input = sample();
|
||||
let fill = &mut input.actions[0].orders[0].fills[0];
|
||||
let original = fill.observed_at;
|
||||
fill.fee_observation_event_id = "fee-receipt-1".into();
|
||||
fill.fee_observation_sequence = 2;
|
||||
fill.fee_observed_at = original + chrono::Duration::hours(1);
|
||||
let fee_time = fill.fee_observed_at;
|
||||
reseal(&mut input);
|
||||
let mut cursor = ManualReplayCursor::new(input).unwrap();
|
||||
assert_eq!(cursor.next_observation_at(), Some(original));
|
||||
let mut portfolio = PortfolioState::new(10_000.);
|
||||
let result = cursor
|
||||
.advance(original, &mut portfolio, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].observed_at, original);
|
||||
assert_eq!(result[0].fee_observed_at, fee_time);
|
||||
assert_eq!(result[0].source_total_fee, "0.1200001");
|
||||
assert!(
|
||||
cursor
|
||||
.advance(fee_time, &mut portfolio, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
|
||||
let input = sample();
|
||||
let original = input.content_sha256.clone();
|
||||
let mut changed = input.clone();
|
||||
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert_eq!(
|
||||
changed.validate().unwrap_err(),
|
||||
"manual replay content digest mismatch"
|
||||
);
|
||||
let mut changed = input;
|
||||
changed.account_id = "another-account".into();
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert!(changed.validate().is_err());
|
||||
}
|
||||
|
||||
fn identity_data(listed: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "test".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(listed),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: listed,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
let applied = observations[0].apply(&mut account, &data, false).unwrap();
|
||||
assert_eq!(
|
||||
applied.gross,
|
||||
FixedMoney::from_decimal_str("1012.345679").unwrap()
|
||||
);
|
||||
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
|
||||
assert_eq!(account.cash(), 8987.534321);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert_eq!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.unwrap()
|
||||
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
|
||||
0
|
||||
);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
assert_eq!(account.starting_cash(), 10_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let mut poor = PortfolioState::new(10.);
|
||||
assert!(observations[0].apply(&mut poor, &data, false).is_err());
|
||||
assert_eq!(poor.cash(), 10.);
|
||||
assert!(poor.positions().is_empty());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert!(observations[0].apply(&mut account, &data, true).is_err());
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
assert!(account.positions().is_empty());
|
||||
observations[0].apply(&mut account, &data, false).unwrap();
|
||||
let before = account.cash();
|
||||
let mut sell = input.clone();
|
||||
sell.actions[0].orders[0].side = OrderSide::Sell;
|
||||
reseal(&mut sell);
|
||||
assert!(
|
||||
sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap_err()
|
||||
.contains("T+1")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
|
||||
assert!(
|
||||
observations[0]
|
||||
.apply(&mut account, &unlisted, false)
|
||||
.unwrap_err()
|
||||
.contains("lifecycle")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
input.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
let mut sell = input.clone();
|
||||
let order = &mut sell.actions[0].orders[0];
|
||||
order.side = OrderSide::Sell;
|
||||
order.order_created_at += chrono::Duration::days(1);
|
||||
order.terminal_observed_at += chrono::Duration::days(1);
|
||||
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
|
||||
order.fills[0].executed_at += chrono::Duration::days(1);
|
||||
order.fills[0].observed_at += chrono::Duration::days(1);
|
||||
order.fills[0].fee_observed_at += chrono::Duration::days(1);
|
||||
sell.observation_cutoff += chrono::Duration::days(1);
|
||||
reseal(&mut sell);
|
||||
let applied = sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(applied.quantity_after, 0);
|
||||
assert_eq!(account.cash(), 9999.76);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
|
||||
let mut input = sample();
|
||||
let mut second = input.actions[0].orders[0].fills[0].clone();
|
||||
second.trade_id = "trade-2".into();
|
||||
second.observation_event_id = "received-2".into();
|
||||
second.observation_sequence = 2;
|
||||
second.fee_observation_event_id = "received-2".into();
|
||||
second.fee_observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.insert(0, second);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input
|
||||
.observations()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row.fill.observation_sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
let mut invalid = input.clone();
|
||||
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
let mut invalid = input;
|
||||
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
|
||||
semantic_result(&input).unwrap();
|
||||
input.actions[0].orders[0].quantity = 100;
|
||||
assert!(
|
||||
semantic_result(&input)
|
||||
.unwrap_err()
|
||||
.contains("terminal status")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
|
||||
let input = sample();
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::milliseconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
let records = replay.advance(at, &mut account, &data, false).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].cash_delta, "-1012.465679");
|
||||
assert_eq!(replay.applied_count(), 1);
|
||||
assert_eq!(replay.next_observation_at(), None);
|
||||
let cash = account.cash();
|
||||
assert!(
|
||||
replay
|
||||
.advance(at, &mut account, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), cash);
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::seconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("backwards")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
|
||||
let mut input = sample();
|
||||
let mut next = input.actions[0].orders[0].fills[0].clone();
|
||||
next.trade_id = "trade-2".into();
|
||||
next.observation_event_id = "received-2".into();
|
||||
next.observation_sequence = 2;
|
||||
next.fee_observation_event_id = "received-2".into();
|
||||
next.fee_observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.push(next);
|
||||
reseal(&mut input);
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(1_500.);
|
||||
assert!(replay.advance(at, &mut account, &data, false).is_err());
|
||||
assert_eq!(account.cash(), 1_500.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(replay.applied_count(), 0);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
|
||||
for text in [
|
||||
"0",
|
||||
"100",
|
||||
"-100",
|
||||
"0.000001",
|
||||
"-0.000001",
|
||||
"12345678901234567890123456.123456",
|
||||
] {
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str(text)
|
||||
.unwrap()
|
||||
.to_decimal_string(),
|
||||
text
|
||||
);
|
||||
}
|
||||
let min = FixedMoney::from_raw(i128::MIN);
|
||||
assert!(min.to_decimal_string().starts_with('-'));
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Complete published daily cross sections, independent of trading candidates and accounts.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_market_event_context_v1";
|
||||
pub fn implementation_sha256() -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
|
||||
}
|
||||
pub const COMMON_FIELDS: &[&str] = &[
|
||||
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
|
||||
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
|
||||
];
|
||||
pub const INDUSTRY_FIELDS: &[&str] = &[
|
||||
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
|
||||
];
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub industry: Option<String>,
|
||||
pub close: Option<f64>,
|
||||
pub high: Option<f64>,
|
||||
pub previous_close: Option<f64>,
|
||||
pub upper_limit: Option<f64>,
|
||||
pub lower_limit: Option<f64>,
|
||||
pub no_limit: Option<bool>,
|
||||
pub paused: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Day {
|
||||
pub date: NaiveDate,
|
||||
pub universe: Vec<String>,
|
||||
pub rows: Vec<Observation>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct State {
|
||||
pub last_date: Option<NaiveDate>,
|
||||
pub streaks: BTreeMap<String, Option<u32>>,
|
||||
pub limit_ups: BTreeSet<String>,
|
||||
pub industry_history: BTreeMap<String, Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub days: Vec<Day>,
|
||||
#[serde(default)]
|
||||
pub previous: State,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OutputDay {
|
||||
pub date: NaiveDate,
|
||||
pub common: BTreeMap<String, Option<f64>>,
|
||||
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
|
||||
pub members: BTreeMap<String, Option<String>>,
|
||||
pub securities: usize,
|
||||
pub active: usize,
|
||||
pub paused: usize,
|
||||
pub no_limit: usize,
|
||||
pub profit_effect_members: Vec<String>,
|
||||
pub profit_effect_missing: Vec<String>,
|
||||
pub industry_missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Output {
|
||||
pub contract: &'static str,
|
||||
pub days: Vec<OutputDay>,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
|
||||
value.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
|
||||
}
|
||||
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
|
||||
values.map(|v| v / n as f64).sum()
|
||||
}
|
||||
|
||||
pub fn aggregate(request: Request) -> Result<Output, String> {
|
||||
let mut state = request.previous;
|
||||
if request.days.is_empty() || request.days.len() > 30
|
||||
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|
||||
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|
||||
|| state.industry_history.len() > 2000
|
||||
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|
||||
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|
||||
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
|
||||
return Err("market_event_history_budget_or_state_invalid".into());
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for day in request.days {
|
||||
if state.last_date.is_some_and(|d| d >= day.date)
|
||||
|| day.universe.is_empty() || day.universe.len() > 20_000
|
||||
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|
||||
|| day.rows.len() != day.universe.len()
|
||||
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
|
||||
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
|
||||
}
|
||||
let mut returns = BTreeMap::new();
|
||||
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
|
||||
let mut members = BTreeMap::new();
|
||||
let mut streaks = BTreeMap::new();
|
||||
let mut ups = BTreeSet::new();
|
||||
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
|
||||
for row in &day.rows {
|
||||
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
|
||||
members.insert(row.symbol.clone(), industry.clone());
|
||||
match row.paused {
|
||||
Some(true) => {
|
||||
paused += 1;
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
|
||||
continue;
|
||||
},
|
||||
Some(false) => {},
|
||||
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
|
||||
}
|
||||
let c = positive(row.close, &row.symbol, "close")?;
|
||||
let h = positive(row.high, &row.symbol, "high")?;
|
||||
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
|
||||
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
|
||||
let change = c / p - 1.0;
|
||||
returns.insert(row.symbol.clone(), change);
|
||||
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
|
||||
let is_up = match row.no_limit {
|
||||
Some(true) => { unlimited += 1; false },
|
||||
Some(false) => {
|
||||
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
|
||||
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
|
||||
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
|
||||
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
|
||||
}
|
||||
let at_up = (c - upper).abs() <= 1e-8;
|
||||
if (c - lower).abs() <= 1e-8 { downs += 1; }
|
||||
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
|
||||
at_up
|
||||
},
|
||||
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
|
||||
};
|
||||
if is_up {
|
||||
ups.insert(row.symbol.clone());
|
||||
// The first observed limit-up may already be a continuing streak.
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
|
||||
} else { streaks.insert(row.symbol.clone(), Some(0)); }
|
||||
}
|
||||
let active = returns.len();
|
||||
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
|
||||
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
|
||||
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
|
||||
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
|
||||
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
|
||||
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
|
||||
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
|
||||
let common = BTreeMap::from([
|
||||
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
|
||||
("market_return".into(), Some(average(returns.values().copied(), active))),
|
||||
("market_limit_up_count".into(), Some(ups.len() as f64)),
|
||||
("market_limit_down_count".into(), Some(downs as f64)),
|
||||
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
|
||||
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
|
||||
("market_high_board".into(), board),
|
||||
("market_profit_effect".into(), profit),
|
||||
]);
|
||||
let mut industries = BTreeMap::new();
|
||||
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
|
||||
state.industry_history.retain(|key, _| groups.contains_key(key));
|
||||
for (industry, values) in groups {
|
||||
let history = state.industry_history.entry(industry.clone()).or_default();
|
||||
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
|
||||
history.push(nav);
|
||||
if history.len() > 21 { history.remove(0); }
|
||||
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
|
||||
industries.insert(industry, BTreeMap::from([
|
||||
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
|
||||
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
|
||||
]));
|
||||
}
|
||||
let universe = industries.keys().cloned().collect::<Vec<_>>();
|
||||
let known = industries.values().all(|g| g["industry_return_20"].is_some());
|
||||
let ranks = if known && !universe.is_empty() {
|
||||
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
|
||||
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
|
||||
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
|
||||
} else { BTreeMap::new() };
|
||||
for (name, fields) in &mut industries {
|
||||
fields.insert("industry_rank".into(), ranks.get(name).copied());
|
||||
fields.insert("industry_size".into(), Some(universe.len() as f64));
|
||||
}
|
||||
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
|
||||
if !industry_missing.is_empty() {
|
||||
// An unclassified member may belong to any group; never silently shrink a group.
|
||||
state.industry_history.clear();
|
||||
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
|
||||
}
|
||||
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
|
||||
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
|
||||
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
|
||||
}
|
||||
Ok(Output {contract:CONTRACT, days:output, state})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn day(n: u32, up: bool) -> Day {
|
||||
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
|
||||
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
|
||||
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
|
||||
}
|
||||
#[test]
|
||||
fn formulas_use_real_limits_and_full_denominators() {
|
||||
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
let d=&r.days[1];
|
||||
assert_eq!(d.common["market_breadth"],Some(0.5));
|
||||
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
|
||||
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
|
||||
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
|
||||
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
|
||||
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
|
||||
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
|
||||
assert_eq!(r.days[0].common["market_profit_effect"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_and_unproven_limit_states_fail() {
|
||||
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
|
||||
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
|
||||
}
|
||||
#[test]
|
||||
fn chunking_and_future_append_preserve_history() {
|
||||
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
|
||||
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
|
||||
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
|
||||
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
|
||||
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(unknown.days[0].common["market_high_board"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
|
||||
let mut missing=day(2,true);missing.rows[0].industry=None;
|
||||
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
|
||||
assert_eq!(r.days[1].industry_missing,vec!["A"]);
|
||||
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
|
||||
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
|
||||
}
|
||||
}
|
||||
+499
-66
@@ -4,12 +4,33 @@ use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::DailyEquityPoint;
|
||||
use crate::events::FillEvent;
|
||||
use crate::events::{AccountEvent, FillEvent};
|
||||
use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
const MONTHS_PER_YEAR: f64 = 12.0;
|
||||
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RiskFreeRateObservation {
|
||||
pub date: NaiveDate,
|
||||
pub source_date: NaiveDate,
|
||||
pub annual_rate: f64,
|
||||
pub daily_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RiskFreeRateContract {
|
||||
pub version: String,
|
||||
pub source: String,
|
||||
pub tenor: String,
|
||||
pub periods_per_year: f64,
|
||||
pub max_staleness_days: usize,
|
||||
pub observed_max_staleness_days: usize,
|
||||
pub sha256: String,
|
||||
pub observations: Vec<RiskFreeRateObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
@@ -29,6 +50,7 @@ pub struct BacktestMetrics {
|
||||
pub max_drawdown_duration_days: usize,
|
||||
pub total_trade_days: usize,
|
||||
pub sortino: f64,
|
||||
pub downside_risk: f64,
|
||||
pub information_ratio: f64,
|
||||
pub tracking_error: f64,
|
||||
pub volatility: f64,
|
||||
@@ -47,46 +69,77 @@ pub struct BacktestMetrics {
|
||||
pub cash_balance: f64,
|
||||
pub unit_nav: f64,
|
||||
pub initial_cash: f64,
|
||||
/// Sum of external deposits (positive) and withdrawals (negative). This
|
||||
/// is reported separately so callers cannot mistake a cash transfer for
|
||||
/// trading performance.
|
||||
#[serde(default)]
|
||||
pub external_cash_flow_total: f64,
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
pub risk_free_rate_contract_version: String,
|
||||
pub risk_free_rate_source: String,
|
||||
pub risk_free_rate_tenor: String,
|
||||
pub risk_free_rate_observation_count: usize,
|
||||
pub risk_free_rate_max_staleness_days: usize,
|
||||
pub risk_free_rate_observed_max_staleness_days: usize,
|
||||
pub risk_free_rate_sha256: String,
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
daily_holdings: &[HoldingSummary],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> BacktestMetrics {
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract)
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics_with_manual(
|
||||
equity_curve: &[DailyEquityPoint], fills: &[FillEvent],
|
||||
manual_executions: &[crate::manual_execution::ManualReplayApplication],
|
||||
daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64,
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
return Ok(BacktestMetrics {
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
});
|
||||
};
|
||||
let Some(last_point) = equity_curve.last() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
return Ok(BacktestMetrics {
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let trade_days = equity_curve.len();
|
||||
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
|
||||
&& first_point.benchmark_prev_close > f64::EPSILON
|
||||
{
|
||||
first_point.benchmark_prev_close
|
||||
} else {
|
||||
first_point.benchmark_close
|
||||
};
|
||||
let mut returns = Vec::with_capacity(equity_curve.len());
|
||||
returns.push(pct_change(initial_cash, first_point.total_equity));
|
||||
returns.extend(
|
||||
let benchmark_start = first_point.benchmark_reference_close();
|
||||
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
||||
point.external_cash_flow.abs() > f64::EPSILON
|
||||
|| (point.unit_nav.is_finite()
|
||||
&& point.unit_nav > 0.0
|
||||
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
|
||||
});
|
||||
let portfolio_nav = if explicit_unit_nav {
|
||||
equity_curve
|
||||
.iter()
|
||||
.map(|point| point_nav(point, initial_cash))
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
|
||||
};
|
||||
let mut returns = Vec::with_capacity(portfolio_nav.len());
|
||||
if let Some(first_nav) = portfolio_nav.first().copied() {
|
||||
returns.push(pct_change(1.0, first_nav));
|
||||
}
|
||||
returns.extend(
|
||||
portfolio_nav
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
|
||||
.map(|window| pct_change(window[0], window[1])),
|
||||
);
|
||||
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
|
||||
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
|
||||
@@ -100,6 +153,7 @@ pub fn compute_backtest_metrics(
|
||||
.zip(benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
let zero_risk_free_rates = vec![0.0; excess_returns.len()];
|
||||
|
||||
let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON {
|
||||
1.0
|
||||
@@ -107,35 +161,41 @@ pub fn compute_backtest_metrics(
|
||||
last_point.benchmark_close / benchmark_start
|
||||
};
|
||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) - 1.0
|
||||
};
|
||||
let final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
|
||||
let total_return = final_nav - 1.0;
|
||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||
total_return
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
||||
portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
||||
|
||||
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
|
||||
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
|
||||
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (daily_risk_free_rates, risk_free_metadata) =
|
||||
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
|
||||
let risk_free_rate =
|
||||
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let sharpe = annualized_sharpe(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let downside_risk =
|
||||
annualized_downside_risk(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(
|
||||
&excess_returns,
|
||||
&zero_risk_free_rates,
|
||||
TRADING_DAYS_PER_YEAR,
|
||||
);
|
||||
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
||||
let excess_sharpe = annualized_sharpe(
|
||||
&excess_returns,
|
||||
&zero_risk_free_rates,
|
||||
TRADING_DAYS_PER_YEAR,
|
||||
);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates);
|
||||
|
||||
let equity_nav = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let equity_nav = portfolio_nav;
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
||||
@@ -154,8 +214,7 @@ pub fn compute_backtest_metrics(
|
||||
let win_rate = ratio(winning_days, returns.len());
|
||||
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
||||
|
||||
let monthly_portfolio_returns =
|
||||
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
|
||||
let monthly_portfolio_returns = group_monthly_returns_from_values(equity_curve, &equity_nav);
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
@@ -163,6 +222,8 @@ pub fn compute_backtest_metrics(
|
||||
.zip(monthly_benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
let monthly_risk_free_returns =
|
||||
group_monthly_risk_free_returns(equity_curve, &daily_risk_free_rates);
|
||||
let monthly_excess_win_rate = ratio(
|
||||
monthly_excess_returns
|
||||
.iter()
|
||||
@@ -172,17 +233,25 @@ pub fn compute_backtest_metrics(
|
||||
);
|
||||
let monthly_sharpe = annualized_sharpe(
|
||||
&monthly_portfolio_returns,
|
||||
risk_free_rate / MONTHS_PER_YEAR,
|
||||
&monthly_risk_free_returns,
|
||||
MONTHS_PER_YEAR,
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
|
||||
let turnover_by_date = fills
|
||||
let mut turnover_by_date = fills
|
||||
.iter()
|
||||
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
||||
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
||||
acc
|
||||
});
|
||||
for execution in manual_executions {
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
let gross = execution.ledger_gross_amount.parse::<rust_decimal::Decimal>()
|
||||
.ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.)
|
||||
.ok_or("manual turnover requires its validated ledger gross amount")?;
|
||||
let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
||||
*turnover_by_date.entry(date).or_default() += gross;
|
||||
}
|
||||
let equity_by_date = equity_curve
|
||||
.iter()
|
||||
.map(|point| (point.date, point.total_equity))
|
||||
@@ -224,7 +293,7 @@ pub fn compute_backtest_metrics(
|
||||
|
||||
let total_trade_days = equity_by_date.len();
|
||||
|
||||
BacktestMetrics {
|
||||
Ok(BacktestMetrics {
|
||||
total_return,
|
||||
annual_return,
|
||||
sharpe,
|
||||
@@ -241,6 +310,7 @@ pub fn compute_backtest_metrics(
|
||||
max_drawdown_duration_days,
|
||||
total_trade_days,
|
||||
sortino,
|
||||
downside_risk,
|
||||
information_ratio,
|
||||
tracking_error,
|
||||
volatility,
|
||||
@@ -257,11 +327,34 @@ pub fn compute_backtest_metrics(
|
||||
average_daily_turnover,
|
||||
total_assets: last_point.total_equity,
|
||||
cash_balance: last_point.cash,
|
||||
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
||||
unit_nav: final_nav,
|
||||
initial_cash,
|
||||
external_cash_flow_total: if explicit_unit_nav {
|
||||
equity_curve
|
||||
.iter()
|
||||
.map(|point| point.external_cash_flow)
|
||||
.sum()
|
||||
} else {
|
||||
external_flow_total_from_events(account_events)
|
||||
},
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
monthly_volatility,
|
||||
risk_free_rate_contract_version: risk_free_metadata.version,
|
||||
risk_free_rate_source: risk_free_metadata.source,
|
||||
risk_free_rate_tenor: risk_free_metadata.tenor,
|
||||
risk_free_rate_observation_count: daily_risk_free_rates.len(),
|
||||
risk_free_rate_max_staleness_days: risk_free_metadata.max_staleness_days,
|
||||
risk_free_rate_observed_max_staleness_days: risk_free_metadata.observed_max_staleness_days,
|
||||
risk_free_rate_sha256: risk_free_metadata.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
|
||||
if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||
point.unit_nav
|
||||
} else {
|
||||
safe_div(point.total_equity, initial_cash, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,13 +378,106 @@ fn annualize_return(total_return: f64, periods: usize) -> f64 {
|
||||
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 {
|
||||
fn aligned_daily_risk_free_rates(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<(Vec<f64>, RiskFreeRateContract), String> {
|
||||
let Some(contract) = contract else {
|
||||
return Ok((
|
||||
vec![0.0; equity_curve.len()],
|
||||
RiskFreeRateContract {
|
||||
version: "not-configured".to_string(),
|
||||
source: "not-configured".to_string(),
|
||||
tenor: "NONE".to_string(),
|
||||
periods_per_year: TRADING_DAYS_PER_YEAR,
|
||||
..RiskFreeRateContract::default()
|
||||
},
|
||||
));
|
||||
};
|
||||
if contract.version.trim().is_empty()
|
||||
|| contract.source.trim().is_empty()
|
||||
|| contract.tenor.trim().is_empty()
|
||||
|| contract.sha256.len() != 64
|
||||
{
|
||||
return Err("risk-free rate contract metadata is incomplete".to_string());
|
||||
}
|
||||
if contract.observations.len() != equity_curve.len() {
|
||||
return Err(format!(
|
||||
"risk-free rate observation count mismatch: expected={} actual={}",
|
||||
equity_curve.len(),
|
||||
contract.observations.len()
|
||||
));
|
||||
}
|
||||
let mut rates = Vec::with_capacity(equity_curve.len());
|
||||
for (point, observation) in equity_curve.iter().zip(&contract.observations) {
|
||||
if observation.date != point.date {
|
||||
return Err(format!(
|
||||
"risk-free rate date mismatch: expected={} actual={}",
|
||||
point.date, observation.date
|
||||
));
|
||||
}
|
||||
if observation.source_date > observation.date {
|
||||
return Err(format!(
|
||||
"risk-free rate uses future observation: date={} source_date={}",
|
||||
observation.date, observation.source_date
|
||||
));
|
||||
}
|
||||
let staleness = observation
|
||||
.date
|
||||
.signed_duration_since(observation.source_date)
|
||||
.num_days();
|
||||
if staleness < 0 || staleness as usize > contract.max_staleness_days {
|
||||
return Err(format!(
|
||||
"risk-free rate observation is stale: date={} source_date={} days={}",
|
||||
observation.date, observation.source_date, staleness
|
||||
));
|
||||
}
|
||||
if !observation.annual_rate.is_finite()
|
||||
|| observation.annual_rate <= -1.0
|
||||
|| observation.annual_rate >= 1.0
|
||||
|| !observation.daily_rate.is_finite()
|
||||
|| observation.daily_rate <= -1.0
|
||||
{
|
||||
return Err(format!(
|
||||
"risk-free rate observation is invalid: date={}",
|
||||
observation.date
|
||||
));
|
||||
}
|
||||
let periods_per_year =
|
||||
if contract.periods_per_year.is_finite() && contract.periods_per_year > 0.0 {
|
||||
contract.periods_per_year
|
||||
} else {
|
||||
TRADING_DAYS_PER_YEAR
|
||||
};
|
||||
let expected_daily = (1.0 + observation.annual_rate).powf(1.0 / periods_per_year) - 1.0;
|
||||
if (expected_daily - observation.daily_rate).abs() > 1e-12 {
|
||||
return Err(format!(
|
||||
"risk-free daily conversion mismatch: date={} expected={} actual={}",
|
||||
observation.date, expected_daily, observation.daily_rate
|
||||
));
|
||||
}
|
||||
rates.push(observation.daily_rate);
|
||||
}
|
||||
Ok((rates, contract.clone()))
|
||||
}
|
||||
|
||||
fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -> f64 {
|
||||
if daily_rates.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_log =
|
||||
daily_rates.iter().map(|rate| rate.ln_1p()).sum::<f64>() / daily_rates.len() as f64;
|
||||
(mean_log * periods_per_year).exp_m1()
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_ret = mean(&adjusted);
|
||||
let std = std_dev(&adjusted);
|
||||
@@ -302,23 +488,24 @@ fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.is_empty() {
|
||||
fn annualized_sortino(
|
||||
returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
periods_per_year: f64,
|
||||
) -> f64 {
|
||||
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let downside = adjusted
|
||||
.iter()
|
||||
.filter(|value| **value < 0.0)
|
||||
.map(|value| value.powi(2))
|
||||
.collect::<Vec<_>>();
|
||||
if downside.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
|
||||
.map(|value| value.min(0.0).powi(2))
|
||||
.sum::<f64>();
|
||||
let downside_dev = (downside / adjusted.len() as f64).sqrt();
|
||||
if downside_dev <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
@@ -326,32 +513,60 @@ fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_downside_risk(
|
||||
returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
periods_per_year: f64,
|
||||
) -> f64 {
|
||||
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_mean_square = returns
|
||||
.iter()
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| (value - risk_free).min(0.0).powi(2))
|
||||
.sum::<f64>()
|
||||
/ returns.len() as f64;
|
||||
downside_mean_square.sqrt() * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
||||
std_dev(values) * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
|
||||
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
||||
fn alpha_beta(
|
||||
returns: &[f64],
|
||||
benchmark_returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
) -> (f64, f64) {
|
||||
if returns.len() < 2
|
||||
|| returns.len() != benchmark_returns.len()
|
||||
|| returns.len() != daily_risk_free_rates.len()
|
||||
{
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let strategy_excess = returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_excess = benchmark_returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_strategy = mean(&strategy_excess);
|
||||
let mean_benchmark = mean(&benchmark_excess);
|
||||
let variance_benchmark = variance(&benchmark_excess);
|
||||
let mean_raw_strategy = mean(returns);
|
||||
let mean_raw_benchmark = mean(benchmark_returns);
|
||||
let variance_benchmark = variance(benchmark_returns);
|
||||
if variance_benchmark <= f64::EPSILON {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let covariance = strategy_excess
|
||||
let covariance = returns
|
||||
.iter()
|
||||
.zip(benchmark_excess.iter())
|
||||
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
|
||||
.zip(benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| (lhs - mean_raw_strategy) * (rhs - mean_raw_benchmark))
|
||||
.sum::<f64>()
|
||||
/ (strategy_excess.len() - 1) as f64;
|
||||
let beta = covariance / variance_benchmark;
|
||||
@@ -360,7 +575,9 @@ fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64
|
||||
}
|
||||
|
||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
let mut peak = 0.0_f64;
|
||||
// NAV is measured against the pre-period capital. The first real loss
|
||||
// must not become a new zero-drawdown baseline.
|
||||
let mut peak = 1.0_f64;
|
||||
let mut max_drawdown = 0.0_f64;
|
||||
let mut duration = 0_usize;
|
||||
let mut max_duration = 0_usize;
|
||||
@@ -384,6 +601,80 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
(max_drawdown, max_duration)
|
||||
}
|
||||
|
||||
fn flow_neutral_nav_series(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> Vec<f64> {
|
||||
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||
for event in account_events {
|
||||
if !(event.note.starts_with("deposit_withdraw amount=")
|
||||
|| event.note.starts_with("deposit_withdraw_settled amount="))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
*external_flow_by_date.entry(event.date).or_default() +=
|
||||
event.cash_after - event.cash_before;
|
||||
}
|
||||
|
||||
let mut units = initial_cash;
|
||||
let mut previous_equity = initial_cash;
|
||||
let mut navs = Vec::with_capacity(equity_curve.len());
|
||||
for point in equity_curve {
|
||||
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
|
||||
let external_flow = external_flow_by_date
|
||||
.get(&point.date)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
|
||||
units += external_flow / unit_nav_before_flow;
|
||||
}
|
||||
let unit_nav = safe_div(point.total_equity, units, 0.0);
|
||||
navs.push(unit_nav);
|
||||
previous_equity = point.total_equity;
|
||||
}
|
||||
navs
|
||||
}
|
||||
|
||||
fn external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
|
||||
account_events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.note.starts_with("deposit_withdraw amount=")
|
||||
|| event.note.starts_with("deposit_withdraw_settled amount=")
|
||||
})
|
||||
.map(|event| event.cash_after - event.cash_before)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn group_monthly_returns_from_values(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
values: &[f64],
|
||||
) -> Vec<f64> {
|
||||
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut previous_value = 1.0;
|
||||
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
|
||||
let key = (point.date.year(), point.date.month());
|
||||
month_first.entry(key).or_insert(previous_value);
|
||||
month_last.insert(key, value);
|
||||
previous_value = value;
|
||||
}
|
||||
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.into_iter()
|
||||
.filter_map(|key| {
|
||||
let first = month_first.get(&key).copied().unwrap_or_default();
|
||||
let last = month_last.get(&key).copied().unwrap_or_default();
|
||||
if first.abs() < f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some((last / first) - 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_monthly_returns<F>(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
initial_value: f64,
|
||||
@@ -417,6 +708,26 @@ where
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_monthly_risk_free_returns(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
daily_risk_free_rates: &[f64],
|
||||
) -> Vec<f64> {
|
||||
if equity_curve.len() != daily_risk_free_rates.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut monthly_growth = BTreeMap::<(i32, u32), f64>::new();
|
||||
for (point, daily_rate) in equity_curve.iter().zip(daily_risk_free_rates) {
|
||||
let growth = monthly_growth
|
||||
.entry((point.date.year(), point.date.month()))
|
||||
.or_insert(1.0);
|
||||
*growth *= 1.0 + daily_rate;
|
||||
}
|
||||
monthly_growth
|
||||
.into_values()
|
||||
.map(|growth| growth - 1.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
0.0
|
||||
@@ -475,6 +786,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
|
||||
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
|
||||
assert!((drawdown + 0.1).abs() < 1e-12);
|
||||
assert_eq!(duration, 2);
|
||||
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
|
||||
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
|
||||
assert_eq!(drawdown_stats(&[]), (0.0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
|
||||
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
|
||||
];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
|
||||
assert_eq!(metrics.total_trade_days, 2);
|
||||
assert_eq!(metrics.max_drawdown_duration_days, 2);
|
||||
}
|
||||
|
||||
fn equity_point(
|
||||
date: &str,
|
||||
total_equity: f64,
|
||||
@@ -482,10 +815,13 @@ mod tests {
|
||||
benchmark_prev_close: f64,
|
||||
) -> DailyEquityPoint {
|
||||
DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||
cash: total_equity,
|
||||
market_value: 0.0,
|
||||
total_equity,
|
||||
external_cash_flow: 0.0,
|
||||
unit_nav: total_equity / 100.0,
|
||||
benchmark_close,
|
||||
benchmark_prev_close,
|
||||
notes: String::new(),
|
||||
@@ -499,8 +835,105 @@ mod tests {
|
||||
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
||||
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
||||
];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||
let expected = 7595.285 / 5957.717 - 1.0;
|
||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
|
||||
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
|
||||
baseline.signal_baseline=true;
|
||||
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
|
||||
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
|
||||
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||
DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash: 220.0,
|
||||
market_value: 0.0,
|
||||
total_equity: 220.0,
|
||||
external_cash_flow: 100.0,
|
||||
unit_nav: 1.1,
|
||||
benchmark_close: 100.0,
|
||||
benchmark_prev_close: 100.0,
|
||||
notes: String::new(),
|
||||
diagnostics: String::new(),
|
||||
},
|
||||
];
|
||||
let events = vec![AccountEvent {
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash_before: 100.0,
|
||||
cash_after: 200.0,
|
||||
total_equity: 200.0,
|
||||
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
|
||||
}];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0, None).unwrap();
|
||||
assert!((metrics.total_return - 0.1).abs() < 1e-12);
|
||||
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
|
||||
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_adjusted_metrics_use_daily_pit_rates_and_all_period_downside() {
|
||||
let curve = vec![
|
||||
equity_point("2026-01-02", 101.0, 100.0, 100.0),
|
||||
equity_point("2026-01-05", 98.98, 100.0, 100.0),
|
||||
equity_point("2026-01-06", 100.4647, 100.0, 100.0),
|
||||
equity_point("2026-01-07", 99.9623765, 100.0, 100.0),
|
||||
];
|
||||
let annual_rates = [0.012, 0.012, 0.013, 0.013];
|
||||
let observations = curve
|
||||
.iter()
|
||||
.zip(annual_rates)
|
||||
.map(|(point, annual_rate)| RiskFreeRateObservation {
|
||||
date: point.date,
|
||||
source_date: point.date,
|
||||
annual_rate,
|
||||
daily_rate: (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0,
|
||||
})
|
||||
.collect();
|
||||
let contract = RiskFreeRateContract {
|
||||
version: "cn-government-bond-3m-pit-daily/v1".to_string(),
|
||||
source: "test".to_string(),
|
||||
tenor: "3M".to_string(),
|
||||
periods_per_year: TRADING_DAYS_PER_YEAR,
|
||||
max_staleness_days: 15,
|
||||
observed_max_staleness_days: 0,
|
||||
sha256: "a".repeat(64),
|
||||
observations,
|
||||
};
|
||||
let metrics =
|
||||
compute_backtest_metrics(&curve, &[], &[], &[], 100.0, Some(&contract)).unwrap();
|
||||
|
||||
let returns = [0.01, -0.02, 0.015, -0.005];
|
||||
let daily_rates = annual_rates
|
||||
.map(|annual_rate| (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0);
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.zip(daily_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let expected_sharpe = mean(&adjusted) / std_dev(&adjusted) * TRADING_DAYS_PER_YEAR.sqrt();
|
||||
let downside = (adjusted
|
||||
.iter()
|
||||
.map(|value| value.min(0.0).powi(2))
|
||||
.sum::<f64>()
|
||||
/ adjusted.len() as f64)
|
||||
.sqrt();
|
||||
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
|
||||
assert!((metrics.sharpe - expected_sharpe).abs() < 1e-12);
|
||||
assert!((metrics.sortino - expected_sortino).abs() < 1e-12);
|
||||
assert!((metrics.downside_risk - downside * TRADING_DAYS_PER_YEAR.sqrt()).abs() < 1e-12);
|
||||
assert_eq!(metrics.risk_free_rate_source, "test");
|
||||
assert_eq!(metrics.risk_free_rate_tenor, "3M");
|
||||
assert_eq!(metrics.risk_free_rate_observation_count, 4);
|
||||
assert_ne!(metrics.risk_free_rate, 0.022);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::ops::Index;
|
||||
|
||||
use compact_str::CompactString;
|
||||
use serde::de::{MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct NumericFactorMap {
|
||||
entries: Vec<(CompactString, f64)>,
|
||||
}
|
||||
|
||||
fn compact_key(key: Cow<'static, str>) -> CompactString {
|
||||
match key {
|
||||
Cow::Borrowed(value) => CompactString::const_new(value),
|
||||
Cow::Owned(value) => CompactString::from(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl NumericFactorMap {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Reserve known new fields without geometric spare capacity per snapshot.
|
||||
pub fn reserve_exact(&mut self, additional: usize) {
|
||||
self.entries.reserve_exact(additional);
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &mut self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
|
||||
self.insert_compact(compact_key(key), value)
|
||||
}
|
||||
|
||||
pub fn insert_compact(&mut self, key: CompactString, value: f64) -> Option<f64> {
|
||||
if self
|
||||
.entries
|
||||
.last()
|
||||
.is_none_or(|(last, _)| last.as_str() < key.as_str())
|
||||
{
|
||||
self.entries.push((key, value));
|
||||
return None;
|
||||
}
|
||||
match self
|
||||
.entries
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
|
||||
{
|
||||
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
|
||||
Err(index) => {
|
||||
self.entries.insert(index, (key, value));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| self.entries.remove(index).1)
|
||||
}
|
||||
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
|
||||
self.entries.retain_mut(|(key, value)| keep(key, value));
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
Iter(self.entries.iter())
|
||||
}
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(key, _)| key)
|
||||
}
|
||||
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(_, value)| value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NumericFactorMap {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_map().entries(self).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<&str> for NumericFactorMap {
|
||||
type Output = f64;
|
||||
fn index(&self, key: &str) -> &Self::Output {
|
||||
self.get(key).expect("numeric factor key not found")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = (&'a CompactString, &'a f64);
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next().map(|(k, v)| (k, v))
|
||||
}
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.0.size_hint()
|
||||
}
|
||||
}
|
||||
impl DoubleEndedIterator for Iter<'_> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
self.0.next_back().map(|(k, v)| (k, v))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Iter<'_> {}
|
||||
impl<'a> IntoIterator for &'a NumericFactorMap {
|
||||
type Item = (&'a CompactString, &'a f64);
|
||||
type IntoIter = Iter<'a>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
impl IntoIterator for NumericFactorMap {
|
||||
type Item = (CompactString, f64);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
|
||||
iter.into_iter().map(|(key, value)| (compact_key(key), value)).collect()
|
||||
}
|
||||
}
|
||||
impl FromIterator<(CompactString, f64)> for NumericFactorMap {
|
||||
fn from_iter<T: IntoIterator<Item = (CompactString, f64)>>(iter: T) -> Self {
|
||||
let mut entries: Vec<_> = iter.into_iter().collect();
|
||||
// Stable sorting preserves last-value-wins for repeated input keys.
|
||||
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
}
|
||||
entries.dedup_by(|later, earlier| {
|
||||
if later.0 == earlier.0 {
|
||||
earlier.1 = later.1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
Self { entries }
|
||||
}
|
||||
}
|
||||
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
|
||||
self.extend(iter.into_iter().map(|(key, value)| (compact_key(key), value)));
|
||||
}
|
||||
}
|
||||
impl Extend<(CompactString, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (CompactString, f64)>>(&mut self, iter: T) {
|
||||
let mut incoming: Self = iter.into_iter().collect();
|
||||
if incoming.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.is_empty() {
|
||||
*self = incoming;
|
||||
return;
|
||||
}
|
||||
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
|
||||
self.entries.append(&mut incoming.entries);
|
||||
return;
|
||||
}
|
||||
// Merge sorted sets in linear time; wide factor batches must not shift
|
||||
// the existing vector once per field. Existing keys keep their identity.
|
||||
let mut merged = Vec::with_capacity(self.len() + incoming.len());
|
||||
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
|
||||
let mut new = incoming.entries.into_iter().peekable();
|
||||
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
|
||||
match left.0.cmp(&right.0) {
|
||||
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
|
||||
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
|
||||
std::cmp::Ordering::Equal => {
|
||||
let (key, _) = old.next().unwrap();
|
||||
merged.push((key, new.next().unwrap().1));
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.extend(old);
|
||||
merged.extend(new);
|
||||
self.entries = merged;
|
||||
}
|
||||
}
|
||||
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
|
||||
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
|
||||
entries.into_iter().collect()
|
||||
}
|
||||
}
|
||||
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
|
||||
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
|
||||
entries.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for NumericFactorMap {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(Some(self.len()))?;
|
||||
for (key, value) in self {
|
||||
map.serialize_entry(key, value)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Fields;
|
||||
impl<'de> Visitor<'de> for Fields {
|
||||
type Value = NumericFactorMap;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a numeric factor map")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
|
||||
entries.push((key, value));
|
||||
}
|
||||
Ok(entries.into_iter().collect())
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Fields)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_reservation_preserves_values_and_avoids_growth_during_known_inserts() {
|
||||
let mut map = NumericFactorMap::from([
|
||||
(Cow::Borrowed("amount"), 125.25),
|
||||
(Cow::Borrowed("nullable_value"), f64::from_bits(0x7ff8000000000042)),
|
||||
(Cow::Borrowed("signal"), -0.0),
|
||||
]);
|
||||
let original = map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>();
|
||||
map.reserve_exact(2);
|
||||
assert_eq!(map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>(), original);
|
||||
let buffer = map.entries.as_ptr();
|
||||
map.insert(Cow::Borrowed("pre_close"), 12.5);
|
||||
map.insert(Cow::Borrowed("no_limit"), 0.0);
|
||||
assert_eq!(map.entries.as_ptr(), buffer);
|
||||
assert_eq!(map.len(), 5);
|
||||
assert_eq!(map["signal"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert_eq!(map["nullable_value"].to_bits(), 0x7ff8000000000042);
|
||||
let before = map.entries.as_ptr();
|
||||
map.reserve_exact(0);
|
||||
assert_eq!(map.entries.as_ptr(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
|
||||
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
|
||||
let map = NumericFactorMap::from([
|
||||
(Cow::Owned("dynamic_factor_20".to_owned()), -0.0),
|
||||
(Cow::Borrowed(LONG), 1.0),
|
||||
]);
|
||||
let cloned = map.clone();
|
||||
let short = cloned.keys().find(|key| key.as_str() == "dynamic_factor_20").unwrap();
|
||||
assert!(!short.is_heap_allocated());
|
||||
let long = cloned.keys().find(|key| key.as_str() == LONG).unwrap();
|
||||
assert_eq!(long.as_static_str(), Some(LONG));
|
||||
assert_eq!(cloned["dynamic_factor_20"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert_eq!(std::mem::size_of::<(CompactString, f64)>(), std::mem::size_of::<(Cow<'static, str>, f64)>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_dynamic_unicode_and_short_keys_keep_the_same_json_map() {
|
||||
let entries = ["", "a", "a_field_longer_than_the_inline_string_capacity", "价格因子", "ths_up_days_stock"]
|
||||
.into_iter().enumerate().map(|(index, key)| (Cow::Owned(key.to_string()), index as f64 + 0.25))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let map = NumericFactorMap::from(entries.clone());
|
||||
assert_eq!(serde_json::to_string(&map).unwrap(), serde_json::to_string(&entries).unwrap());
|
||||
let decoded: NumericFactorMap = serde_json::from_str(&serde_json::to_string(&map).unwrap()).unwrap();
|
||||
assert_eq!(decoded, map);
|
||||
assert!(!decoded.keys().find(|key| key.as_str() == "ths_up_days_stock").unwrap().is_heap_allocated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_order_removal_and_values_match_tree_map() {
|
||||
let mut flat = NumericFactorMap::new();
|
||||
let mut tree = BTreeMap::new();
|
||||
let mut seed = 71_u64;
|
||||
for index in 0..10000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
|
||||
let value = (index as f64 - 5000.0) / 7.0;
|
||||
if index % 11 == 0 {
|
||||
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
|
||||
} else {
|
||||
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
flat.retain(|_, value| *value > 100.0);
|
||||
tree.retain(|_, value| *value > 100.0);
|
||||
assert_eq!(
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::size_of::<NumericFactorMap>(),
|
||||
std::mem::size_of_val(&tree)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
|
||||
let input = vec![
|
||||
(Cow::Borrowed("z"), 2.0),
|
||||
(Cow::Borrowed("a"), 1.0),
|
||||
(Cow::Borrowed("z"), 3.0),
|
||||
(Cow::Borrowed("z"), 4.0),
|
||||
];
|
||||
let flat: NumericFactorMap = input.clone().into_iter().collect();
|
||||
let tree: BTreeMap<_, _> = input.into_iter().collect();
|
||||
assert_eq!(
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(flat["z"], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_keeps_the_map_contract_and_precise_numbers() {
|
||||
let input = [
|
||||
(Cow::Borrowed("zero"), -0.0),
|
||||
(Cow::Borrowed("tiny"), 1.0000000000000002),
|
||||
(Cow::Borrowed("large"), 9.123456789123456e20),
|
||||
];
|
||||
let flat = NumericFactorMap::from(input.clone());
|
||||
let tree = BTreeMap::from(input);
|
||||
let json = serde_json::to_string(&flat).unwrap();
|
||||
assert_eq!(json, serde_json::to_string(&tree).unwrap());
|
||||
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
|
||||
for (key, value) in &flat {
|
||||
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
|
||||
}
|
||||
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
|
||||
assert_eq!(duplicate["x"], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_does_not_share_mutable_values() {
|
||||
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
|
||||
let mut copy = original.clone();
|
||||
*copy.get_mut("signal").unwrap() = 0.0;
|
||||
copy.insert(Cow::Borrowed("other"), 2.0);
|
||||
assert_eq!(original["signal"], 1.0);
|
||||
assert!(!original.contains_key("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
|
||||
let entries = (0..4096)
|
||||
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
|
||||
.collect::<Vec<_>>();
|
||||
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
|
||||
let mut tree = BTreeMap::from_iter(entries);
|
||||
flat.insert(Cow::Borrowed("shared"), -0.0);
|
||||
tree.insert(Cow::Borrowed("shared"), -0.0);
|
||||
let incoming = (2048..8192)
|
||||
.rev()
|
||||
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
|
||||
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
|
||||
.collect::<Vec<_>>();
|
||||
flat.extend(incoming.clone());
|
||||
tree.extend(incoming);
|
||||
assert_eq!(
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(flat.keys().last().map(CompactString::as_str), Some("shared"));
|
||||
assert!(!flat.keys().last().unwrap().is_heap_allocated());
|
||||
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
|
||||
assert!(flat["zz"].is_nan());
|
||||
flat.extend(std::iter::empty::<(CompactString, f64)>());
|
||||
assert_eq!(flat.len(), tree.len() + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
|
||||
use crate::{
|
||||
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
|
||||
factor_events::{field_dependencies, Expr},
|
||||
DataSet,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
|
||||
pub const CONTEXT_FIELDS: &[&str] = &[
|
||||
"index_open",
|
||||
"index_high",
|
||||
"index_low",
|
||||
"index_close",
|
||||
"scope_rank",
|
||||
"scope_percentile",
|
||||
"scope_size",
|
||||
];
|
||||
const STOCK_FIELDS: &[&str] = &[
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"volume",
|
||||
"raw_open",
|
||||
"raw_high",
|
||||
"raw_low",
|
||||
"raw_close",
|
||||
"prev_close",
|
||||
"amount",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionContext {
|
||||
pub contract: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub benchmark: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rank_expression: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rank_universe: Vec<String>,
|
||||
}
|
||||
|
||||
fn valid_symbol(s: &str) -> bool {
|
||||
let Some((code, market)) = s.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|c| c.is_ascii_digit())
|
||||
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
|
||||
let mut fields = field_dependencies(expression);
|
||||
if let Some(rank) = &self.rank_expression {
|
||||
fields.extend(field_dependencies(rank));
|
||||
}
|
||||
fields
|
||||
}
|
||||
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
|
||||
if self.contract != CONTRACT {
|
||||
return Err("pattern_context_contract_invalid".into());
|
||||
}
|
||||
let needed = field_dependencies(expression);
|
||||
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
|
||||
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
|
||||
return Err("pattern_rank_expression_and_universe_required".into());
|
||||
}
|
||||
if ranked
|
||||
&& (self.rank_universe.len() < 2
|
||||
|| self.rank_universe.len() > 20_000
|
||||
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|
||||
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
|
||||
!= self.rank_universe.len())
|
||||
{
|
||||
return Err("pattern_rank_universe_invalid".into());
|
||||
}
|
||||
if let Some(rank) = &self.rank_expression {
|
||||
let fields = field_dependencies(rank);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
|
||||
{
|
||||
return Err("pattern_rank_expression_invalid_or_recursive".into());
|
||||
}
|
||||
}
|
||||
let fields = self.fields(expression);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
|
||||
{
|
||||
return Err("pattern_context_unmapped_field".into());
|
||||
}
|
||||
let index = fields.iter().any(|f| f.starts_with("index_"));
|
||||
if index != self.benchmark.is_some()
|
||||
|| self
|
||||
.benchmark
|
||||
.as_ref()
|
||||
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
|
||||
{
|
||||
return Err("pattern_reference_index_required".into());
|
||||
}
|
||||
if !index && !ranked {
|
||||
return Err("pattern_unused_context".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_dataset_context(
|
||||
spec: &PatternSpec,
|
||||
data: &DataSet,
|
||||
date: NaiveDate,
|
||||
) -> Result<ResearchContext, String> {
|
||||
let Some(config) = &spec.execution_context else {
|
||||
return Ok(ResearchContext::default());
|
||||
};
|
||||
config.validate(
|
||||
spec.expression
|
||||
.as_ref()
|
||||
.ok_or("pattern_context_requires_expression")?,
|
||||
)?;
|
||||
let days = data.calendar().trailing_days(date, spec.history_len());
|
||||
if days.len() != spec.history_len() || days.last() != Some(&date) {
|
||||
return Err("pattern_context_calendar_incomplete".into());
|
||||
}
|
||||
let needed = config.fields(spec.expression.as_ref().unwrap());
|
||||
let mut context = ResearchContext::default();
|
||||
if let Some(symbol) = &config.benchmark {
|
||||
for name in needed.iter().filter(|f| f.starts_with("index_")) {
|
||||
let values = days
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let value = if let Some(b) = data.market(*d, symbol) {
|
||||
match name.as_str() {
|
||||
"index_open" => Some(b.open),
|
||||
"index_high" => Some(b.high),
|
||||
"index_low" => Some(b.low),
|
||||
"index_close" => Some(b.close),
|
||||
_ => None,
|
||||
}
|
||||
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
|
||||
match name.as_str() {
|
||||
"index_open" => Some(b.open),
|
||||
"index_close" => Some(b.close),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
value
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
context.common.insert(name.clone(), values);
|
||||
}
|
||||
}
|
||||
if let Some(expression) = &config.rank_expression {
|
||||
let mut input = spec.clone();
|
||||
input.execution_context = None;
|
||||
input.expression = Some(expression.clone());
|
||||
let mut values = BTreeMap::new();
|
||||
for symbol in &config.rank_universe {
|
||||
let row = evaluate_with_context(
|
||||
&input,
|
||||
&days,
|
||||
&dataset_series(data, &days, symbol),
|
||||
&context.common,
|
||||
true,
|
||||
)?;
|
||||
if let Some(reason) = row.exclusion {
|
||||
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
|
||||
}
|
||||
values.insert(
|
||||
symbol.clone(),
|
||||
serde_json::from_value::<Vec<Option<f64>>>(
|
||||
row.values["expression"]["values"].clone(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
let ranks =
|
||||
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
|
||||
for symbol in &config.rank_universe {
|
||||
let decode = |value: &Value| {
|
||||
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
|
||||
};
|
||||
context.by_symbol.insert(
|
||||
symbol.clone(),
|
||||
BTreeMap::from([
|
||||
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
|
||||
(
|
||||
"scope_percentile".into(),
|
||||
decode(&ranks["percentile"][symbol])?,
|
||||
),
|
||||
(
|
||||
"scope_size".into(),
|
||||
vec![Some(config.rank_universe.len() as f64); days.len()],
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
// A runner bundle also contains source/extract copies. Follow the same
|
||||
// authoritative spec selection as the execution loader, not those copies.
|
||||
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
|
||||
return specs_in_value(spec);
|
||||
}
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
Value::Array(items) => {
|
||||
for v in items {
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
Value::Object(items) => {
|
||||
let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
|
||||
.is_some_and(Value::is_object);
|
||||
for (key, v) in items {
|
||||
// The executable pool already supplies decoded expressions.
|
||||
// Its display/source serialization escapes those expressions
|
||||
// one more time and is not another executable program.
|
||||
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
|
||||
continue;
|
||||
}
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
|
||||
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
|
||||
for spec in specs_in_value(value)? {
|
||||
if let Some(context) = spec.execution_context {
|
||||
if let Some(index) = context.benchmark {
|
||||
indices.insert(index);
|
||||
}
|
||||
stocks.extend(context.rank_universe);
|
||||
}
|
||||
}
|
||||
Ok((indices, stocks))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
|
||||
let pattern = json!({"template":"expression","parameters":{"history_window":20},
|
||||
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
|
||||
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
|
||||
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
|
||||
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
|
||||
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
|
||||
let source = format!("stock_pool.config({pool})");
|
||||
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
|
||||
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
|
||||
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
|
||||
for wrapper in ["strategySpec", "strategy_spec"] {
|
||||
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
|
||||
"strategy_extract":{"parameters":{"source_code":source}}});
|
||||
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
|
||||
}
|
||||
let mut invalid = value.clone();
|
||||
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
|
||||
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
|
||||
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
|
||||
}
|
||||
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
|
||||
}
|
||||
fn data(future: bool, reference: bool) -> DataSet {
|
||||
let mut days = vec![
|
||||
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
|
||||
];
|
||||
if future {
|
||||
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
|
||||
}
|
||||
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let mut instruments = symbols
|
||||
.iter()
|
||||
.map(|s| Instrument {
|
||||
symbol: s.to_string(),
|
||||
name: s.to_string(),
|
||||
board: "SZ_MAIN".into(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if reference {
|
||||
instruments.push(Instrument {
|
||||
symbol: "399006.SZ".into(),
|
||||
name: "reference".into(),
|
||||
board: "INDEX".into(),
|
||||
round_lot: 1,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
});
|
||||
}
|
||||
let mut market = vec![];
|
||||
let mut factors = vec![];
|
||||
let mut benchmark = vec![];
|
||||
for (i, d) in days.iter().enumerate() {
|
||||
for (n, s) in symbols.iter().enumerate() {
|
||||
let c = [
|
||||
[10., 12., 11., 1000.],
|
||||
[10., 11., 12., 1.],
|
||||
[10., 10., 13., 1.],
|
||||
][n][i];
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: *d,
|
||||
symbol: (*s).into(),
|
||||
timestamp: None,
|
||||
day_open: c,
|
||||
open: c,
|
||||
high: c,
|
||||
low: c,
|
||||
close: c,
|
||||
last_price: c,
|
||||
bid1: c,
|
||||
ask1: c,
|
||||
prev_close: 10.,
|
||||
volume: 100000,
|
||||
minute_volume: 0,
|
||||
bid1_volume: 10000,
|
||||
ask1_volume: 10000,
|
||||
trading_phase: None,
|
||||
paused: false,
|
||||
upper_limit: c * 2.,
|
||||
lower_limit: c / 2.,
|
||||
price_tick: 0.01,
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: *d,
|
||||
symbol: (*s).into(),
|
||||
market_cap_bn: 1.,
|
||||
free_float_cap_bn: 1.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
}
|
||||
if reference {
|
||||
let mut row = market.last().unwrap().clone();
|
||||
row.symbol = "399006.SZ".into();
|
||||
row.open = 30.;
|
||||
row.high = 30.;
|
||||
row.low = 30.;
|
||||
row.close = 30.;
|
||||
market.push(row);
|
||||
}
|
||||
benchmark.push(BenchmarkSnapshot {
|
||||
date: *d,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 4000.,
|
||||
close: 4000.,
|
||||
prev_close: 4000.,
|
||||
volume: 1000,
|
||||
});
|
||||
}
|
||||
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
|
||||
}
|
||||
fn spec(rank: bool) -> PatternSpec {
|
||||
let expression = if rank {
|
||||
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
|
||||
} else {
|
||||
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
|
||||
};
|
||||
let context = if rank {
|
||||
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
|
||||
} else {
|
||||
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
|
||||
};
|
||||
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
|
||||
let spec = spec(true);
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
|
||||
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
|
||||
assert_eq!(original.by_symbol, future.by_symbol);
|
||||
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
|
||||
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
|
||||
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
|
||||
assert!(
|
||||
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
let mut incomplete = data(false, true).snapshot_components();
|
||||
incomplete.market.retain(|r| r.symbol != "000003.SZ");
|
||||
let broken = DataSet::from_components(
|
||||
incomplete.instruments,
|
||||
incomplete.market,
|
||||
incomplete.factors,
|
||||
incomplete.candidates,
|
||||
incomplete.benchmarks,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(build_dataset_context(&spec, &broken, date).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reference_index_never_defaults_to_performance_benchmark() {
|
||||
let spec = spec(false);
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
assert!(
|
||||
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
assert!(build_dataset_context(&spec, &data(false, false), date)
|
||||
.unwrap_err()
|
||||
.contains("399006.SZ"));
|
||||
}
|
||||
#[test]
|
||||
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
|
||||
let mut missing = spec(true);
|
||||
missing
|
||||
.execution_context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.rank_universe
|
||||
.clear();
|
||||
assert!(missing.validate().is_err());
|
||||
let mut recursive = spec(true);
|
||||
recursive
|
||||
.execution_context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.rank_expression = Some(Expr::Field {
|
||||
name: "scope_rank".into(),
|
||||
});
|
||||
assert!(recursive.validate().is_err());
|
||||
}
|
||||
}
|
||||
+19606
-2789
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
// day-level
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"benchmark_signal_close",
|
||||
"signal_ma5",
|
||||
"signal_ma10",
|
||||
"signal_ma20",
|
||||
@@ -135,11 +136,12 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
"free_float_cap",
|
||||
"pe_ttm",
|
||||
"volume",
|
||||
"tick_volume",
|
||||
"minute_volume",
|
||||
"bid1_volume",
|
||||
"ask1_volume",
|
||||
"turnover_ratio",
|
||||
"effective_turnover_ratio",
|
||||
"up_days_stock",
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
@@ -154,7 +156,9 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
"round_lot",
|
||||
"paused",
|
||||
"is_st",
|
||||
"is_star_st",
|
||||
"is_kcb",
|
||||
"is_bjse",
|
||||
"is_one_yuan",
|
||||
"is_new_listing",
|
||||
"allow_buy",
|
||||
@@ -223,7 +227,11 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"factor",
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"pattern_signal",
|
||||
"pattern_score",
|
||||
"rolling_mean_current",
|
||||
"rolling_max_current",
|
||||
"rolling_return_stddev_current",
|
||||
"ma",
|
||||
"sma",
|
||||
"vma",
|
||||
@@ -323,10 +331,12 @@ mod tests {
|
||||
for required in [
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"benchmark_signal_close",
|
||||
"close",
|
||||
"avg_cost",
|
||||
"current_price",
|
||||
"stock_ma_short",
|
||||
"up_days_stock",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(required),
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Stock pools emit one frozen framework intent, not a lossy code-strategy translation.
|
||||
use super::*;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
impl PlatformExprStrategy {
|
||||
pub(super) fn stock_pool_decision(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
let program = self
|
||||
.config
|
||||
.stock_pool
|
||||
.as_ref()
|
||||
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
|
||||
.clone();
|
||||
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|
||||
|| self.config.position_target_rules.len() != program.exit_signals.len()
|
||||
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
|
||||
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|
||||
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
|
||||
{
|
||||
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
|
||||
}
|
||||
let mut constraints = pool::stock_pool_constraints_from_configuration(
|
||||
&program.allocation_policy,
|
||||
&program.stop_take_policy,
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if let Some(policy) = constraints
|
||||
.market_timing_policy
|
||||
.as_ref()
|
||||
.filter(|policy| policy.enabled)
|
||||
{
|
||||
let before_close = !ctx.is_lagged_execution()
|
||||
&& ctx
|
||||
.active_datetime
|
||||
.is_some_and(|at| at.time() < NaiveTime::from_hms_opt(15, 0, 0).unwrap());
|
||||
let as_of = if before_close {
|
||||
ctx.data
|
||||
.previous_trading_date(ctx.decision_date, 1)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"market_timing_previous_completed_session_missing".into(),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
ctx.decision_date
|
||||
};
|
||||
let required = policy
|
||||
.required_history()
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut dates = ctx
|
||||
.data
|
||||
.calendar()
|
||||
.iter()
|
||||
.filter(|date| *date <= as_of)
|
||||
.collect::<Vec<_>>();
|
||||
if dates.len() < required {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"market_timing_official_calendar_incomplete:required={required}:available={}",
|
||||
dates.len()
|
||||
)));
|
||||
}
|
||||
dates = dates.split_off(dates.len() - required);
|
||||
let index = policy.index_code.as_ref().expect("validated index policy");
|
||||
let closes = dates
|
||||
.iter()
|
||||
.map(|date| {
|
||||
let row = ctx.data.market(*date, index).ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"market_timing_completed_index_row_missing:{index}:{date}"
|
||||
))
|
||||
})?;
|
||||
Ok(crate::stock_pool_index_policy::IndexClose {
|
||||
date: *date,
|
||||
close: row.close,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, BacktestError>>()?;
|
||||
constraints.market_timing_input =
|
||||
Some(crate::stock_pool_index_policy::MarketTimingInput {
|
||||
index_code: index.clone(),
|
||||
as_of_date: as_of,
|
||||
official_dates: dates,
|
||||
closes,
|
||||
});
|
||||
}
|
||||
let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
|
||||
Some(&program.timing_policy),
|
||||
!self.config.buy_filter_expr.trim().is_empty(),
|
||||
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
|
||||
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if self.config.in_skip_window(ctx.decision_date) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|
||||
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
|
||||
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
|
||||
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
|
||||
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
|
||||
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
|
||||
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
|
||||
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
|
||||
{
|
||||
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let day = self.day_state(ctx, ctx.decision_date)?;
|
||||
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
|
||||
let (low, high) = self.market_cap_band(ctx, &day)?;
|
||||
let (ranked, mut diagnostics, risk_decisions) = self.select_symbols(
|
||||
ctx,
|
||||
market_date,
|
||||
universe_date,
|
||||
factor_date,
|
||||
&day,
|
||||
low,
|
||||
high,
|
||||
usize::MAX,
|
||||
)?;
|
||||
let held = ctx
|
||||
.portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.filter(|p| p.quantity > 0)
|
||||
.map(|p| p.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !self.config.buy_filter_expr.trim().is_empty() {
|
||||
for symbol in &ranked {
|
||||
let stock =
|
||||
self.stock_state_with_factor_date(ctx, market_date, factor_date, symbol)?;
|
||||
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
|
||||
constraints
|
||||
.buy_denials
|
||||
.insert(symbol.clone(), vec!["frozen_buy_condition_not_met".into()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
|
||||
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
|
||||
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
|
||||
}
|
||||
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
|
||||
let final_symbols = ranked
|
||||
.iter()
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
let generation = format!(
|
||||
"stock-pool:{}:{}:{}",
|
||||
program.pool_id,
|
||||
program.version_id,
|
||||
ctx.active_datetime
|
||||
.map(|date| date.to_string())
|
||||
.unwrap_or_else(|| ctx.decision_date.to_string())
|
||||
);
|
||||
let selection = pool::StockPoolSelection {
|
||||
trade_date: ctx.decision_date,
|
||||
requested_symbols: ranked.clone(),
|
||||
normal_trading_symbols: ranked.clone(),
|
||||
risk_eligible_symbols: ranked.clone(),
|
||||
final_symbols,
|
||||
exclusion_reasons: BTreeMap::new(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: program.members.is_empty()
|
||||
&& self.config.candidate_symbols_by_date.is_empty(),
|
||||
generation: Some(generation.clone()),
|
||||
};
|
||||
let by_symbol = program
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| (member.symbol.as_str(), member))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut scope = ranked.clone();
|
||||
// Kept rules are execution metadata, not additional selection candidates.
|
||||
for symbol in held {
|
||||
if by_symbol.contains_key(symbol.as_str()) && !scope.contains(&symbol) {
|
||||
scope.push(symbol)
|
||||
}
|
||||
}
|
||||
let allocation_weights = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.allocation_weights_at(portfolio_loss_decision_at(ctx))
|
||||
.or_else(|| {
|
||||
self.config
|
||||
.position_exposure_timeline
|
||||
.allocation_weights_at(portfolio_loss_decision_at(ctx))
|
||||
});
|
||||
let members = scope
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| {
|
||||
let mut member = by_symbol
|
||||
.get(symbol.as_str())
|
||||
.map(|member| (*member).clone())
|
||||
.unwrap_or_else(|| pool::StockPoolMemberSpec {
|
||||
symbol: symbol.clone(),
|
||||
requested_order: index as i32,
|
||||
recommendation_reason: String::new(),
|
||||
target_weight_bps: None,
|
||||
stop_loss: constraints.default_stop_loss,
|
||||
take_profit: constraints.default_take_profit,
|
||||
});
|
||||
member.requested_order = index as i32;
|
||||
if let Some(weights) = allocation_weights {
|
||||
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
|
||||
}
|
||||
member
|
||||
})
|
||||
.collect();
|
||||
let (base_ratio, reserve_cash) =
|
||||
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let base_exposure = self
|
||||
.config
|
||||
.position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.position_exposure_schedule,
|
||||
f64::from(base_ratio) / 10000.,
|
||||
)
|
||||
.unwrap_or(f64::from(base_ratio) / 10000.);
|
||||
let ratio = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.runtime_position_exposure_schedule,
|
||||
base_exposure,
|
||||
)
|
||||
.or(Some(base_exposure))
|
||||
.map(|value| (value * 10000.).round() as i64)
|
||||
.unwrap_or(i64::from(base_ratio));
|
||||
let invest_ratio_bps = i32::try_from(ratio)
|
||||
.ok()
|
||||
.filter(|ratio| (0..=10000).contains(ratio))
|
||||
.ok_or_else(|| BacktestError::Execution("stock_pool_invest_ratio_invalid".into()))?;
|
||||
let signal_equity =
|
||||
self.signal_visible_total_value(ctx, ctx.decision_date, ctx.is_lagged_execution());
|
||||
let frozen_equity = signal_equity
|
||||
.to_string()
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_signal_equity_invalid".into()))?;
|
||||
diagnostics.push(format!("stock_pool_signal_frozen generation={generation} candidate_count={} frozen_equity={frozen_equity}",ranked.len()));
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::StockPool {
|
||||
contract: Box::new(pool::FrozenStockPoolIntent {
|
||||
pool_id:program.pool_id.clone(),
|
||||
signal_date: ctx.decision_date,
|
||||
frozen_equity,
|
||||
selection,
|
||||
members,
|
||||
rule,
|
||||
constraints,
|
||||
invest_ratio_bps,
|
||||
reserve_cash,
|
||||
out_of_pool_policy: program.out_of_pool_policy,
|
||||
generation,
|
||||
}),
|
||||
}],
|
||||
diagnostics,
|
||||
risk_decisions,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1157
-186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
//! Causal portfolio-loss state, independent of market-data and order adapters.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
const STATE_SCHEMA: &str = "fidc.portfolio-loss-state/v1";
|
||||
const MAX_OBSERVATIONS: usize = 120;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PortfolioLossConfig {
|
||||
pub lookback: usize,
|
||||
pub loss_trigger: f64,
|
||||
pub floor_exposure: f64,
|
||||
pub cooldown_trading_days: usize,
|
||||
}
|
||||
|
||||
impl PortfolioLossConfig {
|
||||
pub fn validate(&self) -> Result<(), PortfolioLossError> {
|
||||
if !matches!(self.lookback, 10 | 20 | 40 | 60)
|
||||
|| !self.loss_trigger.is_finite()
|
||||
|| !(0.02..=0.30).contains(&self.loss_trigger)
|
||||
|| !self.floor_exposure.is_finite()
|
||||
|| !(0.0..=1.0).contains(&self.floor_exposure)
|
||||
|| !(1..=120).contains(&self.cooldown_trading_days)
|
||||
{
|
||||
return Err(PortfolioLossError::InvalidConfig);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalized portfolio accounting, not a market close used as a proxy for NAV.
|
||||
/// Unit NAV must already exclude external deposits and withdrawals.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClosedPortfolioSession {
|
||||
pub date: NaiveDate,
|
||||
pub previous_session_date: Option<NaiveDate>,
|
||||
pub available_at: DateTime<Utc>,
|
||||
pub start_unit_nav: f64,
|
||||
pub end_unit_nav: f64,
|
||||
pub start_gross_exposure: f64,
|
||||
pub end_gross_exposure: f64,
|
||||
pub source_sha256: String,
|
||||
}
|
||||
|
||||
impl ClosedPortfolioSession {
|
||||
fn validate(&self) -> Result<(), PortfolioLossError> {
|
||||
let earliest = self.date.and_hms_opt(7, 30, 0).unwrap().and_utc();
|
||||
if [self.start_unit_nav, self.end_unit_nav]
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || *value <= 0.0)
|
||||
|| [self.start_gross_exposure, self.end_gross_exposure]
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || *value < 0.0)
|
||||
|| self
|
||||
.previous_session_date
|
||||
.is_some_and(|date| date >= self.date)
|
||||
|| self.available_at < earliest
|
||||
|| self.source_sha256.len() != 64
|
||||
|| !self
|
||||
.source_sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(PortfolioLossError::InvalidObservation);
|
||||
}
|
||||
self.unit_return()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unit_return(&self) -> Result<Option<f64>, PortfolioLossError> {
|
||||
let gross = self.start_gross_exposure.max(self.end_gross_exposure);
|
||||
if gross <= 1e-12 {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = (self.end_unit_nav / self.start_unit_nav - 1.0) / gross;
|
||||
if !value.is_finite() {
|
||||
return Err(PortfolioLossError::InvalidObservation);
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PortfolioLossDecision {
|
||||
pub execution_date: NaiveDate,
|
||||
pub observed_through: Option<NaiveDate>,
|
||||
pub observation_count: usize,
|
||||
pub trailing_unit_return: Option<f64>,
|
||||
pub threshold_breached: bool,
|
||||
pub newly_triggered: bool,
|
||||
pub risk_off: bool,
|
||||
pub cooldown_before: usize,
|
||||
pub cooldown_after: usize,
|
||||
pub target_exposure: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PortfolioLossState {
|
||||
schema_version: String,
|
||||
config: PortfolioLossConfig,
|
||||
started_on: NaiveDate,
|
||||
observations: VecDeque<ClosedPortfolioSession>,
|
||||
last_session: Option<ClosedPortfolioSession>,
|
||||
cooldown_remaining: usize,
|
||||
trigger_count: usize,
|
||||
last_decision: Option<PortfolioLossDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum PortfolioLossError {
|
||||
#[error("invalid portfolio loss configuration")]
|
||||
InvalidConfig,
|
||||
#[error("invalid finalized portfolio session observation")]
|
||||
InvalidObservation,
|
||||
#[error("portfolio loss state does not match its frozen configuration")]
|
||||
StateMismatch,
|
||||
#[error("portfolio session history is missing, reordered or corrected")]
|
||||
SessionDiscontinuity,
|
||||
#[error("portfolio loss observation is not visible at the decision")]
|
||||
ObservationNotVisible,
|
||||
#[error("portfolio loss decisions must follow trading-session order")]
|
||||
DecisionOrder,
|
||||
}
|
||||
|
||||
impl PortfolioLossState {
|
||||
pub fn new(
|
||||
config: PortfolioLossConfig,
|
||||
started_on: NaiveDate,
|
||||
) -> Result<Self, PortfolioLossError> {
|
||||
config.validate()?;
|
||||
Ok(Self {
|
||||
schema_version: STATE_SCHEMA.to_owned(),
|
||||
config,
|
||||
started_on,
|
||||
observations: VecDeque::new(),
|
||||
last_session: None,
|
||||
cooldown_remaining: 0,
|
||||
trigger_count: 0,
|
||||
last_decision: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validation is required after deserialization; a JSON hash alone is not
|
||||
/// account/generation authorization, which belongs to the state owner.
|
||||
pub fn validate(&self, expected: &PortfolioLossConfig) -> Result<(), PortfolioLossError> {
|
||||
expected.validate()?;
|
||||
if self.schema_version != STATE_SCHEMA
|
||||
|| &self.config != expected
|
||||
|| self.observations.len() > MAX_OBSERVATIONS
|
||||
|| self.cooldown_remaining >= expected.cooldown_trading_days
|
||||
{
|
||||
return Err(PortfolioLossError::StateMismatch);
|
||||
}
|
||||
let mut previous = None;
|
||||
for item in &self.observations {
|
||||
item.validate()?;
|
||||
if item.date < self.started_on
|
||||
|| previous.is_some_and(|date| item.date <= date)
|
||||
|| item.unit_return()?.is_none()
|
||||
{
|
||||
return Err(PortfolioLossError::StateMismatch);
|
||||
}
|
||||
previous = Some(item.date);
|
||||
}
|
||||
if let Some(last) = &self.last_session {
|
||||
last.validate()?;
|
||||
if last.date < self.started_on
|
||||
|| previous.is_some_and(|date| date > last.date)
|
||||
|| (last.unit_return()?.is_some() && self.observations.back() != Some(last))
|
||||
{
|
||||
return Err(PortfolioLossError::StateMismatch);
|
||||
}
|
||||
} else if !self.observations.is_empty() {
|
||||
return Err(PortfolioLossError::StateMismatch);
|
||||
}
|
||||
if let Some(decision) = &self.last_decision {
|
||||
let breached = decision
|
||||
.trailing_unit_return
|
||||
.is_some_and(|value| value <= -expected.loss_trigger);
|
||||
let triggered = decision.cooldown_before == 0 && breached;
|
||||
let after = if decision.cooldown_before > 0 {
|
||||
decision.cooldown_before - 1
|
||||
} else if triggered {
|
||||
expected.cooldown_trading_days - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if decision.execution_date < self.started_on
|
||||
|| decision
|
||||
.observed_through
|
||||
.is_some_and(|date| date >= decision.execution_date)
|
||||
|| !decision.target_exposure.is_finite()
|
||||
|| !(0.0..=1.0).contains(&decision.target_exposure)
|
||||
|| decision
|
||||
.trailing_unit_return
|
||||
.is_some_and(|value| !value.is_finite())
|
||||
|| decision.cooldown_after != self.cooldown_remaining
|
||||
|| decision.observation_count > MAX_OBSERVATIONS
|
||||
|| decision.cooldown_before >= expected.cooldown_trading_days
|
||||
|| decision.threshold_breached != breached
|
||||
|| decision.newly_triggered != triggered
|
||||
|| decision.risk_off != (decision.cooldown_before > 0 || triggered)
|
||||
|| decision.cooldown_after != after
|
||||
|| decision.trailing_unit_return.is_some()
|
||||
!= (decision.observation_count >= expected.lookback)
|
||||
|| self.trigger_count
|
||||
> (decision.execution_date - self.started_on).num_days() as usize + 1
|
||||
{
|
||||
return Err(PortfolioLossError::StateMismatch);
|
||||
}
|
||||
} else if self.cooldown_remaining != 0 || self.trigger_count != 0 {
|
||||
return Err(PortfolioLossError::StateMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exact duplicate delivery is idempotent. Historical corrections require
|
||||
/// explicit reconciliation instead of changing an already-used window.
|
||||
pub fn observe(&mut self, session: ClosedPortfolioSession) -> Result<bool, PortfolioLossError> {
|
||||
self.validate(&self.config)?;
|
||||
session.validate()?;
|
||||
if self.last_session.as_ref() == Some(&session) {
|
||||
return Ok(false);
|
||||
}
|
||||
let previous_date = self.last_session.as_ref().map(|value| value.date);
|
||||
if session.date < self.started_on
|
||||
|| session.previous_session_date != previous_date
|
||||
|| previous_date.is_some_and(|date| session.date <= date)
|
||||
|| (previous_date.is_none() && session.date != self.started_on)
|
||||
|| self
|
||||
.last_session
|
||||
.as_ref()
|
||||
.is_some_and(|last| session.start_unit_nav != last.end_unit_nav)
|
||||
{
|
||||
return Err(PortfolioLossError::SessionDiscontinuity);
|
||||
}
|
||||
if session.unit_return()?.is_some() {
|
||||
self.observations.push_back(session.clone());
|
||||
if self.observations.len() > MAX_OBSERVATIONS {
|
||||
self.observations.pop_front();
|
||||
}
|
||||
}
|
||||
self.last_session = Some(session);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn decide(
|
||||
&mut self,
|
||||
execution_date: NaiveDate,
|
||||
previous_completed_session: Option<NaiveDate>,
|
||||
decision_at: DateTime<Utc>,
|
||||
risk_on_exposure: f64,
|
||||
) -> Result<PortfolioLossDecision, PortfolioLossError> {
|
||||
self.validate(&self.config)?;
|
||||
if !risk_on_exposure.is_finite() || !(0.0..=1.0).contains(&risk_on_exposure) {
|
||||
return Err(PortfolioLossError::InvalidConfig);
|
||||
}
|
||||
if execution_date < self.started_on
|
||||
|| previous_completed_session.is_some_and(|date| date >= execution_date)
|
||||
|| decision_at
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.date_naive()
|
||||
!= execution_date
|
||||
|| self
|
||||
.last_decision
|
||||
.as_ref()
|
||||
.is_some_and(|last| execution_date < last.execution_date)
|
||||
{
|
||||
return Err(PortfolioLossError::DecisionOrder);
|
||||
}
|
||||
if let Some(last) = &self.last_session {
|
||||
if last.date >= execution_date || last.available_at > decision_at {
|
||||
return Err(PortfolioLossError::ObservationNotVisible);
|
||||
}
|
||||
if Some(last.date) != previous_completed_session {
|
||||
return Err(PortfolioLossError::SessionDiscontinuity);
|
||||
}
|
||||
} else if execution_date != self.started_on {
|
||||
return Err(PortfolioLossError::SessionDiscontinuity);
|
||||
}
|
||||
if let Some(cached) = self
|
||||
.last_decision
|
||||
.as_mut()
|
||||
.filter(|last| last.execution_date == execution_date)
|
||||
{
|
||||
cached.target_exposure = if cached.risk_off {
|
||||
self.config.floor_exposure.min(risk_on_exposure)
|
||||
} else {
|
||||
risk_on_exposure
|
||||
};
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let trailing = if self.observations.len() >= self.config.lookback {
|
||||
let start = self.observations.len() - self.config.lookback;
|
||||
let mut growth = 1.0;
|
||||
for item in self.observations.iter().skip(start) {
|
||||
growth *=
|
||||
(1.0 + item.unit_return()?.expect("nonzero exposure observation")).max(0.0);
|
||||
}
|
||||
let result = growth - 1.0;
|
||||
if !result.is_finite() {
|
||||
return Err(PortfolioLossError::InvalidObservation);
|
||||
}
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let breached = trailing.is_some_and(|value| value <= -self.config.loss_trigger);
|
||||
let before = self.cooldown_remaining;
|
||||
let triggered = before == 0 && breached;
|
||||
let risk_off = before > 0 || triggered;
|
||||
let after = if before > 0 {
|
||||
before - 1
|
||||
} else if triggered {
|
||||
self.config.cooldown_trading_days - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let decision = PortfolioLossDecision {
|
||||
execution_date,
|
||||
observed_through: self.last_session.as_ref().map(|value| value.date),
|
||||
observation_count: self.observations.len(),
|
||||
trailing_unit_return: trailing,
|
||||
threshold_breached: breached,
|
||||
newly_triggered: triggered,
|
||||
risk_off,
|
||||
cooldown_before: before,
|
||||
cooldown_after: after,
|
||||
target_exposure: if risk_off {
|
||||
self.config.floor_exposure.min(risk_on_exposure)
|
||||
} else {
|
||||
risk_on_exposure
|
||||
},
|
||||
};
|
||||
self.cooldown_remaining = after;
|
||||
self.trigger_count += usize::from(triggered);
|
||||
self.last_decision = Some(decision.clone());
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
pub fn last_session(&self) -> Option<&ClosedPortfolioSession> {
|
||||
self.last_session.as_ref()
|
||||
}
|
||||
pub fn last_decision(&self) -> Option<&PortfolioLossDecision> {
|
||||
self.last_decision.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, TimeZone};
|
||||
|
||||
fn date(day: i64) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2023, 1, 3).unwrap() + Duration::days(day)
|
||||
}
|
||||
fn time(day: i64, hour: u32) -> DateTime<Utc> {
|
||||
Utc.from_utc_datetime(&date(day).and_hms_opt(hour, 0, 0).unwrap())
|
||||
}
|
||||
fn config() -> PortfolioLossConfig {
|
||||
PortfolioLossConfig {
|
||||
lookback: 10,
|
||||
loss_trigger: 0.05,
|
||||
floor_exposure: 0.2,
|
||||
cooldown_trading_days: 3,
|
||||
}
|
||||
}
|
||||
fn session(day: i64, start: f64, end: f64, gross: f64) -> ClosedPortfolioSession {
|
||||
ClosedPortfolioSession {
|
||||
date: date(day),
|
||||
previous_session_date: (day > 0).then(|| date(day - 1)),
|
||||
available_at: time(day, 8),
|
||||
start_unit_nav: start,
|
||||
end_unit_nav: end,
|
||||
start_gross_exposure: gross,
|
||||
end_gross_exposure: gross,
|
||||
source_sha256: "a".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_is_exact_and_duplicate_decisions_do_not_consume_cooldown() {
|
||||
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||
let mut nav = 1.0;
|
||||
for day in 0..10 {
|
||||
let end = nav * 0.994;
|
||||
state.observe(session(day, nav, end, 1.0)).unwrap();
|
||||
nav = end;
|
||||
}
|
||||
let first = state
|
||||
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
|
||||
.unwrap();
|
||||
assert!(first.newly_triggered);
|
||||
assert_eq!(first.cooldown_after, 2);
|
||||
let serialized = serde_json::to_string(&state).unwrap();
|
||||
let mut restored: PortfolioLossState = serde_json::from_str(&serialized).unwrap();
|
||||
restored.validate(&config()).unwrap();
|
||||
assert_eq!(
|
||||
first,
|
||||
restored
|
||||
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
|
||||
.unwrap()
|
||||
);
|
||||
let lowered = restored
|
||||
.decide(date(10), Some(date(9)), time(10, 2), 0.1)
|
||||
.unwrap();
|
||||
assert_eq!(lowered.target_exposure, 0.1);
|
||||
assert_eq!(lowered.cooldown_after, 2);
|
||||
for day in 10..15 {
|
||||
let end = nav * 1.01;
|
||||
let row = session(day, nav, end, 0.2);
|
||||
state.observe(row.clone()).unwrap();
|
||||
restored.observe(row).unwrap();
|
||||
nav = end;
|
||||
assert_eq!(
|
||||
state
|
||||
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
|
||||
.unwrap(),
|
||||
restored
|
||||
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_future_missing_corrected_and_incomplete_accounting() {
|
||||
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||
let first = session(0, 1.0, 0.99, 1.0);
|
||||
assert!(state.observe(first.clone()).unwrap());
|
||||
assert!(!state.observe(first.clone()).unwrap());
|
||||
let original = state.clone();
|
||||
let mut changed = first;
|
||||
changed.end_unit_nav = 0.98;
|
||||
assert_eq!(
|
||||
state.observe(changed),
|
||||
Err(PortfolioLossError::SessionDiscontinuity)
|
||||
);
|
||||
assert_eq!(state, original);
|
||||
assert_eq!(
|
||||
state.decide(date(0), None, time(0, 1), 0.9),
|
||||
Err(PortfolioLossError::ObservationNotVisible)
|
||||
);
|
||||
assert_eq!(
|
||||
state.decide(date(2), Some(date(1)), time(2, 1), 0.9),
|
||||
Err(PortfolioLossError::SessionDiscontinuity)
|
||||
);
|
||||
let mut late = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||
let mut delayed = session(0, 1.0, 0.99, 1.0);
|
||||
delayed.available_at = time(2, 1);
|
||||
late.observe(delayed).unwrap();
|
||||
assert_eq!(
|
||||
late.decide(date(1), Some(date(0)), time(1, 1), 0.9),
|
||||
Err(PortfolioLossError::ObservationNotVisible)
|
||||
);
|
||||
let mut invalid = session(1, 0.99, 1.0, 1.0);
|
||||
invalid.end_unit_nav = f64::NAN;
|
||||
assert_eq!(
|
||||
state.observe(invalid),
|
||||
Err(PortfolioLossError::InvalidObservation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_sessions_preserve_continuity_without_inventing_returns() {
|
||||
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||
for day in 0..20 {
|
||||
state.observe(session(day, 1.0, 1.0, 0.0)).unwrap();
|
||||
}
|
||||
let decision = state
|
||||
.decide(date(20), Some(date(19)), time(20, 1), 0.9)
|
||||
.unwrap();
|
||||
assert_eq!(decision.observation_count, 0);
|
||||
assert_eq!(decision.trailing_unit_return, None);
|
||||
assert_eq!(decision.target_exposure, 0.9);
|
||||
assert_eq!(state.last_session().unwrap().date, date(19));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_state_rejects_changed_policy_and_forged_cooldown() {
|
||||
let state = PortfolioLossState::new(config(), date(0)).unwrap();
|
||||
let mut changed = config();
|
||||
changed.floor_exposure = 0.5;
|
||||
assert_eq!(
|
||||
state.validate(&changed),
|
||||
Err(PortfolioLossError::StateMismatch)
|
||||
);
|
||||
let mut forged = state.clone();
|
||||
forged.cooldown_remaining = 1;
|
||||
assert_eq!(
|
||||
forged.validate(&config()),
|
||||
Err(PortfolioLossError::StateMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_serialization_preserves_float_bits() {
|
||||
let mut seed = 0xabcddcba12345678_u64;
|
||||
for _ in 0..2000 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99;
|
||||
let serialized = serde_json::to_string(&value).unwrap();
|
||||
let restored: f64 = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(value.to_bits(), restored.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Dated manual adjustments are ordered facts; restoring is not a 100% target.
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PositionExposureAction {
|
||||
Scale {
|
||||
#[serde(rename = "requestedBps", alias = "requested_bps")]
|
||||
requested_bps: i32,
|
||||
},
|
||||
Set {
|
||||
#[serde(rename = "targetExposureBps", alias = "target_exposure_bps")]
|
||||
target_exposure_bps: i32,
|
||||
},
|
||||
Restore,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PositionExposureEvent {
|
||||
#[serde(alias = "event_id")]
|
||||
pub event_id: String,
|
||||
pub sequence: u64,
|
||||
#[serde(alias = "effective_at")]
|
||||
pub effective_at: DateTime<Utc>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "allocation_weights_bps"
|
||||
)]
|
||||
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
|
||||
#[serde(flatten)]
|
||||
pub action: PositionExposureAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PositionExposureTimeline {
|
||||
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
|
||||
}
|
||||
|
||||
impl PositionExposureTimeline {
|
||||
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
|
||||
let mut result = Self::default();
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut sequences = BTreeSet::new();
|
||||
for event in events {
|
||||
if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) {
|
||||
return Err("position exposure event id is missing or duplicated".into());
|
||||
}
|
||||
if event.sequence == 0 || !sequences.insert(event.sequence) {
|
||||
return Err("position exposure event sequence must be positive and unique".into());
|
||||
}
|
||||
if let PositionExposureAction::Scale { requested_bps } = event.action
|
||||
&& !(0..=10000).contains(&requested_bps)
|
||||
{
|
||||
return Err("position exposure scale must be between 0 and 10000 bps".into());
|
||||
}
|
||||
if let PositionExposureAction::Set {
|
||||
target_exposure_bps,
|
||||
} = event.action
|
||||
&& !(0..=10_000).contains(&target_exposure_bps)
|
||||
{
|
||||
return Err("position exposure target must be between 0 and 10000 bps".into());
|
||||
}
|
||||
if let Some(weights) = &event.allocation_weights_bps {
|
||||
let target = match event.action {
|
||||
PositionExposureAction::Set {
|
||||
target_exposure_bps,
|
||||
} => target_exposure_bps,
|
||||
PositionExposureAction::Scale { requested_bps } => requested_bps,
|
||||
PositionExposureAction::Restore => {
|
||||
return Err(
|
||||
"restoring strategy allocation cannot carry manual weights".into()
|
||||
);
|
||||
}
|
||||
};
|
||||
validate_allocation_weights(weights, target)?;
|
||||
}
|
||||
result.events.insert(
|
||||
(event.effective_at, event.sequence),
|
||||
(event.action.clone(), event.allocation_weights_bps.clone()),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Legacy day-level contracts remain day-level; never invent intraday times.
|
||||
pub fn exposure_at(
|
||||
&self,
|
||||
at: DateTime<Utc>,
|
||||
execution_date: NaiveDate,
|
||||
legacy: &BTreeMap<NaiveDate, f64>,
|
||||
strategy_exposure: f64,
|
||||
) -> Option<f64> {
|
||||
match self
|
||||
.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.map(|(_, (action, _))| action)
|
||||
{
|
||||
Some(PositionExposureAction::Scale { requested_bps }) => {
|
||||
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
|
||||
}
|
||||
Some(PositionExposureAction::Set {
|
||||
target_exposure_bps,
|
||||
}) => Some(f64::from(*target_exposure_bps) / 10_000.),
|
||||
Some(PositionExposureAction::Restore) => None,
|
||||
None => legacy
|
||||
.range(..=execution_date)
|
||||
.next_back()
|
||||
.map(|(_, value)| *value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scale_at(&self, at: DateTime<Utc>) -> Option<i32> {
|
||||
match self
|
||||
.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.map(|(_, (action, _))| action)
|
||||
{
|
||||
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocation_weights_at(&self, at: DateTime<Utc>) -> Option<&BTreeMap<String, i32>> {
|
||||
self.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.and_then(|(_, (_, weights))| weights.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_allocation_weights(
|
||||
weights: &BTreeMap<String, i32>,
|
||||
exposure_bps: i32,
|
||||
) -> Result<(), String> {
|
||||
if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 {
|
||||
return Err("invalid allocation exposure or weight count".into());
|
||||
}
|
||||
for (symbol, weight) in weights {
|
||||
if !(0..=10000).contains(weight)
|
||||
|| !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (weights.is_empty() && exposure_bps != 0)
|
||||
|| (!weights.is_empty() && weights.values().sum::<i32>() != 10000)
|
||||
{
|
||||
return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scale new buys and desired targets without weakening sell/reduction or
|
||||
/// cancellation instructions. Prices, subscriptions and cash flows are intact.
|
||||
pub fn scale_explicit_intent(
|
||||
mut intent: crate::OrderIntent,
|
||||
bps: i32,
|
||||
open_orders: &[crate::OpenOrderView],
|
||||
) -> Result<Option<crate::OrderIntent>, String> {
|
||||
use crate::OrderIntent as I;
|
||||
if !(0..=10000).contains(&bps) {
|
||||
return Err("position scale out of range".into());
|
||||
}
|
||||
if bps == 10000 {
|
||||
return Ok(Some(intent));
|
||||
}
|
||||
if let I::WithTimeInForce {
|
||||
intent: inner,
|
||||
time_in_force,
|
||||
} = intent
|
||||
{
|
||||
return Ok(
|
||||
scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce {
|
||||
intent: Box::new(intent),
|
||||
time_in_force,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32;
|
||||
let amount = |value: f64, target: bool| -> Result<f64, String> {
|
||||
if !value.is_finite() || (target && value < 0.) {
|
||||
return Err("position override received an invalid original amount".into());
|
||||
}
|
||||
Ok(if value > 0. {
|
||||
value * f64::from(bps) / 10000.
|
||||
} else {
|
||||
value
|
||||
})
|
||||
};
|
||||
match &mut intent {
|
||||
I::Shares { quantity, .. }
|
||||
| I::LimitShares { quantity, .. }
|
||||
| I::Lots { lots: quantity, .. }
|
||||
| I::LimitLots { lots: quantity, .. } => {
|
||||
if *quantity > 0 {
|
||||
*quantity = integer(*quantity);
|
||||
if *quantity == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
I::TargetShares {
|
||||
target_quantity, ..
|
||||
}
|
||||
| I::LimitTargetShares {
|
||||
target_quantity, ..
|
||||
} => {
|
||||
if *target_quantity < 0 {
|
||||
return Err("position override received a negative target quantity".into());
|
||||
}
|
||||
*target_quantity = integer(*target_quantity);
|
||||
}
|
||||
I::Value { value, .. }
|
||||
| I::LimitValue { value, .. }
|
||||
| I::AlgoValue { value, .. }
|
||||
| I::Percent { percent: value, .. }
|
||||
| I::LimitPercent { percent: value, .. }
|
||||
| I::AlgoPercent { percent: value, .. } => {
|
||||
*value = amount(*value, false)?;
|
||||
if *value == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
I::TargetValue { target_value, .. }
|
||||
| I::LimitTargetValue { target_value, .. }
|
||||
| I::TimedTargetValue { target_value, .. }
|
||||
| I::TargetPercent {
|
||||
target_percent: target_value,
|
||||
..
|
||||
}
|
||||
| I::LimitTargetPercent {
|
||||
target_percent: target_value,
|
||||
..
|
||||
} => {
|
||||
*target_value = amount(*target_value, true)?;
|
||||
}
|
||||
I::TargetPortfolioSmart { target_weights, .. } => {
|
||||
for value in target_weights.values_mut() {
|
||||
*value = amount(*value, true)?;
|
||||
}
|
||||
}
|
||||
I::ModifyOrder {
|
||||
order_id,
|
||||
new_total_quantity: Some(quantity),
|
||||
..
|
||||
} => {
|
||||
let order = open_orders
|
||||
.iter()
|
||||
.find(|order| order.order_id == *order_id)
|
||||
.ok_or("position override cannot resolve the order being modified")?;
|
||||
if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity {
|
||||
let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000;
|
||||
*quantity = order.requested_quantity + extra as u32;
|
||||
}
|
||||
}
|
||||
I::Futures { .. } | I::StockPool { .. } => {
|
||||
return Err("manual equity scaling cannot transform this intent kind".into());
|
||||
}
|
||||
I::ModifyOrder { .. }
|
||||
| I::CancelOrder { .. }
|
||||
| I::CancelSymbol { .. }
|
||||
| I::CancelAll { .. }
|
||||
| I::UpdateUniverse { .. }
|
||||
| I::Subscribe { .. }
|
||||
| I::Unsubscribe { .. }
|
||||
| I::DepositWithdraw { .. }
|
||||
| I::FinanceRepay { .. }
|
||||
| I::SetManagementFeeRate { .. } => {}
|
||||
I::WithTimeInForce { .. } => unreachable!("wrapper handled first"),
|
||||
}
|
||||
Ok(Some(intent))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() {
|
||||
let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let event = PositionExposureEvent {
|
||||
event_id: "scale".into(),
|
||||
sequence: 1,
|
||||
effective_at: at,
|
||||
allocation_weights_bps: None,
|
||||
action: PositionExposureAction::Scale {
|
||||
requested_bps: 5000,
|
||||
},
|
||||
};
|
||||
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
|
||||
assert_eq!(
|
||||
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.),
|
||||
Some(0.)
|
||||
);
|
||||
assert_eq!(
|
||||
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2),
|
||||
Some(0.1)
|
||||
);
|
||||
let restored = PositionExposureEvent {
|
||||
event_id: "restore".into(),
|
||||
sequence: 2,
|
||||
effective_at: at,
|
||||
allocation_weights_bps: None,
|
||||
action: PositionExposureAction::Restore,
|
||||
};
|
||||
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
|
||||
assert_eq!(
|
||||
timeline
|
||||
.exposure_at(
|
||||
at,
|
||||
at.date_naive(),
|
||||
&BTreeMap::from([(at.date_naive(), 1.)]),
|
||||
0.2
|
||||
)
|
||||
.unwrap_or(0.2),
|
||||
0.2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_is_dated_and_any_later_scalar_or_restore_clears_it() {
|
||||
let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]);
|
||||
let event = PositionExposureEvent {
|
||||
event_id: "allocation".into(),
|
||||
sequence: 1,
|
||||
effective_at: at,
|
||||
action: PositionExposureAction::Set {
|
||||
target_exposure_bps: 8000,
|
||||
},
|
||||
allocation_weights_bps: Some(weights.clone()),
|
||||
};
|
||||
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
|
||||
assert!(
|
||||
timeline
|
||||
.allocation_weights_at(at - chrono::Duration::seconds(1))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(timeline.allocation_weights_at(at), Some(&weights));
|
||||
for action in [
|
||||
PositionExposureAction::Set {
|
||||
target_exposure_bps: 5000,
|
||||
},
|
||||
PositionExposureAction::Restore,
|
||||
] {
|
||||
let next = PositionExposureEvent {
|
||||
event_id: "new".into(),
|
||||
sequence: 2,
|
||||
effective_at: at + chrono::Duration::seconds(1),
|
||||
action,
|
||||
allocation_weights_bps: None,
|
||||
};
|
||||
assert!(
|
||||
PositionExposureTimeline::from_events(&[event.clone(), next])
|
||||
.unwrap()
|
||||
.allocation_weights_at(at + chrono::Duration::seconds(1))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000)
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err());
|
||||
assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
|
||||
use crate::OrderIntent as I;
|
||||
let symbol = "000001.SZ".to_string();
|
||||
let reason = "fixture".to_string();
|
||||
for bps in [0, 3000, 5000, 10000] {
|
||||
let ratio = f64::from(bps) / 10000.;
|
||||
let buy = I::LimitShares {
|
||||
symbol: symbol.clone(),
|
||||
quantity: 1000,
|
||||
limit_price: 12.345,
|
||||
reason: reason.clone(),
|
||||
};
|
||||
let scaled = scale_explicit_intent(buy, bps, &[]).unwrap();
|
||||
if bps == 0 {
|
||||
assert!(scaled.is_none())
|
||||
} else if let Some(I::LimitShares {
|
||||
quantity,
|
||||
limit_price,
|
||||
..
|
||||
}) = scaled
|
||||
{
|
||||
assert_eq!(quantity, (1000. * ratio) as i32);
|
||||
assert_eq!(limit_price, 12.345);
|
||||
} else {
|
||||
panic!("wrong intent")
|
||||
}
|
||||
let sell = I::Shares {
|
||||
symbol: symbol.clone(),
|
||||
quantity: -1000,
|
||||
reason: reason.clone(),
|
||||
};
|
||||
assert!(matches!(
|
||||
scale_explicit_intent(sell, bps, &[]).unwrap(),
|
||||
Some(I::Shares {
|
||||
quantity: -1000,
|
||||
..
|
||||
})
|
||||
));
|
||||
let clear = I::TargetShares {
|
||||
symbol: symbol.clone(),
|
||||
target_quantity: 0,
|
||||
reason: reason.clone(),
|
||||
};
|
||||
assert!(matches!(
|
||||
scale_explicit_intent(clear, bps, &[]).unwrap(),
|
||||
Some(I::TargetShares {
|
||||
target_quantity: 0,
|
||||
..
|
||||
})
|
||||
));
|
||||
let target = I::TargetPercent {
|
||||
symbol: symbol.clone(),
|
||||
target_percent: 0.2,
|
||||
reason: reason.clone(),
|
||||
};
|
||||
if let Some(I::TargetPercent { target_percent, .. }) =
|
||||
scale_explicit_intent(target, bps, &[]).unwrap()
|
||||
{
|
||||
assert!((target_percent - 0.2 * ratio).abs() < 1e-12)
|
||||
} else {
|
||||
panic!("wrong target")
|
||||
}
|
||||
let deposit = I::DepositWithdraw {
|
||||
amount: 123.456,
|
||||
receiving_days: 2,
|
||||
reason: reason.clone(),
|
||||
};
|
||||
assert!(matches!(
|
||||
scale_explicit_intent(deposit, bps, &[]).unwrap(),
|
||||
Some(I::DepositWithdraw {
|
||||
amount: 123.456,
|
||||
receiving_days: 2,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
scale_explicit_intent(
|
||||
I::TargetValue {
|
||||
symbol,
|
||||
target_value: f64::NAN,
|
||||
reason
|
||||
},
|
||||
0,
|
||||
&[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_day_adjustments_restore_and_future_events_keep_their_own_times() {
|
||||
let events: Vec<PositionExposureEvent> = serde_json::from_value(json!([
|
||||
{"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0},
|
||||
{"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000},
|
||||
{"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"},
|
||||
{"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000}
|
||||
])).unwrap();
|
||||
let timeline = PositionExposureTimeline::from_events(&events).unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||
let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]);
|
||||
for (time, expected) in [
|
||||
("09:30:00", Some(0.8)),
|
||||
("10:00:00", Some(0.)),
|
||||
("12:59:59", Some(0.)),
|
||||
("13:00:00", Some(0.5)),
|
||||
("14:00:00", None),
|
||||
("15:00:00", None),
|
||||
] {
|
||||
let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00"))
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
assert_eq!(
|
||||
timeline.exposure_at(at, date, &legacy, 0.2),
|
||||
expected,
|
||||
"{time}"
|
||||
);
|
||||
}
|
||||
let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
assert_eq!(
|
||||
timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() {
|
||||
let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000});
|
||||
for (key, value) in [
|
||||
("action", json!("other")),
|
||||
("effectiveAt", json!("2026-09-10 09:30:00")),
|
||||
("targetExposureBps", json!(null)),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid[key] = value;
|
||||
assert!(serde_json::from_value::<PositionExposureEvent>(invalid).is_err());
|
||||
}
|
||||
let event: PositionExposureEvent = serde_json::from_value(valid).unwrap();
|
||||
assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err());
|
||||
let mut invalid = event.clone();
|
||||
invalid.action = PositionExposureAction::Set {
|
||||
target_exposure_bps: 10001,
|
||||
};
|
||||
assert!(PositionExposureTimeline::from_events(&[invalid]).is_err());
|
||||
let mut duplicate = event.clone();
|
||||
duplicate.event_id = "two".into();
|
||||
assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,10 @@ impl RuleCheck {
|
||||
}
|
||||
|
||||
pub trait EquityRuleHooks {
|
||||
fn duplicates_standard_china_risk(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn can_buy(
|
||||
&self,
|
||||
execution_date: NaiveDate,
|
||||
@@ -49,6 +53,10 @@ pub trait EquityRuleHooks {
|
||||
pub struct ChinaEquityRuleHooks;
|
||||
|
||||
impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
fn duplicates_standard_china_risk(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn can_buy(
|
||||
&self,
|
||||
_execution_date: NaiveDate,
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum ScheduleStage {
|
||||
BeforeTrading,
|
||||
OpenAuction,
|
||||
Bar,
|
||||
Tick,
|
||||
Minute,
|
||||
OnDay,
|
||||
AfterTrading,
|
||||
Settlement,
|
||||
@@ -164,6 +164,16 @@ impl<'a> Scheduler<'a> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Evaluate only the trading-calendar frequency of a rule.
|
||||
///
|
||||
/// Strategy callbacks and order execution clocks are separate contracts:
|
||||
/// a 15:00 schedule is still due on the same daily/weekly/monthly trading
|
||||
/// date even when the engine's coarse `on_day` callback runs at another
|
||||
/// default time. Exact clock matching remains in `triggered_rules_at`.
|
||||
pub fn is_due_on(&self, date: NaiveDate, rule: &ScheduleRule) -> bool {
|
||||
self.matches(date, rule)
|
||||
}
|
||||
|
||||
fn matches(&self, date: NaiveDate, rule: &ScheduleRule) -> bool {
|
||||
match &rule.frequency {
|
||||
ScheduleFrequency::Daily => true,
|
||||
@@ -223,9 +233,9 @@ impl<'a> Scheduler<'a> {
|
||||
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
|
||||
match stage {
|
||||
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")),
|
||||
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
ScheduleStage::Tick => None,
|
||||
ScheduleStage::Minute => None,
|
||||
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
ScheduleStage::AfterTrading => Some(NaiveTime::from_hms_opt(15, 0, 0).expect("valid time")),
|
||||
ScheduleStage::Settlement => Some(NaiveTime::from_hms_opt(15, 1, 0).expect("valid time")),
|
||||
@@ -265,6 +275,38 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_due_is_independent_from_the_order_execution_clock() {
|
||||
let calendar = sample_calendar();
|
||||
let scheduler = Scheduler::new(&calendar);
|
||||
let daily = ScheduleRule::daily("close_signal", ScheduleStage::OnDay)
|
||||
.with_time_rule(ScheduleTimeRule::physical_time(15, 0));
|
||||
|
||||
assert!(scheduler.is_due_on(d(2025, 1, 30), &daily));
|
||||
assert!(scheduler.is_due_on(d(2025, 1, 31), &daily));
|
||||
assert!(
|
||||
scheduler
|
||||
.triggered_rules_at(
|
||||
d(2025, 1, 30),
|
||||
ScheduleStage::OnDay,
|
||||
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
|
||||
std::slice::from_ref(&daily),
|
||||
)
|
||||
.len()
|
||||
== 1
|
||||
);
|
||||
assert!(
|
||||
scheduler
|
||||
.triggered_rules_at(
|
||||
d(2025, 1, 30),
|
||||
ScheduleStage::OnDay,
|
||||
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
|
||||
std::slice::from_ref(&daily),
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_matches_daily_weekly_and_monthly_rules() {
|
||||
let calendar = sample_calendar();
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::ops::Index;
|
||||
|
||||
use super::prefix_sums;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ReferenceMatchedValues {
|
||||
Identical,
|
||||
Owned(Vec<f64>),
|
||||
}
|
||||
|
||||
impl ReferenceMatchedValues {
|
||||
pub(super) fn push(&mut self, value: f64, reference: &[f64], capacity: usize) {
|
||||
let previous_len = reference.len().checked_sub(1).expect("reference row is missing");
|
||||
match self {
|
||||
Self::Identical if value.to_bits() == reference[previous_len].to_bits() => {}
|
||||
Self::Identical => {
|
||||
let mut values = Vec::with_capacity(capacity);
|
||||
values.extend_from_slice(&reference[..previous_len]);
|
||||
values.push(value);
|
||||
*self = Self::Owned(values);
|
||||
}
|
||||
Self::Owned(values) => {
|
||||
debug_assert_eq!(values.len(), previous_len);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn values<'a>(&'a self, reference: &'a [f64]) -> &'a [f64] {
|
||||
match self {
|
||||
Self::Identical => reference,
|
||||
Self::Owned(values) => {
|
||||
debug_assert_eq!(values.len(), reference.len());
|
||||
values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, index: usize, value: f64, reference: &[f64]) {
|
||||
assert!(index < reference.len(), "series index out of bounds");
|
||||
match self {
|
||||
Self::Owned(values) => values[index] = value,
|
||||
Self::Identical if value.to_bits() == reference[index].to_bits() => {}
|
||||
Self::Identical => {
|
||||
let mut values = reference.to_vec();
|
||||
values[index] = value;
|
||||
*self = Self::Owned(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prefix(&self) -> Self {
|
||||
match self {
|
||||
Self::Identical => Self::Identical,
|
||||
Self::Owned(values) => Self::Owned(prefix_sums(values)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RepeatedValues<T> {
|
||||
repeated: T,
|
||||
values: Option<Vec<T>>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Default + Clone + Eq> RepeatedValues<T> {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { repeated: T::default(), values: None, len: 0 }
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, value: &T, capacity: usize) {
|
||||
if let Some(values) = &mut self.values {
|
||||
values.push(value.clone());
|
||||
} else if self.len == 0 {
|
||||
self.repeated = value.clone();
|
||||
} else if *value != self.repeated {
|
||||
let mut values = Vec::with_capacity(capacity);
|
||||
values.resize(self.len, std::mem::take(&mut self.repeated));
|
||||
values.push(value.clone());
|
||||
self.values = Some(values);
|
||||
}
|
||||
self.len += 1;
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, index: usize, value: T) {
|
||||
assert!(index < self.len, "series index out of bounds");
|
||||
if let Some(values) = &mut self.values {
|
||||
values[index] = value;
|
||||
} else if value != self.repeated {
|
||||
let mut values = vec![std::mem::take(&mut self.repeated); self.len];
|
||||
values[index] = value;
|
||||
self.values = Some(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Index<usize> for RepeatedValues<T> {
|
||||
type Output = T;
|
||||
|
||||
fn index(&self, index: usize) -> &T {
|
||||
assert!(index < self.len, "series index out of bounds");
|
||||
match &self.values {
|
||||
Some(values) => &values[index],
|
||||
None => &self.repeated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(values: &[f64]) -> Vec<u64> {
|
||||
values.iter().map(|value| value.to_bits()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_prices_share_only_after_exact_bit_comparison() {
|
||||
let reference = [10., -0., f64::from_bits(0x7ff8_0000_0000_0042), f64::INFINITY];
|
||||
let mut column = ReferenceMatchedValues::Identical;
|
||||
for (index, value) in reference.iter().copied().enumerate() {
|
||||
column.push(value, &reference[..=index], reference.len());
|
||||
}
|
||||
assert!(matches!(column, ReferenceMatchedValues::Identical));
|
||||
assert_eq!(column.values(&reference).as_ptr(), reference.as_ptr());
|
||||
let prefix = prefix_sums(&reference);
|
||||
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix));
|
||||
|
||||
let original = column.clone();
|
||||
column.set(1, 0., &reference);
|
||||
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
|
||||
assert_eq!(column.values(&reference)[1].to_bits(), 0_f64.to_bits());
|
||||
assert_eq!(bits(original.values(&reference)), bits(&reference));
|
||||
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix_sums(column.values(&reference))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_prices_preserve_zero_nan_payloads_and_prior_rows() {
|
||||
let reference = [10., 11., f64::from_bits(0x7ff8_0000_0000_0042), 13.];
|
||||
for actual in [
|
||||
[10., 0., reference[2], 13.],
|
||||
[10., 11., f64::from_bits(0x7ff8_0000_0000_0043), 13.],
|
||||
] {
|
||||
let mut column = ReferenceMatchedValues::Identical;
|
||||
for (index, value) in actual.iter().copied().enumerate() {
|
||||
column.push(value, &reference[..=index], actual.len());
|
||||
}
|
||||
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
|
||||
assert_eq!(bits(column.values(&reference)), bits(&actual));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_values_preserve_nonzero_values_and_copy_on_change() {
|
||||
let mut column = RepeatedValues::new();
|
||||
for _ in 0..128 { column.push(&7_u64, 128); }
|
||||
assert!(column.values.is_none());
|
||||
assert_eq!(column[127], 7);
|
||||
column.set(0, 7);
|
||||
assert!(column.values.is_none());
|
||||
let mut changed = column.clone();
|
||||
changed.set(64, 9);
|
||||
assert_eq!(changed[64], 9);
|
||||
assert_eq!(changed[63], 7);
|
||||
assert_eq!(column[64], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_values_keep_none_distinct_from_empty_and_repeated_text() {
|
||||
for repeated in [None, Some(String::new()), Some("continuous".to_string())] {
|
||||
let mut column = RepeatedValues::new();
|
||||
for _ in 0..12 { column.push(&repeated, 16); }
|
||||
assert!(column.values.is_none());
|
||||
assert_eq!(column[0], repeated);
|
||||
column.push(&Some("closing".to_string()), 16);
|
||||
assert_eq!(column[11], repeated);
|
||||
assert_eq!(column[12].as_deref(), Some("closing"));
|
||||
column.set(5, None);
|
||||
assert_eq!(column[5], None);
|
||||
assert_eq!(column[4], repeated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "series index out of bounds")]
|
||||
fn repeated_values_reject_out_of_range_access() {
|
||||
let column = RepeatedValues::<u64>::new();
|
||||
let _ = column[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Completed, same-session minute events. These bars never become execution quotes.
|
||||
use crate::{
|
||||
daily_patterns::{PatternResult, PatternSpec},
|
||||
factor_events::{Expr, Frame},
|
||||
};
|
||||
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
|
||||
pub const EVENTS: &[&str] = &[
|
||||
"PRICE_CROSS_VWAP_UP",
|
||||
"PRICE_CROSS_VWAP_DOWN",
|
||||
"INTRADAY_HIGH_BREAKOUT",
|
||||
"INTRADAY_LOW_BREAKDOWN",
|
||||
"OPENING_RANGE_BREAKOUT_UP",
|
||||
"OPENING_RANGE_BREAKOUT_DOWN",
|
||||
"INTRADAY_VOLUME_SPIKE",
|
||||
"MORNING_HIGH_BREAKOUT",
|
||||
"MORNING_LOW_BREAKDOWN",
|
||||
"AFTERNOON_MOMENTUM_UP",
|
||||
"AFTERNOON_MOMENTUM_DOWN",
|
||||
"LATE_SESSION_STRENGTH",
|
||||
"LATE_SESSION_WEAKNESS",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MinuteBar {
|
||||
pub symbol: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub available_at: NaiveDateTime,
|
||||
pub open: f64,
|
||||
pub high: f64,
|
||||
pub low: f64,
|
||||
pub close: f64,
|
||||
pub volume: f64,
|
||||
pub amount: f64,
|
||||
}
|
||||
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
|
||||
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
|
||||
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
|
||||
for bar in bars {
|
||||
groups
|
||||
.entry((bar.timestamp.date(), bar.symbol.clone()))
|
||||
.or_default()
|
||||
.push(bar);
|
||||
}
|
||||
for rows in groups.values_mut() {
|
||||
rows.sort_by_key(|r| r.timestamp);
|
||||
if rows
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].timestamp == pair[1].timestamp)
|
||||
{
|
||||
return Err("duplicate_completed_minute_bar".into());
|
||||
}
|
||||
}
|
||||
Ok(Arc::new(groups))
|
||||
}
|
||||
fn f(name: &str) -> Expr {
|
||||
Expr::Field { name: name.into() }
|
||||
}
|
||||
fn n(value: f64) -> Expr {
|
||||
Expr::Number { value }
|
||||
}
|
||||
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
|
||||
Expr::Operator {
|
||||
name: name.into(),
|
||||
args,
|
||||
window,
|
||||
}
|
||||
}
|
||||
fn time(minutes: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
|
||||
}
|
||||
|
||||
pub fn is_regular_label(t: NaiveTime) -> bool {
|
||||
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
|
||||
}
|
||||
|
||||
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
|
||||
let cross = |up: bool, a: Expr, b: Expr| {
|
||||
op(
|
||||
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
|
||||
vec![a, b],
|
||||
None,
|
||||
)
|
||||
};
|
||||
Ok(match event {
|
||||
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
|
||||
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
|
||||
"INTRADAY_HIGH_BREAKOUT" => op(
|
||||
"GT",
|
||||
vec![
|
||||
f("close"),
|
||||
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
|
||||
],
|
||||
None,
|
||||
),
|
||||
"INTRADAY_LOW_BREAKDOWN" => op(
|
||||
"LT",
|
||||
vec![
|
||||
f("close"),
|
||||
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
|
||||
],
|
||||
None,
|
||||
),
|
||||
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
|
||||
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
|
||||
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
|
||||
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
|
||||
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
|
||||
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
|
||||
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
|
||||
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
|
||||
"INTRADAY_VOLUME_SPIKE" => op(
|
||||
"GTE",
|
||||
vec![
|
||||
f("volume"),
|
||||
op(
|
||||
"MUL",
|
||||
vec![
|
||||
op(
|
||||
"LAG",
|
||||
vec![op(
|
||||
"ROLLING_MEAN",
|
||||
vec![f("volume")],
|
||||
Some(p["volume_window"].as_u64().unwrap() as usize),
|
||||
)],
|
||||
Some(1),
|
||||
),
|
||||
n(p["volume_multiple"].as_f64().unwrap()),
|
||||
],
|
||||
None,
|
||||
),
|
||||
],
|
||||
None,
|
||||
),
|
||||
_ => return Err("session_event_not_registered".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
spec: &PatternSpec,
|
||||
symbol: &str,
|
||||
bars: &[MinuteBar],
|
||||
decision: NaiveDateTime,
|
||||
) -> Result<PatternResult, String> {
|
||||
let mut result = PatternResult {
|
||||
symbol: symbol.into(),
|
||||
name: None,
|
||||
matched: false,
|
||||
score: None,
|
||||
checks: vec![],
|
||||
values: json!({}),
|
||||
anchor: Value::Null,
|
||||
exclusion: None,
|
||||
};
|
||||
if bars.is_empty() {
|
||||
return Err(format!(
|
||||
"session_source_missing: {symbol} {}",
|
||||
decision.date()
|
||||
));
|
||||
}
|
||||
let visible = bars
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
b.timestamp.date() == decision.date()
|
||||
&& b.timestamp < decision
|
||||
&& b.available_at <= decision
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if visible.is_empty() {
|
||||
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
|
||||
return Ok(result);
|
||||
}
|
||||
let last = visible.last().unwrap().timestamp;
|
||||
let expected = (570..=690)
|
||||
.chain(781..=900)
|
||||
.map(|m| decision.date().and_time(time(m)))
|
||||
.filter(|t| *t < decision)
|
||||
.last();
|
||||
if expected != Some(last) {
|
||||
return Err(format!(
|
||||
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
|
||||
));
|
||||
}
|
||||
let mut indexed = BTreeMap::new();
|
||||
for b in &visible {
|
||||
if b.symbol != symbol
|
||||
|| !is_regular_label(b.timestamp.time())
|
||||
|| b.available_at < b.timestamp
|
||||
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite())
|
||||
|| b.low <= 0.
|
||||
|| b.open <= 0.
|
||||
|| b.close <= 0.
|
||||
|| b.high < b.open.max(b.close)
|
||||
|| b.low > b.open.min(b.close)
|
||||
|| b.volume < 0.
|
||||
|| b.amount < 0.
|
||||
|| indexed.insert(b.timestamp, b).is_some()
|
||||
{
|
||||
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
|
||||
}
|
||||
}
|
||||
for minute in (571..=690).chain(781..=900) {
|
||||
let stamp = decision.date().and_time(time(minute));
|
||||
if stamp <= last && !indexed.contains_key(&stamp) {
|
||||
return Err(format!(
|
||||
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
|
||||
));
|
||||
}
|
||||
}
|
||||
let opening_end = time(570 + spec.n("opening_minutes") as u32);
|
||||
let (mut volume, mut amount) = (0., 0.);
|
||||
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (mut morning_close, mut late_close) = (None, None);
|
||||
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"volume",
|
||||
"amount",
|
||||
"session_vwap",
|
||||
"opening_high",
|
||||
"opening_low",
|
||||
"morning_high",
|
||||
"morning_low",
|
||||
"afternoon_return",
|
||||
"late_return",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|s| (s.into(), vec![]))
|
||||
.collect();
|
||||
let mut timestamps = vec![];
|
||||
let mut available_at = vec![];
|
||||
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
for b in indexed.values() {
|
||||
let t = b.timestamp.time();
|
||||
volume += b.volume;
|
||||
amount += b.amount;
|
||||
if t <= opening_end {
|
||||
opening_high = opening_high.max(b.high);
|
||||
opening_low = opening_low.min(b.low);
|
||||
}
|
||||
if t <= time(690) {
|
||||
morning_high = morning_high.max(b.high);
|
||||
morning_low = morning_low.min(b.low);
|
||||
}
|
||||
if t == time(690) {
|
||||
morning_close = Some(b.close);
|
||||
}
|
||||
if t == time(870) {
|
||||
late_close = Some(b.close);
|
||||
}
|
||||
for (name, value) in [
|
||||
("open", Some(b.open)),
|
||||
("high", Some(b.high)),
|
||||
("low", Some(b.low)),
|
||||
("close", Some(b.close)),
|
||||
("volume", Some(b.volume)),
|
||||
("amount", Some(b.amount)),
|
||||
("session_vwap", (volume > 0.).then_some(amount / volume)),
|
||||
("opening_high", (t >= opening_end).then_some(opening_high)),
|
||||
("opening_low", (t >= opening_end).then_some(opening_low)),
|
||||
("morning_high", (t >= time(690)).then_some(morning_high)),
|
||||
("morning_low", (t >= time(690)).then_some(morning_low)),
|
||||
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
|
||||
("late_return", late_close.map(|v| b.close / v - 1.)),
|
||||
] {
|
||||
fields.get_mut(name).unwrap().push(value);
|
||||
}
|
||||
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
|
||||
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
|
||||
}
|
||||
let frame = Frame {
|
||||
symbol: symbol.into(),
|
||||
frequency: "1m".into(),
|
||||
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
|
||||
timestamps,
|
||||
available_at,
|
||||
fields,
|
||||
};
|
||||
let event = spec
|
||||
.session_event
|
||||
.as_deref()
|
||||
.ok_or("session_event_id_required")?;
|
||||
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
|
||||
let latest = values.values.last().copied().flatten();
|
||||
result.score = latest;
|
||||
result.matched = latest == Some(1.);
|
||||
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
|
||||
if latest.is_none() {
|
||||
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
|
||||
} else {
|
||||
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn spec(event: &str) -> PatternSpec {
|
||||
serde_json::from_value::<PatternSpec>(
|
||||
json!({"template":"session_event","session_event":event,"parameters":{}}),
|
||||
)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap()
|
||||
}
|
||||
fn bars() -> Vec<MinuteBar> {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
(570..=690)
|
||||
.chain(781..=900)
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let timestamp = date.and_time(time(m));
|
||||
let price = 100. + (i % 17) as f64 / 10.;
|
||||
let volume = if i % 39 == 0 { 1000. } else { 100. };
|
||||
MinuteBar {
|
||||
symbol: "300395.SZ".into(),
|
||||
timestamp,
|
||||
available_at: timestamp,
|
||||
open: price,
|
||||
high: price + 0.1,
|
||||
low: price - 0.1,
|
||||
close: price,
|
||||
volume,
|
||||
amount: volume * price,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn all_thirteen_events_return_native_boolean_series() {
|
||||
let bars = bars();
|
||||
let decision = "2026-09-08T15:00:01".parse().unwrap();
|
||||
for event in EVENTS {
|
||||
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
assert!(value.score.is_some(), "{event}");
|
||||
assert_eq!(value.values["expression"]["value_type"], "boolean");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
|
||||
let mut bars = bars();
|
||||
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||
for event in EVENTS {
|
||||
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
for bar in &mut bars {
|
||||
if bar.timestamp >= decision {
|
||||
bar.open = 1000.;
|
||||
bar.close = 1000.;
|
||||
bar.high = 1001.;
|
||||
bar.low = 999.;
|
||||
}
|
||||
}
|
||||
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
assert_eq!(before.values, after.values);
|
||||
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
|
||||
let mut values = bars();
|
||||
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||
values.retain(|r| r.timestamp.time() != time(600));
|
||||
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
|
||||
.unwrap_err()
|
||||
.contains("session_bar_gap"));
|
||||
let stale = bars()
|
||||
.into_iter()
|
||||
.filter(|r| r.timestamp.time() < time(601))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
|
||||
.unwrap_err()
|
||||
.contains("latest_bar_missing"));
|
||||
}
|
||||
#[test]
|
||||
fn opening_range_is_unavailable_before_the_range_has_completed() {
|
||||
let value = evaluate(
|
||||
&spec("OPENING_RANGE_BREAKOUT_UP"),
|
||||
"300395.SZ",
|
||||
&bars(),
|
||||
"2026-09-08T09:59:01".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(value.score, None);
|
||||
assert!(!value.matched);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! Immutable, account-independent trading signals. Quantity and execution
|
||||
//! prices are intentionally absent; the existing broker owns those decisions.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::strategy::{OrderIntent, StrategyContext};
|
||||
use crate::portfolio::PortfolioState;
|
||||
|
||||
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SignalBookReference {
|
||||
pub book_id: String,
|
||||
pub version_sha256: String,
|
||||
pub artifact_sha256: String,
|
||||
}
|
||||
|
||||
impl SignalBookReference {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|
||||
|| self.book_id != format!("signal_book_{}",self.version_sha256)
|
||||
{ return Err("signal_book_reference_invalid".into()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SignalCache {
|
||||
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
|
||||
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
|
||||
}
|
||||
|
||||
fn signal_cache() -> &'static Mutex<SignalCache> {
|
||||
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
|
||||
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
|
||||
}
|
||||
|
||||
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
|
||||
reference.validate()?;
|
||||
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
||||
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
|
||||
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
|
||||
return Err("signal_book_cached_version_mismatch".into());
|
||||
}
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
|
||||
use sha2::{Digest,Sha256};
|
||||
reference.validate()?;
|
||||
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
|
||||
return Err("signal_book_artifact_hash_or_size_invalid".into());
|
||||
}
|
||||
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
|
||||
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
|
||||
let book=Arc::new(raw.validate()?);
|
||||
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
||||
cache.entries.retain(|_,value|value.strong_count()>0);
|
||||
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
|
||||
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
|
||||
let estimated=body.len().saturating_mul(4);
|
||||
if estimated<=128*1024*1024 {
|
||||
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
|
||||
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
|
||||
cache.retained.pop_front();
|
||||
}
|
||||
}
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignalProvenance {
|
||||
Observed,
|
||||
Reconstructed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignalFrequency {
|
||||
Daily,
|
||||
Minute,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum SignalAction {
|
||||
TargetWeight { symbol: String, weight: f64 },
|
||||
BuyCondition { symbol: String, allowed: bool },
|
||||
Exit { symbol: String },
|
||||
Reduce { symbol: String, remaining_ratio: f64 },
|
||||
}
|
||||
|
||||
impl SignalAction {
|
||||
fn symbol(&self) -> &str {
|
||||
match self {
|
||||
Self::TargetWeight { symbol, .. }
|
||||
| Self::BuyCondition { symbol, .. }
|
||||
| Self::Exit { symbol }
|
||||
| Self::Reduce { symbol, .. } => symbol,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SignalSnapshot {
|
||||
pub signal_at: DateTime<Utc>,
|
||||
pub decision_at: DateTime<Utc>,
|
||||
pub input_as_of: DateTime<Utc>,
|
||||
pub input_available_at: DateTime<Utc>,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub published_at: DateTime<Utc>,
|
||||
pub input_sha256: String,
|
||||
pub complete_targets: bool,
|
||||
pub actions: Vec<SignalAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SignalBook {
|
||||
pub schema: String,
|
||||
pub version_sha256: String,
|
||||
pub generator_sha256: String,
|
||||
pub model_sha256: Option<String>,
|
||||
pub knowledge_cutoff: Option<DateTime<Utc>>,
|
||||
pub provenance: SignalProvenance,
|
||||
pub frequency: SignalFrequency,
|
||||
pub expected_decisions: Vec<DateTime<Utc>>,
|
||||
pub snapshots: Vec<SignalSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidatedSignalBook {
|
||||
book: SignalBook,
|
||||
index: BTreeMap<NaiveDateTime, usize>,
|
||||
}
|
||||
|
||||
fn valid_sha(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
|
||||
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
|
||||
}
|
||||
|
||||
impl SignalBook {
|
||||
pub fn content_sha256(&self) -> Result<String, String> {
|
||||
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
|
||||
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
|
||||
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
|
||||
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
|
||||
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
|
||||
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
|
||||
object.remove("generatedAt");
|
||||
object.remove("publishedAt");
|
||||
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
|
||||
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
|
||||
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
|
||||
}
|
||||
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
|
||||
match action {
|
||||
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
|
||||
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
|
||||
_=>{}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn sorted(value:serde_json::Value)->serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
|
||||
.collect::<BTreeMap<_,_>>().into_iter().collect()),
|
||||
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
|
||||
other=>other,
|
||||
}
|
||||
}
|
||||
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
|
||||
Ok(format!("{:x}",Sha256::digest(raw)))
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
|
||||
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|
||||
|| !valid_sha(&self.generator_sha256)
|
||||
{
|
||||
return Err("signal_book_identity_invalid".into());
|
||||
}
|
||||
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|
||||
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
|
||||
{ return Err("signal_model_training_identity_incomplete".into()); }
|
||||
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|
||||
|| self.expected_decisions.len() != self.snapshots.len()
|
||||
{
|
||||
return Err("signal_book_decision_coverage_incomplete".into());
|
||||
}
|
||||
let mut index = BTreeMap::new();
|
||||
let mut previous = None;
|
||||
let mut total_actions = 0usize;
|
||||
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
|
||||
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
|
||||
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
|
||||
return Err("signal_timestamp_requires_microsecond_precision".into());
|
||||
}
|
||||
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
|
||||
return Err("signal_book_decisions_duplicate_or_unordered".into());
|
||||
}
|
||||
previous = Some(*expected);
|
||||
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|
||||
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|
||||
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|
||||
|| snapshot.generated_at < snapshot.input_available_at
|
||||
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
|
||||
{
|
||||
return Err("signal_book_future_or_invalid_input".into());
|
||||
}
|
||||
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
|
||||
return Err("observed_signal_not_available_at_decision".into());
|
||||
}
|
||||
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
|
||||
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
|
||||
let mut action_keys = BTreeSet::new();
|
||||
let mut target_symbols = BTreeSet::new();
|
||||
let mut reductions = BTreeSet::new();
|
||||
let mut total_weight = 0.0;
|
||||
for action in &snapshot.actions {
|
||||
let symbol = action.symbol();
|
||||
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
|
||||
let kind = match action {
|
||||
SignalAction::TargetWeight { weight, .. } => {
|
||||
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
|
||||
target_symbols.insert(symbol);
|
||||
total_weight += weight;
|
||||
"target"
|
||||
}
|
||||
SignalAction::BuyCondition { .. } => "buy_condition",
|
||||
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
|
||||
SignalAction::Reduce { remaining_ratio, .. } => {
|
||||
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
|
||||
reductions.insert(symbol);
|
||||
"reduce"
|
||||
}
|
||||
};
|
||||
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
|
||||
}
|
||||
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
|
||||
if snapshot.complete_targets && !reductions.is_empty() {
|
||||
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
|
||||
}
|
||||
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
|
||||
for symbol in &reductions {
|
||||
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
|
||||
return Err("signal_exit_reduction_conflict".into());
|
||||
}
|
||||
}
|
||||
index.insert(shanghai(*expected), number);
|
||||
}
|
||||
if self.content_sha256()? != self.version_sha256 {
|
||||
return Err("signal_book_content_hash_mismatch".into());
|
||||
}
|
||||
Ok(ValidatedSignalBook { book: self, index })
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidatedSignalBook {
|
||||
pub fn require_observed(&self) -> Result<(), String> {
|
||||
if self.book.provenance != SignalProvenance::Observed {
|
||||
return Err("reconstructed_signal_forbidden_in_online_execution".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
|
||||
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
|
||||
|
||||
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
|
||||
self.index.keys().map(|value| value.date()).collect()
|
||||
}
|
||||
|
||||
pub fn symbols(&self) -> BTreeSet<String> {
|
||||
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
|
||||
.map(|action| action.symbol().to_owned()).collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
|
||||
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
|
||||
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
|
||||
return Err("observed_signal_consumption_clock_missing".into());
|
||||
}
|
||||
let consumption_clock=ctx.current_datetime()
|
||||
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
|
||||
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
|
||||
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
|
||||
return Err("next_open_signal_contains_execution_session_inputs".into());
|
||||
}
|
||||
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
|
||||
return Err("signal_not_available_at_consumption_clock".into());
|
||||
}
|
||||
if self.book.provenance == SignalProvenance::Observed
|
||||
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
|
||||
return Err("observed_signal_published_after_consumption_clock".into());
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
|
||||
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
|
||||
.next().is_some_and(|(at,_)|at.date()==execution_date)
|
||||
}
|
||||
|
||||
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
|
||||
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
|
||||
execution_date.and_hms_opt(9, 30, 0).expect("next open")
|
||||
} else {
|
||||
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
|
||||
};
|
||||
self.index.get(&at).map(|index| &self.book.snapshots[*index])
|
||||
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
|
||||
}
|
||||
|
||||
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
|
||||
let snapshot = self.snapshot_for(ctx)?;
|
||||
self.snapshot_intents(snapshot, ctx.portfolio)
|
||||
}
|
||||
|
||||
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
|
||||
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
|
||||
let mut intents = Vec::new();
|
||||
let mut weights = BTreeMap::new();
|
||||
for action in &snapshot.actions {
|
||||
match action {
|
||||
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
|
||||
weights.insert(symbol.clone(), *weight);
|
||||
}
|
||||
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
|
||||
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
|
||||
}),
|
||||
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
|
||||
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
|
||||
}),
|
||||
SignalAction::Reduce { symbol, remaining_ratio } => {
|
||||
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
|
||||
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
|
||||
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
|
||||
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
|
||||
}
|
||||
}
|
||||
SignalAction::BuyCondition { .. } => {}
|
||||
}
|
||||
}
|
||||
if snapshot.complete_targets {
|
||||
if weights.is_empty() {
|
||||
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
|
||||
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
|
||||
}
|
||||
} else {
|
||||
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
|
||||
order_prices: None, valuation_prices: None, reason });
|
||||
}
|
||||
}
|
||||
Ok(intents)
|
||||
}
|
||||
|
||||
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
|
||||
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
|
||||
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
|
||||
_ => None,
|
||||
}).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
fn book() -> SignalBook {
|
||||
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
|
||||
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
|
||||
seal(SignalBook {
|
||||
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
|
||||
model_sha256: Some("d".repeat(64)),
|
||||
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
|
||||
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
|
||||
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
|
||||
signal_at: source,
|
||||
decision_at: decision, input_as_of: source, input_available_at: source,
|
||||
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
|
||||
input_sha256: "c".repeat(64), complete_targets: true,
|
||||
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn seal(mut book:SignalBook)->SignalBook {
|
||||
book.version_sha256=book.content_sha256().unwrap();
|
||||
book
|
||||
}
|
||||
|
||||
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
|
||||
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![crate::BenchmarkSnapshot {
|
||||
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
|
||||
open:100.0, close:100.0, prev_close:100.0, volume:1,
|
||||
}]).unwrap();
|
||||
let portfolio = PortfolioState::new(10_000.0);
|
||||
let symbols = BTreeSet::new();
|
||||
action(&StrategyContext {
|
||||
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
|
||||
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
|
||||
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
|
||||
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
|
||||
active_datetime:at, order_events:&[], fills:&[],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
|
||||
let mut raw = book();
|
||||
raw.provenance=SignalProvenance::Observed;
|
||||
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
|
||||
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
|
||||
let value=seal(raw).validate().unwrap();
|
||||
for clock in ["2025-01-06T15:00:00", "2025-01-07T08:45:00"] {
|
||||
at_context(Some(clock.parse().unwrap()), |ctx| {
|
||||
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
|
||||
assert!(ctx.portfolio.positions().is_empty());
|
||||
});
|
||||
}
|
||||
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
|
||||
assert_eq!(value.intents(ctx).unwrap().len(),1);
|
||||
assert!(ctx.portfolio.positions().is_empty());
|
||||
});
|
||||
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
|
||||
let value=book().validate().unwrap();
|
||||
at_context(Some("2025-01-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
|
||||
at_context(Some("2025-01-06T14:59:59".parse().unwrap()), |ctx| {
|
||||
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
|
||||
});
|
||||
let mut raw=book();
|
||||
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
|
||||
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
|
||||
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
|
||||
let value=seal(raw).validate().unwrap();
|
||||
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
|
||||
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_reconstruction_is_not_online_publication() {
|
||||
let validated = book().validate().unwrap();
|
||||
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
|
||||
let mut observed = book();
|
||||
observed.provenance = SignalProvenance::Observed;
|
||||
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
|
||||
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
|
||||
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
|
||||
seal(observed).validate().unwrap().require_observed().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_future_inputs_and_model_knowledge() {
|
||||
for field in 0..3 {
|
||||
let mut value = book();
|
||||
let future = value.snapshots[0].decision_at + Duration::seconds(1);
|
||||
match field {
|
||||
0 => value.snapshots[0].input_as_of = future,
|
||||
1 => value.snapshots[0].input_available_at = future,
|
||||
_ => value.knowledge_cutoff = Some(future),
|
||||
}
|
||||
assert!(value.validate().unwrap_err().contains("future"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_quantities_prices_and_unknown_signal_fields() {
|
||||
for name in ["quantity", "execution_price", "account_id", "cash"] {
|
||||
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
|
||||
action[name] = json!(100);
|
||||
assert!(serde_json::from_value::<SignalAction>(action).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_and_duplicate_actions_fail_closed() {
|
||||
let mut value = book();
|
||||
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
|
||||
assert!(value.validate().unwrap_err().contains("coverage"));
|
||||
let mut value = book();
|
||||
value.snapshots.push(value.snapshots[0].clone());
|
||||
value.expected_decisions.push(value.expected_decisions[0]);
|
||||
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||
let mut value = book();
|
||||
let repeated = value.snapshots[0].actions[0].clone();
|
||||
value.snapshots[0].actions.push(repeated);
|
||||
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
|
||||
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
||||
let mut value = book();
|
||||
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
let mut value = book();
|
||||
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
|
||||
assert!(value.validate().unwrap_err().contains("exposure"));
|
||||
let mut value = book();
|
||||
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_open_uses_decision_session_and_never_nearest_signal() {
|
||||
let value = book().validate().unwrap();
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
|
||||
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
|
||||
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
|
||||
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduction_is_resolved_from_each_accounts_actual_position() {
|
||||
let mut raw = book();
|
||||
raw.snapshots[0].complete_targets = false;
|
||||
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
|
||||
let value = seal(raw).validate().unwrap();
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||
for (held, expected) in [(1000,500),(3000,1500)] {
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
|
||||
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
|
||||
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
|
||||
}
|
||||
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
|
||||
let mut raw = book();
|
||||
raw.snapshots[0].actions.clear();
|
||||
let value = seal(raw).validate().unwrap();
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
|
||||
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_spec_consumes_book_without_running_another_selection() {
|
||||
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
|
||||
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
|
||||
assert!(!config.rotation_enabled && config.signal_book.is_some());
|
||||
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_valid_contents_must_not_reuse_a_version_hash() {
|
||||
let mut raw=book();
|
||||
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
|
||||
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
|
||||
seal(raw).validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_daily_inputs_may_be_published_after_market_close() {
|
||||
let mut raw=book();
|
||||
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
|
||||
raw.snapshots[0].decision_at=raw.expected_decisions[0];
|
||||
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
|
||||
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
|
||||
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
|
||||
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
|
||||
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
|
||||
raw.provenance=SignalProvenance::Observed;
|
||||
seal(raw).validate().unwrap().require_observed().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Candidate provenance and ordering; contains no market-data or broker I/O.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CANDIDATE_SOURCES_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CandidateSourceMode {
|
||||
Manual,
|
||||
FilteredManual,
|
||||
Automatic,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CandidateSourcePriority {
|
||||
#[default]
|
||||
ManualFirst,
|
||||
AutomaticFirst,
|
||||
ListOrder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CandidateSourcePolicy {
|
||||
pub schema_version: u32,
|
||||
pub mode: CandidateSourceMode,
|
||||
#[serde(default)]
|
||||
pub priority: CandidateSourcePriority,
|
||||
#[serde(default)]
|
||||
pub merged_order: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CandidateMember {
|
||||
pub symbol: String,
|
||||
pub manual: bool,
|
||||
pub automatic: bool,
|
||||
}
|
||||
|
||||
fn symbols(values: &[String], label: &str) -> Result<Vec<String>, String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
values.iter().map(|value| {
|
||||
let symbol = value.trim().to_ascii_uppercase();
|
||||
if !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||
code.len() == 6 && code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||
}) {
|
||||
return Err(format!("{label}: invalid qualified security code {value}"));
|
||||
}
|
||||
if !seen.insert(symbol.clone()) {
|
||||
return Err(format!("{label}: duplicate security {symbol}"));
|
||||
}
|
||||
Ok(symbol)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
impl CandidateSourcePolicy {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema_version != CANDIDATE_SOURCES_SCHEMA {
|
||||
return Err("candidate_sources schema_version must be 1".into());
|
||||
}
|
||||
symbols(&self.merged_order, "candidate_sources.merged_order")?;
|
||||
if self.mode != CandidateSourceMode::Mixed && self.priority != CandidateSourcePriority::ManualFirst {
|
||||
return Err("candidate source priority only applies to mixed sources".into());
|
||||
}
|
||||
if self.priority != CandidateSourcePriority::ListOrder && !self.merged_order.is_empty() {
|
||||
return Err("merged_order requires list_order priority".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn uses_screen(&self) -> bool {
|
||||
self.mode != CandidateSourceMode::Manual
|
||||
}
|
||||
|
||||
pub fn validate_screen_binding(&self, manual: &[String], has_screen: bool) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
symbols(manual, "manual candidates")?;
|
||||
if self.uses_screen() != has_screen {
|
||||
return Err("candidate source mode and screen contract must agree".into());
|
||||
}
|
||||
if self.mode == CandidateSourceMode::FilteredManual && manual.is_empty() {
|
||||
return Err("filtered_manual requires manual members; an empty scope must not become all-market".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlap between two valid sources denotes one member with both provenance
|
||||
/// flags. Duplicates *within* a source are invalid evidence, not fixed by dedup.
|
||||
pub fn resolve_candidates(
|
||||
policy: &CandidateSourcePolicy,
|
||||
manual: &[String],
|
||||
automatic: Option<&[String]>,
|
||||
) -> Result<Vec<CandidateMember>, String> {
|
||||
policy.validate_screen_binding(manual, automatic.is_some())?;
|
||||
let manual = symbols(manual, "manual candidates")?;
|
||||
let automatic = automatic.map(|values| symbols(values, "automatic candidates")).transpose()?.unwrap_or_default();
|
||||
let manual_set = manual.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let auto_set = automatic.iter().cloned().collect::<BTreeSet<_>>();
|
||||
if policy.mode == CandidateSourceMode::FilteredManual && !auto_set.is_subset(&manual_set) {
|
||||
return Err("filtered_manual snapshot contains a security outside the manual scope".into());
|
||||
}
|
||||
let mut ordered = match policy.mode {
|
||||
CandidateSourceMode::Manual => manual.clone(),
|
||||
CandidateSourceMode::FilteredManual | CandidateSourceMode::Automatic => automatic.clone(),
|
||||
CandidateSourceMode::Mixed => {
|
||||
let (first, second) = if policy.priority == CandidateSourcePriority::AutomaticFirst {
|
||||
(&automatic, &manual)
|
||||
} else { (&manual, &automatic) };
|
||||
let mut union = first.clone();
|
||||
let mut seen = first.iter().cloned().collect::<BTreeSet<_>>();
|
||||
union.extend(second.iter().filter(|symbol| seen.insert((*symbol).clone())).cloned());
|
||||
union
|
||||
}
|
||||
};
|
||||
if policy.priority == CandidateSourcePriority::ListOrder {
|
||||
let present = ordered.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let prefix = symbols(&policy.merged_order, "candidate_sources.merged_order")?
|
||||
.into_iter().filter(|symbol| present.contains(symbol)).collect::<Vec<_>>();
|
||||
let selected = prefix.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let tail = ordered.into_iter().filter(|symbol| !selected.contains(symbol));
|
||||
ordered = prefix.into_iter().chain(tail).collect();
|
||||
}
|
||||
Ok(ordered.into_iter().map(|symbol| CandidateMember {
|
||||
manual: manual_set.contains(&symbol), automatic: auto_set.contains(&symbol), symbol,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Raw daily automatic candidates remain unchanged. Every resolved list is
|
||||
/// derived by the shared kernel; absent dates never inherit yesterday's list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CandidateSourceBook {
|
||||
pub schema_version: u32,
|
||||
pub policy: CandidateSourcePolicy,
|
||||
pub manual_symbols: Vec<String>,
|
||||
pub automatic_symbols_by_date: BTreeMap<NaiveDate, Vec<String>>,
|
||||
pub source_snapshot_sha256: String,
|
||||
pub source_coverage_sha256: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_symbols: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl CandidateSourceBook {
|
||||
pub fn resolve(&self) -> Result<BTreeMap<NaiveDate, Vec<CandidateMember>>, String> {
|
||||
if self.schema_version != CANDIDATE_SOURCES_SCHEMA || !self.policy.uses_screen() {
|
||||
return Err("candidate source book requires schema 1 and a screened source".into());
|
||||
}
|
||||
for value in [&self.source_snapshot_sha256, &self.source_coverage_sha256] {
|
||||
if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("candidate source book requires snapshot and coverage SHA256".into());
|
||||
}
|
||||
}
|
||||
if self.automatic_symbols_by_date.is_empty() {
|
||||
return Err("candidate source book requires explicit covered trading dates".into());
|
||||
}
|
||||
let scope = self.execution_symbols.as_ref().map(|values| symbols(values, "candidate execution scope")
|
||||
.map(|values| values.into_iter().collect::<BTreeSet<_>>())).transpose()?;
|
||||
self.automatic_symbols_by_date.iter().map(|(day, values)| {
|
||||
resolve_candidates(&self.policy, &self.manual_symbols, Some(values))
|
||||
.map(|members| (*day, members.into_iter().filter(|member| scope.as_ref().is_none_or(|scope| scope.contains(&member.symbol))).collect()))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn resolved_symbols(&self) -> Result<BTreeMap<NaiveDate, Vec<String>>, String> {
|
||||
Ok(self.resolve()?.into_iter().map(|(date, values)|
|
||||
(date, values.into_iter().map(|member| member.symbol).collect())).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn list(values: &[&str]) -> Vec<String> { values.iter().map(|value| value.to_string()).collect() }
|
||||
fn policy(mode: CandidateSourceMode, priority: CandidateSourcePriority) -> CandidateSourcePolicy {
|
||||
CandidateSourcePolicy { schema_version: 1, mode, priority, merged_order: vec![] }
|
||||
}
|
||||
#[test]
|
||||
fn mixed_sources_preserve_priority_and_both_provenances() {
|
||||
let manual = list(&["600000.SH", "000001.SZ"]);
|
||||
let automatic = list(&["000002.SZ", "000001.SZ"]);
|
||||
for (priority, expected) in [
|
||||
(CandidateSourcePriority::ManualFirst, list(&["600000.SH", "000001.SZ", "000002.SZ"])),
|
||||
(CandidateSourcePriority::AutomaticFirst, list(&["000002.SZ", "000001.SZ", "600000.SH"])),
|
||||
] {
|
||||
let result = resolve_candidates(&policy(CandidateSourceMode::Mixed, priority), &manual, Some(&automatic)).unwrap();
|
||||
assert_eq!(result.iter().map(|value| value.symbol.clone()).collect::<Vec<_>>(), expected);
|
||||
let overlap = result.iter().find(|value| value.symbol == "000001.SZ").unwrap();
|
||||
assert!(overlap.manual && overlap.automatic);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn list_order_reuses_explicit_prefix_and_appends_new_candidates() {
|
||||
let mut p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ListOrder);
|
||||
p.merged_order = list(&["000002.SZ", "600036.SH", "600000.SH"]);
|
||||
let result = resolve_candidates(&p, &list(&["600000.SH", "000001.SZ"]), Some(&list(&["000002.SZ", "000003.SZ"]))).unwrap();
|
||||
assert_eq!(result.into_iter().map(|row| row.symbol).collect::<Vec<_>>(), list(&["000002.SZ", "600000.SH", "000001.SZ", "000003.SZ"]));
|
||||
}
|
||||
#[test]
|
||||
fn missing_snapshot_duplicate_input_and_empty_filtered_scope_fail() {
|
||||
let p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ManualFirst);
|
||||
assert!(resolve_candidates(&p, &[], None).is_err());
|
||||
assert!(resolve_candidates(&p, &[], Some(&list(&["000001.SZ", "000001.sz"]))).is_err());
|
||||
let p = policy(CandidateSourceMode::FilteredManual, CandidateSourcePriority::ManualFirst);
|
||||
assert!(resolve_candidates(&p, &[], Some(&[])).unwrap_err().contains("all-market"));
|
||||
assert!(resolve_candidates(&p, &list(&["000001.SZ"]), Some(&list(&["600000.SH"]))).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn zero_automatic_day_keeps_manual_members_without_inheriting_old_auto_targets() {
|
||||
let day1 = NaiveDate::from_ymd_opt(2026, 9, 9).unwrap();
|
||||
let day2 = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||
let book = CandidateSourceBook { schema_version: 1,
|
||||
policy: policy(CandidateSourceMode::Mixed, CandidateSourcePriority::AutomaticFirst),
|
||||
manual_symbols: list(&["510300.SH"]),
|
||||
automatic_symbols_by_date: BTreeMap::from([(day1, list(&["000001.SZ"])), (day2, vec![])]),
|
||||
source_snapshot_sha256: "a".repeat(64), source_coverage_sha256: "b".repeat(64), execution_symbols:None };
|
||||
let result = book.resolved_symbols().unwrap();
|
||||
assert_eq!(result[&day1], list(&["000001.SZ", "510300.SH"]));
|
||||
assert_eq!(result[&day2], list(&["510300.SH"]));
|
||||
let mut auto = book; auto.policy = policy(CandidateSourceMode::Automatic, CandidateSourcePriority::ManualFirst);
|
||||
assert!(auto.resolved_symbols().unwrap()[&day2].is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
//! Dated non-tradability and valuation are separate from execution quotations.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn validate(
|
||||
signal_date: NaiveDate,
|
||||
constraints: &StockPoolDecisionConstraints,
|
||||
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
|
||||
) -> Result<(), String> {
|
||||
for (symbol, fact) in &constraints.frozen_positions {
|
||||
if constraints.execution_date != Some(fact.trade_date)
|
||||
|| fact.trade_date < signal_date
|
||||
|| fact.reason != "paused"
|
||||
|| fact.valuation_price <= Decimal::ZERO
|
||||
|| current.get(symbol).is_none_or(|row| row.0 <= Decimal::ZERO)
|
||||
{
|
||||
return Err(format!("stock_pool_frozen_position_invalid:{symbol}"));
|
||||
}
|
||||
}
|
||||
if constraints
|
||||
.prior_target_weights
|
||||
.iter()
|
||||
.any(|(symbol, weight)| {
|
||||
normalize_stock_symbol(symbol).as_ref() != Some(symbol)
|
||||
|| !(0..=10_000).contains(weight)
|
||||
})
|
||||
{
|
||||
return Err("stock_pool_prior_target_weights_invalid".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn valuation(
|
||||
symbol: &str,
|
||||
quotes: &HashMap<String, &MarketSnapshot>,
|
||||
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
|
||||
) -> Result<Decimal, String> {
|
||||
frozen
|
||||
.get(symbol)
|
||||
.map(|fact| fact.valuation_price)
|
||||
.or_else(|| quotes.get(symbol).map(|quote| quote.last_price))
|
||||
.filter(|price| *price > Decimal::ZERO)
|
||||
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
|
||||
}
|
||||
|
||||
pub(super) fn weights(
|
||||
original: &[String],
|
||||
active: &[String],
|
||||
members: &[StockPoolMemberSpec],
|
||||
explicit: &BTreeMap<String, i32>,
|
||||
constraints: &StockPoolDecisionConstraints,
|
||||
reserved_slots: usize,
|
||||
target_count: usize,
|
||||
) -> Result<BTreeMap<String, i32>, String> {
|
||||
let count = original.len() + reserved_slots;
|
||||
let order = members
|
||||
.iter()
|
||||
.map(|member| (&member.symbol, member.requested_order))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut original_budget_symbols = original.to_vec();
|
||||
for symbol in constraints.frozen_positions.keys() {
|
||||
if order.contains_key(symbol) && !original_budget_symbols.contains(symbol) {
|
||||
original_budget_symbols.push(symbol.clone());
|
||||
}
|
||||
}
|
||||
if original_budget_symbols.len() != original.len() {
|
||||
original_budget_symbols
|
||||
.sort_by_key(|symbol| order.get(symbol).copied().unwrap_or(i32::MAX));
|
||||
}
|
||||
let initial = original_budget_symbols
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| {
|
||||
let weight = if explicit.is_empty() {
|
||||
if count == 0 {
|
||||
0
|
||||
} else {
|
||||
10_000 / count as i32 + i32::from(index < 10_000 % count)
|
||||
}
|
||||
} else {
|
||||
*explicit.get(symbol).unwrap_or(&0)
|
||||
};
|
||||
(symbol.clone(), weight)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut frozen = BTreeMap::new();
|
||||
for symbol in constraints.frozen_positions.keys() {
|
||||
let weight = explicit
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.or_else(|| constraints.prior_target_weights.get(symbol).copied())
|
||||
.or_else(|| {
|
||||
initial
|
||||
.iter()
|
||||
.find(|(key, _)| key == symbol)
|
||||
.map(|(_, weight)| *weight)
|
||||
})
|
||||
.ok_or_else(|| format!("stock_pool_frozen_position_target_weight_missing:{symbol}"))?;
|
||||
frozen.insert(symbol.clone(), weight);
|
||||
}
|
||||
let frozen_total = frozen.values().copied().sum::<i32>();
|
||||
if frozen_total > 10_000 {
|
||||
return Err("stock_pool_frozen_position_weights_exceed_budget".into());
|
||||
}
|
||||
let mut free = initial
|
||||
.into_iter()
|
||||
.filter(|(symbol, _)| !frozen.contains_key(symbol))
|
||||
.map(|(symbol, weight)| (symbol, weight as u32))
|
||||
.collect::<Vec<_>>();
|
||||
let total = free.iter().map(|(_, weight)| *weight).sum::<u32>();
|
||||
let available = (10_000 - frozen_total) as u32;
|
||||
// A paused holding removed from today's candidates still owns its prior
|
||||
// budget. Scale only the new tradable allocation, never the frozen leg.
|
||||
if total > available {
|
||||
let mut remainder = available;
|
||||
for (_, weight) in &mut free {
|
||||
*weight = (u64::from(*weight) * u64::from(available) / u64::from(total)) as u32;
|
||||
remainder -= *weight;
|
||||
}
|
||||
for (_, weight) in free.iter_mut().take(remainder as usize) {
|
||||
*weight += 1;
|
||||
}
|
||||
}
|
||||
let excluded = free
|
||||
.iter()
|
||||
.filter(|(symbol, _)| !active.contains(symbol))
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect();
|
||||
let candidates = active
|
||||
.iter()
|
||||
.filter(|symbol| !frozen.contains_key(*symbol))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let allocated = crate::platform_expr_strategy::replenish_target_weight_bps(
|
||||
&free,
|
||||
&candidates,
|
||||
&excluded,
|
||||
target_count.saturating_sub(
|
||||
frozen
|
||||
.keys()
|
||||
.filter(|symbol| original.contains(symbol))
|
||||
.count(),
|
||||
),
|
||||
);
|
||||
frozen.extend(
|
||||
allocated
|
||||
.into_iter()
|
||||
.map(|(symbol, weight)| (symbol, weight as i32)),
|
||||
);
|
||||
Ok(frozen)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Index exposure is independent of relative-weight maintenance. Reduce the
|
||||
//! existing proportions only when the portfolio exceeds its index budget.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct IndexCapTarget {
|
||||
pub quantity: Decimal,
|
||||
pub blocked_by_t1: bool,
|
||||
}
|
||||
|
||||
pub(super) fn remaining_index_targets(
|
||||
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
|
||||
members: &HashMap<String, &StockPoolMemberSpec>,
|
||||
automatic: &BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
|
||||
already_planned: &[StockPoolPlanRow],
|
||||
quotes: &HashMap<String, &MarketSnapshot>,
|
||||
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
|
||||
budget: Decimal,
|
||||
) -> Result<BTreeMap<String, IndexCapTarget>, String> {
|
||||
struct Entry {
|
||||
symbol: String,
|
||||
quantity: Decimal,
|
||||
minimum: Decimal,
|
||||
price: Decimal,
|
||||
}
|
||||
let other = already_planned
|
||||
.iter()
|
||||
.map(|row| (&row.symbol, row))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut fixed = Decimal::ZERO;
|
||||
let mut entries = Vec::new();
|
||||
for (symbol, (quantity, closable, _)) in current.iter().filter(|(_, row)| row.0 > Decimal::ZERO)
|
||||
{
|
||||
let price = super::frozen::valuation(symbol, quotes, frozen)?;
|
||||
let minimum = (*quantity - *closable).max(Decimal::ZERO);
|
||||
let remaining = other
|
||||
.get(symbol)
|
||||
.map(|row| row.target_quantity.min(*quantity))
|
||||
.unwrap_or(*quantity)
|
||||
.max(minimum);
|
||||
if frozen.contains_key(symbol)
|
||||
|| automatic
|
||||
.get(symbol)
|
||||
.is_some_and(|permission| permission.sell_denial.is_some())
|
||||
{
|
||||
fixed += *quantity * price;
|
||||
} else if members.contains_key(symbol) && remaining > Decimal::ZERO {
|
||||
entries.push(Entry {
|
||||
symbol: symbol.clone(),
|
||||
quantity: remaining,
|
||||
minimum,
|
||||
price,
|
||||
});
|
||||
} else {
|
||||
fixed += remaining * price;
|
||||
}
|
||||
}
|
||||
let mut remaining = entries
|
||||
.iter()
|
||||
.map(|row| row.quantity * row.price)
|
||||
.sum::<Decimal>();
|
||||
let mut available = (budget - fixed).max(Decimal::ZERO);
|
||||
if remaining <= available {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
// The highest non-sellable proportions are fixed first; the remainder
|
||||
// keeps its existing relative weights. No planned sale funds a new buy.
|
||||
entries.sort_by(|left, right| {
|
||||
(right.minimum / right.quantity)
|
||||
.cmp(&(left.minimum / left.quantity))
|
||||
.then_with(|| left.symbol.cmp(&right.symbol))
|
||||
});
|
||||
let mut result = BTreeMap::new();
|
||||
for row in entries {
|
||||
let scale = if remaining > Decimal::ZERO {
|
||||
(available / remaining).min(Decimal::ONE)
|
||||
} else {
|
||||
Decimal::ZERO
|
||||
};
|
||||
let desired = (row.quantity * scale).floor();
|
||||
let blocked = desired < row.minimum;
|
||||
let target = desired.max(row.minimum).min(row.quantity);
|
||||
remaining -= row.quantity * row.price;
|
||||
available = (available - target * row.price).max(Decimal::ZERO);
|
||||
result.insert(
|
||||
row.symbol,
|
||||
IndexCapTarget {
|
||||
quantity: target,
|
||||
blocked_by_t1: blocked,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Explicit index timing, shared by historical and online stock-pool planners.
|
||||
//! Inputs are completed official sessions, not a shortened available-row window.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MarketTimingPolicy {
|
||||
pub enabled: bool,
|
||||
pub index_code: Option<String>,
|
||||
pub fast_window: Option<usize>,
|
||||
pub slow_window: Option<usize>,
|
||||
pub volatility_window: Option<usize>,
|
||||
pub drawdown_window: Option<usize>,
|
||||
pub bull_exposure: Option<f64>,
|
||||
pub bear_exposure: Option<f64>,
|
||||
pub volatility_threshold: Option<f64>,
|
||||
pub volatility_cap: Option<f64>,
|
||||
pub drawdown_threshold: Option<f64>,
|
||||
pub drawdown_cap: Option<f64>,
|
||||
}
|
||||
|
||||
impl MarketTimingPolicy {
|
||||
pub fn from_allocation(value: &serde_json::Value) -> Result<Self, String> {
|
||||
let raw = value.get("market_timing").filter(|value| !value.is_null());
|
||||
let policy = raw
|
||||
.map(|raw| serde_json::from_value::<Self>(raw.clone()))
|
||||
.transpose()
|
||||
.map_err(|error| format!("market_timing_invalid:{error}"))?
|
||||
.unwrap_or_default();
|
||||
policy.validate()?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let Some(index) = &self.index_code {
|
||||
let valid = index.split_once('.').is_some_and(|(code, exchange)| {
|
||||
(6..=12).contains(&code.len())
|
||||
&& code
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_uppercase() || v.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
|
||||
});
|
||||
if !valid {
|
||||
return Err("market_timing_index_code_invalid".into());
|
||||
}
|
||||
} else if self.enabled {
|
||||
return Err("market_timing_index_code_required".into());
|
||||
}
|
||||
for (key, value) in [
|
||||
("fast_window", self.fast_window),
|
||||
("slow_window", self.slow_window),
|
||||
("volatility_window", self.volatility_window),
|
||||
("drawdown_window", self.drawdown_window),
|
||||
] {
|
||||
match value {
|
||||
Some(value) if !(2..=250).contains(&value) => {
|
||||
return Err(format!("market_timing_{key}_must_be_2_to_250"));
|
||||
}
|
||||
None if self.enabled => return Err(format!("market_timing_{key}_required")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(fast), Some(slow)) = (self.fast_window, self.slow_window) {
|
||||
if fast >= slow {
|
||||
return Err("market_timing_fast_window_must_be_less_than_slow_window".into());
|
||||
}
|
||||
}
|
||||
for (key, value) in [
|
||||
("bull_exposure", self.bull_exposure),
|
||||
("bear_exposure", self.bear_exposure),
|
||||
("volatility_threshold", self.volatility_threshold),
|
||||
("volatility_cap", self.volatility_cap),
|
||||
("drawdown_threshold", self.drawdown_threshold),
|
||||
("drawdown_cap", self.drawdown_cap),
|
||||
] {
|
||||
match value {
|
||||
Some(value) if !value.is_finite() || !(0.0..=1.0).contains(&value) => {
|
||||
return Err(format!("market_timing_{key}_must_be_in_0_to_1"));
|
||||
}
|
||||
None if self.enabled => return Err(format!("market_timing_{key}_required")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn required_history(&self) -> Result<usize, String> {
|
||||
self.validate()?;
|
||||
if !self.enabled {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(self
|
||||
.slow_window
|
||||
.unwrap()
|
||||
.max(self.volatility_window.unwrap() + 1)
|
||||
.max(self.drawdown_window.unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexClose {
|
||||
pub date: NaiveDate,
|
||||
pub close: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MarketTimingInput {
|
||||
pub index_code: String,
|
||||
pub as_of_date: NaiveDate,
|
||||
pub official_dates: Vec<NaiveDate>,
|
||||
pub closes: Vec<IndexClose>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MarketTimingEvaluation {
|
||||
pub index_code: String,
|
||||
pub as_of_date: NaiveDate,
|
||||
pub window_start: NaiveDate,
|
||||
pub session_count: usize,
|
||||
pub fast_ma: f64,
|
||||
pub slow_ma: f64,
|
||||
pub volatility: f64,
|
||||
pub drawdown: f64,
|
||||
pub exposure: f64,
|
||||
pub reason_codes: Vec<String>,
|
||||
pub policy_sha256: String,
|
||||
pub input_sha256: String,
|
||||
pub kernel_sha256: String,
|
||||
}
|
||||
|
||||
pub fn implementation_sha256() -> String {
|
||||
format!(
|
||||
"{:x}",
|
||||
Sha256::digest(include_bytes!("stock_pool_index_policy.rs"))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
policy: &MarketTimingPolicy,
|
||||
input: &MarketTimingInput,
|
||||
decision_date: NaiveDate,
|
||||
) -> Result<MarketTimingEvaluation, String> {
|
||||
let needed = policy.required_history()?;
|
||||
if needed == 0 {
|
||||
return Err("market_timing_disabled_has_no_evaluation".into());
|
||||
}
|
||||
if policy.index_code.as_deref() != Some(input.index_code.as_str()) {
|
||||
return Err("market_timing_input_index_mismatch".into());
|
||||
}
|
||||
if input.as_of_date > decision_date {
|
||||
return Err("market_timing_future_input".into());
|
||||
}
|
||||
if input.official_dates.len() != needed
|
||||
|| input.closes.len() != needed
|
||||
|| input.official_dates.last() != Some(&input.as_of_date)
|
||||
|| input
|
||||
.official_dates
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] >= pair[1])
|
||||
{
|
||||
return Err("market_timing_official_calendar_incomplete".into());
|
||||
}
|
||||
if input
|
||||
.closes
|
||||
.iter()
|
||||
.zip(&input.official_dates)
|
||||
.any(|(row, date)| row.date != *date || !row.close.is_finite() || row.close <= 0.)
|
||||
{
|
||||
return Err("market_timing_completed_index_rows_incomplete".into());
|
||||
}
|
||||
let closes = input.closes.iter().map(|row| row.close).collect::<Vec<_>>();
|
||||
let mean = |window: usize| closes[needed - window..].iter().sum::<f64>() / window as f64;
|
||||
let fast_ma = mean(policy.fast_window.unwrap());
|
||||
let slow_ma = mean(policy.slow_window.unwrap());
|
||||
let returns = closes[needed - policy.volatility_window.unwrap() - 1..]
|
||||
.windows(2)
|
||||
.map(|pair| pair[1] / pair[0] - 1.)
|
||||
.collect::<Vec<_>>();
|
||||
let average = returns.iter().sum::<f64>() / returns.len() as f64;
|
||||
let volatility = (returns
|
||||
.iter()
|
||||
.map(|value| (value - average).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (returns.len() - 1) as f64)
|
||||
.sqrt();
|
||||
let peak = closes[needed - policy.drawdown_window.unwrap()..]
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let drawdown = 1. - closes[needed - 1] / peak;
|
||||
let bullish = fast_ma > slow_ma;
|
||||
let mut exposure = if bullish {
|
||||
policy.bull_exposure.unwrap()
|
||||
} else {
|
||||
policy.bear_exposure.unwrap()
|
||||
};
|
||||
let mut reason_codes = vec![
|
||||
if bullish {
|
||||
"fast_above_slow"
|
||||
} else {
|
||||
"fast_not_above_slow"
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
if volatility >= policy.volatility_threshold.unwrap() {
|
||||
exposure = exposure.min(policy.volatility_cap.unwrap());
|
||||
reason_codes.push("volatility_cap".into());
|
||||
}
|
||||
if drawdown >= policy.drawdown_threshold.unwrap() {
|
||||
exposure = exposure.min(policy.drawdown_cap.unwrap());
|
||||
reason_codes.push("drawdown_cap".into());
|
||||
}
|
||||
if [fast_ma, slow_ma, volatility, drawdown, exposure]
|
||||
.iter()
|
||||
.any(|value| !value.is_finite())
|
||||
{
|
||||
return Err("market_timing_nonfinite_result".into());
|
||||
}
|
||||
let hash = |value: &serde_json::Value| -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
|
||||
))
|
||||
};
|
||||
Ok(MarketTimingEvaluation {
|
||||
index_code: input.index_code.clone(),
|
||||
as_of_date: input.as_of_date,
|
||||
window_start: input.official_dates[0],
|
||||
session_count: needed,
|
||||
fast_ma,
|
||||
slow_ma,
|
||||
volatility,
|
||||
drawdown,
|
||||
exposure,
|
||||
reason_codes,
|
||||
policy_sha256: hash(&serde_json::to_value(policy).map_err(|error| error.to_string())?)?,
|
||||
input_sha256: hash(&serde_json::to_value(input).map_err(|error| error.to_string())?)?,
|
||||
kernel_sha256: implementation_sha256(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn policy() -> MarketTimingPolicy {
|
||||
serde_json::from_value(serde_json::json!({"enabled":true,"index_code":"000300.SH","fast_window":10,"slow_window":30,
|
||||
"volatility_window":20,"drawdown_window":60,"bull_exposure":1.0,"bear_exposure":0.3,"volatility_threshold":0.025,
|
||||
"volatility_cap":0.3,"drawdown_threshold":0.08,"drawdown_cap":0.2})).unwrap()
|
||||
}
|
||||
fn input(values: Vec<f64>) -> MarketTimingInput {
|
||||
let dates = (0..values.len())
|
||||
.map(|i| {
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i as i64)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
MarketTimingInput {
|
||||
index_code: "000300.SH".into(),
|
||||
as_of_date: *dates.last().unwrap(),
|
||||
closes: dates
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(date, close)| IndexClose { date: *date, close })
|
||||
.collect(),
|
||||
official_dates: dates,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn trend_and_caps_match_the_declared_math_and_keep_zero_meaningful() {
|
||||
for (prices, expected) in [
|
||||
((0..60).map(|i| 100. + i as f64).collect(), 1.),
|
||||
((0..60).map(|i| 200. - i as f64).collect(), 0.2),
|
||||
(
|
||||
(0..59)
|
||||
.map(|i| 100. + i as f64 * 0.1)
|
||||
.chain([150.])
|
||||
.collect(),
|
||||
0.3,
|
||||
),
|
||||
] {
|
||||
let input = input(prices);
|
||||
let result = evaluate(&policy(), &input, input.as_of_date).unwrap();
|
||||
assert_eq!(result.exposure, expected);
|
||||
assert_eq!(result.session_count, 60);
|
||||
assert_eq!(result.input_sha256.len(), 64);
|
||||
}
|
||||
let input = input(vec![100.; 60]);
|
||||
let mut zero = policy();
|
||||
zero.bear_exposure = Some(0.);
|
||||
assert_eq!(
|
||||
evaluate(&zero, &input, input.as_of_date).unwrap().exposure,
|
||||
0.
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn missing_or_duplicate_or_future_rows_never_shorten_the_window() {
|
||||
let base = input(vec![100.; 60]);
|
||||
let mut invalid = base.clone();
|
||||
invalid.closes.remove(5);
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
let mut invalid = base.clone();
|
||||
invalid.closes[5].date = invalid.closes[4].date;
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
let mut invalid = base.clone();
|
||||
invalid.closes[5].close = f64::NAN;
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
assert!(
|
||||
evaluate(
|
||||
&policy(),
|
||||
&base,
|
||||
base.as_of_date - chrono::Duration::days(1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut invalid = base.clone();
|
||||
invalid.index_code = "000852.SH".into();
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn disabled_is_optional_but_enabled_parameters_are_not_invented() {
|
||||
assert_eq!(
|
||||
MarketTimingPolicy::from_allocation(&serde_json::Value::Null)
|
||||
.unwrap()
|
||||
.required_history()
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
MarketTimingPolicy::from_allocation(
|
||||
&serde_json::json!({"market_timing":{"enabled":true}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut invalid = policy();
|
||||
invalid.fast_window = invalid.slow_window;
|
||||
assert!(invalid.validate().is_err());
|
||||
let mut invalid = policy();
|
||||
invalid.volatility_window = Some(250);
|
||||
assert_eq!(invalid.required_history().unwrap(), 251);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Shared causal indicator calculation for stock-pool screening.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::factor_events::{self, Expr, Frame};
|
||||
|
||||
pub fn implementation_sha256() -> String {
|
||||
use sha2::{Digest,Sha256};
|
||||
let mut identity=Sha256::new();
|
||||
identity.update(include_bytes!("stock_pool_indicators.rs"));
|
||||
identity.update(factor_events::catalog()["expression_kernel_sha256"].as_str().expect("native kernel identity"));
|
||||
format!("{:x}",identity.finalize())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndicatorSpec {
|
||||
pub indicator: String,
|
||||
pub field: String,
|
||||
pub window: usize,
|
||||
#[serde(default)]
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InputSeries {
|
||||
pub frame: Frame,
|
||||
/// Source-admitted historical suspension/lifecycle gaps, not guessed from
|
||||
/// missing prices. The pure endpoint never certifies those source facts.
|
||||
#[serde(default)]
|
||||
pub admitted_missing_dates: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub specs: BTreeMap<String, IndicatorSpec>,
|
||||
pub series: Vec<InputSeries>,
|
||||
pub output_start_date: NaiveDate,
|
||||
pub output_end_date: NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OutputSeries {
|
||||
pub symbol: String,
|
||||
pub indices: Vec<usize>,
|
||||
pub values: BTreeMap<String, Vec<Option<f64>>>,
|
||||
}
|
||||
|
||||
pub fn evaluate(request: Request) -> Result<Vec<OutputSeries>, String> {
|
||||
let row_count: usize = request.series.iter().map(|series| series.frame.timestamps.len()).sum();
|
||||
if request.specs.is_empty() || request.specs.len() > 64 || row_count > 60_000
|
||||
|| row_count.saturating_mul(request.specs.len()) > 1_000_000
|
||||
|| request.output_start_date > request.output_end_date {
|
||||
return Err("stock_pool_indicator_request_budget_or_range_invalid".into());
|
||||
}
|
||||
for spec in request.specs.values() {
|
||||
if !matches!(spec.indicator.as_str(), "ma" | "ema" | "kdj") || !(2..=10_000).contains(&spec.window)
|
||||
|| !matches!(spec.field.as_str(), "close" | "volume" | "high" | "low")
|
||||
|| (spec.indicator == "kdj" && !matches!(spec.output.as_deref(), Some("k" | "d" | "j"))) {
|
||||
return Err("stock_pool_indicator_spec_invalid".into());
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut result = Vec::new();
|
||||
for series in request.series {
|
||||
let frame = series.frame;
|
||||
frame.validate()?;
|
||||
if frame.frequency != "1d" { return Err("stock_pool_indicators_require_daily_source".into()); }
|
||||
if series.admitted_missing_dates.iter().any(|day| !frame.timestamps.iter().any(|stamp| stamp.date_naive() == *day)) {
|
||||
return Err("admitted_missing_date_is_outside_the_input_frame".into());
|
||||
}
|
||||
if !seen.insert(frame.symbol.clone()) { return Err("stock_pool_indicator_duplicate_symbol".into()); }
|
||||
let indices = frame.timestamps.iter().enumerate().filter(|(_, stamp)| {
|
||||
let day = stamp.date_naive(); request.output_start_date <= day && day <= request.output_end_date
|
||||
}).map(|(index, _)| index).collect::<Vec<_>>();
|
||||
let mut values = BTreeMap::new();
|
||||
for (key, spec) in &request.specs {
|
||||
let fields: Vec<&str> = if spec.indicator == "kdj" { vec!["high", "low", "close"] } else { vec![&spec.field] };
|
||||
for field in &fields {
|
||||
let raw = frame.fields.get(*field).ok_or_else(|| format!("indicator_field_missing:{field}"))?;
|
||||
if raw.len() != frame.timestamps.len() { return Err("indicator_field_length_mismatch".into()); }
|
||||
}
|
||||
for index in 0..frame.timestamps.len() {
|
||||
let valid = fields.iter().all(|field| frame.fields[*field][index].is_some_and(f64::is_finite));
|
||||
if valid {
|
||||
if fields.iter().any(|field| frame.fields[*field][index].is_some_and(|value| if *field == "volume" { value < 0.0 } else { value <= 0.0 })) {
|
||||
return Err(format!("indicator_input_value_invalid:{}:{}", frame.symbol, frame.timestamps[index]));
|
||||
}
|
||||
if spec.indicator == "kdj" {
|
||||
let (hi, lo, close) = (frame.fields["high"][index].unwrap(), frame.fields["low"][index].unwrap(), frame.fields["close"][index].unwrap());
|
||||
if hi < lo || close < lo || close > hi { return Err(format!("indicator_ohlc_invalid:{}:{}", frame.symbol, frame.timestamps[index])); }
|
||||
}
|
||||
}
|
||||
else if !series.admitted_missing_dates.contains(&frame.timestamps[index].date_naive()) {
|
||||
return Err(format!("unclassified_indicator_input_gap:{}:{}", frame.symbol, frame.timestamps[index]));
|
||||
}
|
||||
}
|
||||
let native_values = {
|
||||
// The native KDJ has its own initialisation and lookback. Do
|
||||
// not recreate it as STOCH or an independently seeded loop.
|
||||
// Preserve admitted NULL rows too: the shared kernel defines
|
||||
// gap/warmup semantics; compressing the calendar changes them.
|
||||
let expression: Expr = serde_json::from_value(if spec.indicator == "kdj" {
|
||||
json!({"kind":"indicator","name":"KDJ","inputs":[],
|
||||
"parameters":{"optInFastK_Period":spec.window,"optInSlowK_Period":3,
|
||||
"optInSlowK_MAType":13,"optInSlowD_Period":3,"optInSlowD_MAType":13},
|
||||
"output":match spec.output.as_deref() { Some("k") => 0, Some("d") => 1, _ => 2 }})
|
||||
} else {
|
||||
json!({"kind":"indicator","name":if spec.indicator == "ma" {"SMA"} else {"EMA"},
|
||||
"inputs":[{"kind":"field","name":spec.field}],"parameters":{"optInTimePeriod":spec.window}})
|
||||
}).map_err(|error| error.to_string())?;
|
||||
factor_events::evaluate(&expression, &frame)?.values
|
||||
};
|
||||
values.insert(key.clone(), indices.iter().map(|index| native_values[*index]).collect());
|
||||
}
|
||||
result.push(OutputSeries { symbol: frame.symbol, indices, values });
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn request() -> Request {
|
||||
serde_json::from_value(json!({"specs":{"ma":{"indicator":"ma","field":"close","window":3},"ema":{"indicator":"ema","field":"close","window":3},"j":{"indicator":"kdj","field":"close","window":3,"output":"j"}},
|
||||
"series":[{"frame":{"symbol":"000001.SZ","frequency":"1d","decision_at":"2026-09-10T17:00:00+08:00",
|
||||
"timestamps":["2026-09-07T15:00:00+08:00","2026-09-08T15:00:00+08:00","2026-09-09T15:00:00+08:00","2026-09-10T15:00:00+08:00"],
|
||||
"available_at":["2026-09-07T16:00:00+08:00","2026-09-08T16:00:00+08:00","2026-09-09T16:00:00+08:00","2026-09-10T16:00:00+08:00"],
|
||||
"fields":{"close":[1.,2.,3.,4.],"high":[2.,3.,4.,5.],"low":[0.5,1.,2.,3.]}}}],
|
||||
"output_start_date":"2026-09-09","output_end_date":"2026-09-10"})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn uses_shared_ma_ema_and_preserves_recursive_prefix_before_output_window() {
|
||||
let rows = evaluate(request()).unwrap();
|
||||
assert_eq!(rows[0].indices,vec![2,3]);
|
||||
assert_eq!(rows[0].values["ma"],vec![Some(2.),Some(3.)]);
|
||||
assert_eq!(rows[0].values["ema"],vec![Some(2.),Some(3.)]);
|
||||
assert_eq!(rows[0].values["j"], vec![None, None]); // KDJ has a longer native lookback.
|
||||
let mut later = request(); later.output_start_date = NaiveDate::from_ymd_opt(2026,9,10).unwrap();
|
||||
assert_eq!(evaluate(later).unwrap()[0].values["ema"],vec![rows[0].values["ema"][1]]);
|
||||
}
|
||||
#[test]
|
||||
fn kdj_uses_the_same_frozen_native_kernel_as_event_conditions() {
|
||||
let mut data = request();
|
||||
let frame = &mut data.series[0].frame;
|
||||
for i in 4..16 {
|
||||
let day = NaiveDate::from_ymd_opt(2026,9,7).unwrap() + chrono::Duration::days(i);
|
||||
frame.timestamps.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T15:00:00+08:00")).unwrap());
|
||||
frame.available_at.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T16:00:00+08:00")).unwrap());
|
||||
frame.fields.get_mut("close").unwrap().push(Some(i as f64 + 1.));
|
||||
frame.fields.get_mut("high").unwrap().push(Some(i as f64 + 2.));
|
||||
frame.fields.get_mut("low").unwrap().push(Some(i as f64));
|
||||
}
|
||||
frame.decision_at = chrono::DateTime::parse_from_rfc3339("2026-09-23T17:00:00+08:00").unwrap();
|
||||
data.output_start_date=NaiveDate::from_ymd_opt(2026,9,7).unwrap();
|
||||
data.output_end_date=NaiveDate::from_ymd_opt(2026,9,22).unwrap();
|
||||
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"KDJ","inputs":[],"parameters":{"optInFastK_Period":3},"output":2})).unwrap();
|
||||
let expected=factor_events::evaluate(&native,frame).unwrap().values;
|
||||
assert!(expected.iter().any(Option::is_some));
|
||||
assert_eq!(evaluate(data).unwrap()[0].values["j"],expected);
|
||||
}
|
||||
#[test]
|
||||
fn missing_inputs_require_source_admission_and_future_inputs_fail() {
|
||||
let mut data = request(); data.series[0].frame.fields.get_mut("close").unwrap()[1] = None;
|
||||
assert!(evaluate(data).unwrap_err().contains("unclassified_indicator_input_gap"));
|
||||
let mut data = request(); data.series[0].frame.available_at[3] = chrono::DateTime::parse_from_rfc3339("2026-09-11T16:00:00+08:00").unwrap();
|
||||
assert!(evaluate(data).is_err());
|
||||
let mut data=request();data.series[0].frame.fields.get_mut("close").unwrap()[1]=None;
|
||||
data.series[0].admitted_missing_dates.insert(NaiveDate::from_ymd_opt(2026,9,8).unwrap());
|
||||
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"EMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}})).unwrap();
|
||||
let expected=factor_events::evaluate(&native,&data.series[0].frame).unwrap().values;
|
||||
assert_eq!(expected[2..], [None,None]);
|
||||
assert_eq!(evaluate(data).unwrap()[0].values["ema"],expected[2..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Configurable index-to-market-cap band. Values are CNY, not implicit yi.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexMarketCapPolicy {
|
||||
pub schema_version: u32,
|
||||
pub index_code: String,
|
||||
pub field: String,
|
||||
pub value_unit: String,
|
||||
pub index_low: f64,
|
||||
pub index_high: f64,
|
||||
pub lower_at_low: f64,
|
||||
pub lower_at_high: f64,
|
||||
pub upper_at_low: f64,
|
||||
pub upper_at_high: f64,
|
||||
}
|
||||
|
||||
impl IndexMarketCapPolicy {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema_version != 1 || self.value_unit != "CNY"
|
||||
|| !matches!(self.field.as_str(), "market_cap" | "float_market_cap")
|
||||
{ return Err("index_market_cap_contract_invalid".into()); }
|
||||
let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| {
|
||||
(6..=12).contains(&code.len())
|
||||
&& code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
|
||||
});
|
||||
if !index { return Err("index_market_cap_index_invalid".into()); }
|
||||
if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high]
|
||||
.iter().any(|value| !value.is_finite() || *value <= 0.)
|
||||
|| self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low
|
||||
|| self.lower_at_high > self.upper_at_high
|
||||
{ return Err("index_market_cap_bounds_invalid".into()); }
|
||||
Ok(())
|
||||
}
|
||||
pub fn band(&self, close: f64) -> Result<(f64, f64), String> {
|
||||
self.validate()?;
|
||||
if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); }
|
||||
let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low);
|
||||
Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low),
|
||||
self.upper_at_low + t*(self.upper_at_high-self.upper_at_low)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 }
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub policy: IndexMarketCapPolicy,
|
||||
pub official_dates: Vec<NaiveDate>,
|
||||
pub index_code: String,
|
||||
pub closes: Vec<IndexMarketCapRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 }
|
||||
|
||||
pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) }
|
||||
|
||||
pub fn evaluate(input: &Request) -> Result<Vec<Band>, String> {
|
||||
input.policy.validate()?;
|
||||
if input.index_code != input.policy.index_code || input.official_dates.is_empty()
|
||||
|| input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len()
|
||||
|| input.official_dates.windows(2).any(|pair| pair[0]>=pair[1])
|
||||
|| input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day)
|
||||
{ return Err("index_market_cap_calendar_or_identity_mismatch".into()); }
|
||||
input.closes.iter().map(|row| {
|
||||
let (lower,upper)=input.policy.band(row.close)?;
|
||||
Ok(Band{date:row.date,index_close:row.close,lower,upper})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn policy()->IndexMarketCapPolicy {
|
||||
serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY",
|
||||
"index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64,
|
||||
"upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){
|
||||
assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9));
|
||||
assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9));
|
||||
assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9));
|
||||
let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9;
|
||||
assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9));
|
||||
assert!(policy().band(f64::NAN).is_err());
|
||||
let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err());
|
||||
invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err());
|
||||
invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){
|
||||
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
|
||||
let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]};
|
||||
assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9);
|
||||
input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop();
|
||||
input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err());
|
||||
input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Condition facts are distinct from the quote's per-observation fill capacity.
|
||||
//! Only a complete, declared raw-minute prefix can prove a session total.
|
||||
use std::collections::BTreeMap;
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::data::IntradayExecutionQuote;
|
||||
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
|
||||
|
||||
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
|
||||
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
|
||||
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The cash-equity minute feed includes the opening observation and a separate
|
||||
/// post-close segment. Trading eligibility remains owned by the dated rules.
|
||||
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
|
||||
let minute = time.hour() * 60 + time.minute();
|
||||
let next = match minute {
|
||||
570..=689 | 781..=899 | 906..=929 => minute + 1,
|
||||
690 => 781,
|
||||
900 => 906,
|
||||
_ => return None,
|
||||
};
|
||||
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SessionTotalsCache {
|
||||
pub date: Option<NaiveDate>,
|
||||
pub symbols: BTreeMap<String, MinutePrefix>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MinutePrefix {
|
||||
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
|
||||
failure: String,
|
||||
}
|
||||
|
||||
impl MinutePrefix {
|
||||
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
|
||||
let mut values = BTreeMap::new();
|
||||
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
let mut volume = 0_u64;
|
||||
let mut amount = Decimal::ZERO;
|
||||
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
for quote in quotes {
|
||||
let time = quote.timestamp.time();
|
||||
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
|
||||
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
|
||||
break;
|
||||
}
|
||||
if time != expected {
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
|
||||
break;
|
||||
}
|
||||
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
|
||||
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
|
||||
break;
|
||||
}
|
||||
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
|
||||
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
|
||||
quote.amount_delta.to_string().parse::<Decimal>().ok()
|
||||
} else { None };
|
||||
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
|
||||
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
volume = next_volume;
|
||||
amount = next_amount;
|
||||
values.insert(time, (Decimal::from(volume), amount));
|
||||
let Some(next) = next_minute(time) else { break };
|
||||
expected = next;
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
}
|
||||
Self { values, failure }
|
||||
}
|
||||
|
||||
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
|
||||
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
|
||||
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
|
||||
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
|
||||
}
|
||||
#[test]
|
||||
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
|
||||
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
|
||||
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
|
||||
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
|
||||
rows[2].volume_delta = 999999;
|
||||
rows[2].amount_delta = f64::NAN;
|
||||
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
|
||||
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
|
||||
}
|
||||
#[test]
|
||||
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
|
||||
let first = quote(9,30,100,1000.);
|
||||
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
|
||||
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
|
||||
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
|
||||
}
|
||||
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
|
||||
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
|
||||
}
|
||||
#[test]
|
||||
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
|
||||
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
loop {
|
||||
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
|
||||
let Some(next) = next_minute(time) else { break }; time=next;
|
||||
}
|
||||
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
|
||||
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
|
||||
fn real_source_session_prefix_matches_observed_checkpoints() {
|
||||
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
|
||||
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
|
||||
assert_eq!(rows.len(),242);
|
||||
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
|
||||
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
|
||||
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
|
||||
}
|
||||
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Durable intent progress, deliberately separate from actual-fill holding
|
||||
//! protection. A published target starts no holding/protection timer.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::stock_pool_execution::{
|
||||
Position, StockPoolMemberSpec, StockPoolPlan, normalize_stock_symbol,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolEntryProgress {
|
||||
pub pending: bool,
|
||||
pub observed_holding: bool,
|
||||
pub first_decision_date: NaiveDate,
|
||||
pub latest_generation: String,
|
||||
pub latest_target_value: Decimal,
|
||||
/// Fully funded entry goal, fixed at the last plan. Reconcile against
|
||||
/// actual holdings before repricing, never against today's market value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_quantity: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolPositionActionBasis {
|
||||
pub generation: String,
|
||||
pub first_execution_date: NaiveDate,
|
||||
pub quantity: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolExecutionState {
|
||||
pub schema_version: u32,
|
||||
pub last_execution_date: Option<NaiveDate>,
|
||||
pub entries: BTreeMap<String, StockPoolEntryProgress>,
|
||||
#[serde(default)]
|
||||
pub last_target_weights: BTreeMap<String, i32>,
|
||||
/// First signal excluding an actually held member; not an acquisition date.
|
||||
pub removed_since: BTreeMap<String, NaiveDate>,
|
||||
/// Signal progress, not a fill or holding-period fact. Kept across retries
|
||||
/// and later execution sessions until a new generation supersedes it.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
|
||||
}
|
||||
|
||||
pub struct StockPoolGoalObservation<'a> {
|
||||
pub symbol: &'a str,
|
||||
pub target_weight_bps: i32,
|
||||
pub target_value: Decimal,
|
||||
pub current_quantity: Decimal,
|
||||
pub target_quantity: Decimal,
|
||||
pub status: &'a str,
|
||||
}
|
||||
|
||||
impl Default for StockPoolExecutionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: 1,
|
||||
last_execution_date: None,
|
||||
entries: BTreeMap::new(),
|
||||
last_target_weights: BTreeMap::new(),
|
||||
removed_since: BTreeMap::new(),
|
||||
position_action_bases: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StockPoolExecutionState {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema_version != 1
|
||||
|| self.entries.len() > 10000
|
||||
|| self.removed_since.len() > 10000
|
||||
|| self.position_action_bases.len() > 10000
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
|
||||
}
|
||||
for symbol in self
|
||||
.entries
|
||||
.keys()
|
||||
.chain(self.removed_since.keys())
|
||||
.chain(self.last_target_weights.keys())
|
||||
.chain(self.position_action_bases.keys())
|
||||
{
|
||||
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
||||
return Err("stock_pool_execution_state_invalid_symbol".into());
|
||||
}
|
||||
}
|
||||
if self.last_target_weights.len() > 10000
|
||||
|| self
|
||||
.last_target_weights
|
||||
.values()
|
||||
.any(|value| !(0..=10000).contains(value))
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_weights".into());
|
||||
}
|
||||
if self.entries.values().any(|entry| {
|
||||
entry.latest_target_value < Decimal::ZERO
|
||||
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|
||||
|| entry.latest_generation.is_empty()
|
||||
|| self
|
||||
.last_execution_date
|
||||
.is_none_or(|last| entry.first_decision_date > last)
|
||||
}) || self
|
||||
.removed_since
|
||||
.values()
|
||||
.any(|day| self.last_execution_date.is_none_or(|last| *day > last))
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
|
||||
}
|
||||
if self.position_action_bases.values().any(|basis| {
|
||||
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|
||||
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
|
||||
}) {
|
||||
return Err("stock_pool_execution_state_invalid_action_basis".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn observe(
|
||||
&self,
|
||||
decision_date: NaiveDate,
|
||||
execution_date: NaiveDate,
|
||||
official_dates: &[NaiveDate],
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if decision_date > execution_date
|
||||
|| !official_dates.contains(&execution_date)
|
||||
|| !official_dates.contains(&decision_date)
|
||||
|| official_dates.windows(2).any(|pair| pair[0] >= pair[1])
|
||||
|| self
|
||||
.last_execution_date
|
||||
.is_some_and(|last| last > execution_date)
|
||||
{
|
||||
return Err("stock_pool_execution_state_requires_monotone_official_clock".into());
|
||||
}
|
||||
let mut next = self.clone();
|
||||
next.last_execution_date = Some(execution_date);
|
||||
let members = members
|
||||
.iter()
|
||||
.map(|member| member.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let held = positions
|
||||
.iter()
|
||||
.filter(|position| position.quantity > Decimal::ZERO)
|
||||
.map(|position| position.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
next.entries.retain(|symbol, entry| {
|
||||
// Confirmed flat starts a new cycle. A still-unfilled fresh target
|
||||
// may remain pending while the latest pool still requests it.
|
||||
!(entry.observed_holding && !held.contains(symbol))
|
||||
&& (members.contains(symbol) || held.contains(symbol))
|
||||
});
|
||||
next.last_target_weights
|
||||
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
|
||||
for (symbol, entry) in &mut next.entries {
|
||||
entry.observed_holding |= held.contains(symbol);
|
||||
if entry.pending
|
||||
&& entry.completion_quantity.is_some_and(|goal| {
|
||||
positions.iter().any(|position| {
|
||||
&position.symbol == symbol && position.quantity >= goal
|
||||
})
|
||||
})
|
||||
{
|
||||
entry.pending = false;
|
||||
}
|
||||
}
|
||||
next.removed_since
|
||||
.retain(|symbol, _| held.contains(symbol) && !members.contains(symbol));
|
||||
for symbol in held.difference(&members) {
|
||||
next.removed_since
|
||||
.entry(symbol.clone())
|
||||
.or_insert(decision_date);
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn pending_symbols(&self) -> BTreeSet<String> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.pending)
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet<String> {
|
||||
self.removed_since
|
||||
.iter()
|
||||
.filter(|(_, removed)| **removed < execution_date)
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn record_plan(
|
||||
&self,
|
||||
decision_date: NaiveDate,
|
||||
generation: &str,
|
||||
plan: &StockPoolPlan,
|
||||
) -> Result<Self, String> {
|
||||
self.record_targets(
|
||||
decision_date,
|
||||
generation,
|
||||
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
|
||||
symbol: &row.symbol,
|
||||
target_weight_bps: row.target_weight_bps,
|
||||
target_value: row.target_value,
|
||||
current_quantity: row.current_quantity,
|
||||
target_quantity: row.target_quantity,
|
||||
status: &row.status,
|
||||
}),
|
||||
)?.record_position_action_bases(generation, &plan.position_action_bases)
|
||||
}
|
||||
|
||||
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
|
||||
self.position_action_bases.iter()
|
||||
.filter(|(_, basis)| basis.generation == generation)
|
||||
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A verified split changes the share unit, not the intended reduction or
|
||||
/// entry completion. Never infer a split from a changed holding quantity.
|
||||
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
|
||||
return Err("stock_pool_execution_state_split_invalid".into());
|
||||
}
|
||||
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
|
||||
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
|
||||
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
|
||||
let mut next = self.clone();
|
||||
if let Some(entry) = next.entries.get_mut(symbol) {
|
||||
if let Some(quantity) = entry.completion_quantity {
|
||||
let quantity = scale(quantity)?;
|
||||
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
|
||||
}
|
||||
}
|
||||
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
|
||||
basis.quantity = scale(basis.quantity)?;
|
||||
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn record_position_action_bases(
|
||||
&self,
|
||||
generation: &str,
|
||||
quantities: &BTreeMap<String, Decimal>,
|
||||
) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if generation.trim().is_empty() {
|
||||
return Err("stock_pool_execution_state_action_generation_missing".into());
|
||||
}
|
||||
let first_execution_date = self.last_execution_date
|
||||
.ok_or("stock_pool_execution_state_action_clock_missing")?;
|
||||
let mut next = self.clone();
|
||||
next.position_action_bases.retain(|_, basis| basis.generation == generation);
|
||||
for (symbol, quantity) in quantities {
|
||||
if let Some(basis) = next.position_action_bases.get(symbol) {
|
||||
if basis.quantity != *quantity {
|
||||
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
|
||||
}
|
||||
} else {
|
||||
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
|
||||
generation: generation.into(), first_execution_date, quantity: *quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn record_targets<'a>(
|
||||
&self,
|
||||
decision_date: NaiveDate,
|
||||
generation: &str,
|
||||
rows: impl IntoIterator<Item = StockPoolGoalObservation<'a>>,
|
||||
) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if generation.is_empty()
|
||||
|| self
|
||||
.last_execution_date
|
||||
.is_none_or(|date| decision_date > date)
|
||||
{
|
||||
return Err("stock_pool_execution_state_plan_clock_invalid".into());
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for row in rows {
|
||||
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
|
||||
continue;
|
||||
}
|
||||
if row.target_weight_bps > 0 {
|
||||
next.last_target_weights
|
||||
.insert(row.symbol.into(), row.target_weight_bps);
|
||||
}
|
||||
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
|
||||
let completion_quantity = (row.status == "READY"
|
||||
&& row.target_quantity > row.current_quantity)
|
||||
.then_some(row.target_quantity);
|
||||
let satisfied = matches!(
|
||||
row.status,
|
||||
"ALREADY_SATISFIED"
|
||||
| "ENTRY_TARGET_ALREADY_SATISFIED"
|
||||
| "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED"
|
||||
);
|
||||
if row.current_quantity == Decimal::ZERO && eligible && satisfied {
|
||||
next.entries.remove(row.symbol);
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = next.entries.get_mut(row.symbol) {
|
||||
entry.latest_generation = generation.into();
|
||||
entry.latest_target_value = row.target_value;
|
||||
if entry.pending && completion_quantity.is_some() {
|
||||
entry.completion_quantity = completion_quantity;
|
||||
}
|
||||
entry.observed_holding |= row.current_quantity > Decimal::ZERO;
|
||||
if entry.pending && eligible && satisfied {
|
||||
entry.pending = false;
|
||||
}
|
||||
} else if eligible && row.current_quantity == Decimal::ZERO && !satisfied {
|
||||
next.entries.insert(
|
||||
row.symbol.into(),
|
||||
StockPoolEntryProgress {
|
||||
pending: true,
|
||||
observed_holding: false,
|
||||
first_decision_date: decision_date,
|
||||
latest_generation: generation.into(),
|
||||
latest_target_value: row.target_value,
|
||||
completion_quantity,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
}
|
||||
+903
-427
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,30 @@ pub struct StrategyAiCatalog {
|
||||
pub indicator_factors: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub clickhouse_table_fields: Vec<ManualFactorSource>,
|
||||
pub data_lake_fields: Vec<ManualFactorSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyAiHoldingCountContract {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "holdingCount",
|
||||
alias = "holding_count",
|
||||
alias = "targetHoldingCount",
|
||||
alias = "target_holding_count"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "kind",
|
||||
alias = "holdingCountMode",
|
||||
alias = "holding_count_mode",
|
||||
alias = "targetHoldingCountMode",
|
||||
alias = "target_holding_count_mode"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -79,6 +102,9 @@ pub struct StrategyAiGenerateRequest {
|
||||
pub market: String,
|
||||
pub benchmark_symbol: String,
|
||||
pub signal_symbol: String,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -87,8 +113,15 @@ pub struct StrategyAiOptimizeRequest {
|
||||
pub objective: String,
|
||||
pub result_summary: serde_json::Value,
|
||||
pub diagnostics: Vec<String>,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
|
||||
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "max_order_quantity=1000000、max_order_notional=100000000、max_symbol_position=10000000、reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
||||
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "max_order_quantity=1000000, max_order_notional=100000000, max_symbol_position=10000000, reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
||||
|
||||
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
StrategyAiManual {
|
||||
title: "OmniQuant 平台策略脚本手册".to_string(),
|
||||
@@ -97,11 +130,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
"平台策略脚本采用声明式 DSL + 表达式执行模型。".to_string(),
|
||||
"支持 let 变量、fn 自定义函数、when/unless/else 条件块、可用指标/因子字段映射。".to_string(),
|
||||
"支持数值型和字符串型因子,字符串字段可用于行业、概念、标签、板块等分类过滤。".to_string(),
|
||||
"当前默认回测数据已支持 OHLCV、市值、流通市值、换手率、有效换手率、上市天数、停牌/ST/板块、涨跌停价格、tick 触达涨跌停、常用价格/成交量均线,以及 stock_indicator_factors_v1 中已入库的通用指标因子。".to_string(),
|
||||
"用户明确指定目标持仓数量或最低持仓数量时,selection.limit 必须严格表达该数量;不要因为优化收益、减少交易或转换框架而擅自改小持仓数。".to_string(),
|
||||
"当前默认回测数据已支持 OHLCV、市值、流通市值、换手率、有效换手率、上市天数、停牌/ST/板块、涨跌停价格、分钟线触达涨跌停、常用价格/成交量均线,以及 stock_indicator_factors_v1 中已入库的通用指标因子。".to_string(),
|
||||
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
|
||||
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(),
|
||||
"任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(),
|
||||
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息。".to_string(),
|
||||
"next_bar_open 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
|
||||
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
|
||||
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
||||
],
|
||||
@@ -203,8 +237,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。`.at([...])` 的最后一个时刻会编进分钟级 schedule/time_rule;当前平台把 on_day 近似到 10:18,把 open_auction 近似到 09:31。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "bar / tick 生命周期".to_string(),
|
||||
detail: "回测内核支持 平台内核 风格的 bar/tick 生命周期:日内会发布 pre_bar/bar/post_bar 过程事件;存在 tick 订阅或 tick 调度规则时,会按 execution_quotes 的时间顺序发布 pre_tick/tick/post_tick,并把 tick 阶段下单限制在当前 tick 时间窗内撮合。平台 DSL 中可通过 subscribe([...])、trading.subscription_guard(true) 和 process_event 字段配合显式订单模拟 tick 订阅策略。".to_string(),
|
||||
title: "bar / minute execution 生命周期".to_string(),
|
||||
detail: "回测内核支持 平台内核 风格的 bar/分钟执行价生命周期:日内会发布 pre_bar/bar/post_bar 过程事件;存在分钟执行价订阅或分钟调度规则时,会按 execution_quotes 的时间顺序发布 pre_minute/minute/post_minute 过程事件,并把日内阶段下单限制在当前分钟执行价时间窗内撮合。平台 DSL 中可通过 subscribe([...])、trading.subscription_guard(true) 和 process_event 字段配合显式订单模拟日内订阅策略。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(),
|
||||
@@ -214,21 +248,37 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
title: "filter.stock_expr / risk.stop_loss / risk.take_profit / allocation.buy_scale".to_string(),
|
||||
detail: "表达式型规则,支持多条组合。stop_loss/take_profit 多条按 OR 组合,filter.stock_expr 多条按 AND 组合。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "risk.policy / risk.blacklist".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 max_order_quantity、max_order_notional、max_symbol_position,以及 ST/*ST、停牌、退市、新股、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量、流动性和交易成本等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;源策略明确写出的业务选股排除属于策略本身,必须原样保留,不能反向修改冻结的 reject_*_selection 开关;冻结的 `reject_*_selection` 值不得改变。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
|
||||
detail: "历史兼容的回测账务再投模型:corporate_actions.dividend_reinvestment(true) 在分红结算时按调整后的参考价分配整手股数,零头留现金,费用为0;来源标记为 dividend_reinvestment,不是交易所委托或真实自动买入。新策略不应手工处理公司行为。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_price;next_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 平台内核 的 tick 最优价语义;counterparty_offer 在存在 order_book_depth 多档盘口数据时会按真实档位逐档扫单并计算加权成交价,不存在 depth 时回退 L1 对手方报价;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;next_bar_open 使用决策日信号并在下一可交易日开盘撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buy;sell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、limit price tick、price limit 校验,用于兼容特殊数据,但默认全部开启。".to_string(),
|
||||
title: "期货 runtime action 与提交校验".to_string(),
|
||||
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
|
||||
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的 tick 订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 平台内核 的 order_to,order.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
title: "trading.automatic_trade_protection(...)".to_string(),
|
||||
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
||||
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "order.time_in_force target runtime scope".to_string(),
|
||||
detail: "回测支持 DAY/IOC/FOK/GTC;paper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "order.modify".to_string(),
|
||||
detail: "回测中可用 order.modify(order_id, total_quantity=?, limit_price=?) 原位修改仍未完成的限价单。total_quantity 是新的总委托量而不是增量,不能低于已成交量;改价或增量会重置盘口队列优先级,减少总量且不改价保留优先级,同时保留 order_id、有效期、累计成交和费用状态。paper/live 在适配器提供持久且确认的 cancel-replace 合同前必须拒绝该动作,不得静默转换为撤单加新订单。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "when / unless / else".to_string(),
|
||||
@@ -242,6 +292,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
fields: vec![
|
||||
ManualField { name: "signal_open/signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数当日开盘价与前一日收盘价。".to_string() },
|
||||
ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() },
|
||||
ManualField { name: "benchmark_signal_close".to_string(), field_type: "float".to_string(), detail: "信号日收盘后可见的基准当日收盘价;用于 T 日生成信号、T+1 开盘成交的条件,不能在盘中或 T 日开盘决策中使用。".to_string() },
|
||||
ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() },
|
||||
ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() },
|
||||
ManualField { name: "cash/available_cash/frozen_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户可用资金、挂单冻结资金、市值与总权益;available_cash 会扣减当前买入挂单冻结估算。".to_string() },
|
||||
@@ -267,12 +318,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
fields: vec![
|
||||
ManualField { name: "symbol".to_string(), field_type: "string".to_string(), detail: "证券代码。".to_string() },
|
||||
ManualField { name: "market_cap/free_float_cap".to_string(), field_type: "float".to_string(), detail: "总市值、流通市值。".to_string() },
|
||||
ManualField { name: "turnover/turnover_ratio/effective_turnover_ratio".to_string(), field_type: "float".to_string(), detail: "换手率、换手率标准字段、有效换手率;turnover 是 turnover_ratio 的兼容别名。".to_string() },
|
||||
ManualField { name: "turnover_ratio/effective_turnover_ratio".to_string(), field_type: "float".to_string(), detail: "换手率标准字段和有效换手率。".to_string() },
|
||||
ManualField { name: "open/high/low/close/last/last_price/prev_close/amount".to_string(), field_type: "float".to_string(), detail: "开盘、最高、最低、收盘、盘中价、昨收和成交额。".to_string() },
|
||||
ManualField { name: "upper_limit/lower_limit/price_tick/round_lot/minimum_order_quantity/order_step_size".to_string(), field_type: "float/int".to_string(), detail: "涨跌停、最小价位、整手、最小下单量和数量步长。KSH/BJSE 等板块可与 round_lot 不同。".to_string() },
|
||||
ManualField { name: "paused/is_st/is_kcb/is_one_yuan/is_new_listing".to_string(), field_type: "bool".to_string(), detail: "可交易性与板块标志。".to_string() },
|
||||
ManualField { name: "paused/is_st/is_star_st/is_kcb/is_one_yuan/is_new_listing".to_string(), field_type: "bool".to_string(), detail: "可交易性与板块标志,ST 与 *ST 是独立字段。".to_string() },
|
||||
ManualField { name: "allow_buy/allow_sell/at_upper_limit/at_lower_limit".to_string(), field_type: "bool".to_string(), detail: "盘中买卖与涨跌停状态。".to_string() },
|
||||
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日 tick 曾经触达涨跌停。".to_string() },
|
||||
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日分钟执行价曾经触达涨跌停。".to_string() },
|
||||
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
|
||||
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
|
||||
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
|
||||
@@ -304,13 +355,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
functions: vec![
|
||||
ManualFunction { name: "factor".to_string(), signature: "factor(\"column_name\")".to_string(), detail: "读取当前股票当日可用因子列。数值因子返回 float,字符串因子返回 string;缺失字段默认返回 0 或空字符串,建议重要条件配合 diagnostics 查看候选过滤数量。".to_string() },
|
||||
ManualFunction { name: "day_factor".to_string(), signature: "day_factor(\"field_name\")".to_string(), detail: "读取日级/指数级字段映射。".to_string() },
|
||||
ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\" | \"tick\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟或 tick 字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前交易日;分钟/tick 会按当前 on_bar、on_tick 或调度时刻截断,include_now=false 排除当前 bar/tick,避免未来函数。".to_string() },
|
||||
ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前交易日指定证券的日级快照,可用于获得当日 open/close/last/upper_limit/lower_limit 等字段。".to_string() },
|
||||
ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前信号日;分钟线会按当前 on_bar、日内事件或调度时刻截断,include_now=false 排除当前分钟执行价。next_bar_open 下该 API 只能看到信号日数据,不能读取实际成交日数据。".to_string() },
|
||||
ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前信号日指定证券的日级快照,可用于获得信号日 open/close/last/upper_limit/lower_limit 等字段;next_bar_open 的实际成交日涨跌停、停牌、ST、退市、一元、黑名单、成交量和盘口流动性由撮合层按执行日判断。".to_string() },
|
||||
ManualFunction { name: "instrument/instruments/all_instruments".to_string(), signature: "ctx.instrument(symbol)".to_string(), detail: "读取证券元数据,包括名称、板块、上市日期、退市日期、最小下单量、整手、最小价位等;all_instruments 按证券代码稳定排序返回全量证券。".to_string() },
|
||||
ManualFunction { name: "active_instruments/instruments_history".to_string(), signature: "ctx.active_instruments(&[symbol])".to_string(), detail: "active_instruments 返回当前交易日已上市且未退市的证券;instruments_history 返回给定代码的历史证券记录,包含当前已退市标的,对齐 平台内核 的 active_instruments/instruments_history 能力。".to_string() },
|
||||
ManualFunction { name: "active_instruments/instruments_history".to_string(), signature: "ctx.active_instruments(&[symbol])".to_string(), detail: "active_instruments 返回当前信号日已上市且未退市的证券;instruments_history 返回给定代码的历史证券记录,包含当前已退市标的,对齐 平台内核 的 active_instruments/instruments_history 能力。".to_string() },
|
||||
ManualFunction { name: "get_trading_dates/get_previous_trading_date/get_next_trading_date".to_string(), signature: "ctx.get_previous_trading_date(date, n)".to_string(), detail: "交易日历 API。get_trading_dates 返回闭区间交易日;previous/next 返回相对某日向前或向后的第 n 个交易日,当前日自身不计入。".to_string() },
|
||||
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前交易日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应平台内核的 is_suspended/is_st_stock 数据能力。".to_string() },
|
||||
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
|
||||
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前信号日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应平台内核的 is_suspended/is_st_stock 数据能力。执行日停牌或 ST 只能由撮合风控判断,不能在 next_bar_open 的 T 日提前固化。".to_string() },
|
||||
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟线返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
|
||||
ManualFunction { name: "get_dividend / dividend_cash / has_dividend".to_string(), signature: "dividend_cash(lookback) / has_dividend(lookback)".to_string(), detail: "高级数据 风格分红 API。Rust Context 可用 ctx.get_dividend(symbol, start_date) 读取明细;平台表达式可用 dividend_cash(lookback) 汇总当前股票最近 N 个交易日现金分红,用 has_dividend(lookback) 判断是否发生分红,也支持 dividend_cash(\"600000.SH\", lookback)。".to_string() },
|
||||
ManualFunction { name: "get_split / split_ratio / has_split".to_string(), signature: "split_ratio(lookback) / has_split(lookback)".to_string(), detail: "高级数据 风格拆分/送转 API。Rust Context 可用 ctx.get_split(symbol, start_date) 读取明细;平台表达式可用 split_ratio(lookback) 计算当前股票最近 N 个交易日累计拆分比例,has_split(lookback) 判断是否发生送转。".to_string() },
|
||||
ManualFunction { name: "get_factor / factor_value".to_string(), signature: "factor_value(\"field\", lookback=1)".to_string(), detail: "数值因子 API。factor(\"field\") 读取当前股票当日因子;factor_value(\"field\", lookback) 会在最近 N 个交易日内取该字段最新数值,适合读取任意可用指标或自定义数值因子。Rust Context 可用 ctx.get_factor(symbol, start, end, field) 读取完整数值序列。".to_string() },
|
||||
@@ -327,14 +378,14 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
ManualFunction { name: "get_dominant_future / dominant_future / dominant_future_price".to_string(), signature: "dominant_future(\"IF\") / dominant_future_price(\"IF\", \"close\", lookback=1)".to_string(), detail: "主力合约 API。dominant_future 返回当前日期匹配前缀的主力期货合约代码;dominant_future_price 读取该主力合约最近 N 个交易日指定字段的最新价格。Rust Context 可用 ctx.get_dominant_future(...) 和 ctx.get_dominant_future_price(...)。".to_string() },
|
||||
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
|
||||
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
|
||||
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。回测中 deposit_withdraw 正数入金、负数出金,receiving_days 大于 0 时按交易日延迟到账,并保持现金流中性净值不把外部资金流当成收益;回测 finance_repay 与 management_fee 按账户合同结算。模拟盘只接受由 runtime 明确返回的即时 deposit_withdraw,并通过幂等现金流账本落库;延迟流、融资/管理费动作必须显式失败。实盘禁止策略侧改变现金,必须以券商资产和已核验资金流水为真相,策略返回上述动作会在下单前 fail-closed。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "数据库指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "stock_indicator_factors_v1 中的指标会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;需要收益率波动时先使用已注册收益率字段或发布因子,不要写 rolling_stddev(pct_change(\"close\", 1), 20)。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0。".to_string() },
|
||||
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
|
||||
],
|
||||
factor_sources: vec![
|
||||
@@ -360,12 +411,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualFactorSource {
|
||||
table: "盘口深度参数".to_string(),
|
||||
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 counterparty_offer / next_tick_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
|
||||
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 minute_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
|
||||
fields: vec![],
|
||||
},
|
||||
ManualFactorSource {
|
||||
table: "期货交易参数".to_string(),
|
||||
detail: "字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测会按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价 tick 校验。".to_string(),
|
||||
detail: "来自 futures_contract_spec_history 与 futures_cost_margin_history;字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测按交易日选择不晚于当前日期的最新参数。schema catalog 未同时发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 时,该能力视为不可用。".to_string(),
|
||||
fields: vec![],
|
||||
},
|
||||
],
|
||||
@@ -383,8 +434,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
code: "filter.stock_expr(industry_name(\"citics\", 1) == \"电子\" && factor_text(\"concept\") == \"ai_chip\")".to_string(),
|
||||
},
|
||||
ManualExample {
|
||||
title: "next tick 撮合 + tick 滑点".to_string(),
|
||||
code: "execution.matching_type(\"next_tick_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
|
||||
title: "分钟执行价撮合 + 最小价位滑点".to_string(),
|
||||
code: "execution.matching_type(\"minute_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
|
||||
},
|
||||
ManualExample {
|
||||
title: "动态 universe 和订阅".to_string(),
|
||||
@@ -432,17 +483,22 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str("## AI 代码生成硬约束\n");
|
||||
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
||||
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.rebalance_cash_mode`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;框架默认的 ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。源策略明确写出的业务选股排除必须保留为策略表达式,不能改写冻结的 selection 风控开关。\n");
|
||||
out.push_str("- 禁止伪 DSL:`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
|
||||
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
|
||||
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
|
||||
out.push_str("- `rolling_mean`、`rolling_sum/min/max/stddev/zscore`、`pct_change`、`factor_value` 等 helper 的第一个参数必须是字段名或字符串字段名;不要输出 `rolling_stddev(pct_change(\"close\", 1), 20)` 这类嵌套表达式。\n");
|
||||
out.push_str("- 自定义 `fn` 必须通过参数传入运行时字段;不要用 `fn score()` 这类零参数函数直接引用 `market_cap`、`close`、`ma5` 等股票字段。\n");
|
||||
out.push_str("- `selection.market_cap_band` 必须写命名参数:`field=\"market_cap\"` 或 `field=\"free_float_cap\"`,并包含 `lower=...` 与 `upper=...`。\n");
|
||||
out.push_str(
|
||||
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
|
||||
);
|
||||
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或策略明确声明的业务过滤条件;转换器不得自行把框架默认基础风控注入过滤表达式。源策略明确写出的 `!is_st`、`!is_star_st`、`!is_kcb`、`!is_bjse` 等业务选股排除必须原样保留,同时冻结的 `reject_*_selection` 值不得改变。\n");
|
||||
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
||||
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
|
||||
out.push_str("- 日线目标金额、目标比例和目标权重在 `next_bar_open` 下冻结 T 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n");
|
||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
out.push_str("## 语句块\n");
|
||||
for item in &manual.statement_blocks {
|
||||
@@ -514,13 +570,19 @@ pub fn build_generation_prompt(
|
||||
prompt.push_str("- 不要输出解释文本。\n");
|
||||
prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n");
|
||||
prompt.push_str("- 如需自定义参数,使用 let 和 fn。\n");
|
||||
prompt.push_str("- 优先使用数据库已存在字段和 factors[...]。\n\n");
|
||||
prompt.push_str("- 优先使用 Strategy Factory Source Lake 已注册 source rows 字段、已发布指标/因子 artifact 和运行时已存在字段、factors[...];不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。\n\n");
|
||||
prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
|
||||
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
|
||||
prompt.push_str("- ");
|
||||
prompt.push_str(PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、execution.matching_type、execution.slippage、universe.exclude。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,写 !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;execution.matching_type 只能取 next_tick_last、next_tick_best_own、next_tick_best_counterparty、counterparty_offer、vwap、current_bar_close、next_bar_open、open_auction;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.rebalance_cash_mode、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;日线 execution.rebalance_cash_mode 只允许 same_point_net、sell_then_buy、pre_open_cash,分钟线固定使用 sell_then_buy;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图并在收盘冻结目标金额或目标权益,T+1 按实际 open、滑点、手续费和证券数量步长重算股数,不能用 T+1 prev_close 或 T 日估算股数直接成交;涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;金额预算始终严格,禁止 execution.strict_value_budget(false);execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002),并且请求里指定固定滑点时必须使用请求值。\n"));
|
||||
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && !is_st && !paused && close > 2 && !at_upper_limit && !at_lower_limit)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(");
|
||||
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
|
||||
prompt.push_str(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.002)\n}\n\n");
|
||||
prompt.push_str("用户目标:\n");
|
||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||
if !request.constraints.is_empty() {
|
||||
@@ -547,7 +609,10 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str("输出格式硬约束:回复第一行必须是 strategy(\"...\")、let、fn、const 或 //;回复中不得包含 Markdown、解释、思考过程、手册复述、JSON 包装或自然语言总结。\n");
|
||||
prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\n");
|
||||
prompt.push_str("优化不限制在原策略已有参数或少量扰动。只要 OmniQuant/FIDC 已支持,可以自由增加、修改、删除策略代码、参数、候选池、过滤函数、排序、仓位、止盈止损、调仓周期、指标因子和辅助函数;不得引入手册未列出的字段或外部平台 API 名称。\n");
|
||||
prompt.push_str("可以使用所有已入库日频字段、指标因子和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
|
||||
prompt.push_str(PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("优化目标:\n");
|
||||
prompt.push_str(&format!("- {}\n\n", request.objective));
|
||||
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
||||
@@ -569,3 +634,81 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str(manual_markdown);
|
||||
prompt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn generation_prompt_uses_explicit_performance_acceptance_contract() {
|
||||
let prompt = build_generation_prompt(
|
||||
"manual",
|
||||
&StrategyAiGenerateRequest {
|
||||
user_goal: "生成策略".to_string(),
|
||||
constraints: Vec::new(),
|
||||
market: "CN_A".to_string(),
|
||||
benchmark_symbol: "000852.SH".to_string(),
|
||||
signal_symbol: "000001.SH".to_string(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
|
||||
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
|
||||
assert!(!prompt.contains("总收益严格 > 120%"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
assert!(prompt.contains("T 日只生成订单意图"));
|
||||
assert!(prompt.contains("按实际成交日判断"));
|
||||
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
|
||||
assert!(prompt.contains("execution.rebalance_cash_mode"));
|
||||
assert!(prompt.contains("same_point_net、sell_then_buy、pre_open_cash"));
|
||||
assert!(prompt.contains("分钟线固定使用 sell_then_buy"));
|
||||
assert!(prompt.contains("必须覆盖完整默认配置面"));
|
||||
assert!(prompt.contains("reject_inactive_buy=true"));
|
||||
assert!(prompt.contains("reject_inactive_sell=true"));
|
||||
assert!(prompt.contains("reject_new_listing_buy=true"));
|
||||
assert!(prompt.contains("reject_kcb_buy=true"));
|
||||
assert!(prompt.contains("reject_bjse_buy=true"));
|
||||
assert!(prompt.contains("reject_one_yuan_buy=true"));
|
||||
assert!(prompt.contains("respect_allow_buy_sell=true"));
|
||||
assert!(prompt.contains("stamp_tax_rate_before_change=0.001"));
|
||||
assert!(prompt.contains("stamp_tax_change_date=\"2023-08-28\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_separates_explicit_business_selection_from_framework_risk_policy() {
|
||||
let markdown = render_manual_markdown(&built_in_strategy_manual());
|
||||
assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身"));
|
||||
assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关"));
|
||||
assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变"));
|
||||
assert!(markdown.contains("time_in_force=\"day|ioc|fok|gtc\""));
|
||||
assert!(markdown.contains("FOK 必须全量可成交否则零成交"));
|
||||
assert!(markdown.contains("GTC 仅支持限价单并跨交易日保留"));
|
||||
assert!(markdown.contains("paper/live 当前只支持 DAY/IOC/FOK"));
|
||||
assert!(markdown.contains("paper/live 必须明确拒绝并禁止降级为 DAY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimization_prompt_uses_explicit_performance_acceptance_contract() {
|
||||
let prompt = build_optimization_prompt(
|
||||
"manual",
|
||||
&StrategyAiOptimizeRequest {
|
||||
current_code: "strategy(\"demo\") {}".to_string(),
|
||||
objective: "优化收益".to_string(),
|
||||
result_summary: json!({ "total_return": 1.49 }),
|
||||
diagnostics: Vec::new(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
|
||||
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
|
||||
assert!(!prompt.contains("总收益严格 > 120%"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use chrono::NaiveDate;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
|
||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BandRegime {
|
||||
@@ -39,6 +40,7 @@ pub struct SelectionDiagnostics {
|
||||
pub missing_market_cap_symbols: Vec<String>,
|
||||
pub selected_symbols: Vec<String>,
|
||||
pub rejection_examples: Vec<String>,
|
||||
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
||||
}
|
||||
|
||||
pub struct SelectionContext<'a> {
|
||||
@@ -47,20 +49,62 @@ pub struct SelectionContext<'a> {
|
||||
pub reference_level: f64,
|
||||
pub data: &'a DataSet,
|
||||
pub dynamic_universe: Option<&'a BTreeSet<String>>,
|
||||
pub risk_config: Option<&'a FidcRiskControlConfig>,
|
||||
pub defer_selection_risk: bool,
|
||||
}
|
||||
|
||||
impl SelectionContext<'_> {
|
||||
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
|
||||
let eligible = self.data.eligible_universe_on(self.decision_date);
|
||||
let eligible = match (self.risk_config, self.defer_selection_risk) {
|
||||
(Some(risk_config), false) => self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
||||
_ => self.data.eligible_universe_on(self.decision_date).to_vec(),
|
||||
};
|
||||
match self.dynamic_universe {
|
||||
Some(symbols) if !symbols.is_empty() => eligible
|
||||
.iter()
|
||||
.into_iter()
|
||||
.filter(|row| symbols.contains(&row.symbol))
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => eligible.to_vec(),
|
||||
_ => eligible,
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
|
||||
let default_risk_config;
|
||||
let risk_config = match self.risk_config {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
default_risk_config = FidcRiskControlConfig::default();
|
||||
&default_risk_config
|
||||
}
|
||||
};
|
||||
let mut decisions = Vec::new();
|
||||
for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
|
||||
if self
|
||||
.dynamic_universe
|
||||
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(candidate) = self.data.candidate(self.decision_date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = self.data.market(self.decision_date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
self.decision_date,
|
||||
candidate,
|
||||
market,
|
||||
self.data.instrument(&factor.symbol),
|
||||
risk_config,
|
||||
) {
|
||||
decisions.push(decision);
|
||||
}
|
||||
}
|
||||
decisions
|
||||
}
|
||||
}
|
||||
|
||||
pub trait UniverseSelector {
|
||||
@@ -166,9 +210,23 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
||||
missing_market_cap_symbols: Vec::new(),
|
||||
selected_symbols: Vec::new(),
|
||||
rejection_examples: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
};
|
||||
|
||||
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
|
||||
diagnostics.factor_total = ctx.data.factor_snapshot_rows_on(ctx.decision_date).len();
|
||||
diagnostics.risk_decisions = ctx.selection_risk_decisions();
|
||||
diagnostics.not_eligible_count = diagnostics.risk_decisions.len();
|
||||
diagnostics.paused_count = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.filter(|decision| decision.rule_code == "paused")
|
||||
.count();
|
||||
diagnostics.rejection_examples = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|decision| format!("{} rejected by {}", decision.symbol, decision.rule_code))
|
||||
.collect();
|
||||
let eligible = ctx.eligible_universe();
|
||||
diagnostics.market_cap_missing_count =
|
||||
diagnostics.factor_total.saturating_sub(eligible.len());
|
||||
@@ -221,3 +279,267 @@ fn to_universe_candidate(
|
||||
band_high,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::{
|
||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
};
|
||||
use crate::instrument::Instrument;
|
||||
|
||||
fn d() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()
|
||||
}
|
||||
|
||||
fn instrument(symbol: &str) -> Instrument {
|
||||
Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: symbol.rsplit('.').next().unwrap_or("").to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-02 10:00:00".to_string()),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 1_000_000,
|
||||
minute_volume: 10_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: market_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(0.01),
|
||||
effective_turnover_ratio: Some(0.01),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(),
|
||||
symbol: symbol.into(),
|
||||
is_st,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark() -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date: d(),
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 2000.0,
|
||||
close: 2000.0,
|
||||
prev_close: 1990.0,
|
||||
volume: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_records_structured_selection_risk_decisions() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
assert_eq!(
|
||||
diagnostics.not_eligible_count,
|
||||
diagnostics.risk_decisions.len()
|
||||
);
|
||||
assert!(
|
||||
diagnostics.risk_decisions[0]
|
||||
.diagnostic_line()
|
||||
.starts_with("risk_decision=")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_applies_configured_selection_risk_on_decision_date() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
let selected_symbols = selected
|
||||
.iter()
|
||||
.map(|candidate| candidate.symbol.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(!selected_symbols.contains("000001.SZ"));
|
||||
assert!(!selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
assert_eq!(diagnostics.not_eligible_count, 2);
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: true,
|
||||
});
|
||||
|
||||
let selected_symbols = selected
|
||||
.iter()
|
||||
.map(|candidate| candidate.symbol.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(selected_symbols.contains("000001.SZ"));
|
||||
assert!(selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
|
||||
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
|
||||
};
|
||||
|
||||
fn d(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||
}
|
||||
fn data() -> DataSet {
|
||||
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||
DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "测试".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.0,
|
||||
low: 10.0,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| fidc_core::DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.0),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 1_000_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
PlatformTradeAction::Order {
|
||||
kind: PlatformExplicitOrderKind::Shares,
|
||||
symbol: "000001.SZ".into(),
|
||||
amount_expr: quantity.into(),
|
||||
when_expr: Some(when.into()),
|
||||
limit_price_expr: None,
|
||||
time_in_force: None,
|
||||
start_time_expr: None,
|
||||
end_time_expr: None,
|
||||
reason: "configured_strategy_action".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
|
||||
for sell_during_lock in [false, true] {
|
||||
let sale = if sell_during_lock {
|
||||
("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200)
|
||||
} else {
|
||||
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100)
|
||||
};
|
||||
let actions = [
|
||||
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100),
|
||||
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100),
|
||||
sale,
|
||||
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| {
|
||||
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
|
||||
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
|
||||
let created = executed - chrono::Duration::seconds(1);
|
||||
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
|
||||
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
|
||||
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
|
||||
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
|
||||
"timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let mut parts = data().snapshot_components();
|
||||
for row in &mut parts.market {
|
||||
if row.date >= d(15) {
|
||||
row.day_open = 5.;
|
||||
row.open = 5.;
|
||||
row.high = 5.;
|
||||
row.low = 5.;
|
||||
row.close = 5.;
|
||||
row.last_price = 5.;
|
||||
row.bid1 = 5.;
|
||||
row.ask1 = 5.;
|
||||
row.prev_close = 5.;
|
||||
row.upper_limit = 5.5;
|
||||
row.lower_limit = 4.5;
|
||||
}
|
||||
}
|
||||
parts.corporate_actions.push(fidc_core::CorporateAction {
|
||||
date: d(15),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 1.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
});
|
||||
let data = DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode =
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
max_holding_days: 1,
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(16),
|
||||
end_date: Some(d(17)),
|
||||
}],
|
||||
};
|
||||
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(result.manual_executions.len(), 3);
|
||||
assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 });
|
||||
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
|
||||
assert_eq!(
|
||||
(
|
||||
result.fills[0].date,
|
||||
result.fills[0].side,
|
||||
result.fills[0].quantity,
|
||||
result.fills[0].price
|
||||
),
|
||||
(d(18), OrderSide::Sell, 200, 5.)
|
||||
);
|
||||
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
|
||||
for day in [14, 15] {
|
||||
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
|
||||
if rule == "sell_fill_cooldown" && sell_during_lock { continue; }
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
|
||||
}
|
||||
}
|
||||
for day in [16, 17] {
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 })
|
||||
);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.equity_curve
|
||||
.iter()
|
||||
.all(|point| point.external_cash_flow == 0.)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.automatic_trade_protection = policy;
|
||||
config.explicit_actions = vec![
|
||||
action(
|
||||
"100",
|
||||
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
|
||||
),
|
||||
action("-100", "decision_date >= \"2026-09-14\""),
|
||||
];
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
PlatformExprStrategy::new(config),
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
|
||||
let result = run(AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.map(|fill| (fill.date, fill.side, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
|
||||
);
|
||||
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|
||||
|| order.date == d(15)
|
||||
|| order.date == d(16)
|
||||
|| order.date == d(18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
|
||||
let result = run(AutomaticTradeProtection {
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(11),
|
||||
end_date: None,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.fills.is_empty());
|
||||
assert!(result.order_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
|
||||
let result = run(AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.map(|fill| (fill.date, fill.side))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.any(|order| order.reason == "max_holding_days_exit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
|
||||
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
|
||||
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
|
||||
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
|
||||
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
|
||||
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
|
||||
assert_eq!(cfg.max_holding_days, Some(90));
|
||||
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
|
||||
}
|
||||
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
|
||||
assert!(
|
||||
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("conflicting maximum")
|
||||
);
|
||||
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
|
||||
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||
let base = data();
|
||||
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||
let symbols = ["000001.SZ", "000002.SZ"];
|
||||
let dataset = DataSet::from_components(
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let mut row = base.instruments()["000001.SZ"].clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
symbols.iter().map(|symbol| {
|
||||
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
symbols.iter().map(|symbol| {
|
||||
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
symbols.iter().map(|symbol| {
|
||||
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 100_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.strategy_name = "protection_test".into();
|
||||
config.max_positions = 1;
|
||||
config.selection_limit_expr = "1".into();
|
||||
config.refresh_rate = 1;
|
||||
config.exposure_expr = "0.5".into();
|
||||
config.market_cap_lower_expr = "0".into();
|
||||
config.market_cap_upper_expr = "100".into();
|
||||
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
|
||||
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(14),
|
||||
end_date: Some(d(16)),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
PlatformExprStrategy::new(config),
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.first()
|
||||
.map(|fill| (fill.symbol.as_str(), fill.date)),
|
||||
Some(("000001.SZ", d(11)))
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
assert!(
|
||||
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
|
||||
&& fill.side == OrderSide::Buy
|
||||
&& fill.date == d(17)),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,9 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
fn candidate() -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -30,7 +31,7 @@ fn candidate() -> CandidateEligibility {
|
||||
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2024-01-03 10:18:00".to_string()),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -42,7 +43,7 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho
|
||||
ask1: open,
|
||||
prev_close: 10.0,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 50_000,
|
||||
ask1_volume: 50_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -62,10 +63,33 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
|
||||
assert_eq!(buy.stamp_tax, 0.0);
|
||||
|
||||
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
|
||||
assert!((sell.commission - 80.0).abs() < 1e-9);
|
||||
assert!((sell.commission - 30.0).abs() < 1e-9);
|
||||
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_cost_model_matches_declared_run_options() {
|
||||
let model =
|
||||
ChinaAShareCostModel::from_trading_constraints(fidc_core::TradingConstraintConfig {
|
||||
commission_rate: 0.0003,
|
||||
minimum_commission: 5.0,
|
||||
stamp_tax_rate_before_change: 0.0005,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
..fidc_core::TradingConstraintConfig::default()
|
||||
});
|
||||
|
||||
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
|
||||
assert!((buy.commission - 14.993652).abs() < 1e-9);
|
||||
assert_eq!(buy.stamp_tax, 0.0);
|
||||
|
||||
let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72);
|
||||
assert!((sell.commission - 30.217416).abs() < 1e-9);
|
||||
assert!((sell.stamp_tax - 50.36236).abs() < 1e-9);
|
||||
|
||||
let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0);
|
||||
assert!((small_buy.commission - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn china_cost_model_switches_stamp_tax_rate_after_2023_08_28() {
|
||||
let model = ChinaAShareCostModel::default();
|
||||
@@ -113,7 +137,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
|
||||
|
||||
assert!((first.commission - 5.0).abs() < 1e-9);
|
||||
assert!(second.commission.abs() < 1e-9);
|
||||
assert!((third.commission - 12.6).abs() < 1e-9);
|
||||
assert!((third.commission - 1.6).abs() < 1e-9);
|
||||
assert!((another_order.commission - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
@@ -232,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
|
||||
|
||||
let snapshot = DailyMarketSnapshot {
|
||||
date: d(2024, 4, 7),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2024-04-07 10:18:00".to_string()),
|
||||
day_open: 2.53,
|
||||
open: 2.53,
|
||||
@@ -244,7 +268,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
|
||||
ask1: 2.53,
|
||||
prev_close: 2.80,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 50_000,
|
||||
ask1_volume: 50_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
|
||||
@@ -49,14 +49,30 @@ fn portfolio_settles_cash_receivable_on_payable_date() {
|
||||
amount: 500.0,
|
||||
reason: "cash_dividend 0.5".to_string(),
|
||||
});
|
||||
portfolio.add_cash_receivable(CashReceivable {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
ex_date: d(2025, 1, 2),
|
||||
payable_date: d(2025, 1, 5),
|
||||
amount: 250.0,
|
||||
reason: "cash_dividend 0.25".to_string(),
|
||||
});
|
||||
|
||||
let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4));
|
||||
assert!(settled_early.is_empty());
|
||||
let due_early = portfolio.take_due_cash_receivables(d(2025, 1, 4));
|
||||
assert!(due_early.is_empty());
|
||||
assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9);
|
||||
|
||||
let settled = portfolio.settle_cash_receivables(d(2025, 1, 5));
|
||||
assert_eq!(settled.len(), 1);
|
||||
assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9);
|
||||
let due = portfolio.take_due_cash_receivables(d(2025, 1, 5));
|
||||
assert_eq!(due.len(), 2);
|
||||
let mut cash_chain = Vec::new();
|
||||
for receivable in &due {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio.settle_cash_receivable(receivable).unwrap();
|
||||
cash_chain.push((cash_before, portfolio.cash()));
|
||||
}
|
||||
assert_eq!(
|
||||
cash_chain,
|
||||
vec![(1_000_000.0, 1_000_500.0), (1_000_500.0, 1_000_750.0)]
|
||||
);
|
||||
assert!(portfolio.cash_receivables().is_empty());
|
||||
}
|
||||
|
||||
@@ -74,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -88,10 +105,229 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
},
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.1,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys() {
|
||||
struct ConvertedSale {
|
||||
dates: [NaiveDate; 3],
|
||||
seen: std::rc::Rc<std::cell::RefCell<Option<(Option<NaiveDate>, Option<NaiveDate>)>>>,
|
||||
}
|
||||
impl Strategy for ConvertedSale {
|
||||
fn name(&self) -> &str {
|
||||
"successor FIFO"
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let (symbol, quantity) = if ctx.execution_date == self.dates[0] {
|
||||
("000001.SZ", 100)
|
||||
} else if ctx.execution_date == self.dates[1] {
|
||||
("000002.SZ", 100)
|
||||
} else {
|
||||
let holding = ctx.portfolio.position("000002.SZ").unwrap();
|
||||
*self.seen.borrow_mut() = Some((holding.opened_date(), holding.last_buy_date()));
|
||||
("000002.SZ", -200)
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![fidc_core::OrderIntent::Shares {
|
||||
symbol: symbol.into(),
|
||||
quantity,
|
||||
reason: "dated lot test".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let dates = [d(2026, 9, 11), d(2026, 9, 14), d(2026, 9, 15)];
|
||||
let symbols = ["000001.SZ", "000002.SZ"];
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for date in dates {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == symbols[0] {
|
||||
10.
|
||||
} else if date == dates[2] {
|
||||
6.
|
||||
} else {
|
||||
20.
|
||||
};
|
||||
let mut quote = stock_market_snapshot(date);
|
||||
quote.symbol = symbol.into();
|
||||
quote.day_open = price;
|
||||
quote.open = price;
|
||||
quote.high = price;
|
||||
quote.low = price;
|
||||
quote.close = price;
|
||||
quote.last_price = price;
|
||||
quote.bid1 = price;
|
||||
quote.ask1 = price;
|
||||
quote.prev_close = price;
|
||||
quote.upper_limit = price * 1.1;
|
||||
quote.lower_limit = price * 0.9;
|
||||
market.push(quote);
|
||||
let mut factor = stock_factor_snapshot(date);
|
||||
factor.symbol = symbol.into();
|
||||
factors.push(factor);
|
||||
let mut candidate = stock_candidate(date);
|
||||
candidate.symbol = symbol.into();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components_with_actions(
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
dates.map(benchmark_snapshot).into(),
|
||||
vec![CorporateAction {
|
||||
date: dates[2],
|
||||
symbol: symbols[0].into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some(symbols[1].into()),
|
||||
successor_ratio: Some(2.),
|
||||
successor_cash: Some(0.),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let seen = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(fidc_core::MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
ConvertedSale {
|
||||
dates,
|
||||
seen: seen.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(dates[0]),
|
||||
end_date: Some(dates[2]),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(*seen.borrow(), Some((Some(dates[0]), Some(dates[1]))));
|
||||
assert_eq!(result.fills.len(), 3);
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(result.fills[2].symbol, symbols[1]);
|
||||
let remaining = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == symbols[1])
|
||||
.unwrap();
|
||||
assert_eq!(remaining.quantity, 100);
|
||||
assert_eq!(remaining.realized_pnl, 200.);
|
||||
assert!(
|
||||
result
|
||||
.position_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == symbols[0]
|
||||
&& event.quantity_after == 0
|
||||
&& event.reason.starts_with("successor_conversion"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
@@ -110,7 +346,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-01 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -122,7 +358,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -133,7 +369,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -145,7 +381,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -156,7 +392,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -168,7 +404,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -181,40 +417,44 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -225,8 +465,9 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -237,8 +478,9 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -298,10 +540,12 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
first_date: buy_date,
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0008)
|
||||
.with_minimum_commission(0.0),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
@@ -330,3 +574,96 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
assert_eq!(reinvest_fill.commission, 0.0);
|
||||
assert_eq!(reinvest_fill.stamp_tax, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
let ex_date = d(2025, 1, 2);
|
||||
let data = DataSet::from_components_with_actions(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "Anchor".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![
|
||||
stock_market_snapshot(buy_date),
|
||||
stock_market_snapshot(ex_date),
|
||||
],
|
||||
vec![
|
||||
stock_factor_snapshot(buy_date),
|
||||
stock_factor_snapshot(ex_date),
|
||||
],
|
||||
vec![stock_candidate(buy_date), stock_candidate(ex_date)],
|
||||
vec![benchmark_snapshot(buy_date), benchmark_snapshot(ex_date)],
|
||||
vec![CorporateAction {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
payable_date: Some(ex_date),
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.2,
|
||||
share_gift: 0.0,
|
||||
issue_quantity: 0.0,
|
||||
issue_price: 0.0,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyAndHoldStrategy {
|
||||
first_date: buy_date,
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
start_date: Some(buy_date),
|
||||
end_date: Some(ex_date),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_dividend_reinvestment(true);
|
||||
|
||||
let result = engine.run().expect("backtest run");
|
||||
let final_holding = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == "000001.SZ")
|
||||
.expect("holding");
|
||||
assert_eq!(final_holding.quantity, 1_300);
|
||||
|
||||
let reinvest_fill = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.reason == "dividend_reinvestment")
|
||||
.expect("reinvestment fill");
|
||||
assert_eq!(reinvest_fill.quantity, 100);
|
||||
assert!((reinvest_fill.price - ((10.0 - 1.05) / 1.2)).abs() < 1e-9);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.position_events
|
||||
.iter()
|
||||
.any(|event| event.reason == "stock_split 1.200000" && event.quantity_after == 1_200)
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.account_events
|
||||
.iter()
|
||||
.any(|event| event.note.contains("cash_receivable_reinvested"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ use chrono::{Duration, NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -15,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
||||
}
|
||||
|
||||
fn fixture_instruments() -> Vec<Instrument> {
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "quote-plan-fixture".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DecisionQuoteReader {
|
||||
day_count: usize,
|
||||
@@ -62,16 +75,237 @@ impl Strategy for DecisionQuoteReader {
|
||||
}
|
||||
}
|
||||
|
||||
struct NoLoaderDecisionQuoteStrategy {
|
||||
symbol_plan_calls: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl Strategy for NoLoaderDecisionQuoteStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"no_loader_decision_quote_strategy"
|
||||
}
|
||||
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||
vec![t(10, 18, 0)]
|
||||
}
|
||||
|
||||
fn decision_quote_symbols(
|
||||
&mut self,
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
*self
|
||||
.symbol_plan_calls
|
||||
.lock()
|
||||
.expect("symbol plan counter mutex") += 1;
|
||||
Ok(BTreeSet::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
fixture_instruments(),
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.2,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 10.78,
|
||||
lower_limit: 8.82,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1001.0,
|
||||
prev_close: 999.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_account_dependent_quote_scope_uses_the_actual_account() {
|
||||
struct AccountDependentQuoteReader;
|
||||
impl Strategy for AccountDependentQuoteReader {
|
||||
fn name(&self) -> &str { "account_dependent_quote_reader" }
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> { vec![t(10, 18, 0)] }
|
||||
fn decision_quote_symbols(&mut self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
Ok(if ctx.portfolio.cash() < 50_000.0 {
|
||||
BTreeSet::from(["000001.SZ".into()])
|
||||
} else { BTreeSet::new() })
|
||||
}
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let loaded = ctx.data.execution_quotes_on(ctx.execution_date, "000001.SZ").iter().any(|quote|
|
||||
quote.timestamp.time()==t(10,17,59) && quote.last_price==10.0);
|
||||
assert_eq!(loaded, ctx.portfolio.cash() < 50_000.0,
|
||||
"quote scope must match this account, not a fixed-capital planning account");
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let date = d(2026, 1, 5);
|
||||
for initial_cash in [10_000.0, 100_000.0] {
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close,
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash, benchmark_code:"000852.SH".into(),
|
||||
start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0,
|
||||
execution_price_field:PriceField::Close,
|
||||
};
|
||||
let mut engine = BacktestEngine::new(single_day_quote_plan_data(date), AccountDependentQuoteReader, broker, config)
|
||||
.with_execution_quote_loader(move |request| Ok(request.symbols.into_iter().map(|symbol| IntradayExecutionQuote {
|
||||
observation_kind:Default::default(), date:request.date, symbol,
|
||||
timestamp:request.date.and_time(t(10,17,59)), last_price:10.0,bid1:10.0,ask1:10.0,
|
||||
bid1_volume:10_000,ask1_volume:10_000,volume_delta:10_000,amount_delta:100_000.0,
|
||||
trading_phase:Some("continuous".into()),
|
||||
}).collect()));
|
||||
engine.run().expect("account-dependent quote planning");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_resolves_the_runtime_strategy_scope_when_a_loader_exists() {
|
||||
let date = d(2026, 1, 5);
|
||||
let data = single_day_quote_plan_data(date);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Close,
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(date),
|
||||
end_date: Some(date),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
};
|
||||
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
|
||||
let loader_calls = Arc::new(Mutex::new(0usize));
|
||||
let strategy = NoLoaderDecisionQuoteStrategy {
|
||||
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||
};
|
||||
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
||||
.with_execution_quote_loader(move |request| {
|
||||
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 17, 59)),
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 10_000,
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.collect())
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
|
||||
assert_eq!(
|
||||
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||
1,
|
||||
"quote planning must use the actual run context"
|
||||
);
|
||||
assert_eq!(
|
||||
*loader_calls.lock().expect("loader counter mutex"),
|
||||
0,
|
||||
"an empty runtime scope must not fetch unrequested symbols"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
||||
let date = d(2026, 1, 5);
|
||||
let data = single_day_quote_plan_data(date);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Close,
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(date),
|
||||
end_date: Some(date),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
};
|
||||
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
|
||||
let strategy = NoLoaderDecisionQuoteStrategy {
|
||||
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||
};
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
|
||||
assert_eq!(
|
||||
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||
0,
|
||||
"a preloaded/no-loader engine cannot use a newly computed quote symbol plan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -83,7 +317,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
tick_volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -94,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -106,7 +340,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
ask1: 10.6,
|
||||
prev_close: 10.0,
|
||||
volume: 10_000,
|
||||
tick_volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -119,30 +353,33 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -153,8 +390,9 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -189,8 +427,8 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextTickLast)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -209,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 39, 59)),
|
||||
@@ -233,11 +471,11 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -249,7 +487,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
tick_volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -260,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -272,7 +510,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
ask1: 10.6,
|
||||
prev_close: 10.0,
|
||||
volume: 10_000,
|
||||
tick_volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -285,30 +523,33 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -319,8 +560,9 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -350,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: first.and_time(t(10, 39, 59)),
|
||||
@@ -363,7 +605,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: second.and_time(t(10, 39, 59)),
|
||||
@@ -384,8 +626,8 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextTickLast)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -464,11 +706,11 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -480,7 +722,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
tick_volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -491,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -503,7 +745,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
ask1: 10.6,
|
||||
prev_close: 10.0,
|
||||
volume: 10_000,
|
||||
tick_volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -516,30 +758,33 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -550,8 +795,9 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -586,8 +832,8 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextTickLast)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -616,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -34,6 +35,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(StrategyDecision::default())
|
||||
@@ -41,7 +43,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
|
||||
fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let delist_date = d(2025, 1, 3);
|
||||
let date2 = d(2025, 1, 6);
|
||||
@@ -69,7 +71,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -81,7 +83,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -92,7 +94,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 5.0,
|
||||
open: 5.0,
|
||||
@@ -104,7 +106,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ask1: 5.01,
|
||||
prev_close: 5.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -113,9 +115,32 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
lower_limit: 4.5,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 5.05,
|
||||
open: 5.05,
|
||||
high: 5.15,
|
||||
low: 5.0,
|
||||
close: 5.05,
|
||||
last_price: 5.05,
|
||||
bid1: 5.04,
|
||||
ask1: 5.06,
|
||||
prev_close: 5.0,
|
||||
volume: 110_000,
|
||||
minute_volume: 110_000,
|
||||
bid1_volume: 110_000,
|
||||
ask1_volume: 110_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 5.5,
|
||||
lower_limit: 4.5,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 5.1,
|
||||
open: 5.1,
|
||||
@@ -127,7 +152,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ask1: 5.11,
|
||||
prev_close: 5.0,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
minute_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -140,40 +165,55 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.5,
|
||||
free_float_cap_bn: 28.5,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -184,8 +224,22 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -196,8 +250,9 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -216,6 +271,14 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: delist_date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.5,
|
||||
close: 100.5,
|
||||
prev_close: 100.0,
|
||||
volume: 1_050_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: date2,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
@@ -232,7 +295,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
@@ -248,24 +311,33 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
assert_eq!(result.fills.len(), 2);
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason.contains("delisted_cash_settlement")
|
||||
&& fill.symbol == "000001.SZ")
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.all(|holding| holding.symbol != "000001.SZ")
|
||||
.all(|fill| !fill.reason.contains("delisted_cash_settlement"))
|
||||
);
|
||||
let unresolved = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|holding| holding.symbol == "000001.SZ")
|
||||
.expect("unresolved delisted holding remains auditable");
|
||||
assert_eq!(unresolved.quantity, 900);
|
||||
assert_eq!(unresolved.last_price, 0.0);
|
||||
assert_eq!(unresolved.market_value, 0.0);
|
||||
assert!(result.equity_curve.iter().any(|point| {
|
||||
point
|
||||
.notes
|
||||
.contains("unresolved_delisted_position symbol=000001.SZ")
|
||||
&& point.notes.contains("settlement_action=missing")
|
||||
&& point.notes.contains("valuation_policy=zero")
|
||||
&& point.notes.contains("no_order=true")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let date2 = d(2025, 1, 3);
|
||||
let data = DataSet::from_components_with_actions(
|
||||
@@ -292,7 +364,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -304,7 +376,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -315,7 +387,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 20.0,
|
||||
open: 20.0,
|
||||
@@ -327,7 +399,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ask1: 20.0,
|
||||
prev_close: 20.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -338,7 +410,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 21.0,
|
||||
open: 21.0,
|
||||
@@ -350,7 +422,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ask1: 21.0,
|
||||
prev_close: 20.0,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
minute_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -363,40 +435,44 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -407,8 +483,9 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -419,8 +496,9 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
@@ -470,7 +548,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks,
|
||||
DataSet, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
use std::{cell::RefCell, collections::BTreeSet, rc::Rc};
|
||||
|
||||
const SYMBOL: &str = "000001.SZ";
|
||||
fn day(value: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
|
||||
}
|
||||
|
||||
fn data() -> DataSet {
|
||||
let days = [11, 14, 15].map(day);
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![fidc_core::Instrument {
|
||||
symbol: SYMBOL.into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(day(1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
days.iter()
|
||||
.map(|&date| {
|
||||
let price = if date == day(11) { 10. } else { 8.95 };
|
||||
fidc_core::DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::CandidateEligibility {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.collect(),
|
||||
vec![fidc_core::CorporateAction {
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
payable_date: Some(day(14)),
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
}],
|
||||
[(9, 15), (9, 31)]
|
||||
.into_iter()
|
||||
.map(|(hour, minute)| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: day(14).and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 8.95,
|
||||
bid1: 8.95,
|
||||
ask1: 8.95,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
volume_delta: 10000,
|
||||
amount_delta: 89500.,
|
||||
trading_phase: Some("continuous".into()),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct Hold {
|
||||
seen: Rc<RefCell<Vec<(NaiveTime, u32)>>>,
|
||||
}
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"accounting reinvestment contract"
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
[SYMBOL.into()].into()
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(if ctx.execution_date == day(11) {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.into(),
|
||||
quantity: 1000,
|
||||
reason: "initial".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &fidc_core::IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.seen.borrow_mut().push((
|
||||
ctx.current_time().unwrap(),
|
||||
ctx.portfolio
|
||||
.position(SYMBOL)
|
||||
.map_or(0, |position| position.quantity),
|
||||
));
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> BacktestEngine<Hold, ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
Hold {
|
||||
seen: Rc::new(RefCell::new(Vec::new())),
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0008)
|
||||
.with_minimum_commission(0.),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 50000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(11)),
|
||||
end_date: Some(day(15)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_dividend_reinvestment(true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_reinvestment_has_an_explicit_origin_clock_and_progress_delivery() {
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let reinvest = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.reason == "dividend_reinvestment")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
reinvest.quantity,
|
||||
reinvest.price,
|
||||
reinvest.commission,
|
||||
reinvest.order_id
|
||||
),
|
||||
(100, 8.95, 0., None)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reinvest).unwrap()["origin"],
|
||||
"dividend_reinvestment"
|
||||
);
|
||||
assert_eq!(reinvest.execution_timestamp, day(14).and_hms_opt(0, 0, 0));
|
||||
let received = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert!(
|
||||
received
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason == "dividend_reinvestment")
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn manual_source(delayed: bool) -> fidc_core::manual_execution::ManualExecutionReplay {
|
||||
let observed = if delayed {
|
||||
"2026-09-14T01:15:00Z"
|
||||
} else {
|
||||
"2026-09-11T06:00:01Z"
|
||||
};
|
||||
let mut source: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-09-15T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
|
||||
"confirmedAt":"2026-09-11T05:59:59Z","confirmationObservedAt":"2026-09-11T05:59:59Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"manual-order","sourceAdapter":"paper","symbol":SYMBOL,"side":"Buy","quantity":1000,
|
||||
"orderCreatedAt":"2026-09-11T05:59:59Z","terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":"manual-fill","observationEventId":"receipt","observationSequence":1,"feeObservationEventId":"receipt","feeObservationSequence":1,
|
||||
"feeObservedAt":observed,"tradeDate":"2026-09-11","executedAt":"2026-09-11T06:00:00Z","observedAt":observed,
|
||||
"timestampPrecision":"second","quantity":1000,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_receipt_before_market_open_reconciles_accounting_not_future_market_fills() {
|
||||
let timely = engine()
|
||||
.with_observed_manual_executions(manual_source(false))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
let delayed = engine()
|
||||
.with_observed_manual_executions(manual_source(true))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 2200);
|
||||
assert_eq!(
|
||||
delayed.holdings_summary[0].quantity,
|
||||
timely.holdings_summary[0].quantity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.corporate_cash_delta,
|
||||
"155"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
|
||||
let mut source = manual_source(true);
|
||||
let observed = "2026-09-12T02:00:00Z".parse().unwrap();
|
||||
let order = &mut source.actions[0].orders[0];
|
||||
order.terminal_observed_at = observed;
|
||||
order.fills[0].observed_at = observed;
|
||||
order.fills[0].fee_observed_at = observed;
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let monday = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert_eq!(monday.daily_manual_fill_count, 1);
|
||||
assert_eq!(monday.manual_executions[0].observed_at, observed);
|
||||
assert!(
|
||||
monday
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.origin == fidc_core::FillOrigin::DividendReinvestment)
|
||||
);
|
||||
assert!(
|
||||
monday
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == fidc_core::ProcessEventKind::ManualExecutionObserved)
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len() + result.manual_executions.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn exposure_event(id: &str, sequence: u64, at: &str, action: fidc_core::position_exposure::PositionExposureAction)
|
||||
-> fidc_core::position_exposure::PositionExposureEvent {
|
||||
fidc_core::position_exposure::PositionExposureEvent {
|
||||
event_id: id.into(), sequence, effective_at: at.parse().unwrap(), allocation_weights_bps: None, action,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleared_reinvestment_case(
|
||||
events: Vec<fidc_core::position_exposure::PositionExposureEvent>,
|
||||
legacy: std::collections::BTreeMap<NaiveDate, i32>,
|
||||
extra_buy_delayed: Option<bool>,
|
||||
) -> fidc_core::BacktestResult {
|
||||
let mut parts = data().snapshot_components();
|
||||
parts.corporate_actions[0].payable_date = Some(day(15));
|
||||
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
|
||||
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, parts.execution_quotes).unwrap();
|
||||
let mut source = manual_source(false);
|
||||
let mut sale = source.actions[0].clone();
|
||||
sale.action_id = "clear".into(); sale.audit_event_ids = vec!["clear-audit".into()];
|
||||
sale.confirmed_at = "2026-09-14T05:59:59Z".parse().unwrap();
|
||||
sale.confirmation_observed_at = sale.confirmed_at;
|
||||
let order = &mut sale.orders[0];
|
||||
order.order_id = "clear-order".into(); order.side = fidc_core::OrderSide::Sell;
|
||||
order.order_created_at = sale.confirmed_at;
|
||||
order.terminal_observed_at = "2026-09-14T06:00:01Z".parse().unwrap();
|
||||
let fill = &mut order.fills[0];
|
||||
fill.trade_id = "clear-fill".into(); fill.observation_event_id = "clear-receipt".into();
|
||||
fill.observation_sequence = 2; fill.fee_observation_event_id = "clear-receipt".into();
|
||||
fill.fee_observation_sequence = 2; fill.trade_date = day(14);
|
||||
fill.executed_at = "2026-09-14T06:00:00Z".parse().unwrap();
|
||||
fill.observed_at = order.terminal_observed_at; fill.fee_observed_at = order.terminal_observed_at;
|
||||
fill.price = "8.95".parse().unwrap();
|
||||
source.actions.push(sale);
|
||||
if let Some(delayed) = extra_buy_delayed {
|
||||
let mut extra = source.actions[0].clone();
|
||||
extra.action_id = "extra".into(); extra.audit_event_ids = vec!["extra-audit".into()];
|
||||
extra.confirmed_at = "2026-09-11T06:00:59Z".parse().unwrap();
|
||||
extra.confirmation_observed_at = extra.confirmed_at;
|
||||
let order = &mut extra.orders[0];
|
||||
order.order_id = "extra-order".into(); order.order_created_at = extra.confirmed_at;
|
||||
order.terminal_observed_at = if delayed { "2026-09-15T01:15:00Z" } else { "2026-09-11T06:01:01Z" }.parse().unwrap();
|
||||
let fill = &mut order.fills[0];
|
||||
fill.trade_id = "extra-fill".into(); fill.observation_event_id = "extra-receipt".into();
|
||||
fill.observation_sequence = if delayed { 3 } else { 2 };
|
||||
fill.fee_observation_event_id = "extra-receipt".into(); fill.fee_observation_sequence = fill.observation_sequence;
|
||||
fill.executed_at = "2026-09-11T06:01:00Z".parse().unwrap(); fill.observed_at = order.terminal_observed_at;
|
||||
fill.fee_observed_at = order.terminal_observed_at;
|
||||
if !delayed {
|
||||
source.actions[1].orders[0].fills[0].observation_sequence = 3;
|
||||
source.actions[1].orders[0].fills[0].fee_observation_sequence = 3;
|
||||
}
|
||||
source.actions.push(extra);
|
||||
}
|
||||
source.position_exposure_events = events;
|
||||
source.legacy_position_exposure_bps = legacy;
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = SYMBOL.into(); config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false; config.matching_type = MatchingType::CurrentBarClose;
|
||||
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false),
|
||||
BacktestConfig { initial_cash: 50000., benchmark_code: "000300.SH".into(), start_date: Some(day(11)),
|
||||
end_date: Some(day(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Close })
|
||||
.with_dividend_reinvestment(true).with_observed_manual_executions(source).unwrap().run().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effective_manual_zero_must_not_recreate_a_cleared_position_on_dividend_payment() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
let zero = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 });
|
||||
let result = cleared_reinvestment_case(vec![zero], Default::default(), None);
|
||||
assert!(result.fills.is_empty(), "{:?}", result.fills);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
assert_eq!(result.equity_curve.last().unwrap().cash, 49998.);
|
||||
assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_exposure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clear_without_a_manual_zero_keeps_the_declared_legacy_reinvestment_model() {
|
||||
let result = cleared_reinvestment_case(vec![], Default::default(), None);
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!((result.fills[0].quantity, result.fills[0].price, result.fills[0].commission), (100, 8.95, 0.));
|
||||
assert_eq!(result.fills[0].gross_amount, 895.);
|
||||
assert_eq!(result.fills[0].net_cash_flow, -895.);
|
||||
assert!(result.order_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinvestment_respects_zero_restore_same_instant_sequence_and_legacy_granularity() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
let before = "2026-09-14T07:00:00Z";
|
||||
let settlement = "2026-09-14T16:00:00Z";
|
||||
let later = "2026-09-15T01:31:00Z";
|
||||
let zero = || exposure_event("zero", 1, before, Action::Scale { requested_bps: 0 });
|
||||
let cases = vec![
|
||||
(vec![zero()], Default::default(), false),
|
||||
(vec![exposure_event("zero-at-settlement", 1, settlement, Action::Set { target_exposure_bps: 0 })], Default::default(), false),
|
||||
(vec![exposure_event("later-zero", 1, later, Action::Set { target_exposure_bps: 0 })], Default::default(), true),
|
||||
(vec![zero(), exposure_event("restore", 2, before, Action::Restore)], Default::default(), true),
|
||||
(vec![exposure_event("restore", 1, before, Action::Restore), exposure_event("last-zero", 2, before, Action::Set { target_exposure_bps: 0 })], Default::default(), false),
|
||||
(vec![exposure_event("restore", 2, before, Action::Restore), zero()], Default::default(), true),
|
||||
(vec![zero(), exposure_event("later-restore", 2, later, Action::Restore)], Default::default(), false),
|
||||
(vec![], std::collections::BTreeMap::from([(day(14), 0)]), false),
|
||||
(vec![exposure_event("restore-legacy", 1, before, Action::Restore)], std::collections::BTreeMap::from([(day(14), 0)]), true),
|
||||
(vec![exposure_event("positive", 1, before, Action::Set { target_exposure_bps: 3000 })], Default::default(), true),
|
||||
];
|
||||
for (events, legacy, allowed) in cases {
|
||||
let result = cleared_reinvestment_case(events, legacy, None);
|
||||
assert_eq!(result.fills.len(), usize::from(allowed));
|
||||
assert_eq!(result.equity_curve.last().unwrap().cash, if allowed { 49103. } else { 49998. });
|
||||
assert_eq!(result.holdings_summary.iter().map(|holding| holding.quantity).sum::<u32>(), if allowed {100} else {0});
|
||||
assert!(result.order_events.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_zero_member_weight_or_omission_blocks_only_that_reinvestment() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
for included in [false, true] {
|
||||
for weight in [0, 10000] {
|
||||
let mut event = exposure_event("allocation", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 5000 });
|
||||
let mut weights = std::collections::BTreeMap::from([("000002.SZ".into(), if included {10000-weight} else {10000})]);
|
||||
if included { weights.insert(SYMBOL.into(), weight); }
|
||||
event.allocation_weights_bps = Some(weights);
|
||||
let allowed = included && weight > 0;
|
||||
let result = cleared_reinvestment_case(vec![event], Default::default(), None);
|
||||
assert_eq!(result.fills.len(), usize::from(allowed));
|
||||
if !allowed { assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_allocation")); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_corporate_replay_uses_the_same_zero_policy_and_retains_actual_manual_shares() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
let event = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 });
|
||||
let timely = cleared_reinvestment_case(vec![event.clone()], Default::default(), Some(false));
|
||||
let late = cleared_reinvestment_case(vec![event], Default::default(), Some(true));
|
||||
assert!(timely.fills.is_empty()); assert!(late.fills.is_empty());
|
||||
assert_eq!(timely.equity_curve.last().unwrap().cash, 41047.);
|
||||
assert_eq!(late.equity_curve.last().unwrap().cash, timely.equity_curve.last().unwrap().cash);
|
||||
assert_eq!(late.holdings_summary[0].quantity, 1000);
|
||||
assert_eq!(late.holdings_summary[0].quantity, timely.holdings_summary[0].quantity);
|
||||
assert_eq!(late.manual_executions.last().unwrap().corporate_adjustment.as_ref().unwrap().corporate_cash_delta, "1050");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"strategyId": "fixture_hold_without_stops_backtest",
|
||||
"version": "1.0.0",
|
||||
"market": "CN_A",
|
||||
"benchmark": {
|
||||
"instrumentId": "000300.SH",
|
||||
"fallbackInstrumentId": "000300.SH",
|
||||
"note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。"
|
||||
},
|
||||
"universe": {
|
||||
"exclude": [],
|
||||
"implementationNotes": [
|
||||
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
|
||||
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
|
||||
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
|
||||
],
|
||||
"include": [
|
||||
"000001.SZ",
|
||||
"000002.SZ"
|
||||
]
|
||||
},
|
||||
"selectors": [
|
||||
{
|
||||
"type": "dynamicRange",
|
||||
"field": "market_cap",
|
||||
"lowerExpr": "0",
|
||||
"upperExpr": "1000000000000",
|
||||
"mapping": "close -> strategy_factory_source_lake.runtime_fields.close"
|
||||
},
|
||||
{
|
||||
"type": "filter",
|
||||
"expr": "(close > 0)"
|
||||
},
|
||||
{
|
||||
"type": "rank",
|
||||
"orderBy": [
|
||||
"market_cap asc"
|
||||
],
|
||||
"limitExpr": "2"
|
||||
}
|
||||
],
|
||||
"rebalance": {
|
||||
"frequencyDays": 1,
|
||||
"tradeTimes": [],
|
||||
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
|
||||
"schedule": {
|
||||
"frequency": "daily"
|
||||
}
|
||||
},
|
||||
"risk": {
|
||||
"takeProfitExpr": "",
|
||||
"stopLossExpr": "",
|
||||
"indexThrottleExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
|
||||
"stopTakeReferencePriceMode": "position_average_entry_price"
|
||||
},
|
||||
"seasonality": {
|
||||
"skipWindows": []
|
||||
},
|
||||
"execution": {
|
||||
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
|
||||
"executionGranularity": "daily_or_minute_bar",
|
||||
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
|
||||
"matchingType": "current_bar_close",
|
||||
"rebalanceCashMode": "sell_then_buy",
|
||||
"slippageModel": "none",
|
||||
"slippageValue": 0,
|
||||
"riskPolicy": {
|
||||
"rejectStSelection": false,
|
||||
"rejectStarStSelection": false,
|
||||
"rejectPausedSelection": false,
|
||||
"rejectInactiveSelection": false,
|
||||
"rejectNewListingSelection": false,
|
||||
"rejectKcbSelection": false,
|
||||
"rejectBjseSelection": false,
|
||||
"rejectOneYuanSelection": false,
|
||||
"rejectUpperLimitSelection": false,
|
||||
"rejectLowerLimitSelection": false,
|
||||
"rejectStBuy": true,
|
||||
"rejectStarStBuy": true,
|
||||
"rejectPausedBuy": true,
|
||||
"rejectInactiveBuy": true,
|
||||
"rejectNewListingBuy": true,
|
||||
"rejectKcbBuy": true,
|
||||
"rejectBjseBuy": true,
|
||||
"rejectOneYuanBuy": true,
|
||||
"rejectUpperLimitBuy": true,
|
||||
"rejectPausedSell": true,
|
||||
"rejectInactiveSell": true,
|
||||
"rejectLowerLimitSell": true,
|
||||
"respectAllowBuySell": true,
|
||||
"forbidSameDayRebuyAfterSell": true,
|
||||
"blacklistEnabled": true,
|
||||
"blacklistedSymbols": [],
|
||||
"allowMarketOrders": true,
|
||||
"liveTradingEnabled": false,
|
||||
"volumeLimitEnabled": true,
|
||||
"liquidityLimitEnabled": true,
|
||||
"volumePercent": 0.25,
|
||||
"maxOrderQuantity": 1000000,
|
||||
"maxOrderNotional": 100000000,
|
||||
"maxSymbolPosition": 10000000,
|
||||
"commissionRate": 0.0003,
|
||||
"minimumCommission": 5,
|
||||
"stampTaxRateBeforeChange": 0.001,
|
||||
"stampTaxRateAfterChange": 0.0005,
|
||||
"stampTaxChangeDate": "2023-08-28"
|
||||
},
|
||||
"sourceLanguage": "engine-script",
|
||||
"sourceKind": "platform-strategy",
|
||||
"extractor": "omniquant-engine-script-v2",
|
||||
"sellThenBuyDelaySlippageRate": 0,
|
||||
"strictValueBudget": true
|
||||
},
|
||||
"factorRefs": [
|
||||
"close"
|
||||
],
|
||||
"runtimeExpressions": {
|
||||
"prelude": "",
|
||||
"schedule": {
|
||||
"frequency": "daily"
|
||||
},
|
||||
"selection": {
|
||||
"limitExpr": "2",
|
||||
"candidateLimitExpr": "2",
|
||||
"marketCapField": "close",
|
||||
"marketCapLowerExpr": "0",
|
||||
"marketCapUpperExpr": "1000000000000",
|
||||
"stockFilterExpr": "(close > 0)"
|
||||
},
|
||||
"risk": {
|
||||
"exposureExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
|
||||
"stopLossExpr": "",
|
||||
"takeProfitExpr": "",
|
||||
"stopTakeReferencePriceMode": "position_average_entry_price"
|
||||
},
|
||||
"allocation": {
|
||||
"buyScaleExpr": "1.0"
|
||||
},
|
||||
"ordering": {
|
||||
"rankBy": "market_cap",
|
||||
"rankExpr": "(symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2",
|
||||
"rankOrder": "asc"
|
||||
},
|
||||
"trading": {
|
||||
"rotationEnabled": true,
|
||||
"subscriptionGuardRequired": false,
|
||||
"stage": "on_day",
|
||||
"actions": []
|
||||
}
|
||||
},
|
||||
"engineConfig": {
|
||||
"templateId": "fixture_hold_without_stops_backtest",
|
||||
"benchmarkSymbol": "000300.SH",
|
||||
"signalSymbol": "000300.SH",
|
||||
"rankLimit": 2,
|
||||
"refreshRate": 1,
|
||||
"rsiRate": 1.0001,
|
||||
"dynamicRange": {
|
||||
"baseIndexLevel": 2000,
|
||||
"baseCapFloor": 7,
|
||||
"capSpan": 1000000000000,
|
||||
"xs": 0.008
|
||||
},
|
||||
"stopLossMultiplier": null,
|
||||
"takeProfitMultiplier": null,
|
||||
"matchingType": "current_bar_close",
|
||||
"rebalanceCashMode": "sell_then_buy",
|
||||
"slippageModel": "none",
|
||||
"slippageValue": 0,
|
||||
"riskPolicy": {
|
||||
"rejectStSelection": false,
|
||||
"rejectStarStSelection": false,
|
||||
"rejectPausedSelection": false,
|
||||
"rejectInactiveSelection": false,
|
||||
"rejectNewListingSelection": false,
|
||||
"rejectKcbSelection": false,
|
||||
"rejectBjseSelection": false,
|
||||
"rejectOneYuanSelection": false,
|
||||
"rejectUpperLimitSelection": false,
|
||||
"rejectLowerLimitSelection": false,
|
||||
"rejectStBuy": true,
|
||||
"rejectStarStBuy": true,
|
||||
"rejectPausedBuy": true,
|
||||
"rejectInactiveBuy": true,
|
||||
"rejectNewListingBuy": true,
|
||||
"rejectKcbBuy": true,
|
||||
"rejectBjseBuy": true,
|
||||
"rejectOneYuanBuy": true,
|
||||
"rejectUpperLimitBuy": true,
|
||||
"rejectPausedSell": true,
|
||||
"rejectInactiveSell": true,
|
||||
"rejectLowerLimitSell": true,
|
||||
"respectAllowBuySell": true,
|
||||
"forbidSameDayRebuyAfterSell": true,
|
||||
"blacklistEnabled": true,
|
||||
"blacklistedSymbols": [],
|
||||
"allowMarketOrders": true,
|
||||
"liveTradingEnabled": false,
|
||||
"volumeLimitEnabled": true,
|
||||
"liquidityLimitEnabled": true,
|
||||
"volumePercent": 0.25,
|
||||
"maxOrderQuantity": 1000000,
|
||||
"maxOrderNotional": 100000000,
|
||||
"maxSymbolPosition": 10000000,
|
||||
"commissionRate": 0.0003,
|
||||
"minimumCommission": 5,
|
||||
"stampTaxRateBeforeChange": 0.001,
|
||||
"stampTaxRateAfterChange": 0.0005,
|
||||
"stampTaxChangeDate": "2023-08-28"
|
||||
},
|
||||
"skipWindows": [],
|
||||
"rebalanceSchedule": {
|
||||
"frequency": "daily"
|
||||
},
|
||||
"dividendReinvestment": false,
|
||||
"sellThenBuyDelaySlippageRate": 0,
|
||||
"strictValueBudget": true
|
||||
},
|
||||
"stockPool": {
|
||||
"schema_version": 1,
|
||||
"pool_id": "fixture-pool",
|
||||
"version_id": "fixture-version",
|
||||
"members": [
|
||||
{
|
||||
"symbol": "000001.SZ",
|
||||
"requested_order": 0,
|
||||
"recommendation_reason": "",
|
||||
"target_weight_bps": null,
|
||||
"stop_loss": null,
|
||||
"take_profit": null
|
||||
},
|
||||
{
|
||||
"symbol": "000002.SZ",
|
||||
"requested_order": 1,
|
||||
"recommendation_reason": "",
|
||||
"target_weight_bps": null,
|
||||
"stop_loss": null,
|
||||
"take_profit": null
|
||||
}
|
||||
],
|
||||
"allocation_policy": {
|
||||
"target_holding_count": 2,
|
||||
"portfolio_policy": {
|
||||
"schema_version": 1,
|
||||
"membership": "retain_holdings",
|
||||
"rebalance_weights": false
|
||||
},
|
||||
"invest_ratio_bps": 10000,
|
||||
"reserve_cash": 0
|
||||
},
|
||||
"timing_policy": {
|
||||
"schema_version": 1,
|
||||
"auto_execute": true,
|
||||
"freeze_time": "00:00",
|
||||
"window_start": "09:30",
|
||||
"window_end": "15:00",
|
||||
"trigger_mode": "scheduled_bar",
|
||||
"pricing_mode": "first_tick",
|
||||
"automatic_trade_protection": {
|
||||
"buy_protection_days": 0,
|
||||
"sell_cooldown_days": 0,
|
||||
"max_holding_days": 0,
|
||||
"locks": []
|
||||
}
|
||||
},
|
||||
"stop_take_policy": {
|
||||
"stop_loss": null,
|
||||
"take_profit": null
|
||||
},
|
||||
"out_of_pool_policy": "hold"
|
||||
},
|
||||
"signalSymbol": "000300.SH",
|
||||
"sourceCode": "strategy(\"fixture_hold_without_stops_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1)\n universe.include([\"000001.SZ\", \"000002.SZ\"])\n selection.limit(2)\n selection.candidate_limit(2)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr(close > 0)\n ordering.rank_expr((symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2, \"asc\")\n risk.index_exposure(max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale(1.0)\n stock_pool.config({\"schema_version\":1,\"pool_id\":\"fixture-pool\",\"version_id\":\"fixture-version\",\"members\":[{\"symbol\":\"000001.SZ\",\"requested_order\":0,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null},{\"symbol\":\"000002.SZ\",\"requested_order\":1,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null}],\"allocation_policy\":{\"target_holding_count\":2,\"portfolio_policy\":{\"schema_version\":1,\"membership\":\"retain_holdings\",\"rebalance_weights\":false},\"invest_ratio_bps\":10000,\"reserve_cash\":0},\"timing_policy\":{\"schema_version\":1,\"auto_execute\":true,\"freeze_time\":\"00:00\",\"window_start\":\"09:30\",\"window_end\":\"15:00\",\"trigger_mode\":\"scheduled_bar\",\"pricing_mode\":\"first_tick\",\"automatic_trade_protection\":{\"buy_protection_days\":0,\"sell_cooldown_days\":0,\"max_holding_days\":0,\"locks\":[]}},\"stop_take_policy\":{\"stop_loss\":null,\"take_profit\":null},\"out_of_pool_policy\":\"hold\"})\n risk.reference_price_mode(\"position_average_entry_price\")\n execution.matching_type(\"current_bar_close\")\n}\n",
|
||||
"mode": "rotation"
|
||||
}
|
||||
@@ -208,3 +208,134 @@ fn futures_expiration_settlement_closes_all_contract_directions() {
|
||||
);
|
||||
assert!((account.total_cash() - 1_003_000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_full_close_preserves_closed_position_daily_metrics() {
|
||||
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||
let mut account = FuturesAccountState::new(100_000.0);
|
||||
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
|
||||
account.begin_trading_day();
|
||||
|
||||
let realized = account
|
||||
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
|
||||
.expect("close overnight position");
|
||||
|
||||
assert!(account.positions().is_empty());
|
||||
assert!((realized - 98.0).abs() < 1e-12);
|
||||
assert!((account.position_pnl() - 100.0).abs() < 1e-12);
|
||||
assert!(account.trading_pnl().abs() < 1e-12);
|
||||
assert!((account.transaction_cost() - 2.0).abs() < 1e-12);
|
||||
assert!((account.daily_pnl() - 98.0).abs() < 1e-12);
|
||||
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
|
||||
|
||||
account.begin_trading_day();
|
||||
assert!(account.daily_pnl().abs() < 1e-12);
|
||||
assert!(account.transaction_cost().abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_intraday_roundtrip_preserves_closed_trading_pnl() {
|
||||
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||
let mut account = FuturesAccountState::new(100_000.0);
|
||||
account.begin_trading_day();
|
||||
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
|
||||
account
|
||||
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
|
||||
.expect("close intraday position");
|
||||
|
||||
assert!(account.positions().is_empty());
|
||||
assert!((account.trading_pnl() - 100.0).abs() < 1e-12);
|
||||
assert!(account.position_pnl().abs() < 1e-12);
|
||||
assert!((account.transaction_cost() - 3.0).abs() < 1e-12);
|
||||
assert!((account.daily_pnl() - 97.0).abs() < 1e-12);
|
||||
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_partial_close_offsets_later_mark_with_trading_pnl() {
|
||||
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||
let mut account = FuturesAccountState::new(100_000.0);
|
||||
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 2, 100.0, 0.0);
|
||||
account.begin_trading_day();
|
||||
account
|
||||
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 0.0)
|
||||
.expect("partially close overnight position");
|
||||
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 120.0);
|
||||
|
||||
assert!((account.position_pnl() - 400.0).abs() < 1e-12);
|
||||
assert!((account.trading_pnl() + 100.0).abs() < 1e-12);
|
||||
assert!((account.daily_pnl() - 300.0).abs() < 1e-12);
|
||||
assert!((account.total_value() - 100_300.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_settlement_keeps_same_day_pnl_visible_until_next_day() {
|
||||
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||
let mut account = FuturesAccountState::new(100_000.0);
|
||||
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 0.0);
|
||||
account.begin_trading_day();
|
||||
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 110.0);
|
||||
|
||||
let settled = account.settle(&BTreeMap::from([("IF2506.CCFX".to_string(), 110.0)]));
|
||||
|
||||
assert!((settled - 100.0).abs() < 1e-12);
|
||||
assert!((account.daily_pnl() - 100.0).abs() < 1e-12);
|
||||
assert!((account.total_cash() - 100_100.0).abs() < 1e-12);
|
||||
assert!((account.total_value() - 100_100.0).abs() < 1e-12);
|
||||
|
||||
account.begin_trading_day();
|
||||
assert!(account.daily_pnl().abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_cash_and_closed_cost_accumulate_micro_yuan_exactly() {
|
||||
let spec = FuturesContractSpec::new(1.0, 0.0, 0.0);
|
||||
let mut account = FuturesAccountState::new(1_000_000.0);
|
||||
account.begin_trading_day();
|
||||
for _ in 0..10_000 {
|
||||
account.open(
|
||||
"IF2506.CCFX",
|
||||
FuturesDirection::Long,
|
||||
spec,
|
||||
1,
|
||||
100.0,
|
||||
0.000001,
|
||||
);
|
||||
account
|
||||
.close("IF2506.CCFX", FuturesDirection::Long, 1, 100.0, 0.000001)
|
||||
.expect("close micro-cost position");
|
||||
}
|
||||
|
||||
assert!((account.total_cash() - 999_999.98).abs() < 1e-12);
|
||||
assert!((account.transaction_cost() - 0.02).abs() < 1e-12);
|
||||
assert!((account.daily_pnl() + 0.02).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_margin_gate_and_fill_cash_use_exact_micro_yuan() {
|
||||
let date = d(2025, 1, 2);
|
||||
let spec = FuturesContractSpec::new(1.0, 1.0, 1.0);
|
||||
let intent = FuturesOrderIntent::open(
|
||||
"IF2506.CCFX",
|
||||
FuturesDirection::Long,
|
||||
spec,
|
||||
1,
|
||||
100.0,
|
||||
0.000001,
|
||||
"micro margin boundary",
|
||||
);
|
||||
|
||||
let mut insufficient = FuturesAccountState::new(100.0);
|
||||
let rejected = insufficient.execute_order(date, Some(1), intent.clone());
|
||||
assert_eq!(rejected.order_events[0].status, OrderStatus::Rejected);
|
||||
assert!((insufficient.total_cash() - 100.0).abs() < 1e-12);
|
||||
|
||||
let mut exact = FuturesAccountState::new(100.000001);
|
||||
let filled = exact.execute_order(date, Some(2), intent);
|
||||
assert_eq!(filled.order_events[0].status, OrderStatus::Filled);
|
||||
assert_eq!(filled.fill_events.len(), 1);
|
||||
assert!((filled.fill_events[0].gross_amount - 100.0).abs() < 1e-12);
|
||||
assert!((filled.fill_events[0].commission - 0.000001).abs() < 1e-12);
|
||||
assert!((filled.fill_events[0].net_cash_flow + 0.000001).abs() < 1e-12);
|
||||
assert!(exact.cash().abs() < 1e-12);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use fidc_core::{
|
||||
BenchmarkSnapshot, DailyMarketSnapshot, DataSet, Instrument, IntradayExecutionQuote,
|
||||
};
|
||||
|
||||
const SYMBOL: &str = "000001.SZ";
|
||||
|
||||
fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid start date");
|
||||
let dates = (0..day_count)
|
||||
.map(|offset| start + Duration::days(offset as i64))
|
||||
.collect::<Vec<_>>();
|
||||
let markets = dates
|
||||
.iter()
|
||||
.map(|date| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.5,
|
||||
low: 9.5,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
prev_close: 10.0,
|
||||
volume: 1_000_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let benchmarks = dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1_000.0,
|
||||
close: 1_000.0,
|
||||
prev_close: 1_000.0,
|
||||
volume: 10_000_000,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut quotes = Vec::with_capacity(day_count * bars_per_day);
|
||||
for date in &dates {
|
||||
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
|
||||
for offset in 0..bars_per_day {
|
||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp,
|
||||
last_price: 10.0 + offset as f64 / 10_000.0,
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 1_000,
|
||||
amount_delta: 10_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
vec![Instrument {
|
||||
symbol: SYMBOL.to_string(),
|
||||
name: "平安银行".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(start - Duration::days(1_000)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
markets,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
benchmarks,
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.expect("build intraday history dataset");
|
||||
(data, dates)
|
||||
}
|
||||
|
||||
fn timestamp(date: NaiveDate, time: &str) -> NaiveDateTime {
|
||||
let time = NaiveTime::parse_from_str(time, "%H:%M:%S").expect("valid time");
|
||||
date.and_time(time)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intraday_history_is_bounded_by_visibility_and_preserves_order() {
|
||||
let (data, dates) = dataset(3, 4);
|
||||
let rows = data.history_intraday_quotes_at(
|
||||
dates[1],
|
||||
Some(timestamp(dates[1], "09:32:00")),
|
||||
SYMBOL,
|
||||
3,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
rows.iter().map(|row| row.timestamp).collect::<Vec<_>>(),
|
||||
vec![
|
||||
timestamp(dates[0], "09:33:00"),
|
||||
timestamp(dates[1], "09:30:00"),
|
||||
timestamp(dates[1], "09:31:00"),
|
||||
]
|
||||
);
|
||||
|
||||
let including_now = data.history_intraday_quotes_at(
|
||||
dates[1],
|
||||
Some(timestamp(dates[1], "09:32:00")),
|
||||
SYMBOL,
|
||||
3,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
including_now
|
||||
.iter()
|
||||
.map(|row| row.timestamp)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
timestamp(dates[1], "09:30:00"),
|
||||
timestamp(dates[1], "09:31:00"),
|
||||
timestamp(dates[1], "09:32:00"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual release-mode intraday history benchmark"]
|
||||
fn benchmark_bounded_intraday_history() {
|
||||
let (data, dates) = dataset(250, 240);
|
||||
let active_datetime = timestamp(*dates.last().expect("last date"), "13:29:00");
|
||||
|
||||
for _ in 0..5 {
|
||||
black_box(data.history_intraday_quotes_at(
|
||||
active_datetime.date(),
|
||||
Some(active_datetime),
|
||||
SYMBOL,
|
||||
30,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0_i64;
|
||||
for _ in 0..200 {
|
||||
let rows = data.history_intraday_quotes_at(
|
||||
active_datetime.date(),
|
||||
Some(active_datetime),
|
||||
SYMBOL,
|
||||
30,
|
||||
true,
|
||||
);
|
||||
checksum += rows
|
||||
.last()
|
||||
.expect("history row")
|
||||
.timestamp
|
||||
.and_utc()
|
||||
.timestamp();
|
||||
black_box(&rows);
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"intraday_history_benchmark iterations=200 rows_per_dataset=60000 elapsed_seconds={:.6} checksum={checksum}",
|
||||
elapsed.as_secs_f64(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual release-mode quote-stream benchmark"]
|
||||
fn benchmark_borrowed_execution_quote_stream() {
|
||||
let (data, dates) = dataset(250, 240);
|
||||
let date = *dates.last().expect("last date");
|
||||
let symbols = std::collections::BTreeSet::from([SYMBOL.to_string()]);
|
||||
|
||||
for _ in 0..5 {
|
||||
black_box(data.execution_quotes_on_date_for_symbols(date, Some(&symbols)));
|
||||
black_box(
|
||||
data.execution_quotes_iter_on_date_for_symbols(date, Some(&symbols))
|
||||
.count(),
|
||||
);
|
||||
}
|
||||
|
||||
let materialized_started = Instant::now();
|
||||
let mut materialized_checksum = 0_i64;
|
||||
for _ in 0..5_000 {
|
||||
let rows = data.execution_quotes_on_date_for_symbols(date, Some(&symbols));
|
||||
materialized_checksum += rows
|
||||
.iter()
|
||||
.map(|quote| quote.timestamp.and_utc().timestamp())
|
||||
.sum::<i64>();
|
||||
black_box(rows);
|
||||
}
|
||||
let materialized_seconds = materialized_started.elapsed().as_secs_f64();
|
||||
|
||||
let streamed_started = Instant::now();
|
||||
let mut streamed_checksum = 0_i64;
|
||||
for _ in 0..5_000 {
|
||||
let count = data
|
||||
.execution_quotes_iter_on_date_for_symbols(date, Some(&symbols))
|
||||
.map(|quote| quote.timestamp.and_utc().timestamp())
|
||||
.sum::<i64>();
|
||||
streamed_checksum += count;
|
||||
black_box(count);
|
||||
}
|
||||
let streamed_seconds = streamed_started.elapsed().as_secs_f64();
|
||||
eprintln!(
|
||||
"quote_stream_benchmark iterations=5000 rows_per_day=240 materialized_seconds={materialized_seconds:.6} streamed_seconds={streamed_seconds:.6} materialized_checksum={materialized_checksum} streamed_checksum={streamed_checksum}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use fidc_core::manual_execution::{MANUAL_REPLAY_SCHEMA, ManualExecutionReplay};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot,
|
||||
DailyMarketSnapshot, DataSet, Instrument, MatchingType, PriceField, Strategy,
|
||||
};
|
||||
|
||||
fn date(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Action {
|
||||
Split,
|
||||
Dividend,
|
||||
Successor,
|
||||
}
|
||||
|
||||
fn data(action: Action) -> DataSet {
|
||||
data_with_successor_metadata(action, true)
|
||||
}
|
||||
|
||||
fn data_with_successor_metadata(action: Action, include_successor: bool) -> DataSet {
|
||||
let days = [10, 11, 14, 15].map(date);
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for day in days {
|
||||
for symbol in ["000001.SZ", "000002.SZ"] {
|
||||
if matches!(action, Action::Successor) && symbol == "000001.SZ" && day >= date(14) {
|
||||
continue;
|
||||
}
|
||||
let price = if day < date(14)
|
||||
|| (symbol == "000002.SZ" && !matches!(action, Action::Successor))
|
||||
{
|
||||
10.
|
||||
} else if matches!(action, Action::Dividend) {
|
||||
9.
|
||||
} else {
|
||||
5.
|
||||
};
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some(format!("{day} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
candidates.push(CandidateEligibility {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
["000001.SZ", "000002.SZ"]
|
||||
.into_iter()
|
||||
.filter(|symbol| include_successor || *symbol != "000002.SZ")
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(date(1)),
|
||||
delisted_at: (matches!(action, Action::Successor) && symbol == "000001.SZ")
|
||||
.then_some(date(14)),
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
days.map(|day| BenchmarkSnapshot {
|
||||
date: day,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.into(),
|
||||
vec![CorporateAction {
|
||||
date: date(14),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: Some(date(14)),
|
||||
share_cash: if matches!(action, Action::Dividend) {
|
||||
1.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
share_bonus: if matches!(action, Action::Split) {
|
||||
1.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: matches!(action, Action::Successor).then(|| "000002.SZ".into()),
|
||||
successor_ratio: matches!(action, Action::Successor).then_some(2.),
|
||||
successor_cash: matches!(action, Action::Successor).then_some(0.5),
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(delayed: bool, sell: bool) -> ManualExecutionReplay {
|
||||
let trades = if sell {
|
||||
vec![
|
||||
("initial-buy", "Buy", 10, 200, false),
|
||||
("sale", "Sell", 11, 100, delayed),
|
||||
]
|
||||
} else {
|
||||
vec![("buy", "Buy", 11, 100, delayed)]
|
||||
};
|
||||
let actions = trades.into_iter().enumerate().map(|(index, (id, side, day, quantity, late))| {
|
||||
let executed = format!("2026-09-{day:02}T06:00:00Z").parse::<DateTime<Utc>>().unwrap();
|
||||
let observed = if late { "2026-09-15T05:00:00Z".parse().unwrap() } else { executed + chrono::Duration::seconds(1) };
|
||||
let created = executed - chrono::Duration::seconds(1);
|
||||
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
|
||||
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,"tradeDate":date(day),
|
||||
"executedAt":executed,"observedAt":observed,"feeObservationEventId":id,"feeObservationSequence":index+1,
|
||||
"feeObservedAt":observed,"timestampPrecision":"second","quantity":quantity,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut source: ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),
|
||||
"contentSha256":"","observationCutoff":"2026-09-15T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source.validate().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
struct Hold;
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"manual corporate observation"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn run_custom<S: Strategy>(
|
||||
data: DataSet,
|
||||
source: ManualExecutionReplay,
|
||||
strategy: S,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(date(10)),
|
||||
end_date: Some(date(15)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_cash_dividends(cash_dividends)
|
||||
.with_cash_dividend_cost_basis_adjustment(adjust_cost)
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run()
|
||||
}
|
||||
|
||||
fn run(
|
||||
action: Action,
|
||||
delayed: bool,
|
||||
sell: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
run_custom(data(action), source(delayed, sell), Hold, true, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_buy_does_not_lose_corporate_entitlements() {
|
||||
for action in [Action::Split, Action::Dividend, Action::Successor] {
|
||||
let timely = run(action, false, false).unwrap();
|
||||
let delayed = run(action, true, false).unwrap();
|
||||
let project = |result: &fidc_core::BacktestResult| {
|
||||
(
|
||||
result.equity_curve.last().unwrap().cash,
|
||||
result.equity_curve.last().unwrap().total_equity,
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(project(&delayed), project(&timely), "{action:?}");
|
||||
assert_eq!(delayed.manual_executions.len(), 1);
|
||||
assert!(delayed.fills.is_empty());
|
||||
if matches!(action, Action::Successor)
|
||||
&& let Ok(directory) = std::env::var("FIDC_CORPORATE_QA_OUTPUT")
|
||||
{
|
||||
use std::io::Write;
|
||||
let path = std::path::Path::new(&directory).join("corporate-successor-result.json");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path).unwrap();
|
||||
file.write_all(&serde_json::to_vec(&serde_json::json!({
|
||||
"source":delayed.manual_execution_source.as_deref(), "applications":delayed.manual_executions,
|
||||
})).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_sale_does_not_keep_unearned_corporate_entitlements() {
|
||||
for action in [Action::Split, Action::Dividend, Action::Successor] {
|
||||
let timely = run(action, false, true).unwrap();
|
||||
let delayed = run(action, true, true).unwrap();
|
||||
let project = |result: &fidc_core::BacktestResult| {
|
||||
(
|
||||
result.equity_curve.last().unwrap().cash,
|
||||
result.equity_curve.last().unwrap().total_equity,
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(project(&delayed), project(&timely), "{action:?}");
|
||||
assert_eq!(delayed.manual_executions.len(), 2);
|
||||
assert!(delayed.fills.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_and_broker_observations_require_the_same_frozen_successor_scope() {
|
||||
for adapter in ["paper", "gt", "qmt"] {
|
||||
for delayed in [false, true] {
|
||||
for sell in [false, true] {
|
||||
let mut replay = source(delayed, sell);
|
||||
for action in &mut replay.actions {
|
||||
for order in &mut action.orders { order.source_adapter = Some(adapter.into()); }
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
replay.validate().unwrap();
|
||||
let complete = run_custom(data(Action::Successor), replay.clone(), Hold, true, true).unwrap();
|
||||
assert_eq!(complete.holdings_summary[0].symbol, "000002.SZ");
|
||||
assert_eq!(complete.holdings_summary[0].quantity, 200);
|
||||
assert!(complete.fills.is_empty());
|
||||
assert!(complete.order_events.is_empty());
|
||||
let error = run_custom(data_with_successor_metadata(Action::Successor, false),
|
||||
replay, Hold, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"),
|
||||
"{adapter} delayed={delayed} sell={sell}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protected_successor_run(delayed: bool, locked: bool, amount: i32)
|
||||
-> fidc_core::BacktestResult {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
sell_cooldown_days: if locked { 0 } else { 3 },
|
||||
locks: if locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
protected_successor_case(delayed, policy, amount, "partial")
|
||||
}
|
||||
|
||||
fn protected_successor_case(delayed: bool,
|
||||
policy: fidc_core::holding_policy::AutomaticTradeProtection, amount: i32, scenario: &str)
|
||||
-> fidc_core::BacktestResult {
|
||||
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000002.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.explicit_action_schedule = Some(fidc_core::PlatformRebalanceSchedule {
|
||||
frequency: fidc_core::PlatformScheduleFrequency::Daily,
|
||||
time_rule: Some(fidc_core::ScheduleTimeRule::physical_time(14, 30)),
|
||||
});
|
||||
config.automatic_trade_protection = policy;
|
||||
config.explicit_actions = vec![fidc_core::PlatformTradeAction::Order {
|
||||
kind: fidc_core::PlatformExplicitOrderKind::Shares, symbol: "000002.SZ".into(),
|
||||
amount_expr: amount.to_string(), when_expr: Some("decision_date == \"2026-09-15\"".into()),
|
||||
limit_price_expr: None, time_in_force: None, start_time_expr: None, end_time_expr: None,
|
||||
reason: "configured_successor_action".into(),
|
||||
}];
|
||||
let data = successor_execution_data();
|
||||
let mut replay = source(delayed, true);
|
||||
if scenario == "sold_before" {
|
||||
let order = &mut replay.actions[1].orders[0];
|
||||
order.quantity = 200; order.fills[0].quantity = 200;
|
||||
} else if scenario == "cleared_after" {
|
||||
let mut row = serde_json::to_value(&replay.actions[1]).unwrap();
|
||||
let at = "2026-09-15T05:30:00Z";
|
||||
let receipt = "2026-09-15T05:30:01Z";
|
||||
row["actionId"] = "clear".into(); row["auditEventIds"] = serde_json::json!(["audit-clear"]);
|
||||
row["confirmedAt"] = at.into(); row["confirmationObservedAt"] = at.into();
|
||||
let order = &mut row["orders"][0];
|
||||
order["orderId"] = "clear-order".into(); order["brokerOrderId"] = "clear-order".into();
|
||||
order["symbol"] = "000002.SZ".into(); order["quantity"] = 200.into();
|
||||
order["orderCreatedAt"] = at.into(); order["terminalObservedAt"] = receipt.into();
|
||||
let fill = &mut order["fills"][0];
|
||||
fill["tradeId"] = "clear-trade".into(); fill["observationEventId"] = "clear-receipt".into();
|
||||
fill["observationSequence"] = 3.into(); fill["tradeDate"] = "2026-09-15".into();
|
||||
fill["executedAt"] = at.into(); fill["observedAt"] = receipt.into();
|
||||
fill["feeObservationEventId"] = "clear-receipt".into(); fill["feeObservationSequence"] = 3.into();
|
||||
fill["feeObservedAt"] = receipt.into(); fill["price"] = "5".into(); fill["quantity"] = 200.into();
|
||||
replay.actions.push(serde_json::from_value(row).unwrap());
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(date(10)), end_date: Some(date(15)), decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
}).with_observed_manual_executions(replay).unwrap().run().unwrap()
|
||||
}
|
||||
|
||||
fn successor_execution_data() -> DataSet {
|
||||
let parts = data(Action::Successor).snapshot_components();
|
||||
DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
|
||||
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions,
|
||||
[30, 31].into_iter().map(|minute| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: date(15), symbol: "000002.SZ".into(), timestamp: date(15).and_hms_opt(14,minute,0).unwrap(),
|
||||
last_price: 5., bid1: 5., ask1: 5., bid1_volume: 100000, ask1_volume: 100000,
|
||||
volume_delta: 100000, amount_delta: 500000., trading_phase: Some("continuous".into()),
|
||||
}).collect()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pool_rebalance_applies_inherited_protection_without_rewriting_its_target() {
|
||||
use fidc_core::stock_pool_execution as pool;
|
||||
struct NativePool { locked: bool, expires: u32, exposure: i32 }
|
||||
impl Strategy for NativePool {
|
||||
fn name(&self) -> &str { "native pool successor protection" }
|
||||
fn requires_minute_callbacks(&self) -> bool { false }
|
||||
fn schedule_rules(&self) -> Vec<fidc_core::ScheduleRule> {
|
||||
vec![fidc_core::ScheduleRule::daily("pool", fidc_core::ScheduleStage::OnDay)
|
||||
.with_time_rule(fidc_core::ScheduleTimeRule::physical_time(14,30))]
|
||||
}
|
||||
fn on_scheduled(&mut self, ctx: &fidc_core::StrategyContext<'_>, _: &fidc_core::ScheduleRule)
|
||||
-> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != date(15) { return Ok(Default::default()); }
|
||||
let symbols = vec!["000002.SZ".to_owned()];
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
sell_cooldown_days: if self.locked { 0 } else { 3 },
|
||||
locks: if self.locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(self.expires)),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let contract = pool::FrozenStockPoolIntent {
|
||||
pool_id: "pool".into(), signal_date: date(15), frozen_equity: 10000.into(),
|
||||
selection: pool::StockPoolSelection { trade_date: date(15), requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(), risk_eligible_symbols: symbols.clone(), final_symbols: symbols,
|
||||
exclusion_reasons: Default::default(), inherited_from_generation: None, explicit_empty: false,
|
||||
generation: Some("latest".into()),
|
||||
}, members: vec![pool::StockPoolMemberSpec { symbol: "000002.SZ".into(), requested_order: 0,
|
||||
recommendation_reason: String::new(), target_weight_bps: None, stop_loss: None, take_profit: None }],
|
||||
rule: pool::StockPoolExecutionRule { pricing_mode: pool::POOL_PRICE_FIRST_TICK.into(),
|
||||
window_start: "14:30".into(), window_end: "15:00".into(), automatic_trade_protection: policy,
|
||||
..Default::default() }, constraints: pool::StockPoolDecisionConstraints {
|
||||
target_holding_count: Some(1), ..Default::default() },
|
||||
invest_ratio_bps: self.exposure, reserve_cash: 0.into(), out_of_pool_policy: "hold".into(), generation: "latest".into(),
|
||||
};
|
||||
Ok(fidc_core::StrategyDecision { order_intents: vec![fidc_core::OrderIntent::StockPool { contract: Box::new(contract) }], ..Default::default() })
|
||||
}
|
||||
}
|
||||
for delayed in [false, true] {
|
||||
for (locked, expires, exposure) in [(false, 15, 10000), (true, 15, 10000), (true, 15, 0), (true, 14, 10000)] {
|
||||
let result = BacktestEngine::new(successor_execution_data(), NativePool { locked, expires, exposure },
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(14,30,0).unwrap())
|
||||
.with_volume_limit(false).with_liquidity_limit(false),
|
||||
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(), start_date: Some(date(10)),
|
||||
end_date: Some(date(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Last })
|
||||
.with_observed_manual_executions(source(delayed, true)).unwrap().run().unwrap();
|
||||
if expires == 14 {
|
||||
assert!(!result.fills.is_empty(), "positive control {:?}", result.order_events);
|
||||
} else {
|
||||
assert!(result.fills.is_empty(), "delayed={delayed} locked={locked} exposure={exposure}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_keeps_sell_cooldown_for_timely_and_delayed_receipts() {
|
||||
for delayed in [false, true] {
|
||||
let result = protected_successor_run(delayed, false, 100);
|
||||
assert!(result.fills.is_empty(), "delayed={delayed}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty(), "delayed={delayed}: {:?}", result.order_events);
|
||||
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
|
||||
&& row.date == date(15) && !row.accepted && row.rule_code == "sell_fill_cooldown"),
|
||||
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
|
||||
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_holding_does_not_lose_its_configured_date_lock() {
|
||||
for delayed in [false, true] {
|
||||
for amount in [-100, 100] {
|
||||
let result = protected_successor_run(delayed, true, amount);
|
||||
assert!(result.fills.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.order_events);
|
||||
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
|
||||
&& row.date == date(15) && !row.accepted && row.rule_code == "automatic_trade_locked"),
|
||||
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
|
||||
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_lock_expires_on_the_original_configured_date_not_the_conversion_date() {
|
||||
for delayed in [false, true] {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(14)),
|
||||
}], ..Default::default()
|
||||
};
|
||||
let result = protected_successor_case(delayed, policy, 100, "partial");
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].quantity, 100);
|
||||
assert_eq!(result.holdings_summary[0].quantity, 300);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_survives_a_manual_clear_after_conversion_but_not_a_disproved_conversion() {
|
||||
for delayed in [false, true] {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
|
||||
}], ..Default::default()
|
||||
};
|
||||
let cleared = protected_successor_case(delayed, policy.clone(), 100, "cleared_after");
|
||||
assert!(cleared.fills.is_empty());
|
||||
assert!(cleared.order_events.is_empty());
|
||||
assert!(cleared.holdings_summary.is_empty());
|
||||
assert_eq!(cleared.manual_executions.len(), 3);
|
||||
let unconverted = protected_successor_case(delayed, policy, 100, "sold_before");
|
||||
assert_eq!(unconverted.fills.len(), 1);
|
||||
assert_eq!(unconverted.holdings_summary[0].quantity, 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
|
||||
struct ExistingActivity {
|
||||
receiving_days: usize,
|
||||
}
|
||||
impl Strategy for ExistingActivity {
|
||||
fn name(&self) -> &str {
|
||||
"corporate replay with original activity"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &fidc_core::StrategyContext<'_>,
|
||||
) -> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
use fidc_core::OrderIntent;
|
||||
let order_intents = if ctx.execution_date == date(10) {
|
||||
vec![
|
||||
OrderIntent::DepositWithdraw {
|
||||
amount: 500.,
|
||||
receiving_days: self.receiving_days,
|
||||
reason: "original deposit".into(),
|
||||
},
|
||||
OrderIntent::FinanceRepay {
|
||||
amount: 200.,
|
||||
reason: "original financing".into(),
|
||||
},
|
||||
OrderIntent::SetManagementFeeRate {
|
||||
rate: 0.001,
|
||||
reason: "original fee policy".into(),
|
||||
},
|
||||
]
|
||||
} else if ctx.execution_date == date(11) {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: "000002.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "unrelated stock".into(),
|
||||
}]
|
||||
} else if ctx.execution_date == date(14) {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "already issued after corporate action".into(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
Ok(fidc_core::StrategyDecision {
|
||||
order_intents,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn management_fee(
|
||||
&mut self,
|
||||
_: &fidc_core::StrategyContext<'_>,
|
||||
_: f64,
|
||||
) -> Result<Option<f64>, fidc_core::BacktestError> {
|
||||
Ok(Some(0.25))
|
||||
}
|
||||
}
|
||||
for receiving_days in [0, 1] {
|
||||
for sell in [false, true] {
|
||||
let timely = run_custom(
|
||||
data(Action::Split),
|
||||
source(false, sell),
|
||||
ExistingActivity { receiving_days },
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let delayed = run_custom(
|
||||
data(Action::Split),
|
||||
source(true, sell),
|
||||
ExistingActivity { receiving_days },
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&timely.fills).unwrap(),
|
||||
serde_json::to_value(&delayed.fills).unwrap()
|
||||
);
|
||||
assert_eq!(delayed.fills.len(), 2);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().total_equity,
|
||||
timely.equity_curve.last().unwrap().total_equity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed
|
||||
.equity_curve
|
||||
.iter()
|
||||
.map(|row| row.external_cash_flow)
|
||||
.sum::<f64>(),
|
||||
500.
|
||||
);
|
||||
assert_eq!(delayed.manual_executions.len(), if sell { 2 } else { 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_and_disabled_dividends_keep_the_configured_cash_and_cost_contract() {
|
||||
for paid in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for adjust_cost in [false, true] {
|
||||
let fixture = || {
|
||||
let mut parts = data(Action::Dividend).snapshot_components();
|
||||
parts.corporate_actions[0].payable_date =
|
||||
Some(date(if paid { 14 } else { 16 }));
|
||||
DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let timely =
|
||||
run_custom(fixture(), source(false, false), Hold, enabled, adjust_cost)
|
||||
.unwrap();
|
||||
let delayed =
|
||||
run_custom(fixture(), source(true, false), Hold, enabled, adjust_cost).unwrap();
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
let financial = |result: &fidc_core::BacktestResult| {
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.symbol.clone(),
|
||||
row.quantity,
|
||||
row.average_cost,
|
||||
row.last_price,
|
||||
row.market_value,
|
||||
row.unrealized_pnl,
|
||||
row.realized_pnl,
|
||||
row.pnl,
|
||||
row.dividend_receivable,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Receipt-day turnover is deliberately different when the
|
||||
// dividend option is disabled and no adjustment is required.
|
||||
assert_eq!(financial(&delayed), financial(&timely));
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0].corporate_adjustment.is_some(),
|
||||
enabled
|
||||
);
|
||||
if enabled && !paid {
|
||||
assert_eq!(delayed.terminal_audit.cash_receivable_count, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_fill_replays_aggregate_split_rounding_not_an_independent_rounded_fragment() {
|
||||
let fixture = || {
|
||||
let mut parts = data(Action::Split).snapshot_components();
|
||||
parts.corporate_actions[0].share_bonus = 0.125;
|
||||
for row in &mut parts.market {
|
||||
if row.symbol == "000001.SZ" && row.date >= date(14) {
|
||||
row.day_open = 8.89;
|
||||
row.open = 8.89;
|
||||
row.close = 8.89;
|
||||
row.last_price = 8.89;
|
||||
row.high = 8.89;
|
||||
row.low = 8.89;
|
||||
row.prev_close = 8.89;
|
||||
row.bid1 = 8.89;
|
||||
row.ask1 = 8.89;
|
||||
row.upper_limit = 9.78;
|
||||
row.lower_limit = 8.;
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let input = |delayed| {
|
||||
let mut value = source(delayed, true);
|
||||
value.actions[0].orders[0].quantity = 100;
|
||||
value.actions[0].orders[0].fills[0].quantity = 100;
|
||||
value.actions[1].orders[0].side = fidc_core::OrderSide::Buy;
|
||||
value.content_sha256 = value.content_digest().unwrap();
|
||||
value
|
||||
};
|
||||
let timely = run_custom(fixture(), input(false), Hold, true, true).unwrap();
|
||||
let delayed = run_custom(fixture(), input(true), Hold, true, true).unwrap();
|
||||
assert_eq!(timely.holdings_summary[0].quantity, 225);
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 225);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().total_equity,
|
||||
timely.equity_curve.last().unwrap().total_equity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[1]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.positions["000001.SZ"]
|
||||
.quantity_before,
|
||||
113
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
use fidc_core::DataSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let uniq = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("fidc-bt-partitioned-{uniq}"));
|
||||
fs::create_dir_all(&dir).expect("mkdir temp");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_load_partitioned_snapshot_dir() {
|
||||
let dir = temp_dir();
|
||||
fs::create_dir_all(dir.join("benchmark/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("market/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("factors/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("candidates/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("instruments.csv"),
|
||||
"symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("benchmark/2024/01/2024-01-02.csv"),
|
||||
"date,benchmark,open,close,prev_close,volume\n2024-01-02,CSI300.DEMO,2990,3000,2980,100000000\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("market/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit,day_open,last_price,bid1,ask1,price_tick\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9,10.1,10.15,10.14,10.16,0.01\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("factors/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm,turnover_ratio,effective_turnover_ratio\n2024-01-02,000001.SZ,40,35,12,3.2,2.1\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("candidates/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("corporate_actions/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,payable_date,share_cash,share_bonus,share_gift,issue_quantity,issue_price,reform,adjust_factor\n2024-01-02,000001.SZ,2024-01-05,0.5,0.1,0.0,0,0,false,1.05\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset");
|
||||
assert_eq!(data.benchmark_code(), "CSI300.DEMO");
|
||||
assert!(
|
||||
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap())
|
||||
.len()
|
||||
== 1
|
||||
);
|
||||
let market_rows =
|
||||
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
|
||||
let snapshot = market_rows.first().expect("market snapshot");
|
||||
assert_eq!(snapshot.day_open, 10.1);
|
||||
assert_eq!(snapshot.last_price, 10.15);
|
||||
assert_eq!(snapshot.price_tick, 0.01);
|
||||
assert_eq!(
|
||||
data.instruments()
|
||||
.get("000001.SZ")
|
||||
.expect("instrument")
|
||||
.round_lot,
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
data.instruments()
|
||||
.get("000001.SZ")
|
||||
.expect("instrument")
|
||||
.listed_at,
|
||||
Some(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
|
||||
);
|
||||
let actions = data.corporate_actions_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(
|
||||
actions[0].payable_date,
|
||||
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 5).unwrap())
|
||||
);
|
||||
assert!((actions[0].share_cash - 0.5).abs() < 1e-9);
|
||||
assert!((actions[0].split_ratio() - 1.1).abs() < 1e-9);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::stock_pool_execution::*;
|
||||
use fidc_core::stock_pool_state::StockPoolExecutionState;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn day(value: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
|
||||
}
|
||||
fn member() -> StockPoolMemberSpec {
|
||||
StockPoolMemberSpec {
|
||||
symbol: "000001.SZ".into(),
|
||||
requested_order: 0,
|
||||
recommendation_reason: String::new(),
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
}
|
||||
}
|
||||
fn held(quantity: i64, closable: i64) -> Position {
|
||||
Position {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: quantity.into(),
|
||||
closable_quantity: closable.into(),
|
||||
average_cost: 10.into(),
|
||||
}
|
||||
}
|
||||
fn quote() -> MarketSnapshot {
|
||||
MarketSnapshot {
|
||||
symbol: "000001.SZ".into(),
|
||||
last_price: 10.into(),
|
||||
prev_close: Some(10.into()),
|
||||
volume: Some(1000000.into()),
|
||||
turnover: Some(10000000.into()),
|
||||
bid_price_1: Some(10.into()),
|
||||
ask_price_1: Some(10.into()),
|
||||
is_kcb: Some(false),
|
||||
instrument_rules: None,
|
||||
buy_sizing_price: None,
|
||||
sell_sizing_price: None,
|
||||
}
|
||||
}
|
||||
fn plan(
|
||||
state: &StockPoolExecutionState,
|
||||
at: NaiveDate,
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
cash: i64,
|
||||
outside: &str,
|
||||
) -> StockPoolPlan {
|
||||
plan_at_price(state, at, members, positions, cash, outside, 10)
|
||||
}
|
||||
fn plan_at_price(
|
||||
state: &StockPoolExecutionState,
|
||||
at: NaiveDate,
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
cash: i64,
|
||||
outside: &str,
|
||||
price: i64,
|
||||
) -> StockPoolPlan {
|
||||
let symbols = members
|
||||
.iter()
|
||||
.map(|member| member.symbol.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let selection = StockPoolSelection {
|
||||
trade_date: at,
|
||||
requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(),
|
||||
risk_eligible_symbols: symbols.clone(),
|
||||
final_symbols: symbols,
|
||||
exclusion_reasons: BTreeMap::new(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: false,
|
||||
generation: Some("same-goal".into()),
|
||||
};
|
||||
let mut constraints = stock_pool_constraints_from_configuration(
|
||||
&json!({"top_n_rebalance_policy":"preserve_existing"}),
|
||||
&json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
constraints.pending_entry_symbols = state.pending_symbols();
|
||||
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
|
||||
let mut market = quote();
|
||||
market.last_price = price.into();
|
||||
market.bid_price_1 = Some(price.into());
|
||||
market.ask_price_1 = Some(price.into());
|
||||
build_stock_pool_target_plan_with_constraints(
|
||||
&selection,
|
||||
members,
|
||||
&StockPoolExecutionRule::default(),
|
||||
&AccountSnapshot {
|
||||
total_equity: 10000.into(),
|
||||
cash: cash.into(),
|
||||
frozen_cash: Decimal::ZERO,
|
||||
},
|
||||
positions,
|
||||
&[market],
|
||||
10000,
|
||||
Decimal::ZERO,
|
||||
outside,
|
||||
"preserve_existing",
|
||||
&constraints,
|
||||
"same-goal",
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
|
||||
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
|
||||
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
|
||||
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
|
||||
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
|
||||
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
|
||||
assert!(!observed.pending_symbols().contains("000001.SZ"));
|
||||
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
|
||||
assert_eq!(next.rows[0].target_quantity, 1000.into());
|
||||
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
|
||||
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
|
||||
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
|
||||
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
|
||||
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
|
||||
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
|
||||
for quantity in [0, 500] {
|
||||
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
|
||||
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
|
||||
assert!(observed.pending_symbols().contains("000001.SZ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
|
||||
let original = json!({
|
||||
"schema_version":1,"last_execution_date":"2026-09-11",
|
||||
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
|
||||
"first_decision_date":"2026-09-11","latest_generation":"legacy",
|
||||
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
|
||||
});
|
||||
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
|
||||
state.validate().unwrap();
|
||||
assert_eq!(serde_json::to_value(state).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
|
||||
let original = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
|
||||
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
|
||||
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
|
||||
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
|
||||
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
|
||||
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
|
||||
assert!(next_day.position_action_bases_for("new-signal").is_empty());
|
||||
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
|
||||
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
|
||||
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
|
||||
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
|
||||
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
|
||||
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
|
||||
}
|
||||
assert!(original.record_position_action_bases(" ", &basis).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
|
||||
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
|
||||
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
|
||||
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
|
||||
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
|
||||
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
|
||||
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
|
||||
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
|
||||
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
|
||||
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let first = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[])
|
||||
.unwrap();
|
||||
let initial = plan(&first, day(11), &members, &[], 10000, "hold");
|
||||
assert_eq!(initial.rows[0].target_quantity, 1000.into());
|
||||
let pending = first.record_plan(day(11), "same-goal", &initial).unwrap();
|
||||
assert!(pending.entries["000001.SZ"].pending);
|
||||
assert!(!pending.entries["000001.SZ"].observed_holding);
|
||||
let persisted = serde_json::to_vec(&pending).unwrap();
|
||||
let restored: StockPoolExecutionState = serde_json::from_slice(&persisted).unwrap();
|
||||
let partial = restored
|
||||
.observe(day(11), day(11), &calendar, &members, &[held(500, 0)])
|
||||
.unwrap();
|
||||
let retry = plan(&partial, day(11), &members, &[held(500, 0)], 5000, "hold");
|
||||
assert_eq!(retry.rows[0].delta_quantity, 500.into(), "{retry:?}");
|
||||
let pending = partial.record_plan(day(11), "same-goal", &retry).unwrap();
|
||||
assert!(pending.entries["000001.SZ"].pending);
|
||||
let filled = pending
|
||||
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
|
||||
.unwrap();
|
||||
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
|
||||
assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
|
||||
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
|
||||
assert!(!completed.entries["000001.SZ"].pending);
|
||||
assert_eq!(
|
||||
plan(
|
||||
&completed,
|
||||
day(14),
|
||||
&members,
|
||||
&[held(1000, 1000)],
|
||||
0,
|
||||
"hold"
|
||||
)
|
||||
.rows[0]
|
||||
.status,
|
||||
"PRESERVED_EXISTING_POSITION"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_anchor_is_not_reset_by_rechecks_weekends_or_t_plus_one() {
|
||||
let calendar = vec![day(11), day(14), day(15)];
|
||||
let positions = vec![held(1000, 1000)];
|
||||
let removed = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &[], &positions)
|
||||
.unwrap();
|
||||
assert_eq!(removed.removed_since["000001.SZ"], day(11));
|
||||
assert_eq!(
|
||||
plan(
|
||||
&removed,
|
||||
day(11),
|
||||
&[],
|
||||
&positions,
|
||||
0,
|
||||
"reduce_next_trading_day"
|
||||
)
|
||||
.rows[0]
|
||||
.status,
|
||||
"DEFERRED_T_PLUS_ONE"
|
||||
);
|
||||
assert!(
|
||||
removed
|
||||
.observe(day(12), day(12), &calendar, &[], &positions)
|
||||
.is_err()
|
||||
);
|
||||
let restored: StockPoolExecutionState =
|
||||
serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
|
||||
let monday = restored
|
||||
.observe(day(14), day(14), &calendar, &[], &[held(1000, 0)])
|
||||
.unwrap();
|
||||
assert_eq!(monday.removed_since["000001.SZ"], day(11));
|
||||
assert_eq!(
|
||||
plan(
|
||||
&monday,
|
||||
day(14),
|
||||
&[],
|
||||
&[held(1000, 0)],
|
||||
0,
|
||||
"reduce_next_trading_day"
|
||||
)
|
||||
.rows[0]
|
||||
.delta_quantity,
|
||||
Decimal::ZERO
|
||||
);
|
||||
let next = monday
|
||||
.observe(day(15), day(15), &calendar, &[], &positions)
|
||||
.unwrap();
|
||||
let exit = plan(
|
||||
&next,
|
||||
day(15),
|
||||
&[],
|
||||
&positions,
|
||||
0,
|
||||
"reduce_next_trading_day",
|
||||
);
|
||||
assert_eq!(exit.rows[0].target_quantity, Decimal::ZERO);
|
||||
assert_eq!(exit.rows[0].side, Some(OrderSide::Sell));
|
||||
let returned = next
|
||||
.observe(day(15), day(15), &calendar, &[member()], &positions)
|
||||
.unwrap();
|
||||
assert!(returned.removed_since.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_preview_does_not_start_a_timer_and_next_open_uses_signal_removal_date() {
|
||||
let state = StockPoolExecutionState::default();
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let preview = state
|
||||
.observe(day(11), day(14), &calendar, &[], &[held(1000, 1000)])
|
||||
.unwrap();
|
||||
assert!(state.removed_since.is_empty());
|
||||
assert!(preview.next_day_exit_symbols(day(14)).contains("000001.SZ"));
|
||||
let mut invalid = preview;
|
||||
invalid.schema_version = 0;
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
@@ -1,15 +1,583 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{
|
||||
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, OmniMicroCapConfig,
|
||||
BenchmarkSnapshot, CandidateEligibility, CnSmallCapRotationConfig, CnSmallCapRotationStrategy,
|
||||
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, OmniMicroCapConfig,
|
||||
OmniMicroCapStrategy, PortfolioState, Strategy, StrategyContext,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn d(value: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
fn instrument(symbol: &str, name: &str) -> Instrument {
|
||||
Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: name.to_string(),
|
||||
board: "Main".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn market(
|
||||
date: &str,
|
||||
symbol: &str,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
prev_close: f64,
|
||||
volume: u64,
|
||||
paused: bool,
|
||||
) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: open,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
last_price: close,
|
||||
bid1: close,
|
||||
ask1: close,
|
||||
prev_close,
|
||||
volume,
|
||||
minute_volume: 0,
|
||||
bid1_volume: 0,
|
||||
ask1_volume: 0,
|
||||
trading_phase: None,
|
||||
paused,
|
||||
upper_limit: (prev_close * 1.10 * 100.0).round() / 100.0,
|
||||
lower_limit: (prev_close * 0.90 * 100.0).round() / 100.0,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
fn factor(
|
||||
date: &str,
|
||||
symbol: &str,
|
||||
market_cap_bn: f64,
|
||||
free_float_cap_bn: f64,
|
||||
) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(
|
||||
date: &str,
|
||||
symbol: &str,
|
||||
is_new_listing: bool,
|
||||
is_paused: bool,
|
||||
allow_buy: bool,
|
||||
allow_sell: bool,
|
||||
) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(date),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing,
|
||||
is_paused,
|
||||
allow_buy,
|
||||
allow_sell,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark(date: &str, open: f64, close: f64, prev_close: f64, volume: u64) -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date: d(date),
|
||||
benchmark: "CSI300.DEMO".to_string(),
|
||||
open,
|
||||
close,
|
||||
prev_close,
|
||||
volume,
|
||||
}
|
||||
}
|
||||
|
||||
fn strategy_test_dataset() -> DataSet {
|
||||
let dates = [
|
||||
"2024-01-02",
|
||||
"2024-01-03",
|
||||
"2024-01-04",
|
||||
"2024-01-05",
|
||||
"2024-01-08",
|
||||
"2024-01-09",
|
||||
"2024-01-10",
|
||||
"2024-01-11",
|
||||
"2024-01-12",
|
||||
];
|
||||
let instruments = vec![
|
||||
instrument("000001.SZ", "Alpha Components"),
|
||||
instrument("000002.SZ", "Beta Precision"),
|
||||
instrument("000003.SZ", "Charlie Materials"),
|
||||
instrument("600001.SH", "Delta Industrials"),
|
||||
];
|
||||
let market = vec![
|
||||
market(
|
||||
"2024-01-02",
|
||||
"000001.SZ",
|
||||
10.0,
|
||||
10.2,
|
||||
9.9,
|
||||
10.1,
|
||||
9.8,
|
||||
1_200_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-02",
|
||||
"000002.SZ",
|
||||
11.0,
|
||||
11.3,
|
||||
10.9,
|
||||
11.2,
|
||||
10.8,
|
||||
1_100_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-02",
|
||||
"000003.SZ",
|
||||
8.0,
|
||||
8.1,
|
||||
7.8,
|
||||
7.9,
|
||||
8.0,
|
||||
900_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-02",
|
||||
"600001.SH",
|
||||
15.0,
|
||||
15.2,
|
||||
14.9,
|
||||
15.1,
|
||||
15.0,
|
||||
800_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"000001.SZ",
|
||||
10.2,
|
||||
10.5,
|
||||
10.1,
|
||||
10.4,
|
||||
10.1,
|
||||
1_250_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"000002.SZ",
|
||||
11.2,
|
||||
11.6,
|
||||
11.1,
|
||||
11.5,
|
||||
11.2,
|
||||
1_120_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"000003.SZ",
|
||||
7.8,
|
||||
7.9,
|
||||
7.3,
|
||||
7.4,
|
||||
7.9,
|
||||
930_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"600001.SH",
|
||||
15.1,
|
||||
15.3,
|
||||
15.0,
|
||||
15.2,
|
||||
15.1,
|
||||
820_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"000001.SZ",
|
||||
10.5,
|
||||
10.8,
|
||||
10.4,
|
||||
10.7,
|
||||
10.4,
|
||||
1_280_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"000002.SZ",
|
||||
11.4,
|
||||
11.9,
|
||||
11.3,
|
||||
11.8,
|
||||
11.5,
|
||||
1_150_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"000003.SZ",
|
||||
7.3,
|
||||
7.4,
|
||||
7.0,
|
||||
7.1,
|
||||
7.4,
|
||||
940_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"600001.SH",
|
||||
15.2,
|
||||
15.5,
|
||||
15.1,
|
||||
15.4,
|
||||
15.2,
|
||||
830_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"000001.SZ",
|
||||
10.8,
|
||||
11.1,
|
||||
10.7,
|
||||
11.0,
|
||||
10.7,
|
||||
1_300_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"000002.SZ",
|
||||
11.9,
|
||||
12.1,
|
||||
11.8,
|
||||
12.0,
|
||||
11.8,
|
||||
1_180_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"000003.SZ",
|
||||
7.0,
|
||||
7.1,
|
||||
6.8,
|
||||
6.9,
|
||||
7.1,
|
||||
950_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"600001.SH",
|
||||
15.4,
|
||||
15.6,
|
||||
15.3,
|
||||
15.5,
|
||||
15.4,
|
||||
840_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"000001.SZ",
|
||||
11.1,
|
||||
11.6,
|
||||
11.0,
|
||||
11.5,
|
||||
11.0,
|
||||
1_400_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"000002.SZ",
|
||||
12.1,
|
||||
12.5,
|
||||
12.0,
|
||||
12.4,
|
||||
12.0,
|
||||
1_200_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"000003.SZ",
|
||||
7.0,
|
||||
7.3,
|
||||
6.9,
|
||||
7.2,
|
||||
6.9,
|
||||
980_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"600001.SH",
|
||||
15.5,
|
||||
15.7,
|
||||
15.4,
|
||||
15.6,
|
||||
15.5,
|
||||
850_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"000001.SZ",
|
||||
11.6,
|
||||
12.4,
|
||||
11.5,
|
||||
12.3,
|
||||
11.5,
|
||||
1_500_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"000002.SZ",
|
||||
12.5,
|
||||
12.9,
|
||||
12.4,
|
||||
12.8,
|
||||
12.4,
|
||||
1_250_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"000003.SZ",
|
||||
7.2,
|
||||
7.5,
|
||||
7.1,
|
||||
7.4,
|
||||
7.2,
|
||||
990_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"600001.SH",
|
||||
15.6,
|
||||
15.7,
|
||||
15.4,
|
||||
15.5,
|
||||
15.6,
|
||||
860_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"000001.SZ",
|
||||
12.2,
|
||||
12.3,
|
||||
11.9,
|
||||
12.0,
|
||||
12.3,
|
||||
1_450_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"000002.SZ",
|
||||
12.7,
|
||||
12.8,
|
||||
12.5,
|
||||
12.6,
|
||||
12.8,
|
||||
1_220_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"000003.SZ",
|
||||
7.5,
|
||||
7.6,
|
||||
7.4,
|
||||
7.5,
|
||||
7.4,
|
||||
1_000_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"600001.SH",
|
||||
15.4,
|
||||
15.5,
|
||||
15.1,
|
||||
15.2,
|
||||
15.5,
|
||||
870_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"000001.SZ",
|
||||
12.0,
|
||||
12.1,
|
||||
11.5,
|
||||
11.6,
|
||||
12.0,
|
||||
1_420_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"000002.SZ",
|
||||
12.5,
|
||||
12.6,
|
||||
12.1,
|
||||
12.2,
|
||||
12.6,
|
||||
1_210_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"000003.SZ",
|
||||
7.4,
|
||||
7.5,
|
||||
7.2,
|
||||
7.3,
|
||||
7.5,
|
||||
980_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"600001.SH",
|
||||
15.2,
|
||||
15.2,
|
||||
15.2,
|
||||
15.2,
|
||||
15.2,
|
||||
0,
|
||||
true,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"000001.SZ",
|
||||
11.5,
|
||||
11.6,
|
||||
11.1,
|
||||
11.2,
|
||||
11.6,
|
||||
1_380_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"000002.SZ",
|
||||
12.1,
|
||||
12.2,
|
||||
11.8,
|
||||
11.9,
|
||||
12.2,
|
||||
1_190_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"000003.SZ",
|
||||
7.2,
|
||||
7.2,
|
||||
6.9,
|
||||
7.0,
|
||||
7.3,
|
||||
960_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"600001.SH",
|
||||
14.8,
|
||||
15.0,
|
||||
14.7,
|
||||
14.9,
|
||||
15.2,
|
||||
850_000,
|
||||
false,
|
||||
),
|
||||
];
|
||||
let factors = dates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(idx, date)| {
|
||||
let i = idx as f64;
|
||||
[
|
||||
factor(date, "000001.SZ", 38.0 + i, 24.0 + i * 0.5),
|
||||
factor(date, "000002.SZ", 45.0 + i, 30.0 + i * 0.5),
|
||||
factor(date, "000003.SZ", 65.0 - i, 40.0 - i * 0.5),
|
||||
factor(date, "600001.SH", 85.0 + i, 55.0 + i * 0.5),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidates = dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
let first_two = *date == "2024-01-02" || *date == "2024-01-03";
|
||||
let paused_600001 = *date == "2024-01-11";
|
||||
[
|
||||
candidate(date, "000001.SZ", first_two, false, !first_two, true),
|
||||
candidate(date, "000002.SZ", false, false, true, true),
|
||||
candidate(date, "000003.SZ", false, false, true, true),
|
||||
candidate(
|
||||
date,
|
||||
"600001.SH",
|
||||
false,
|
||||
paused_600001,
|
||||
!paused_600001,
|
||||
!paused_600001,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let benchmarks = vec![
|
||||
benchmark("2024-01-02", 2990.0, 3000.0, 2980.0, 100_000_000),
|
||||
benchmark("2024-01-03", 3005.0, 3020.0, 3000.0, 102_000_000),
|
||||
benchmark("2024-01-04", 3025.0, 3050.0, 3020.0, 105_000_000),
|
||||
benchmark("2024-01-05", 3055.0, 3080.0, 3050.0, 108_000_000),
|
||||
benchmark("2024-01-08", 3085.0, 3110.0, 3080.0, 109_000_000),
|
||||
benchmark("2024-01-09", 3100.0, 3090.0, 3110.0, 107_000_000),
|
||||
benchmark("2024-01-10", 3080.0, 3040.0, 3090.0, 111_000_000),
|
||||
benchmark("2024-01-11", 3030.0, 2990.0, 3040.0, 115_000_000),
|
||||
benchmark("2024-01-12", 2980.0, 2950.0, 2990.0, 118_000_000),
|
||||
];
|
||||
DataSet::from_components(instruments, market, factors, candidates, benchmarks)
|
||||
.expect("strategy test dataset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_emits_target_weights_and_diagnostics() {
|
||||
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
|
||||
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
|
||||
let data = strategy_test_dataset();
|
||||
let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
@@ -53,8 +621,7 @@ fn strategy_emits_target_weights_and_diagnostics() {
|
||||
|
||||
#[test]
|
||||
fn omni_strategy_emits_same_day_decision() {
|
||||
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
|
||||
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
|
||||
let data = strategy_test_dataset();
|
||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let mut cfg = OmniMicroCapConfig::omni_microcap();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
[package]
|
||||
name = "bt-demo"
|
||||
name = "fidc-signal-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
fidc-core = { path = "../fidc-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Shared signal transport for FIDC backtest and trading services.
|
||||
|
||||
use std::sync::Arc;
|
||||
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
|
||||
use reqwest::Client;
|
||||
use serde_json::{Value,json};
|
||||
|
||||
#[derive(Clone,Copy)]
|
||||
pub enum Purpose { Backtest, Online }
|
||||
|
||||
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
|
||||
-> Result<Arc<ValidatedSignalBook>,String>
|
||||
{
|
||||
reference.validate()?;
|
||||
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
|
||||
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
|
||||
let payload=json!({"reference":reference,"purpose":purpose_name});
|
||||
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
|
||||
// Registration/purpose validation always precedes a process-cache hit.
|
||||
let response=client.post(format!("{root}/validate"))
|
||||
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||
.map_err(|_|"signal_validation_service_unavailable")?;
|
||||
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
|
||||
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
|
||||
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
|
||||
return Err("signal_validation_identity_mismatch".into());
|
||||
}
|
||||
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
|
||||
let mut response=client.post(format!("{root}/book"))
|
||||
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||
.map_err(|_|"signal_book_service_unavailable")?;
|
||||
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
|
||||
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
|
||||
let mut bytes=Vec::new();
|
||||
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
|
||||
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
register_signal_book(reference,&bytes)?
|
||||
};
|
||||
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
|
||||
Ok(book)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
let refresh_rate = 15;
|
||||
let stocknum = 40;
|
||||
let close_rate = 1.07;
|
||||
let loss_rate = 0.93;
|
||||
let rsi_rate = 1.0001;
|
||||
let trade_rate = 0.5;
|
||||
let xs = 4 / 500;
|
||||
let base_index_level = 2000;
|
||||
let base_cap_floor = 3;
|
||||
let base_cap_ceiling = 28;
|
||||
|
||||
fn band_start(current_price, base_index_level, xs, base_cap_floor) {
|
||||
if current_price == base_index_level {
|
||||
base_cap_floor
|
||||
} else if current_price > 0 {
|
||||
round((current_price - base_index_level) * xs + base_cap_floor)
|
||||
} else {
|
||||
base_cap_floor
|
||||
}
|
||||
}
|
||||
|
||||
fn band_end(current_price, base_index_level, xs, base_cap_ceiling) {
|
||||
if current_price == base_index_level {
|
||||
base_cap_ceiling
|
||||
} else if current_price > 0 {
|
||||
round((current_price - base_index_level) * xs + base_cap_ceiling)
|
||||
} else {
|
||||
base_cap_ceiling
|
||||
}
|
||||
}
|
||||
|
||||
strategy("microcap_volume_trend_000852") {
|
||||
market("CN_A")
|
||||
benchmark("000852.SH")
|
||||
signal("000852.SH")
|
||||
|
||||
rebalance.every_days(refresh_rate).at("10:18")
|
||||
|
||||
universe.exclude("paused", "st", "kcb", "one_yuan", "new_listing")
|
||||
|
||||
selection.limit(stocknum)
|
||||
selection.market_cap_band(
|
||||
field="market_cap",
|
||||
lower=band_start(signal_close, base_index_level, xs, base_cap_floor),
|
||||
upper=band_end(signal_close, base_index_level, xs, base_cap_ceiling)
|
||||
)
|
||||
|
||||
risk.index_exposure(
|
||||
signal_ma5 > signal_ma10 * rsi_rate ? 1.0 : trade_rate
|
||||
)
|
||||
|
||||
filter.stock_expr(
|
||||
stock_ma5 > stock_ma10 * rsi_rate &&
|
||||
stock_ma10 > stock_ma30 * rsi_rate &&
|
||||
rolling_mean("volume", 5) < rolling_mean("volume", 60)
|
||||
)
|
||||
|
||||
risk.take_profit(close_rate)
|
||||
risk.stop_loss(loss_rate)
|
||||
allocation.buy_scale(touched_upper_limit ? 1.0 : trade_rate)
|
||||
|
||||
ordering.rank_by("market_cap", "asc")
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"strategyId": "microcap_volume_trend_000852",
|
||||
"version": "2",
|
||||
"parser": "omniquant-engine-script-v2",
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000852.SH",
|
||||
"benchmark": {
|
||||
"instrumentId": "000852.SH",
|
||||
"fallbackInstrumentId": "000852.SH"
|
||||
},
|
||||
"engineConfig": {
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000852.SH",
|
||||
"benchmarkSymbol": "000852.SH",
|
||||
"refreshRate": 15,
|
||||
"rankLimit": 40
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"prelude": "let refresh_rate = 15;\nlet stocknum = 40;\nlet close_rate = 1.07;\nlet loss_rate = 0.93;\nlet rsi_rate = 1.0001;\nlet trade_rate = 0.5;\nlet xs = 4 / 500;\nlet base_index_level = 2000;\nlet base_cap_floor = 3;\nlet base_cap_ceiling = 28;\nfn band_start(current_price, base_index_level, xs, base_cap_floor) {\n if current_price == base_index_level {\n base_cap_floor\n } else if current_price > 0 {\n round((current_price - base_index_level) * xs + base_cap_floor)\n } else {\n base_cap_floor\n }\n}\nfn band_end(current_price, base_index_level, xs, base_cap_ceiling) {\n if current_price == base_index_level {\n base_cap_ceiling\n } else if current_price > 0 {\n round((current_price - base_index_level) * xs + base_cap_ceiling)\n } else {\n base_cap_ceiling\n }\n}",
|
||||
"selection": {
|
||||
"limitExpr": "stocknum",
|
||||
"marketCapField": "market_cap",
|
||||
"marketCapLowerExpr": "band_start(signal_close, base_index_level, xs, base_cap_floor)",
|
||||
"marketCapUpperExpr": "band_end(signal_close, base_index_level, xs, base_cap_ceiling)",
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10 * rsi_rate && stock_ma10 > stock_ma30 * rsi_rate && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 60)"
|
||||
},
|
||||
"risk": {
|
||||
"exposureExpr": "signal_ma5 > signal_ma10 * rsi_rate ? 1.0 : trade_rate",
|
||||
"stopLossExpr": "loss_rate",
|
||||
"takeProfitExpr": "close_rate"
|
||||
},
|
||||
"allocation": {
|
||||
"buyScaleExpr": "touched_upper_limit ? 1.0 : trade_rate"
|
||||
},
|
||||
"ordering": {
|
||||
"rankBy": "market_cap",
|
||||
"rankExpr": "",
|
||||
"rankOrder": "asc"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
let refresh_rate = 15;
|
||||
let stocknum = 40;
|
||||
let xs = 0.008;
|
||||
let base_index_level = 2000;
|
||||
let lower_offset = 3;
|
||||
let upper_offset = 28;
|
||||
|
||||
fn cap_floor(current_price, base_index_level, xs, lower_offset) {
|
||||
round((current_price - base_index_level) * xs + lower_offset)
|
||||
}
|
||||
|
||||
fn cap_ceiling(current_price, base_index_level, xs, upper_offset) {
|
||||
round((current_price - base_index_level) * xs + upper_offset)
|
||||
}
|
||||
|
||||
strategy("ai_generated_000001_open_cap_band") {
|
||||
market("CN_A")
|
||||
benchmark("000852.SH")
|
||||
signal("000001.SH")
|
||||
|
||||
rebalance.every_days(refresh_rate).at("10:18")
|
||||
|
||||
universe.exclude("paused", "st", "kcb", "one_yuan", "new_listing")
|
||||
|
||||
selection.limit(stocknum)
|
||||
selection.market_cap_band(
|
||||
field="market_cap",
|
||||
lower=cap_floor(signal_open, base_index_level, xs, lower_offset),
|
||||
upper=cap_ceiling(signal_open, base_index_level, xs, upper_offset)
|
||||
)
|
||||
|
||||
filter.stock_expr(
|
||||
stock_ma5 > stock_ma10 &&
|
||||
stock_ma10 > stock_ma30 &&
|
||||
rolling_mean("volume", 5) < rolling_mean("volume", 60) &&
|
||||
!ends_with(symbol, ".BJ") &&
|
||||
!at_upper_limit &&
|
||||
!at_lower_limit
|
||||
)
|
||||
|
||||
ordering.rank_by("market_cap", "asc")
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"strategyId": "ai_generated_000001_open_cap_band",
|
||||
"version": "2",
|
||||
"parser": "omniquant-engine-script-v2",
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000001.SH",
|
||||
"benchmark": {
|
||||
"instrumentId": "000852.SH",
|
||||
"fallbackInstrumentId": "000852.SH"
|
||||
},
|
||||
"engineConfig": {
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000001.SH",
|
||||
"benchmarkSymbol": "000852.SH",
|
||||
"refreshRate": 15,
|
||||
"rankLimit": 40
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"prelude": "let refresh_rate = 15;\nlet stocknum = 40;\nlet xs = 0.008;\nlet base_index_level = 2000;\nlet lower_offset = 3;\nlet upper_offset = 28;\n\nfn cap_floor(current_price, base_index_level, xs, lower_offset) {\nround((current_price - base_index_level) * xs + lower_offset)\n}\n\nfn cap_ceiling(current_price, base_index_level, xs, upper_offset) {\nround((current_price - base_index_level) * xs + upper_offset)\n}",
|
||||
"selection": {
|
||||
"limitExpr": "stocknum",
|
||||
"marketCapField": "market_cap",
|
||||
"marketCapLowerExpr": "cap_floor(signal_open, base_index_level, xs, lower_offset)",
|
||||
"marketCapUpperExpr": "cap_ceiling(signal_open, base_index_level, xs, upper_offset)",
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10 && stock_ma10 > stock_ma30 && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 60) && !ends_with(symbol, \".BJ\") && !at_upper_limit && !at_lower_limit"
|
||||
},
|
||||
"ordering": {
|
||||
"rankBy": "market_cap",
|
||||
"rankExpr": "",
|
||||
"rankOrder": "asc"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
date,benchmark,open,close,prev_close,volume
|
||||
2024-01-02,CSI300.DEMO,2990,3000,2980,100000000
|
||||
2024-01-03,CSI300.DEMO,3005,3020,3000,102000000
|
||||
2024-01-04,CSI300.DEMO,3025,3050,3020,105000000
|
||||
2024-01-05,CSI300.DEMO,3055,3080,3050,108000000
|
||||
2024-01-08,CSI300.DEMO,3085,3110,3080,109000000
|
||||
2024-01-09,CSI300.DEMO,3100,3090,3110,107000000
|
||||
2024-01-10,CSI300.DEMO,3080,3040,3090,111000000
|
||||
2024-01-11,CSI300.DEMO,3030,2990,3040,115000000
|
||||
2024-01-12,CSI300.DEMO,2980,2950,2990,118000000
|
||||
|
@@ -1,37 +0,0 @@
|
||||
date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan
|
||||
2024-01-02,000001.SZ,false,true,false,false,true,false,false
|
||||
2024-01-02,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-02,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-02,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-03,000001.SZ,false,true,false,false,true,false,false
|
||||
2024-01-03,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-03,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-03,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-04,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-04,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-04,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-04,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-05,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-05,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-05,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-05,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-08,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-08,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-08,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-08,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-09,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-09,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-09,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-09,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-10,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-10,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-10,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-10,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-11,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-11,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-11,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-11,600001.SH,false,false,true,false,false,false,false
|
||||
2024-01-12,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-12,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-12,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-12,600001.SH,false,false,false,true,true,false,false
|
||||
|
@@ -1,37 +0,0 @@
|
||||
date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm
|
||||
2024-01-02,000001.SZ,38,24,18
|
||||
2024-01-02,000002.SZ,45,30,20
|
||||
2024-01-02,000003.SZ,65,40,15
|
||||
2024-01-02,600001.SH,85,55,13
|
||||
2024-01-03,000001.SZ,39,24.5,18
|
||||
2024-01-03,000002.SZ,46,30.5,20
|
||||
2024-01-03,000003.SZ,64,39.5,15
|
||||
2024-01-03,600001.SH,85,55,13
|
||||
2024-01-04,000001.SZ,40,25,18
|
||||
2024-01-04,000002.SZ,47,31,20
|
||||
2024-01-04,000003.SZ,63,39,15
|
||||
2024-01-04,600001.SH,86,55.5,13
|
||||
2024-01-05,000001.SZ,41,25.5,18
|
||||
2024-01-05,000002.SZ,48,32,20
|
||||
2024-01-05,000003.SZ,62,38.5,15
|
||||
2024-01-05,600001.SH,86,56,13
|
||||
2024-01-08,000001.SZ,42,26,18
|
||||
2024-01-08,000002.SZ,50,33,21
|
||||
2024-01-08,000003.SZ,61,38,15
|
||||
2024-01-08,600001.SH,87,56.5,13
|
||||
2024-01-09,000001.SZ,44,27,19
|
||||
2024-01-09,000002.SZ,52,34,21
|
||||
2024-01-09,000003.SZ,60,37.5,15
|
||||
2024-01-09,600001.SH,88,57,13
|
||||
2024-01-10,000001.SZ,43,26.5,19
|
||||
2024-01-10,000002.SZ,53,34.5,21
|
||||
2024-01-10,000003.SZ,59,37,15
|
||||
2024-01-10,600001.SH,89,57.5,13
|
||||
2024-01-11,000001.SZ,42,26,18
|
||||
2024-01-11,000002.SZ,52,34,21
|
||||
2024-01-11,000003.SZ,58,36.5,15
|
||||
2024-01-11,600001.SH,90,58,13
|
||||
2024-01-12,000001.SZ,40,25,18
|
||||
2024-01-12,000002.SZ,50,33,20
|
||||
2024-01-12,000003.SZ,57,36,15
|
||||
2024-01-12,600001.SH,92,59,13
|
||||
|
@@ -1,5 +0,0 @@
|
||||
symbol,name,board
|
||||
000001.SZ,Alpha Components,Main
|
||||
000002.SZ,Beta Precision,Main
|
||||
000003.SZ,Charlie Materials,Main
|
||||
600001.SH,Delta Industrials,Main
|
||||
|
@@ -1,37 +0,0 @@
|
||||
date,symbol,open,high,low,close,prev_close,volume,paused
|
||||
2024-01-02,000001.SZ,10.0,10.2,9.9,10.1,9.8,1200000,false
|
||||
2024-01-02,000002.SZ,11.0,11.3,10.9,11.2,10.8,1100000,false
|
||||
2024-01-02,000003.SZ,8.0,8.1,7.8,7.9,8.0,900000,false
|
||||
2024-01-02,600001.SH,15.0,15.2,14.9,15.1,15.0,800000,false
|
||||
2024-01-03,000001.SZ,10.2,10.5,10.1,10.4,10.1,1250000,false
|
||||
2024-01-03,000002.SZ,11.2,11.6,11.1,11.5,11.2,1120000,false
|
||||
2024-01-03,000003.SZ,7.8,7.9,7.3,7.4,7.9,930000,false
|
||||
2024-01-03,600001.SH,15.1,15.3,15.0,15.2,15.1,820000,false
|
||||
2024-01-04,000001.SZ,10.5,10.8,10.4,10.7,10.4,1280000,false
|
||||
2024-01-04,000002.SZ,11.4,11.9,11.3,11.8,11.5,1150000,false
|
||||
2024-01-04,000003.SZ,7.3,7.4,7.0,7.1,7.4,940000,false
|
||||
2024-01-04,600001.SH,15.2,15.5,15.1,15.4,15.2,830000,false
|
||||
2024-01-05,000001.SZ,10.8,11.1,10.7,11.0,10.7,1300000,false
|
||||
2024-01-05,000002.SZ,11.9,12.1,11.8,12.0,11.8,1180000,false
|
||||
2024-01-05,000003.SZ,7.0,7.1,6.8,6.9,7.1,950000,false
|
||||
2024-01-05,600001.SH,15.4,15.6,15.3,15.5,15.4,840000,false
|
||||
2024-01-08,000001.SZ,11.1,11.6,11.0,11.5,11.0,1400000,false
|
||||
2024-01-08,000002.SZ,12.1,12.5,12.0,12.4,12.0,1200000,false
|
||||
2024-01-08,000003.SZ,7.0,7.3,6.9,7.2,6.9,980000,false
|
||||
2024-01-08,600001.SH,15.5,15.7,15.4,15.6,15.5,850000,false
|
||||
2024-01-09,000001.SZ,11.6,12.4,11.5,12.3,11.5,1500000,false
|
||||
2024-01-09,000002.SZ,12.5,12.9,12.4,12.8,12.4,1250000,false
|
||||
2024-01-09,000003.SZ,7.2,7.5,7.1,7.4,7.2,990000,false
|
||||
2024-01-09,600001.SH,15.6,15.7,15.4,15.5,15.6,860000,false
|
||||
2024-01-10,000001.SZ,12.2,12.3,11.9,12.0,12.3,1450000,false
|
||||
2024-01-10,000002.SZ,12.7,12.8,12.5,12.6,12.8,1220000,false
|
||||
2024-01-10,000003.SZ,7.5,7.6,7.4,7.5,7.4,1000000,false
|
||||
2024-01-10,600001.SH,15.4,15.5,15.1,15.2,15.5,870000,false
|
||||
2024-01-11,000001.SZ,12.0,12.1,11.5,11.6,12.0,1420000,false
|
||||
2024-01-11,000002.SZ,12.5,12.6,12.1,12.2,12.6,1210000,false
|
||||
2024-01-11,000003.SZ,7.4,7.5,7.2,7.3,7.5,980000,false
|
||||
2024-01-11,600001.SH,15.2,15.2,15.2,15.2,15.2,0,true
|
||||
2024-01-12,000001.SZ,11.5,11.6,11.1,11.2,11.6,1380000,false
|
||||
2024-01-12,000002.SZ,12.1,12.2,11.8,11.9,12.2,1190000,false
|
||||
2024-01-12,000003.SZ,7.2,7.2,6.9,7.0,7.3,960000,false
|
||||
2024-01-12,600001.SH,14.8,15.0,14.7,14.9,15.2,850000,false
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user