Compare commits
724 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 820dabfbf3 | |||
| e6a673e27d | |||
| 05010d20e2 | |||
| bef3c610b7 | |||
| bbd7c768f8 | |||
| 65fe2d7361 | |||
| 79d94bed5f | |||
| 2670e2f8ee | |||
| c4119c1aab | |||
| 40dcd6e8f0 | |||
| 6b2c99cea4 | |||
| 50ac181b8d | |||
| 91ccb061a7 | |||
| b3496bed2d | |||
| b6194b4756 | |||
| 645e304b3e | |||
| 5c6ffa4742 | |||
| ff104cfc31 | |||
| 7a9dc35c63 | |||
| a17ba0efb9 | |||
| d029d5c4e7 | |||
| 88b80d66e8 | |||
| dd585ac83b | |||
| bf1ee43ffe | |||
| 72d2f360c6 | |||
| 4ad1014108 | |||
| c43f92cdeb | |||
| 777b7465b7 | |||
| a4891d58cd | |||
| 55439977f6 | |||
| 7045e11082 | |||
| 23882ac1ef | |||
| 14252915ab | |||
| fbd00df72f | |||
| 4d103a7a38 | |||
| dd72908e0e | |||
| 6e6d27f323 | |||
| 0fffc0faa1 | |||
| 45365df694 | |||
| 09a8e1bfbe | |||
| c88d313f60 | |||
| 23203f6ebb | |||
| 5b19a49ffb | |||
| 4bc352519a | |||
| 5d10d60dcb | |||
| 8a534160f0 | |||
| 18b5e9cfed | |||
| c331c89289 | |||
| 598cd17c0b | |||
| 20d53e1ae2 | |||
| 00e3007949 | |||
| 04dcbcd6ea | |||
| 3a82c11e78 | |||
| 636a81d6df | |||
| e1417f607d | |||
| 8ca9ce4223 | |||
| 1ac41dde33 | |||
| a7076f48a6 | |||
| f884b016cc | |||
| ecf9481dfe | |||
| ee3d489d8b | |||
| f5aefb27e3 | |||
| e2f11d580e | |||
| 91eb4307bd | |||
| 28ad77c517 | |||
| 6cc83ab165 | |||
| 468e53f959 | |||
| 2e8349a2c9 | |||
| e1bad1bcfa | |||
| 2b5d5545e2 | |||
| 67460685e8 | |||
| 5fb59b1b21 | |||
| d088ff389a | |||
| 58cc82f520 | |||
| 98458c0c05 | |||
| e852b9bc89 | |||
| b68772f031 | |||
| 5b97de4892 | |||
| f2ce23d7de | |||
| cf84b8336f | |||
| fedfebc76d | |||
| a13dff84ba | |||
| d76c57466d | |||
| 617d3c910e | |||
| 3d0d099cb9 | |||
| 8cb2beb956 | |||
| 9545725d7f | |||
| 723c6dafc6 | |||
| 26b6f3ae9b | |||
| a6367ab7c2 | |||
| c2bb9d3901 | |||
| b3aabe61ce | |||
| d1aa3550ba | |||
| ea5c6ec71e | |||
| a3db39909d | |||
| a80280732c | |||
| 5d0d1e2673 | |||
| 9291ab9b3d | |||
| de8e92c985 | |||
| 368388899d | |||
| b7c5623161 | |||
| 10dcf20077 | |||
| c5e80a56b4 | |||
| 5202ed06ff | |||
| b8b99a0fc5 | |||
| 0e81b974e7 | |||
| 7a10f4b474 | |||
| 87643699e7 | |||
| 25bcc35063 | |||
| 7b8826dfa1 | |||
| 11a901d22e | |||
| 64ab74f7fc | |||
| 7f0506b897 | |||
| 2cd43b607d | |||
| 798e2d6cc2 | |||
| 9c9924cc5a | |||
| c16a8cb7a5 | |||
| e3aa612bf3 | |||
| 96b8165be1 | |||
| e0ab26a01e | |||
| ef8d89e72e | |||
| d3c8058178 | |||
| 3bc1a4a1ed | |||
| 66038e060c | |||
| 3d4ab83bdb | |||
| 2d9e99760f | |||
| a3c4fd522b | |||
| cdd3cd25ae | |||
| 2e2670491f | |||
| ee1859a118 | |||
| 262ce600fa | |||
| cf415a1150 | |||
| 659cf2e119 | |||
| bd5b4b32da | |||
| 13d818faf2 | |||
| 6a8eda8628 | |||
| adae35d016 | |||
| 1a8733ced6 | |||
| acf3e10ea2 | |||
| 887148a85e | |||
| 608befe748 | |||
| 60d684def9 | |||
| de78934444 | |||
| d306c1ff29 | |||
| c7cfd2cb59 | |||
| 617b55f24c | |||
| 715ffa4566 | |||
| 7d609e40f6 | |||
| 6b7a4575ad | |||
| 812a4b2d32 | |||
| c82c33ec53 | |||
| 623ec7c5fa | |||
| 3732dafb95 | |||
| dc5b747385 | |||
| f38f0893df | |||
| 80729274bc | |||
| 56ddce4be0 | |||
| 75e6f58a56 | |||
| 045121a457 | |||
| 21a375a198 | |||
| 7273891794 | |||
| e6d24c308f | |||
| 5566894e84 | |||
| c2c7d428bf | |||
| 1ef209ce2f | |||
| 81f61370fe | |||
| 93174babbc | |||
| e5575c96a0 | |||
| 734d8a05d5 | |||
| f66f6e53d6 | |||
| 17dc79cd56 | |||
| 10112d2f58 | |||
| c74be88cf9 | |||
| 699ef065b7 | |||
| 41e322642a | |||
| bb60630fd7 | |||
| 85452fdf56 | |||
| 9978ac3e01 | |||
| 414708794d | |||
| 1c97f0add1 | |||
| d64a92fd24 | |||
| dd8f1c2d73 | |||
| 56b630029f | |||
| c9339c1bca | |||
| dc5bd9a496 | |||
| ccc21b7e9d | |||
| 804b720b3c | |||
| 33f0ee48b2 | |||
| bf4be0436b | |||
| 341397bc45 | |||
| 9670d2bc77 | |||
| 0affe08798 | |||
| 90f9beb139 | |||
| ce667ba1b6 | |||
| e478a1f08d | |||
| b5d5e96f0b | |||
| 4c92a1ad4d | |||
| 28e0cf8f55 | |||
| ffbb2d9765 | |||
| f32ce52428 | |||
| 3b288dc4c4 | |||
| d631ae08ad | |||
| 17c6c13d4f | |||
| 945c2c4e20 | |||
| d808d89fdd | |||
| a4529e0409 | |||
| 29e8c6f06a | |||
| 68e3744650 | |||
| a2da66ca5e | |||
| 058fb1008b | |||
| 472ef9fa3b | |||
| c1cc144b8e | |||
| cd08ac24e9 | |||
| 6df18ec3f0 | |||
| 140a0f16ae | |||
| ece77d7595 | |||
| 0edcc11d3f | |||
| 685d65a36a | |||
| 931a59d893 | |||
| 5aacf99cda | |||
| a5eed410d3 | |||
| 317c57ccd1 | |||
| c2a69cb94e | |||
| f394a44bdc | |||
| 75f180a568 | |||
| 52ca7d3efa | |||
| baede6a485 | |||
| 92af51b87f | |||
| 78ff3c159b | |||
| 390df86a79 | |||
| 6de29ed2e1 | |||
| 446e303743 | |||
| 43231d4474 | |||
| baafd4fd81 | |||
| a764beedd6 | |||
| 074ad1c36c | |||
| e64d252081 | |||
| 5d6f906adb | |||
| 9e74883fe7 | |||
| d7903f4387 | |||
| 89497bdf69 | |||
| 54b03321cb | |||
| c0df56e7b8 | |||
| b8985dc52e | |||
| 53f17ac4f2 | |||
| 59dc74b5e1 | |||
| f8133d6003 | |||
| 8849ef8fe6 | |||
| 852010b8d4 | |||
| d2a9e9a9b3 | |||
| 032d46ec1d | |||
| 4844af2273 | |||
| 7f8dca2066 | |||
| 22872f26e7 | |||
| f7eeca1021 | |||
| ad936bfd91 | |||
| 3534f759d0 | |||
| 5ee416bd0a | |||
| 29365f10a5 | |||
| d05d11148e | |||
| b6725ea36d | |||
| ed8e4e0b67 | |||
| 7e61d1a7e5 | |||
| 9a49ff3f5e | |||
| 0b47f4b487 | |||
| 6f3fef4a3c | |||
| 3e0725f680 | |||
| 933cae74b4 | |||
| ad14a007e7 | |||
| f0dec1e65e | |||
| c79dfda495 | |||
| 8fbbe54c46 | |||
| 6672f02989 | |||
| 637b580bbd | |||
| 7e9d7a4b59 | |||
| bdb314d1a7 | |||
| 0574b0cc20 | |||
| af97173eed | |||
| 8b93f8ff54 | |||
| 759ee7a8e4 | |||
| 8957185062 | |||
| e36fb1e97b | |||
| c9cff88452 | |||
| a4d8c1aa39 | |||
| e9cc9a5eb1 | |||
| b6d7b07708 | |||
| fd2531341d | |||
| e4d123d94d | |||
| 45e9c28437 | |||
| 79a652e8a9 | |||
| 36c27ea243 | |||
| 32a3ba0ea4 | |||
| 4832678d72 | |||
| 20c8395c0b | |||
| 2be0b71018 | |||
| 5db20061eb | |||
| 2415167132 | |||
| 5aa2a9e384 | |||
| 89394e12ef | |||
| b2dd758977 | |||
| 0012eddc81 | |||
| e79bc2d291 | |||
| 864b1fa1f9 | |||
| 3bad968844 | |||
| 4c97314805 | |||
| 219fda39d9 | |||
| 6cbc56ac5a | |||
| f5fc3ca0cd | |||
| 5817d4157d | |||
| 348dfe132a | |||
| eeb5f563db | |||
| 442a9c8560 | |||
| f661350990 | |||
| 2aa44dd5c7 | |||
| 678b94000c | |||
| 0a7aaf19a6 | |||
| c18ff504e4 | |||
| 53225118c1 | |||
| 160dd65a54 | |||
| cfda3a796c | |||
| a80f34137c | |||
| dd65adb9d8 | |||
| faf88387a1 | |||
| 73a51d73e8 | |||
| f49a5bfbb2 | |||
| f8f0d69e5c | |||
| b1b1f76bcb | |||
| 891b4d2422 | |||
| 41d740d611 | |||
| 60ac4bae31 | |||
| 79b33599ac | |||
| b8a3c223f5 | |||
| 105713c799 | |||
| 5e0dc3ec53 | |||
| dc164205f6 | |||
| 5176dff82d | |||
| fe6f8b43b0 | |||
| 24f2a82893 | |||
| dbc8923b76 | |||
| fb81e01aae | |||
| a39a74de2f | |||
| 4fc4b538a5 | |||
| ecec3fc087 | |||
| 89a82b5fb5 | |||
| 9b9f548de9 | |||
| 1edee81cf3 | |||
| 8022464e78 | |||
| 982d192499 | |||
| 0234104a63 | |||
| 2f0b496066 | |||
| cb625a2b0e | |||
| 0f8d160c54 | |||
| 1766a71f63 | |||
| b36c712ee4 | |||
| a0005b1e37 | |||
| 717ad3c6f4 | |||
| b0662f6fef | |||
| d04d6c9efb | |||
| 75dd7464cc | |||
| db955d59ea | |||
| 133d341605 | |||
| 2f9889d63a | |||
| ef2691a37b | |||
| 84a2d4ce06 | |||
| 8ba9567f64 | |||
| 0c24a3ed6d | |||
| dd01383b3a | |||
| edf41efad6 | |||
| 871d21792e | |||
| 5fa3bb1b59 | |||
| 10a0788398 | |||
| 573393409d | |||
| 9b2ac614cb | |||
| 4e59c41c81 | |||
| b00790850c | |||
| 44a273c9d1 | |||
| 6f7e847b01 | |||
| 66512d2623 | |||
| 05f5d3c2dd | |||
| 452dfe2218 | |||
| 56bcb89d3d | |||
| 5ef0b49299 | |||
| 82d7c0ea6d | |||
| 781d8cf476 | |||
| 9a5f9abf21 | |||
| 60093d3162 | |||
| 6e3b7f60a2 | |||
| 5e4070ad27 | |||
| 4de009b401 | |||
| 23f7e29acc | |||
| 9ff0685756 | |||
| 95192e8b58 | |||
| 72e93f8e93 | |||
| 1af33c3ab8 | |||
| df892ce9a6 | |||
| 76d5a0abe8 | |||
| 3fd52ad9d4 | |||
| a714817cdf | |||
| 4488d6a9b1 | |||
| e1e000e854 | |||
| d50ced1b8f | |||
| f4949212d8 | |||
| 82d60cf05f | |||
| a5c508cce8 | |||
| 0c2e22e770 | |||
| ac4f70e33d | |||
| 89914a296b | |||
| 6e2baa14ad | |||
| 9d6eb54a4f | |||
| bddf7f1372 | |||
| ae37e79fbb | |||
| a6e7d6682d | |||
| f2ab62ba83 | |||
| 3ca5b71299 | |||
| a7be2adbbb | |||
| 49238cf420 | |||
| 6b741cc1ec | |||
| 3b8098051e | |||
| 7e0d955d22 | |||
| 4c6a93498c | |||
| 96b642ffac | |||
| 3d4a0148fe | |||
| 8cb5b3b310 | |||
| d07aaf49b4 | |||
| d83733399e | |||
| 9db37c6d7b | |||
| cd9de0926b | |||
| 3c013e300a | |||
| e56040a855 | |||
| 53c9ed2118 | |||
| b9130cb30a | |||
| c4d4d0cdbc | |||
| cb7526e799 | |||
| 6d132e6a7f | |||
| 476a4f85d8 | |||
| b93a75516d | |||
| 19bd8f3636 | |||
| 1326176a85 | |||
| c38dc80f58 | |||
| 5b1402fc7c | |||
| eaa695ec47 | |||
| 4d5a5b674d | |||
| 473ed82202 | |||
| d4640a578d | |||
| 0e4e7aebb2 | |||
| 2b6b18ec20 | |||
| 0e0aad3171 | |||
| aec47adda1 | |||
| 2fa27f54ac | |||
| 671a862400 | |||
| a9d71c18aa | |||
| e12d935cde | |||
| 546bc0c21e | |||
| 67701a4715 | |||
| 073d0efbb0 | |||
| 2d1cb55151 | |||
| 2d459f3052 | |||
| 6bdad2eae9 | |||
| ff420c4bec | |||
| 426bd7deab | |||
| 3e941a238a | |||
| ad16ae6b3b | |||
| 1c4287380e | |||
| 8c640b8616 | |||
| 8a5e8957c5 | |||
| 06ac6bab32 | |||
| abdce3cd3b | |||
| c6321253fe | |||
| 4a8eeaae5e | |||
| 78b23ef361 | |||
| 1b2731ed12 | |||
| 6041d25f23 | |||
| d99a3c25e9 | |||
| fd59cd018d | |||
| 6cee86ec7b | |||
| 44e8921e5c | |||
| 4acd0d9da6 | |||
| bd21b05fbc | |||
| 22e2409e13 | |||
| 415f03466d | |||
| 4c6eaa6390 | |||
| 3351991fe1 | |||
| 1645fd88d0 | |||
| 9d8c59f741 | |||
| deec8221ea | |||
| 9e400a3802 | |||
| c74c193b02 | |||
| 07f61f8ca9 | |||
| 38ac9844c1 | |||
| 1ab10a9c90 | |||
| a83200d505 | |||
| 8ade6dd7d2 | |||
| 5c2977fe71 | |||
| ff6d6c550f | |||
| 55099c3dd8 | |||
| 02fd7d9e49 | |||
| d39534dff4 | |||
| 58e6557c66 | |||
| 2d9aa576dd | |||
| 04e0369255 | |||
| c60baaa1e8 | |||
| 341c6d0a93 | |||
| a83d9e7649 | |||
| 07bb8e1d15 | |||
| 8e2317592d | |||
| 80ca892611 | |||
| a24185e6dc | |||
| 4a938395be | |||
| 2e02133696 | |||
| b27bedbca3 | |||
| 884a7899f3 | |||
| 04d0ae1125 | |||
| 6f14ef20db | |||
| 9d3aa7fb53 | |||
| 5d514a4950 | |||
| 87b24aa071 | |||
| e7a13c9be7 | |||
| 6a4ec08b12 | |||
| 6746650bb1 | |||
| 861b394e49 | |||
| cbf6a12f79 | |||
| 767e0e7de1 | |||
| e93f052559 | |||
| a614cbd298 | |||
| f7fb2ec83b | |||
| 30de937d04 | |||
| 7aea20c956 | |||
| b45010a92e | |||
| 5e8548999a | |||
| 747c8aa401 | |||
| e7fc3c97cb | |||
| 1a284c9990 | |||
| 77488e561a | |||
| c2a78e7b62 | |||
| 3845432ac2 | |||
| c031dd0e28 | |||
| c6f1eac6c1 | |||
| f47ea93ec1 | |||
| 856e9aa6d1 | |||
| e981188bf8 | |||
| d8639f40f9 | |||
| 6655476fd4 | |||
| 06df0a87dd | |||
| 00400f1392 | |||
| bbb47b1634 | |||
| 189a77d03d | |||
| 0f7322dbd8 | |||
| 9f7bbffca1 | |||
| 9868784494 | |||
| d4892a5294 | |||
| e590150fa9 | |||
| 644b592fe8 | |||
| 4cc579ccb3 | |||
| c4b1844a67 | |||
| f9154ff0f2 | |||
| c96a630d1b | |||
| 3bca2093f0 | |||
| 2ee00f8016 | |||
| 6c08a68413 | |||
| 37494a7192 | |||
| a79abc02de | |||
| f4a37e4bdd | |||
| 5617e5b112 | |||
| eb056a890b | |||
| 5ad89b4c29 | |||
| ac90ef9206 | |||
| c8103179e1 | |||
| 4f13faa311 | |||
| 8dd9aa59b3 | |||
| 96676e7097 | |||
| c9d03fd3e1 | |||
| 02add6d994 | |||
| 0e220d890f | |||
| 4bda402270 | |||
| 8bfe23138c | |||
| f5acae3c81 | |||
| 294ac30026 | |||
| fb4ba622a8 | |||
| d7d1744313 | |||
| 357e01be3c | |||
| 3529d97d58 | |||
| 329e3afe38 | |||
| 6eca1da3f9 | |||
| 4ab749c136 | |||
| d2472fa0c2 | |||
| 6c4f43fba6 | |||
| 79944432fb | |||
| c006d13fea | |||
| 7a28810f25 | |||
| c5fdb93ae5 | |||
| 2202f8e211 | |||
| fc48795299 | |||
| fd12c15518 | |||
| 22b414f69e | |||
| 5e653c2ec1 | |||
| 9dd59ceb4e | |||
| a9b1449680 | |||
| a7ba7aed3a | |||
| 1240fbb673 | |||
| d56889c2e5 | |||
| 1fba498c37 | |||
| b94037f328 | |||
| 1493c72138 | |||
| a221b3e969 | |||
| bfffbdedd0 | |||
| 26fff84c3a | |||
| 7038b14904 | |||
| c9006f250f | |||
| 1a4d75c126 | |||
| 7555da8e6a | |||
| d97bb76480 | |||
| c779b20a4f | |||
| cac84dcc0c | |||
| e734c93b34 | |||
| 067ee63ba4 | |||
| 88918677c0 | |||
| 041c9fb4fa | |||
| 1d3ccbb7cf | |||
| 8dd212f0af | |||
| ded09df749 | |||
| 239c9c446d | |||
| 538cc7db2d | |||
| 6055f8328e | |||
| 611811101b | |||
| 9e80719cbe | |||
| db9071101a | |||
| 86467d4e92 | |||
| 1680bd5871 | |||
| 6802f647b3 | |||
| 557882600e | |||
| 40f231b76d | |||
| 30b289ec8f | |||
| 9adcfdb203 | |||
| cfb746300a | |||
| a173da1b00 | |||
| 7372b7a73f | |||
| 8a8a47e31d | |||
| fe9bf61ad6 | |||
| 7084bdf1ec | |||
| 470cd6c95f | |||
| 6f83b9d7ea | |||
| a3bef5185e | |||
| 963e717523 | |||
| a29ebfae27 | |||
| b1d0e41fd4 | |||
| c8eed2f315 | |||
| f7a453e39b | |||
| 191be9c5fd | |||
| 12c2b39b16 | |||
| 84e7518b04 | |||
| 209027afb3 | |||
| 83d2de5438 | |||
| 4d584f4828 | |||
| 5d630bb910 | |||
| a32a49f3c7 | |||
| 203094a97c | |||
| a5522b4b16 | |||
| 9026ccf4c0 | |||
| e062fedb3a | |||
| 4006e82ded | |||
| fc146b6e0c | |||
| 106dd3305b | |||
| 574f007607 | |||
| 88b8366067 | |||
| 2061dfe8b5 | |||
| 12b0c76caf | |||
| 5a6a487a97 | |||
| 8b1abf3d56 | |||
| a6fbbb4bef | |||
| 9a8ed0e15e | |||
| 64f8ff21ec | |||
| ff4c8b0139 | |||
| c56c490b60 | |||
| c50a057562 | |||
| 349f3e53c7 | |||
| eb47e0a078 | |||
| ff7938187c | |||
| 3d4e803488 | |||
| e344fae75f | |||
| 52ad2dfde4 | |||
| 55bb06d213 | |||
| 76b4a5df59 | |||
| 9ff2a99618 | |||
| 3c2d1ce6fa | |||
| 787d04619b | |||
| 81e9aeb98f | |||
| 78cdb006e5 | |||
| 2ad4c7f1b4 | |||
| 17b93803af | |||
| b26ec05b81 | |||
| 8075fb66b7 | |||
| 23e5d10f65 | |||
| f4f613ad02 | |||
| 5512ebf133 | |||
| 40cd8fc2a6 | |||
| 3f351ac60c | |||
| e1d21c6ce3 | |||
| 163ad8cb4e | |||
| e23a90a961 | |||
| 13a67cdc85 | |||
| eed0345045 | |||
| fe3f48f255 | |||
| ba352feb34 | |||
| 04b2f0e4a3 | |||
| bf5e92e656 | |||
| 27010d5fb3 | |||
| 058ab7a619 | |||
| fe681503ae | |||
| 341825747f | |||
| d94476f943 | |||
| bf70d0b42a | |||
| e2bea367a1 | |||
| 27095025e7 | |||
| 2b2784d811 | |||
| b3ea8dcfa7 | |||
| c0bb2acf78 | |||
| 8cc7e9a996 | |||
| c9d3a27e88 | |||
| e52f1a46f6 | |||
| ce1da6a686 | |||
| de0f037bf4 | |||
| 9635387beb | |||
| 5ff9fade5f | |||
| 0e529358c9 |
@@ -3,7 +3,14 @@
|
||||
"allow": [
|
||||
"Bash(npm --prefix \"/e/web/PolyWeather/frontend\" run build)",
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(git:*)"
|
||||
"Bash(git:*)",
|
||||
"Bash(python -c \"from web.services.city_payloads import build_city_detail_payload; print\\('OK'\\)\")",
|
||||
"Bash(python -m ruff check web/services/city_runtime.py web/services/city_api.py)",
|
||||
"Bash(python -m ruff check .)",
|
||||
"Bash(python -m pytest tests/ -q)",
|
||||
"Bash(python -m ruff check . --fix)",
|
||||
"Bash(ssh *)",
|
||||
"Bash(curl *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,12 @@ POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
UVICORN_WORKERS=1
|
||||
# Optional: host user/group mapping for Docker on Linux.
|
||||
# Windows / macOS can usually keep the defaults.
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_PROMETHEUS_PORT=9090
|
||||
POLYWEATHER_ALERTMANAGER_PORT=9093
|
||||
POLYWEATHER_ALERT_RELAY_PORT=9099
|
||||
POLYWEATHER_GRAFANA_PORT=3001
|
||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||
# Backend CORS allowlist. Add your Vercel production/preview domains when
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
|
||||
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app
|
||||
@@ -34,21 +29,45 @@ WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_CHAT_IDS=
|
||||
POLYWEATHER_TELEGRAM_GROUP_ID=
|
||||
# Optional: restrict message-points accrual to these chat IDs.
|
||||
# Example: POLYWEATHER_BOT_POINTS_CHAT_IDS=-1003965137823
|
||||
POLYWEATHER_BOT_POINTS_CHAT_IDS=
|
||||
POLYWEATHER_GROUP_MEMBER_PRICE_USDC=5
|
||||
POLYWEATHER_PUBLIC_PRICE_USDC=10
|
||||
TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
||||
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
|
||||
TELEGRAM_PUSH_LANGUAGE=both
|
||||
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
|
||||
TELEGRAM_AIRPORT_PUSH_ENABLED=true
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
# Optional airport-only override.
|
||||
TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
|
||||
# Docker-only safety limits for the bot service.
|
||||
POLYWEATHER_BOT_CPUS=0.75
|
||||
POLYWEATHER_BOT_MEM_LIMIT=768m
|
||||
POLYWEATHER_BOT_MEMSWAP_LIMIT=1g
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=180
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
|
||||
########################################
|
||||
# 3) Weather + cache
|
||||
########################################
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=3600
|
||||
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=1
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
|
||||
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
|
||||
POLYWEATHER_HTTP_TIMEOUT_SEC=8
|
||||
POLYWEATHER_HTTP_RETRY_COUNT=0
|
||||
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
|
||||
@@ -57,26 +76,19 @@ POLYWEATHER_METAR_TIMEOUT_SEC=4
|
||||
POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5
|
||||
METAR_CACHE_TTL_SEC=600
|
||||
JMA_AMEDAS_CACHE_TTL_SEC=120
|
||||
METEOBLUE_CACHE_TTL_SEC=7200
|
||||
# Probability engine modes:
|
||||
# - legacy: production-safe primary path.
|
||||
# - emos_shadow: user-facing probability stays legacy, EMOS is generated for comparison.
|
||||
# - emos_primary: only after offline evaluation passes and manual rollout is approved.
|
||||
POLYWEATHER_PROBABILITY_ENGINE=legacy
|
||||
POLYWEATHER_EMOS_AUTO_MIN_SAMPLES=50
|
||||
POLYWEATHER_EMOS_AUTO_MAX_DELTA_CRPS=0
|
||||
POLYWEATHER_EMOS_AUTO_MAX_DELTA_MAE=0.05
|
||||
POLYWEATHER_EMOS_AUTO_MIN_DELTA_BUCKET_HIT_RATE=-0.05
|
||||
# Optional: cap recent probability snapshots used by EMOS retraining.
|
||||
# Recommended on VPS: do not train there; pull the SQLite DB to a local machine.
|
||||
# POLYWEATHER_EMOS_TRAINING_SNAPSHOT_LIMIT=20000
|
||||
# Optional: set this to a writable runtime path if you manually deploy a
|
||||
# locally trained EMOS calibration file.
|
||||
# POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
|
||||
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
|
||||
|
||||
# ── Country-specific data source URLs ──
|
||||
# These are kept in .env to avoid exposing competitive data-source discovery
|
||||
# work on the public GitHub repository. Leave empty to use built-in defaults.
|
||||
# AMSC_AWOS_BASE_URL=https://www.amsc.net.cn/gateway/api/saas/rest/amc/AwosController/getWindPlate
|
||||
# KMA_BASE_URL=https://www.weather.go.kr
|
||||
# AMOS_BASE_URL=https://global.amo.go.kr/amosobsnew/AmosRealTimeImage.do
|
||||
# JMA_AMEDAS_BASE_URL=https://www.jma.go.jp
|
||||
# MGM_BASE_URL=https://servis.mgm.gov.tr/web
|
||||
# MGM_ORIGIN_URL=https://www.mgm.gov.tr
|
||||
# FMI_BASE_URL=https://opendata.fmi.fi/wfs
|
||||
# HKO_BASE_URL=https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather
|
||||
# SINGAPORE_MSS_BASE_URL=https://api.data.gov.sg/v1/environment/air-temperature
|
||||
|
||||
########################################
|
||||
# 4) Auth / entitlement
|
||||
@@ -92,16 +104,11 @@ SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
|
||||
|
||||
########################################
|
||||
# 5) Alerts / operations
|
||||
# 5) Operations
|
||||
########################################
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
|
||||
########################################
|
||||
@@ -116,40 +123,21 @@ NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
||||
# bypass Vercel Functions / Fluid Compute instead of going through Next.js API proxies.
|
||||
# Example: NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
# Set to "false" to disable app analytics event tracking (conversion funnel etc.)
|
||||
# Default: enabled. Only set this if you need to opt out.
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=true
|
||||
|
||||
########################################
|
||||
# 7) Optional modules
|
||||
# 7) Admin / Ops
|
||||
########################################
|
||||
# Comma-separated admin email list for /ops dashboard access
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=
|
||||
# KNMI 10-minute observation data (Amsterdam)
|
||||
KNMI_API_KEY=
|
||||
|
||||
# Optional Groq commentary rewrite for intraday structure cards
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
GROQ_API_KEY=
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
|
||||
# Optional OpenAI-compatible market scan review for Pro users
|
||||
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
|
||||
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
# Backward-compatible legacy DeepSeek variables are still read if the generic
|
||||
# POLYWEATHER_SCAN_AI_* variables are unset.
|
||||
# POLYWEATHER_DEEPSEEK_API_KEY=
|
||||
# POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18
|
||||
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
|
||||
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
|
||||
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
||||
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
||||
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900
|
||||
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
########################################
|
||||
# 8) Optional modules
|
||||
########################################
|
||||
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC=1800
|
||||
@@ -180,7 +168,8 @@ POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
@@ -195,26 +184,6 @@ POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
# Polymarket market scan
|
||||
POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
POLYMARKET_CLOB_URL=https://clob.polymarket.com
|
||||
POLYMARKET_CHAIN_ID=137
|
||||
POLYMARKET_HTTP_TIMEOUT_SEC=20
|
||||
POLYMARKET_MARKET_CACHE_TTL_SEC=60
|
||||
POLYMARKET_PRICE_CACHE_TTL_SEC=30
|
||||
# false = fetch CLOB book/depth for orderbook analysis; true = price-only, lighter but no book levels
|
||||
POLYMARKET_FAST_PRICE_ONLY=false
|
||||
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
|
||||
POLYMARKET_WS_PRICE_ENABLED=false
|
||||
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
|
||||
POLYMARKET_WS_QUOTE_TTL_SEC=8
|
||||
POLYMARKET_WS_MAX_ASSETS=256
|
||||
POLYMARKET_WS_RECONNECT_DELAY_SEC=3
|
||||
POLYMARKET_DISCOVERY_PAGES=6
|
||||
POLYMARKET_DISCOVERY_LIMIT=200
|
||||
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
# Polygon watcher
|
||||
@@ -232,36 +201,9 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
||||
|
||||
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
|
||||
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
|
||||
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
|
||||
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
|
||||
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
|
||||
|
||||
########################################
|
||||
# 8) Optional proxies
|
||||
########################################
|
||||
HTTPS_PROXY=
|
||||
HTTP_PROXY=
|
||||
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
# Telegram
|
||||
########################################
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
POLYWEATHER_TELEGRAM_GROUP_ID=
|
||||
POLYWEATHER_GROUP_MEMBER_PRICE_USDC=10
|
||||
POLYWEATHER_PUBLIC_PRICE_USDC=10
|
||||
|
||||
########################################
|
||||
# Supabase
|
||||
@@ -32,6 +35,7 @@ METEOBLUE_API_KEY=
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
|
||||
@@ -51,14 +51,84 @@ jobs:
|
||||
- name: Business state tests
|
||||
run: npm run test:business
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
build-and-push:
|
||||
needs: [python-quality, frontend-quality]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
image:
|
||||
- name: backend
|
||||
context: .
|
||||
file: Dockerfile
|
||||
tag: ghcr.io/yangyuan-zhen/polyweather-backend
|
||||
- name: frontend
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
tag: ghcr.io/yangyuan-zhen/polyweather-frontend
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
docker-build:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
if: matrix.image.name == 'backend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push frontend
|
||||
if: matrix.image.name == 'frontend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t polyweather-ci .
|
||||
- name: Deploy to VPS
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
|
||||
"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Scratch / temp scripts
|
||||
scratch/
|
||||
|
||||
# Data and Logs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
@@ -63,3 +66,9 @@ frontend/.next-start.log
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
tmp_apikey.js
|
||||
tmp_obs.js
|
||||
tmp_rctp.html
|
||||
playwright-home-check.png
|
||||
.codex-backend-*.log
|
||||
frontend-next-*.log
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"less.validate": false
|
||||
"less.validate": false,
|
||||
"python.analysis.extraPaths": [
|
||||
"./"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.0 - 2026-05-10
|
||||
## 1.8.0 - 2026-05-27
|
||||
|
||||
### 新增与重构
|
||||
- **终端大洲区域过滤与分组**:终端重构支持按大洲/区域过滤与分组,添加移动端大洲 Tab 与卡片流响应式布局。
|
||||
- **巨鲸盯盘面板**:对接 Polymarket Data API `/holders`,按区域展示 Polymarket 成交量最大的城市、温度合约及真实巨鲸持仓数据。
|
||||
- **气温走势图升级**:使用 Recharts 交互式图表,支持双向概率分布对比柱状图,并在图表底部渲染 Polymarket 市场点击直达链接。
|
||||
- **日内偏差动态修正**:引入实时偏差修正算法,用实况观测与多模型小时预报的偏差来动态修正 DEB 预报中枢以及 Mu 概率分布,极大提高了预报和校准的精度。
|
||||
- **多数据源气温监控图表**:引入 `LiveTemperatureThresholdChart` 组件,展示实时跑道观测、DEB 预报中枢、多模型区间及目标阈值。
|
||||
- **全站中文化与多语言 (i18n)**:全站支持中英文一键切换,硬编码字符串彻底清理并接入翻译词条。
|
||||
- **机构落地页与鉴权优化**:首页重构为专业的机构落地页,添加了基于中间件的双层终端门控(/terminal 路由和 landing page 登录态感知)。
|
||||
- **超大组件拆分与解耦**:`AccountCenter` 组件彻底重构拆分为多个细粒度 Hook(`useWalletBind`、`usePaymentFlow`、`useBilling`),主组件代码缩减 60%,提升可维护性。
|
||||
- **Telegram 高频推送与内存优化**:机场观测推送重构,限制 LRU 缓存避免内存膨胀,并针对 Bot 动作和 API 接入进行连接复用与速率限制。
|
||||
|
||||
### 修复与优化
|
||||
- **类型异常修复**:修复在 `_in_peak_time_window` 决策卡时间窗口计算中 `last_h` 为 `None` 导致 `NoneType` 异常报错的问题。
|
||||
- **清理冗余类型转换**:移除 `src/utils/telegram_push.py` 中 8 处冗余的 `str()` 显式包装,精简 Python 代码。
|
||||
|
||||
|
||||
## 1.7.0 - 2026-05-23
|
||||
|
||||
### 新增能力
|
||||
- 市场监控面板(MonitorPanel):22 城实时温度监控,温度分辨率链(AMOS 跑道 → airport_primary → airport_current → current),按数据源新鲜度驱动刷新
|
||||
- 中国城市天气日报:AI 生成每日天气摘要,接入 CMA weather.com.cn 预报数据,推送至 Telegram 论坛群
|
||||
- 后台管理系统重写:从 1694 行单页拆分为 9 个模块(总览、会员、订阅、支付、训练、Telegram 审计、健康检查、配置、日志),含漏斗图、KPI 卡片、缓存饼图、增长趋势图
|
||||
- 跑道观测系统重构:全跑道展示、结算跑道标注、热力模型、风场分析,推送增加市场状态标签(超预期/升温中/冲顶观察/降温中)
|
||||
- 新增 6 个高频数据源:AEROWEB (Météo-France)、NCM (沙特)、IMS Lod (以色列)、AMSC AWOS (中国跑道)、MSS 1 分钟 (新加坡)、AROME HD 15 分钟 (巴黎)
|
||||
- 接入 HKO 1 分钟、流浮山 LFS 1 分钟、CWA 10 分钟 (台北松山) 实时温度
|
||||
- NOAA MADIS HFMETAR 适配新格式(netCDF stationId 替代 icaoId)+ 目录迁移适配
|
||||
- KNMI 适配新数据布局 (station,time) + 5 位 WMO 码 + S3 下载认证修复
|
||||
- 新增 GET /api/cities/model-range 端点
|
||||
- 积分转账功能:管理员手动扣除/划转用户积分
|
||||
- 支付提交前 Tx 预校验:链上验签收款地址与金额
|
||||
- CI 全流程自动化:测试通过后自动 SSH 部署到 VPS
|
||||
- 一键部署脚本:deploy.sh + deploy.ps1
|
||||
|
||||
### 移除
|
||||
- 删除 LGBM 全部代码和模型文件,EMOS 简化为纯 legacy 高斯分桶
|
||||
- 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine)
|
||||
- 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源
|
||||
- 删除预热(prewarm)系统
|
||||
- 删除市场提醒引擎(market_alert_engine)
|
||||
- 删除 Lagos、Masroor Air Base 城市
|
||||
- 移除季付/年付计划,统一月付 10 USDC
|
||||
|
||||
### 修复与优化
|
||||
- 修复移动端城市列表搜索无数据、Leaflet flyTo NaN 崩溃
|
||||
- 修复 MacBook Safari 布局崩溃(100vw/dvh、-webkit-backdrop-filter、grid minmax 溢出)
|
||||
- 修复温度曲线图三个渲染问题:数据点过少、张力过高、canvas CSS 拉伸
|
||||
- 修复 Open-Meteo 冷却期无限循环导致多模型数据缺失
|
||||
- 修复转化漏斗数据显示 3750%(前端重复乘以 100)
|
||||
- 多模型缓存优化 + ETag 缓存 + stale-while-revalidate
|
||||
- 性能优化:Context 重渲染、LGBM 循环移除、TTL 对齐
|
||||
- 账户页 Pro 状态偶发性丢失修复
|
||||
- 机场推送重构:观测缓存分离 + 全城市覆盖 + 四路并发
|
||||
|
||||
- 全面修复前端 UI 设计审查 15 项问题:消除工程债务、统一 token 体系、提升可维护性
|
||||
- CSS 架构:消除 !important 滥用(134→49,仅保留 Leaflet/图表所必需项)、浅色主题重构为 `html.light` 选择器体系
|
||||
@@ -54,7 +107,7 @@
|
||||
- 右侧详情面板识别稀疏 detail / 单日 forecast 中间态,并显示同步占位卡,避免用户把未补齐数据误认为完整结果
|
||||
- 概率区改为“校准模型概率”:有 LGBM 时展示 LGBM 校准概率;模型共识与市场价格降级为辅助参考
|
||||
- 模型层补齐 DWD ICON、ECMWF AIFS、ECCC GEM/GDPS/RDPS/HRDPS 等开放模型说明,并明确 AIFS 不称作“AI 预报”
|
||||
- 新增 / 补齐 Manila、Karachi、Masroor Air Base 等城市说明;机场市场以 METAR / 机场主站为结算锚点,Wunderground 仅作为历史页面或参考入口
|
||||
- 新增 / 补齐 Manila、Karachi 等城市说明;机场市场以 METAR / 机场主站为结算锚点,Wunderground 仅作为历史页面或参考入口
|
||||
- 历史对账、模型栈、LGBM、监控、前端 README 与网页 `/docs` 文档同步更新到当前产品口径
|
||||
|
||||
## 1.5.3 - 2026-04-10
|
||||
|
||||
@@ -4,150 +4,141 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, maps weather to Polymarket quotes for mispricing scans, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
|
||||
|
||||
## Environment & Preferences (ALWAYS follow)
|
||||
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
|
||||
### Working Directory
|
||||
- All commands run from the repo root: `E:/web/PolyWeather`
|
||||
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
|
||||
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
|
||||
- When I say "start the server", assume the correct working directory is `E:/web/PolyWeather`
|
||||
## Environment & Preferences
|
||||
|
||||
### Git Conventions
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
- Format: Lore Commit Protocol — intent line in Chinese, trailers in English
|
||||
- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径`
|
||||
- **NEVER** use English for commit subject lines
|
||||
|
||||
### Tooling
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
- Working directory: repo root
|
||||
- Python: `python` (not python3), venv at `venv/`
|
||||
- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript)
|
||||
- NEVER ask me about these preferences again — commit to memory
|
||||
- Frontend: `cd frontend && npm run dev` → localhost:3000
|
||||
- Backend: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000`
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
- **NEVER start commit messages with `@`** — Chinese directly, no prefix
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run dev # dev server :3000
|
||||
npm run build # production build
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:business # 19 business state tests
|
||||
|
||||
# Backend
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
python bot_listener.py # Telegram bot
|
||||
|
||||
# Python tests
|
||||
python -m pytest tests/
|
||||
python -m pytest tests/test_supabase_entitlement.py
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
ruff format .
|
||||
|
||||
# Docker (VPS)
|
||||
docker compose down && docker compose up -d --build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
|
||||
↓
|
||||
Weather Collector (METAR, TAF, Open-Meteo, country networks)
|
||||
↓
|
||||
Analysis (DEB + Trend + Probability + Market Scan)
|
||||
↓
|
||||
Payment Layer (Intent + Event + Confirm Loop)
|
||||
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
|
||||
/terminal (paid gate) Weather Collector
|
||||
/ (landing page) Analysis (DEB + Mu)
|
||||
Payment Layer (USDC on Polygon)
|
||||
Telegram Bot → bot_listener.py
|
||||
```
|
||||
|
||||
- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/core.py` + `web/routes.py` + `web/analysis_service.py`)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 on port 3000 (dev)
|
||||
- **Bot**: Telegram bot via `bot_listener.py` → `src/bot/`
|
||||
- **Shared analysis core** in `src/` is used by both web API and bot
|
||||
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
|
||||
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
|
||||
### Frontend Structure
|
||||
|
||||
## Commands
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `app/page.tsx` | Landing page (`InstitutionalLandingPage`) |
|
||||
| `app/terminal/page.tsx` | Paid terminal (`ScanTerminalDashboard`) |
|
||||
| `app/account/` | Account center with payment/subscription |
|
||||
| `app/auth/` | Supabase login/signup |
|
||||
| `components/dashboard/scan-terminal/` | Terminal sub-components |
|
||||
| `components/account/` | Account + payment hooks |
|
||||
| `components/landing/` | Institutional landing page |
|
||||
| `components/subscription/` | `UnlockProOverlay` payment overlay |
|
||||
| `lib/dashboard-types.ts` | All TypeScript types |
|
||||
|
||||
### Frontend (dev on port 3000)
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Next.js dev server
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint via next lint
|
||||
```
|
||||
### Terminal Component Map
|
||||
|
||||
### Backend (dev on port 8000)
|
||||
```bash
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
- `ScanTerminalDashboard.tsx` — entry, auth gate, `ProductAccessRequired`
|
||||
- `PolyWeatherTerminal` — main layout: sidebar + region tabs + 2-column grid
|
||||
- `CityRegionList` — city list panel (left top)
|
||||
- `CityContractDetail` — contract table panel (left bottom)
|
||||
- `LiveTemperatureThresholdChart` — multi-source overlay: obs + DEB + model curves + thresholds
|
||||
- `RealtimeScrollChart` — lightweight realtime scrolling temperature + threshold bars
|
||||
- `TrainingDashboard` — DEB + Mu accuracy charts (sidebar "训练数据" tab)
|
||||
- `continent-grouping.ts` — 7 trading regions (`TRADING_REGIONS`), city-to-region fallback (`CITY_REGION_FALLBACK`), timezone detection (`detectLocalRegion`)
|
||||
|
||||
### Telegram Bot
|
||||
```bash
|
||||
python bot_listener.py
|
||||
# or via wrapper:
|
||||
python run.py
|
||||
```
|
||||
### Account Module
|
||||
|
||||
### Docker (production-like stack)
|
||||
```bash
|
||||
docker compose up -d --build # bot + web API
|
||||
docker compose --profile workers up -d # + prewarm worker
|
||||
docker compose --profile monitoring up -d # + Prometheus/Grafana/Alertmanager
|
||||
```
|
||||
- `AccountCenter.tsx` (~1280 lines) — main component
|
||||
- `useAccountPayment.ts` — master payment hook, composes sub-hooks
|
||||
- `useWalletBind.ts` — EVM/WalletConnect binding
|
||||
- `usePaymentFlow.ts` — intent creation, payment, confirmation
|
||||
- `useBilling.ts` — subscription recovery, billing computation
|
||||
|
||||
### Python tests
|
||||
```bash
|
||||
pytest tests/ # all tests
|
||||
pytest tests/test_web_observability.py # single test file
|
||||
```
|
||||
### Backend Key Files
|
||||
|
||||
### Lint & Format
|
||||
```bash
|
||||
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
|
||||
ruff format . # Python format (Black-compatible, double quotes)
|
||||
```
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `web/routers/city.py` | City detail/summary/realtime-stream endpoints |
|
||||
| `web/routers/scan.py` | Scan terminal aggregation |
|
||||
| `web/services/city_payloads.py` | City detail and summary payload builders |
|
||||
| `web/scan_terminal_city_row.py` | Builds terminal rows from analysis data |
|
||||
| `src/data_collection/city_registry.py` | 50-city registry with tz_offset |
|
||||
| `src/analysis/deb_algorithm.py` | DEB prediction + Mu calibration + accuracy |
|
||||
| `web/services/analysis_utils.py` | Clock helpers, bucket labeling, time parsing |
|
||||
| `web/services/observation_freshness.py` | Source profiles and freshness computation |
|
||||
| `web/services/scan_ai_config.py` | Scan terminal and AI configuration constants |
|
||||
|
||||
### Health & Ops checks
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
## Auth Gating
|
||||
|
||||
## Key Directories
|
||||
Middleware (`middleware.ts`) handles two layers:
|
||||
1. **Terminal gate** (`handleTerminalGate`): `/terminal/*` → redirect to `/auth/login` if no Supabase session
|
||||
2. **Global auth** (`handleSupabaseAuthGate`): enforced when `POLYWEATHER_AUTH_REQUIRED=true`
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer |
|
||||
| `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding |
|
||||
| `src/models/` | LightGBM daily-high model training and feature engineering |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/bot/` | Telegram bot handlers and orchestrator |
|
||||
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
|
||||
| `web/` | FastAPI app, routes (~65K), analysis service (~130K), scan terminal service (~56K), AI scan modules |
|
||||
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
|
||||
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className |
|
||||
| `frontend/lib/` | Shared client logic: types, API client, chart utils, i18n, dashboard utils |
|
||||
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
|
||||
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation, prewarm worker |
|
||||
| `config/` | YAML config (city list, weather settings, logging) |
|
||||
| `docs/` | Bilingual product & technical docs |
|
||||
| `monitoring/` | Prometheus/Grafana/Alertmanager configs |
|
||||
Client-side gate (`ProductAccessRequired`): `/terminal` checks auth + subscription via `/api/auth/me`, shows paywall if needed.
|
||||
|
||||
## Key Technical Details
|
||||
Local dev bypass: set `NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false` to test auth locally.
|
||||
|
||||
- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules
|
||||
- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined
|
||||
- **Frontend package manager**: npm
|
||||
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
|
||||
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
|
||||
- **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth.
|
||||
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
|
||||
- **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout
|
||||
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
|
||||
## Polymarket Integration
|
||||
|
||||
## Commit Convention
|
||||
**Removed.** No Polymarket price fetching, no market scan, no WS cache. Terminal operates on weather data only (Live observations + DEB predictions + model probabilities). All `polymarket_readonly.py`, `polymarket_ws_cache.py`, and market-scan API routes have been deleted.
|
||||
|
||||
This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what).
|
||||
## Trading Regions
|
||||
|
||||
- Always write git commit messages in **Chinese (简体中文)**.
|
||||
7 regions: east_asia, southeast_asia, central_asia, west_asia, europe_africa, south_america, north_america. Mappings in `continent-grouping.ts` (`CITY_REGION_FALLBACK` — all 50 cities hardcoded) and `scan_terminal_filters.py` (`market_region_from_tz_offset`). Default region auto-detected from browser timezone.
|
||||
|
||||
## Scan Terminal Performance
|
||||
|
||||
- **Region lazy-loading**: `region=east_asia` filters cities server-side before scanning (see `_market_region_from_tz_offset`)
|
||||
- **Weather-only**: Terminal returns 1 row per city with Live/DEB/probability data; no market contract matching
|
||||
- **DB**: SQLite WAL mode + `busy_timeout=5000` enabled in `db_manager.py` (fixes "database is locked" with parallel workers)
|
||||
- **VPS env**: `POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=2`, `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=180`
|
||||
- **Caching**: `_cache` is `LRUDict(256)` with `_CACHE_LOCK`; `_SUMMARY_CACHE` is `LRUDict(128)`; weather caches trimmed every 200 writes
|
||||
|
||||
## Intraday Bias Correction
|
||||
|
||||
`analysis_service.py:_analyze()` applies intraday correction after probability generation:
|
||||
- Compares current observed temp vs model hourly forecast for current hour
|
||||
- Time-of-day weight: 0.15↗0.35 pre-peak, 0.40↗0.75 during peak, 0.80 post-peak
|
||||
- Also checks if max-so-far already exceeds DEB prediction (strong upward nudge)
|
||||
- Correction capped at ±5°F / ±3°C, applied to both `deb_val` and `mu`
|
||||
|
||||
## Code Style
|
||||
|
||||
- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding.
|
||||
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
|
||||
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
|
||||
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
|
||||
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
|
||||
|
||||
## Quality Gates (MANDATORY)
|
||||
|
||||
Before marking any task as complete, you MUST:
|
||||
|
||||
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
|
||||
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
|
||||
3. **Dual-theme CSS** — For any UI change, confirm BOTH the dark CSS module AND `ScanTerminalLightTheme.module.css` were updated
|
||||
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
|
||||
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
|
||||
|
||||
If any gate fails, fix it BEFORE reporting success.
|
||||
- No `\uXXXX` escapes — write characters directly in UTF-8
|
||||
- Use `var(--color-*)` CSS tokens, not hardcoded hex
|
||||
- Minimum font size: 10px (`text-[10px]`)
|
||||
- Avoid `!important` except Leaflet map overrides
|
||||
- Remove dead code immediately when features are removed
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
TZ=UTC
|
||||
|
||||
# 安装系统依赖 (如果有必要的包可以取消注释)
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libhdf5-dev libnetcdf-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制 requirements 文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir --prefer-binary -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --prefer-binary -r requirements.txt
|
||||
|
||||
# 复制项目代码
|
||||
COPY . .
|
||||
|
||||
# 启动机器人
|
||||
CMD ["python", "bot_listener.py"]
|
||||
|
||||
@@ -21,10 +21,12 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-04-27)
|
||||
## Product Status (2026-05-23)
|
||||
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
|
||||
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
@@ -33,22 +35,18 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to Polymarket temperature buckets.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to temperature buckets.
|
||||
- AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine; EMOS/LGBM are surfaced only when evaluated or shadowed, while model consensus and market prices remain secondary references.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
|
||||
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `KMA` (Korea), `HKO` (Hong Kong), and `CWA` (Taiwan).
|
||||
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `AMOS` (Korea, runway-level, Seoul/Busan), `HKO` (Hong Kong), and `CWA` (Taiwan).
|
||||
- Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer.
|
||||
- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`.
|
||||
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
|
||||
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
|
||||
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (134→49 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
@@ -62,17 +60,15 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Aggregates observations and forecasts for 52 monitored cities.
|
||||
- Aggregates observations and forecasts for 51 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution), with `LGBM` metadata surfaced when the calibrated engine is active.
|
||||
- Maps weather view to Polymarket quotes for mispricing scan.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network enhancement layers for China, Japan, Korea, Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
|
||||
## Reference Architecture
|
||||
|
||||
@@ -89,22 +85,20 @@ flowchart LR
|
||||
WX --> MGM["MGM (Turkey station network)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> JMA["JMA AMeDAS (Japan)"]
|
||||
WX --> KMA["KMA (Korea)"]
|
||||
WX --> AMOS["AMOS runway sensors (Korea)"]
|
||||
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
|
||||
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
ANA --> LLM["Optional Groq Commentary Rewrite"]
|
||||
API --> PREWARM["Dashboard Prewarm API / Worker"]
|
||||
ANA --> STATE["SQLite runtime state"]
|
||||
```
|
||||
|
||||
## Monitored Cities (52)
|
||||
## Monitored Cities (51)
|
||||
|
||||
- Europe / Middle East / Africa: Ankara, Istanbul, Moscow, London, Paris, Munich, Milan, Warsaw, Madrid, Tel Aviv, Amsterdam, Helsinki, Lagos, Cape Town, Jeddah
|
||||
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Qingdao, Wuhan, Chengdu, Chongqing, Shenzhen, Guangzhou, Singapore, Tokyo, Kuala Lumpur, Jakarta, Manila, Wellington
|
||||
- Americas: Toronto, New York, Los Angeles, San Francisco, Aurora, Austin, Houston, Chicago, Dallas, Miami, Atlanta, Seattle, Mexico City, Buenos Aires, Sao Paulo, Panama City
|
||||
- South Asia: Lucknow, Karachi, Masroor Air Base
|
||||
- South Asia: Lucknow, Karachi
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -129,7 +123,7 @@ npm run dev
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- `LGBM` can power the calibrated probability panel; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
|
||||
## Runtime Data (Recommended on VPS)
|
||||
@@ -165,20 +159,6 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard prewarm worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `cache.analysis.hit_rate`
|
||||
- `cache.open_meteo_forecast_entries`
|
||||
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
@@ -197,12 +177,6 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### Wallet activity logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -220,26 +194,25 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
|
||||
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
|
||||
- Model stack & DEB (ZH): [docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- EMOS + LGBM system (ZH): [docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
|
||||
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- Airport realtime sources: [docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- Airport market monitor (ZH): [docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
- Services overview (ZH): [docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- Monitoring guide (ZH): [docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- Release process: [RELEASE.md](RELEASE.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.5.4`
|
||||
- Last Updated: `2026-04-19`
|
||||
- Version: `v1.8.0`
|
||||
- Last Updated: `2026-05-23`
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-04-27)
|
||||
## 当前产品状态(2026-05-23)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 5 USDC`。
|
||||
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`。
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
|
||||
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
@@ -30,7 +32,7 @@
|
||||
- `MGM`(土耳其)
|
||||
- `CMA/NMC`(中国内地)
|
||||
- `JMA AMeDAS`(日本)
|
||||
- `KMA`(韩国)
|
||||
- `AMOS`(韩国,跑道级传感器,首尔/釜山)
|
||||
- `HKO`(香港)
|
||||
- `CWA`(台湾)
|
||||
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
|
||||
@@ -38,13 +40,12 @@
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到 Polymarket 温度桶。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。
|
||||
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。
|
||||
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
|
||||
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
@@ -59,15 +60,13 @@
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 聚合 52 个监控城市的实测与预报数据。
|
||||
- 聚合 51 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),并在 LGBM 生效时展示校准引擎元数据。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 官方增强层支持按国家 provider 统一接入,但不替代机场主站、METAR 或明确官方结算站。
|
||||
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
|
||||
## 参考架构
|
||||
|
||||
@@ -82,25 +81,21 @@ flowchart LR
|
||||
WX --> METAR["Aviation Weather(METAR)"]
|
||||
WX --> MGM["MGM(土耳其站网)"]
|
||||
WX --> JMA["JMA AMeDAS(日本)"]
|
||||
WX --> KMA["KMA(韩国)"]
|
||||
WX --> AMOS["AMOS 跑道传感器(韩国)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
|
||||
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket 只读层"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
API --> PREWARM["Dashboard 预热接口 / Worker"]
|
||||
ANA --> LLM["可选 Groq 文案改写层"]
|
||||
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||
```
|
||||
|
||||
## 监控城市(52)
|
||||
## 监控城市(51)
|
||||
|
||||
- 欧洲/中东/非洲:Ankara、Istanbul、Moscow、London、Paris、Munich、Milan、Warsaw、Madrid、Tel Aviv、Amsterdam、Helsinki、Lagos、Cape Town、Jeddah
|
||||
- 亚太:Seoul、Busan、Hong Kong、Lau Fau Shan、Taipei、Shanghai、Beijing、Wuhan、Chengdu、Chongqing、Shenzhen、Guangzhou、Singapore、Tokyo、Kuala Lumpur、Jakarta、Manila、Wellington
|
||||
- 美洲:Toronto、New York、Los Angeles、San Francisco、Aurora、Austin、Houston、Chicago、Dallas、Miami、Atlanta、Seattle、Mexico City、Buenos Aires、Sao Paulo、Panama City
|
||||
- 南亚:Lucknow、Karachi、Masroor Air Base
|
||||
- 南亚:Lucknow、Karachi
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -156,21 +151,7 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard 预热 Worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `prewarm.runtime.last_summary_ok`
|
||||
- `cache.analysis.hit_rate`
|
||||
|
||||
### 前端缓存头
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
@@ -213,12 +194,6 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
### 钱包异动监听日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram 指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
@@ -239,24 +214,21 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
- 外部服务总览:[docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- LightGBM 日最高温模型:[docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- EMOS + LGBM 系统:[docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.5.4`
|
||||
- 文档最后更新:`2026-04-19`
|
||||
- 版本:`v1.8.0`
|
||||
- 文档最后更新:`2026-05-23`
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
示例:
|
||||
|
||||
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
|
||||
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
|
||||
- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代
|
||||
- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级
|
||||
|
||||
## 日常升版步骤
|
||||
|
||||
@@ -29,7 +29,7 @@ python scripts/bump_version.py patch
|
||||
```bash
|
||||
python scripts/bump_version.py minor
|
||||
python scripts/bump_version.py major
|
||||
python scripts/bump_version.py 1.5.0
|
||||
python scripts/bump_version.py 1.8.0
|
||||
```
|
||||
|
||||
### 2. 检查同步结果
|
||||
@@ -68,15 +68,15 @@ python -m pytest
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v1.4.1"
|
||||
git tag v1.4.1
|
||||
git commit -m "release: v1.7.1"
|
||||
git tag v1.7.1
|
||||
```
|
||||
|
||||
### 6. 推送
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push origin v1.4.1
|
||||
git push origin v1.7.1
|
||||
```
|
||||
|
||||
## 当前约束
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"model_type": "LightGBMRegressor",
|
||||
"target": "actual_high",
|
||||
"horizon": "D0",
|
||||
"feature_names": [
|
||||
"actual_high_lag_1",
|
||||
"actual_high_lag_2",
|
||||
"actual_high_lag_3",
|
||||
"actual_high_lag_7",
|
||||
"actual_high_mean_7",
|
||||
"actual_high_mean_14",
|
||||
"actual_high_trend_3",
|
||||
"open_meteo",
|
||||
"ecmwf",
|
||||
"gfs",
|
||||
"gem",
|
||||
"jma",
|
||||
"icon",
|
||||
"mgm",
|
||||
"nws",
|
||||
"deb_prediction",
|
||||
"model_median",
|
||||
"model_spread",
|
||||
"current_temp",
|
||||
"max_so_far",
|
||||
"humidity",
|
||||
"wind_speed_kt",
|
||||
"visibility_mi",
|
||||
"local_hour",
|
||||
"month",
|
||||
"weekday",
|
||||
"peak_status_code"
|
||||
],
|
||||
"base_model_columns": [
|
||||
"open_meteo",
|
||||
"ecmwf",
|
||||
"gfs",
|
||||
"gem",
|
||||
"jma",
|
||||
"icon",
|
||||
"mgm",
|
||||
"nws"
|
||||
],
|
||||
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
|
||||
"sample_count": 1247,
|
||||
"train_count": 998,
|
||||
"validation_count": 249,
|
||||
"metrics": {
|
||||
"validation": {
|
||||
"sample_count": 249,
|
||||
"lgbm_mae": 2.626,
|
||||
"deb_mae": 2.745,
|
||||
"best_single_mae": 1.733,
|
||||
"median_mae": 2.866
|
||||
},
|
||||
"full_sample": {
|
||||
"sample_count": 1247,
|
||||
"lgbm_mae": 0.953,
|
||||
"deb_mae": 1.872,
|
||||
"best_single_mae": 1.052,
|
||||
"median_mae": 1.952
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-05-06T10:45:04.319587Z",
|
||||
"trained_at": "2026-05-06T10:45:04.319587Z"
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
{
|
||||
"version": "emos-auto-20260421122743",
|
||||
"trained_at": "2026-04-21T12:28:05.031165+00:00",
|
||||
"global": {
|
||||
"mu": {
|
||||
"intercept": 0.34330438,
|
||||
"raw_mu_coef": 0.48749629,
|
||||
"deb_coef": 0.45733479,
|
||||
"ens_median_coef": 0.05955297,
|
||||
"max_so_far_gap_coef": -0.104935
|
||||
},
|
||||
"sigma": {
|
||||
"intercept": -0.62765582,
|
||||
"raw_sigma_coef": 0.38492261,
|
||||
"spread_coef": 0.29095921,
|
||||
"peak_flag_coef": 0.33966327,
|
||||
"max_so_far_gap_coef": 0.08357376
|
||||
}
|
||||
},
|
||||
"sigma_constraints": {
|
||||
"min_ratio": 0.85,
|
||||
"max_ratio": 1.35,
|
||||
"absolute_min": 0.25,
|
||||
"absolute_max": 3.0
|
||||
},
|
||||
"selection_guardrails": {
|
||||
"max_mae_increase": 0.02,
|
||||
"max_bucket_hit_drop": 0.01,
|
||||
"max_bucket_brier_increase": 0.05
|
||||
},
|
||||
"blending": {
|
||||
"alpha_mu": 0.25,
|
||||
"alpha_sigma": 1.0
|
||||
},
|
||||
"cities": {
|
||||
"istanbul": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.438949,
|
||||
"sigma_scale": 0.553793,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 22,
|
||||
"mu_bias": -1.044971,
|
||||
"sigma_scale": 1.389753,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"ankara": {
|
||||
"samples": 23,
|
||||
"mu_bias": 0.067406,
|
||||
"sigma_scale": 1.270298,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"london": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.010833,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"paris": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.21677,
|
||||
"sigma_scale": 1.095669,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"new york": {
|
||||
"samples": 21,
|
||||
"mu_bias": 1.072189,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"lagos": {
|
||||
"samples": 10,
|
||||
"mu_bias": -0.092732,
|
||||
"sigma_scale": 1.558646,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.627862,
|
||||
"sigma_scale": 1.761239,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.424101,
|
||||
"sigma_scale": 1.326675,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.347796,
|
||||
"sigma_scale": 0.893459,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"los angeles": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.659983,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"san francisco": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.941818,
|
||||
"sigma_scale": 1.979403,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"aurora": {
|
||||
"samples": 20,
|
||||
"mu_bias": 2.040009,
|
||||
"sigma_scale": 0.982492,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"austin": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.990023,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"houston": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.188787,
|
||||
"sigma_scale": 1.819594,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 21,
|
||||
"mu_bias": 2.285098,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 21,
|
||||
"mu_bias": 0.258095,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 22,
|
||||
"mu_bias": -2.093105,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 21,
|
||||
"mu_bias": -0.982188,
|
||||
"sigma_scale": 1.695172,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.786242,
|
||||
"sigma_scale": 1.427037,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"munich": {
|
||||
"samples": 22,
|
||||
"mu_bias": 0.096559,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"milan": {
|
||||
"samples": 23,
|
||||
"mu_bias": -0.045038,
|
||||
"sigma_scale": 1.626331,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 23,
|
||||
"mu_bias": -0.50853,
|
||||
"sigma_scale": 1.239146,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"helsinki": {
|
||||
"samples": 17,
|
||||
"mu_bias": 0.442247,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"amsterdam": {
|
||||
"samples": 17,
|
||||
"mu_bias": 0.129889,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"panama city": {
|
||||
"samples": 17,
|
||||
"mu_bias": -0.040792,
|
||||
"sigma_scale": 1.393259,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"cape town": {
|
||||
"samples": 10,
|
||||
"mu_bias": 0.507293,
|
||||
"sigma_scale": 1.420004,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 22,
|
||||
"mu_bias": -2.736316,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"manila": {
|
||||
"samples": 5,
|
||||
"mu_bias": 1.772229,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 22,
|
||||
"mu_bias": 0.289639,
|
||||
"sigma_scale": 1.124006,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.332129,
|
||||
"sigma_scale": 0.729483,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 22,
|
||||
"mu_bias": 1.6533,
|
||||
"sigma_scale": 0.82648,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"busan": {
|
||||
"samples": 16,
|
||||
"mu_bias": 0.343239,
|
||||
"sigma_scale": 1.886868,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"mexico city": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.087122,
|
||||
"sigma_scale": 1.108884,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 23,
|
||||
"mu_bias": 0.348022,
|
||||
"sigma_scale": 0.810527,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"beijing": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.288176,
|
||||
"sigma_scale": 0.952972,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 21,
|
||||
"mu_bias": -0.006114,
|
||||
"sigma_scale": 0.728494,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"wuhan": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.771154,
|
||||
"sigma_scale": 1.30329,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"chengdu": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.445906,
|
||||
"sigma_scale": 0.796989,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"chongqing": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.021301,
|
||||
"sigma_scale": 1.106566,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 24,
|
||||
"mu_bias": 0.37527,
|
||||
"sigma_scale": 0.517756,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 23,
|
||||
"mu_bias": 0.600671,
|
||||
"sigma_scale": 0.5,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"guangzhou": {
|
||||
"samples": 6,
|
||||
"mu_bias": 0.7082,
|
||||
"sigma_scale": 1.975103,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"lau fau shan": {
|
||||
"samples": 19,
|
||||
"mu_bias": 0.41371,
|
||||
"sigma_scale": 0.656578,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"kuala lumpur": {
|
||||
"samples": 16,
|
||||
"mu_bias": 0.499197,
|
||||
"sigma_scale": 1.361171,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"jakarta": {
|
||||
"samples": 16,
|
||||
"mu_bias": 0.293537,
|
||||
"sigma_scale": 0.751498,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 22,
|
||||
"mu_bias": -1.593437,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"karachi": {
|
||||
"samples": 5,
|
||||
"mu_bias": -0.035595,
|
||||
"sigma_scale": 0.588612,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"jeddah": {
|
||||
"samples": 10,
|
||||
"mu_bias": 0.516879,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"moscow": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.609602,
|
||||
"sigma_scale": 0.783723,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 22,
|
||||
"mu_bias": 0.436837,
|
||||
"sigma_scale": 1.46524,
|
||||
"confidence": 1.0
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"sample_count": 978,
|
||||
"mean_crps": 1.330733,
|
||||
"legacy_mean_crps": 1.376936,
|
||||
"legacy_mean_mae": 1.589438,
|
||||
"legacy_bucket_hit_rate": 0.43865,
|
||||
"legacy_bucket_brier": 0.824571,
|
||||
"selected_mean_crps": 1.311763,
|
||||
"selected_mean_mae": 1.553658,
|
||||
"selected_bucket_hit_rate": 0.432515,
|
||||
"selected_bucket_brier": 0.789817,
|
||||
"selected_score": 2.910718,
|
||||
"legacy_score": 2.996333,
|
||||
"filled_actual_from_history": 0,
|
||||
"settlement_history_city_count": 30,
|
||||
"legacy_archive_samples": 54
|
||||
},
|
||||
"source": "artifacts\\local_runtime\\probability_calibration\\candidates\\emos-auto-20260421122743\\default.json"
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
{
|
||||
"summary": {
|
||||
"sample_count": 74,
|
||||
"filled_actual_from_history": 0,
|
||||
"legacy": {
|
||||
"mean_crps": 3.474108,
|
||||
"mean_mae": 3.679324,
|
||||
"bucket_hit_rate": 0.5
|
||||
},
|
||||
"emos": {
|
||||
"mean_crps": 3.33124,
|
||||
"mean_mae": 3.622584,
|
||||
"bucket_hit_rate": 0.5
|
||||
},
|
||||
"delta": {
|
||||
"crps": -0.142868,
|
||||
"mae": -0.056741,
|
||||
"bucket_hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 1.365135,
|
||||
"emos_mean_crps": 1.392829,
|
||||
"legacy_mean_mae": 1.466667,
|
||||
"emos_mean_mae": 1.444819,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 30.449382,
|
||||
"emos_mean_crps": 29.886236,
|
||||
"legacy_mean_mae": 32.015,
|
||||
"emos_mean_mae": 31.322804,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 9.113412,
|
||||
"emos_mean_crps": 8.463522,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.024533,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"busan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.276202,
|
||||
"emos_mean_crps": 0.326485,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.239636,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"chengdu": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.306469,
|
||||
"emos_mean_crps": 0.378812,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.153224,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.250268,
|
||||
"emos_mean_crps": 0.714669,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.319756,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 2.173363,
|
||||
"emos_mean_crps": 0.840297,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 1.028146,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 7,
|
||||
"legacy_mean_crps": 0.706099,
|
||||
"emos_mean_crps": 0.731926,
|
||||
"legacy_mean_mae": 0.657143,
|
||||
"emos_mean_mae": 0.669911,
|
||||
"legacy_bucket_hit_rate": 0.714286,
|
||||
"emos_bucket_hit_rate": 0.571429
|
||||
},
|
||||
"istanbul": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.264103,
|
||||
"emos_mean_crps": 1.214967,
|
||||
"legacy_mean_mae": 1.5,
|
||||
"emos_mean_mae": 1.469837,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.885033,
|
||||
"emos_mean_crps": 3.799841,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.027503,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 2.487193,
|
||||
"emos_mean_crps": 2.107441,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"emos_mean_mae": 2.876943,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 6.27726,
|
||||
"emos_mean_crps": 5.810228,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.195133,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 28.637631,
|
||||
"emos_mean_crps": 27.592664,
|
||||
"legacy_mean_mae": 30.175,
|
||||
"emos_mean_mae": 29.284979,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 4.401392,
|
||||
"emos_mean_crps": 3.782613,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"emos_mean_mae": 3.989808,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.145192,
|
||||
"emos_mean_crps": 2.990712,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.61502,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 3.692845,
|
||||
"emos_mean_crps": 3.021798,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.54437,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 4.013782,
|
||||
"emos_mean_crps": 3.915303,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.179694,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.540967,
|
||||
"emos_mean_crps": 5.018605,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.314389,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.315488,
|
||||
"emos_mean_crps": 0.524119,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.673609,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.508331,
|
||||
"emos_mean_crps": 0.520762,
|
||||
"legacy_mean_mae": 0.2,
|
||||
"emos_mean_mae": 0.247862,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.299116,
|
||||
"emos_mean_crps": 0.402453,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.189437,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.198351,
|
||||
"emos_mean_crps": 1.490963,
|
||||
"legacy_mean_mae": 1.3,
|
||||
"emos_mean_mae": 1.628189,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.281993,
|
||||
"emos_mean_crps": 0.370374,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.118005,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.950739,
|
||||
"emos_mean_crps": 1.042332,
|
||||
"legacy_mean_mae": 0.94,
|
||||
"emos_mean_mae": 0.951241,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.446758,
|
||||
"emos_mean_crps": 0.578691,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.116966,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.879366,
|
||||
"emos_mean_crps": 0.876608,
|
||||
"legacy_mean_mae": 1.022,
|
||||
"emos_mean_mae": 1.008317,
|
||||
"legacy_bucket_hit_rate": 0.2,
|
||||
"emos_bucket_hit_rate": 0.4
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.497916,
|
||||
"emos_mean_crps": 5.268783,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"emos_mean_mae": 6.388522,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.618875,
|
||||
"emos_mean_crps": 1.524219,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.006524,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.364919,
|
||||
"emos_mean_crps": 0.484124,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.123335,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"wuhan": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.476225,
|
||||
"emos_mean_crps": 0.578405,
|
||||
"legacy_mean_mae": 0.8,
|
||||
"emos_mean_mae": 0.962852,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"evaluation_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\evaluation_report.json",
|
||||
"shadow_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\shadow_report.json",
|
||||
"evaluation_report_exists": true,
|
||||
"shadow_report_exists": true,
|
||||
"decision": {
|
||||
"decision": "hold",
|
||||
"ready_for_primary": false,
|
||||
"summary": "当前指标不足以切换 emos_primary,应继续保持 shadow。",
|
||||
"thresholds": {
|
||||
"evaluation_min_samples": 80,
|
||||
"shadow_min_samples": 50,
|
||||
"max_delta_mae": 0.05,
|
||||
"min_delta_crps": -0.02,
|
||||
"min_delta_bucket_hit_rate": 0.0,
|
||||
"max_delta_bucket_brier_promote": 0.02,
|
||||
"max_delta_bucket_brier_observe": 0.15
|
||||
},
|
||||
"evaluation": {
|
||||
"sample_count": 54,
|
||||
"delta_crps": -0.086732,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0
|
||||
},
|
||||
"shadow": {
|
||||
"sample_count": 48,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.041666,
|
||||
"delta_bucket_brier": 0.123252
|
||||
},
|
||||
"blocking_reasons": [
|
||||
"离线评估样本不足:54 < 80",
|
||||
"shadow 样本不足:48 < 50",
|
||||
"shadow bucket brier 退化超限:delta=0.123252"
|
||||
],
|
||||
"worst_shadow_regressions": [
|
||||
{
|
||||
"city": "dallas",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.792585
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.791878
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.61609
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"samples": 2,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.509203
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"samples": 2,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.439879
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,933 +0,0 @@
|
||||
{
|
||||
"generated_at": "2026-04-02T16:23:24.376528Z",
|
||||
"summary": {
|
||||
"samples": 48,
|
||||
"legacy_mean_mae": 3.04125,
|
||||
"shadow_mean_mae": 3.04125,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.68666,
|
||||
"shadow_bucket_brier": 0.814079,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.127419
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.494847,
|
||||
"shadow_bucket_brier": 0.654648,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.159801
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 17.06,
|
||||
"shadow_mean_mae": 17.06,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.029097,
|
||||
"shadow_bucket_brier": 1.064101,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.035004
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"shadow_mean_mae": 10.27,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.117726,
|
||||
"shadow_bucket_brier": 1.116732,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.000994
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.791878,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.791878
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.792585,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.792585
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"shadow_bucket_hit_rate": 0.666667,
|
||||
"legacy_bucket_brier": 0.882203,
|
||||
"shadow_bucket_brier": 0.437187,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.333334,
|
||||
"delta_bucket_brier": -0.445016
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"shadow_mean_mae": 4.135,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 0.929652,
|
||||
"shadow_bucket_brier": 1.039551,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.109899
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"shadow_mean_mae": 3.205,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.673607,
|
||||
"shadow_bucket_brier": 0.96272,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.710887
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"shadow_mean_mae": 7.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.148213,
|
||||
"shadow_bucket_brier": 1.133888,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.014325
|
||||
},
|
||||
"miami": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 11.04,
|
||||
"shadow_mean_mae": 11.04,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.182605,
|
||||
"shadow_bucket_brier": 1.067257,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.115348
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"shadow_mean_mae": 4.06,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"shadow_bucket_hit_rate": 0.666667,
|
||||
"legacy_bucket_brier": 0.587548,
|
||||
"shadow_bucket_brier": 0.78616,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.198612
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"shadow_mean_mae": 3.64,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 0.918378,
|
||||
"shadow_bucket_brier": 0.945234,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.026856
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"shadow_mean_mae": 4.94,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.111711,
|
||||
"shadow_bucket_brier": 1.099556,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.012155
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"shadow_mean_mae": 4.265,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.90186,
|
||||
"shadow_bucket_brier": 0.9967,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.09484
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"shadow_mean_mae": 6.57,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.263988,
|
||||
"shadow_bucket_brier": 1.159352,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.104636
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.61609,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.61609
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.258977,
|
||||
"shadow_bucket_brier": 0.548485,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.289508
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.1156,
|
||||
"shadow_bucket_brier": 0.501323,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.385723
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.306527,
|
||||
"shadow_bucket_brier": 0.553647,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.24712
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 0.333333,
|
||||
"legacy_bucket_brier": 0.195261,
|
||||
"shadow_bucket_brier": 0.684998,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": -0.666667,
|
||||
"delta_bucket_brier": 0.489737
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"shadow_mean_mae": 0.3,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.257895,
|
||||
"shadow_bucket_brier": 0.697774,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.439879
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.25,
|
||||
"shadow_mean_mae": 0.25,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.284759,
|
||||
"shadow_bucket_brier": 0.69701,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.5,
|
||||
"delta_bucket_brier": 0.412251
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"shadow_mean_mae": 6.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.256736,
|
||||
"shadow_bucket_brier": 1.221422,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.035314
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"shadow_mean_mae": 2.056667,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"shadow_bucket_hit_rate": 0.333333,
|
||||
"legacy_bucket_brier": 0.86425,
|
||||
"shadow_bucket_brier": 0.750985,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.113265
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.095481,
|
||||
"shadow_bucket_brier": 0.604684,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.509203
|
||||
}
|
||||
},
|
||||
"by_date": {
|
||||
"2026-03-17": {
|
||||
"samples": 4,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.101543,
|
||||
"shadow_bucket_brier": 0.6636,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": -0.5,
|
||||
"delta_bucket_brier": 0.562057
|
||||
},
|
||||
"2026-03-18": {
|
||||
"samples": 25,
|
||||
"legacy_mean_mae": 2.5476,
|
||||
"shadow_mean_mae": 2.5476,
|
||||
"legacy_bucket_hit_rate": 0.52,
|
||||
"shadow_bucket_hit_rate": 0.56,
|
||||
"legacy_bucket_brier": 0.657516,
|
||||
"shadow_bucket_brier": 0.74574,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.04,
|
||||
"delta_bucket_brier": 0.088224
|
||||
},
|
||||
"2026-03-19": {
|
||||
"samples": 19,
|
||||
"legacy_mean_mae": 4.299474,
|
||||
"shadow_mean_mae": 4.299474,
|
||||
"legacy_bucket_hit_rate": 0.368421,
|
||||
"shadow_bucket_hit_rate": 0.421053,
|
||||
"legacy_bucket_brier": 0.848191,
|
||||
"shadow_bucket_brier": 0.935678,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.052632,
|
||||
"delta_bucket_brier": 0.087487
|
||||
}
|
||||
},
|
||||
"recent_observations": [
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.0,
|
||||
"actual_bucket": 18,
|
||||
"legacy_mu": 18.3,
|
||||
"shadow_mu": 18.3,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 7.0,
|
||||
"actual_bucket": 7,
|
||||
"legacy_mu": 12.03,
|
||||
"shadow_mu": 12.03,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": -2.0,
|
||||
"actual_bucket": -2,
|
||||
"legacy_mu": 5.67,
|
||||
"shadow_mu": 5.67,
|
||||
"legacy_top_bucket": 6,
|
||||
"shadow_top_bucket": 6,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 16.5,
|
||||
"shadow_mu": 16.5,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 21.3,
|
||||
"shadow_mu": 21.3,
|
||||
"legacy_top_bucket": 21,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 22.0,
|
||||
"actual_bucket": 22,
|
||||
"legacy_mu": 21.7,
|
||||
"shadow_mu": 21.7,
|
||||
"legacy_top_bucket": 22,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 32.3,
|
||||
"shadow_mu": 32.3,
|
||||
"legacy_top_bucket": 32,
|
||||
"shadow_top_bucket": 32,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 12.0,
|
||||
"actual_bucket": 12,
|
||||
"legacy_mu": 12.3,
|
||||
"shadow_mu": 12.3,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 10.0,
|
||||
"actual_bucket": 10,
|
||||
"legacy_mu": 10.3,
|
||||
"shadow_mu": 10.3,
|
||||
"legacy_top_bucket": 10,
|
||||
"shadow_top_bucket": 10,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 26.5,
|
||||
"shadow_mu": 26.5,
|
||||
"legacy_top_bucket": 26,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 16.06,
|
||||
"shadow_mu": 16.06,
|
||||
"legacy_top_bucket": 16,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"actual_bucket": 5,
|
||||
"legacy_mu": 11.62,
|
||||
"shadow_mu": 11.62,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"actual_bucket": 5,
|
||||
"legacy_mu": 16.58,
|
||||
"shadow_mu": 16.58,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 18.23,
|
||||
"shadow_mu": 18.23,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 30.0,
|
||||
"actual_bucket": 30,
|
||||
"legacy_mu": 35.3,
|
||||
"shadow_mu": 35.3,
|
||||
"legacy_top_bucket": 35,
|
||||
"shadow_top_bucket": 35,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 15.71,
|
||||
"shadow_mu": 15.71,
|
||||
"legacy_top_bucket": 16,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 27.3,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 27.6,
|
||||
"shadow_mu": 27.6,
|
||||
"legacy_top_bucket": 28,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 27.39,
|
||||
"shadow_mu": 27.39,
|
||||
"legacy_top_bucket": 27,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 11.0,
|
||||
"actual_bucket": 11,
|
||||
"legacy_mu": 11.0,
|
||||
"shadow_mu": 11.0,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 21.0,
|
||||
"shadow_mu": 21.0,
|
||||
"legacy_top_bucket": 21,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"actual_bucket": 13,
|
||||
"legacy_mu": 13.84,
|
||||
"shadow_mu": 13.84,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": -6.0,
|
||||
"actual_bucket": -6,
|
||||
"legacy_mu": -1.01,
|
||||
"shadow_mu": -1.01,
|
||||
"legacy_top_bucket": -1,
|
||||
"shadow_top_bucket": -1,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 17.0,
|
||||
"shadow_mu": 17.0,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 30.0,
|
||||
"actual_bucket": 30,
|
||||
"legacy_mu": 30.3,
|
||||
"shadow_mu": 30.3,
|
||||
"legacy_top_bucket": 30,
|
||||
"shadow_top_bucket": 30,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 29.0,
|
||||
"actual_bucket": 29,
|
||||
"legacy_mu": 29.0,
|
||||
"shadow_mu": 29.0,
|
||||
"legacy_top_bucket": 29,
|
||||
"shadow_top_bucket": 29,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 32.0,
|
||||
"shadow_mu": 32.0,
|
||||
"legacy_top_bucket": 32,
|
||||
"shadow_top_bucket": 32,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"actual_bucket": 13,
|
||||
"legacy_mu": 13.0,
|
||||
"shadow_mu": 13.0,
|
||||
"legacy_top_bucket": 13,
|
||||
"shadow_top_bucket": 13,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 8.0,
|
||||
"shadow_mu": 8.0,
|
||||
"legacy_top_bucket": 8,
|
||||
"shadow_top_bucket": 8,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 55.9,
|
||||
"actual_bucket": 56,
|
||||
"legacy_mu": 55.9,
|
||||
"shadow_mu": 55.9,
|
||||
"legacy_top_bucket": 56,
|
||||
"shadow_top_bucket": 56,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 22.0,
|
||||
"actual_bucket": 22,
|
||||
"legacy_mu": 29.64,
|
||||
"shadow_mu": 29.64,
|
||||
"legacy_top_bucket": 30,
|
||||
"shadow_top_bucket": 30,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 14.47,
|
||||
"shadow_mu": 14.47,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "new york",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 36.94,
|
||||
"shadow_mu": 36.94,
|
||||
"legacy_top_bucket": 37,
|
||||
"shadow_top_bucket": 37,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 10.0,
|
||||
"actual_bucket": 10,
|
||||
"legacy_mu": 10.66,
|
||||
"shadow_mu": 10.66,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 14.3,
|
||||
"shadow_mu": 14.3,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 59.0,
|
||||
"actual_bucket": 59,
|
||||
"legacy_mu": 70.04,
|
||||
"shadow_mu": 70.04,
|
||||
"legacy_top_bucket": 70,
|
||||
"shadow_top_bucket": 70,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 18.43,
|
||||
"shadow_mu": 18.43,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 34.0,
|
||||
"actual_bucket": 34,
|
||||
"legacy_mu": 35.11,
|
||||
"shadow_mu": 35.11,
|
||||
"legacy_top_bucket": 35,
|
||||
"shadow_top_bucket": 35,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 16.56,
|
||||
"shadow_mu": 16.56,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 27.8,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 27.8,
|
||||
"shadow_mu": 27.8,
|
||||
"legacy_top_bucket": 28,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "dallas",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 73.9,
|
||||
"actual_bucket": 74,
|
||||
"legacy_mu": 73.9,
|
||||
"shadow_mu": 73.9,
|
||||
"legacy_top_bucket": 74,
|
||||
"shadow_top_bucket": 74,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 46.0,
|
||||
"actual_bucket": 46,
|
||||
"legacy_mu": 46.0,
|
||||
"shadow_mu": 46.0,
|
||||
"legacy_top_bucket": 46,
|
||||
"shadow_top_bucket": 46,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 26.15,
|
||||
"shadow_mu": 26.15,
|
||||
"legacy_top_bucket": 26,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 37.0,
|
||||
"actual_bucket": 37,
|
||||
"legacy_mu": 54.06,
|
||||
"shadow_mu": 54.06,
|
||||
"legacy_top_bucket": 54,
|
||||
"shadow_top_bucket": 54,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 15.0,
|
||||
"actual_bucket": 15,
|
||||
"legacy_mu": 15.2,
|
||||
"shadow_mu": 15.2,
|
||||
"legacy_top_bucket": 15,
|
||||
"shadow_top_bucket": 15,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 11.0,
|
||||
"actual_bucket": 11,
|
||||
"legacy_mu": 11.3,
|
||||
"shadow_mu": 11.3,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 26.7,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 26.7,
|
||||
"shadow_mu": 26.7,
|
||||
"legacy_top_bucket": 27,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 17.3,
|
||||
"shadow_mu": 17.3,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 24.0,
|
||||
"actual_bucket": 24,
|
||||
"legacy_mu": 24.0,
|
||||
"shadow_mu": 24.0,
|
||||
"legacy_top_bucket": 24,
|
||||
"shadow_top_bucket": 23,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,981 +0,0 @@
|
||||
{
|
||||
"sample_count": 54,
|
||||
"snapshot_sample_count": 1,
|
||||
"daily_record_sample_count": 53,
|
||||
"filled_actual_from_history": 2,
|
||||
"samples": [
|
||||
{
|
||||
"city": "shenzhen",
|
||||
"date": "2026-03-25",
|
||||
"timestamp": "2026-03-25T08:57:11.783182+00:00",
|
||||
"actual_high": 28.0,
|
||||
"raw_mu": 26.7,
|
||||
"raw_sigma": 0.18016764322916676,
|
||||
"deb_prediction": 28.1,
|
||||
"ens_median": 31.4,
|
||||
"ensemble_spread": 0.5078125000000002,
|
||||
"max_so_far_gap": 1.4000000000000021,
|
||||
"peak_flag": 1.0,
|
||||
"sample_source": "snapshot",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 15.0,
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2000000000000002,
|
||||
"deb_prediction": 15.4,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 1.2000000000000002,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 11.0,
|
||||
"raw_mu": 11.0,
|
||||
"raw_sigma": 2.05,
|
||||
"deb_prediction": 9.8,
|
||||
"ens_median": 10.1,
|
||||
"ensemble_spread": 2.05,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-29",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.0,
|
||||
"raw_sigma": 0.9000000000000004,
|
||||
"deb_prediction": 9.7,
|
||||
"ens_median": 10.0,
|
||||
"ensemble_spread": 0.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 37.0,
|
||||
"raw_mu": 54.06,
|
||||
"raw_sigma": 4.0,
|
||||
"deb_prediction": 52.5,
|
||||
"ens_median": 52.2,
|
||||
"ensemble_spread": 4.0,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.6,
|
||||
"raw_mu": 65.57,
|
||||
"raw_sigma": 1.5500000000000007,
|
||||
"deb_prediction": 65.7,
|
||||
"ens_median": 65.9,
|
||||
"ensemble_spread": 1.5500000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 26.15,
|
||||
"raw_sigma": 2.0,
|
||||
"deb_prediction": 26.4,
|
||||
"ens_median": 26.0,
|
||||
"ensemble_spread": 2.0,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 27.39,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 26.8,
|
||||
"ens_median": 27.9,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 46.0,
|
||||
"raw_mu": 46.0,
|
||||
"raw_sigma": 5.350000000000001,
|
||||
"deb_prediction": 42.7,
|
||||
"ens_median": 44.2,
|
||||
"ensemble_spread": 5.350000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "dallas",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 73.9,
|
||||
"raw_mu": 73.9,
|
||||
"raw_sigma": 9.299999999999997,
|
||||
"deb_prediction": 75.2,
|
||||
"ens_median": 74.4,
|
||||
"ensemble_spread": 9.299999999999997,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 24.0,
|
||||
"raw_mu": 24.0,
|
||||
"raw_sigma": 1.9000000000000004,
|
||||
"deb_prediction": 23.1,
|
||||
"ens_median": 23.1,
|
||||
"ensemble_spread": 1.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 27.8,
|
||||
"raw_mu": 27.8,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 24.5,
|
||||
"ens_median": 24.9,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 27.3,
|
||||
"raw_mu": 27.6,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 24.9,
|
||||
"ens_median": 25.0,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-23",
|
||||
"actual_high": 27.4,
|
||||
"raw_mu": 27.4,
|
||||
"raw_sigma": 1.6999999999999993,
|
||||
"deb_prediction": 25.2,
|
||||
"ens_median": 25.1,
|
||||
"ensemble_spread": 1.6999999999999993,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 16.56,
|
||||
"raw_sigma": 1.299999999999999,
|
||||
"deb_prediction": 16.5,
|
||||
"ens_median": 16.8,
|
||||
"ensemble_spread": 1.299999999999999,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 15.71,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 15.9,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 34.0,
|
||||
"raw_mu": 35.11,
|
||||
"raw_sigma": 1.3000000000000007,
|
||||
"deb_prediction": 34.3,
|
||||
"ens_median": 34.6,
|
||||
"ensemble_spread": 1.3000000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 30.0,
|
||||
"raw_mu": 35.3,
|
||||
"raw_sigma": 1.75,
|
||||
"deb_prediction": 34.5,
|
||||
"ens_median": 34.7,
|
||||
"ensemble_spread": 1.75,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 18.43,
|
||||
"raw_sigma": 1.8499999999999996,
|
||||
"deb_prediction": 18.3,
|
||||
"ens_median": 18.7,
|
||||
"ensemble_spread": 1.8499999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 18.23,
|
||||
"raw_sigma": 1.8999999999999995,
|
||||
"deb_prediction": 18.4,
|
||||
"ens_median": 18.8,
|
||||
"ensemble_spread": 1.8999999999999995,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 59.0,
|
||||
"raw_mu": 70.04,
|
||||
"raw_sigma": 2.8999999999999986,
|
||||
"deb_prediction": 69.1,
|
||||
"ens_median": 70.4,
|
||||
"ensemble_spread": 2.8999999999999986,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 24.6,
|
||||
"raw_mu": 73.91,
|
||||
"raw_sigma": 2.5500000000000043,
|
||||
"deb_prediction": 74.2,
|
||||
"ens_median": 74.0,
|
||||
"ensemble_spread": 2.5500000000000043,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 17.3,
|
||||
"raw_sigma": 9.1,
|
||||
"deb_prediction": 12.5,
|
||||
"ens_median": 15.1,
|
||||
"ensemble_spread": 9.1,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 14.3,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 13.7,
|
||||
"ens_median": 13.8,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"raw_mu": 16.58,
|
||||
"raw_sigma": 1.25,
|
||||
"deb_prediction": 16.9,
|
||||
"ens_median": 17.0,
|
||||
"ensemble_spread": 1.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.66,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 10.6,
|
||||
"ens_median": 10.6,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"raw_mu": 11.62,
|
||||
"raw_sigma": 1.3000000000000007,
|
||||
"deb_prediction": 11.5,
|
||||
"ens_median": 11.8,
|
||||
"ensemble_spread": 1.3000000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "new york",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 36.94,
|
||||
"raw_sigma": 2.25,
|
||||
"deb_prediction": 36.8,
|
||||
"ens_median": 37.0,
|
||||
"ensemble_spread": 2.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 14.47,
|
||||
"raw_sigma": 0.9000000000000004,
|
||||
"deb_prediction": 14.2,
|
||||
"ens_median": 14.8,
|
||||
"ensemble_spread": 0.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 16.06,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 16.2,
|
||||
"ens_median": 16.3,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 22.0,
|
||||
"raw_mu": 29.64,
|
||||
"raw_sigma": 2.450000000000001,
|
||||
"deb_prediction": 30.0,
|
||||
"ens_median": 29.4,
|
||||
"ensemble_spread": 2.450000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 26.5,
|
||||
"raw_sigma": 1.200000000000001,
|
||||
"deb_prediction": 25.5,
|
||||
"ens_median": 26.5,
|
||||
"ensemble_spread": 1.200000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 55.9,
|
||||
"raw_mu": 55.9,
|
||||
"raw_sigma": 1.3500000000000014,
|
||||
"deb_prediction": 55.8,
|
||||
"ens_median": 55.9,
|
||||
"ensemble_spread": 1.3500000000000014,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 8.0,
|
||||
"raw_sigma": 0.7999999999999998,
|
||||
"deb_prediction": 7.4,
|
||||
"ens_median": 7.4,
|
||||
"ensemble_spread": 0.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.3,
|
||||
"raw_sigma": 1.7999999999999998,
|
||||
"deb_prediction": 7.6,
|
||||
"ens_median": 6.3,
|
||||
"ensemble_spread": 1.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"raw_mu": 13.0,
|
||||
"raw_sigma": 1.1500000000000004,
|
||||
"deb_prediction": 13.2,
|
||||
"ens_median": 13.0,
|
||||
"ensemble_spread": 1.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 12.0,
|
||||
"raw_mu": 12.3,
|
||||
"raw_sigma": 0.7999999999999998,
|
||||
"deb_prediction": 10.7,
|
||||
"ens_median": 11.2,
|
||||
"ensemble_spread": 0.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-29",
|
||||
"actual_high": 18.0,
|
||||
"raw_mu": 18.0,
|
||||
"raw_sigma": 1.6999999999999993,
|
||||
"deb_prediction": 18.4,
|
||||
"ens_median": 17.6,
|
||||
"ensemble_spread": 1.6999999999999993,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 32.0,
|
||||
"raw_sigma": 0.9500000000000011,
|
||||
"deb_prediction": 30.2,
|
||||
"ens_median": 30.1,
|
||||
"ensemble_spread": 0.9500000000000011,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 32.3,
|
||||
"raw_sigma": 1.3499999999999996,
|
||||
"deb_prediction": 31.5,
|
||||
"ens_median": 32.1,
|
||||
"ensemble_spread": 1.3499999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 26.7,
|
||||
"raw_mu": 26.7,
|
||||
"raw_sigma": 2.25,
|
||||
"deb_prediction": 24.9,
|
||||
"ens_median": 25.4,
|
||||
"ensemble_spread": 2.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 29.0,
|
||||
"raw_mu": 29.0,
|
||||
"raw_sigma": 1.0500000000000007,
|
||||
"deb_prediction": 27.2,
|
||||
"ens_median": 27.5,
|
||||
"ensemble_spread": 1.0500000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 22.0,
|
||||
"raw_mu": 21.7,
|
||||
"raw_sigma": 1.1500000000000004,
|
||||
"deb_prediction": 21.5,
|
||||
"ens_median": 21.3,
|
||||
"ensemble_spread": 1.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 30.0,
|
||||
"raw_mu": 30.3,
|
||||
"raw_sigma": 2.1500000000000004,
|
||||
"deb_prediction": 29.0,
|
||||
"ens_median": 28.9,
|
||||
"ensemble_spread": 2.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 21.3,
|
||||
"raw_sigma": 1.5,
|
||||
"deb_prediction": 20.7,
|
||||
"ens_median": 21.1,
|
||||
"ensemble_spread": 1.5,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 17.0,
|
||||
"raw_sigma": 1.5499999999999998,
|
||||
"deb_prediction": 15.4,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 1.5499999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 16.5,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 17.8,
|
||||
"ens_median": 18.5,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": -6.0,
|
||||
"raw_mu": -1.01,
|
||||
"raw_sigma": 0.8,
|
||||
"deb_prediction": -0.6,
|
||||
"ens_median": -1.1,
|
||||
"ensemble_spread": 0.8,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": -2.0,
|
||||
"raw_mu": 5.67,
|
||||
"raw_sigma": 2.1500000000000004,
|
||||
"deb_prediction": 6.4,
|
||||
"ens_median": 6.3,
|
||||
"ensemble_spread": 2.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 11.0,
|
||||
"raw_mu": 11.3,
|
||||
"raw_sigma": 0.6499999999999995,
|
||||
"deb_prediction": 10.4,
|
||||
"ens_median": 10.5,
|
||||
"ensemble_spread": 0.6499999999999995,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"raw_mu": 13.84,
|
||||
"raw_sigma": 1.4000000000000004,
|
||||
"deb_prediction": 13.6,
|
||||
"ens_median": 14.2,
|
||||
"ensemble_spread": 1.4000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 7.0,
|
||||
"raw_mu": 12.03,
|
||||
"raw_sigma": 1.5999999999999996,
|
||||
"deb_prediction": 11.8,
|
||||
"ens_median": 12.3,
|
||||
"ensemble_spread": 1.5999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 21.0,
|
||||
"raw_sigma": 0.9500000000000011,
|
||||
"deb_prediction": 19.2,
|
||||
"ens_median": 19.1,
|
||||
"ensemble_spread": 0.9500000000000011,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.0,
|
||||
"raw_mu": 18.3,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 17.9,
|
||||
"ens_median": 17.1,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
$VPS = "root@38.54.27.70"
|
||||
$PROJECT = "/root/PolyWeather"
|
||||
|
||||
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
|
||||
|
||||
ssh $VPS "cd $PROJECT && git pull && docker compose up -d --build"
|
||||
|
||||
Write-Host "✅ Deploy complete. Checking health..." -ForegroundColor Green
|
||||
Start-Sleep 8
|
||||
ssh $VPS "curl -s http://localhost:8000/healthz"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
COMPOSE_DIR="/root/PolyWeather"
|
||||
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
|
||||
PREVIOUS_TAG=""
|
||||
if [ -f "$TAG_FILE" ]; then
|
||||
PREVIOUS_TAG=$(cat "$TAG_FILE")
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
fi
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Wait for backend to be ready (retry up to 150s)
|
||||
echo "Waiting for backend..."
|
||||
for i in $(seq 1 30); do
|
||||
sleep 5
|
||||
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
|
||||
echo "✅ healthz ready after ${i}x5s"
|
||||
break
|
||||
fi
|
||||
echo " retry $i/30..."
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
export IMAGE_TAG="$PREVIOUS_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
echo "✅ Rolled back to $PREVIOUS_TAG"
|
||||
else
|
||||
echo "⚠️ No previous tag to rollback to"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$TAG_FILE")"
|
||||
echo "$NEW_TAG" > "$TAG_FILE"
|
||||
docker image prune -af
|
||||
echo "✅ Deployed $NEW_TAG"
|
||||
@@ -0,0 +1,53 @@
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name polyweather.top www.polyweather.top;
|
||||
|
||||
# Supabase auth can set multiple chunked session cookies during OAuth
|
||||
# callback. Nginx defaults are too small and can raise:
|
||||
# "upstream sent too big header while reading response header from upstream".
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.polyweather.top;
|
||||
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
location /api/events {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,92 @@
|
||||
x-polyweather-base: &polyweather-base
|
||||
build: .
|
||||
image: polyweather-app:latest
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
services:
|
||||
polyweather:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_bot
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Persist runtime data outside git workspace.
|
||||
# Host path defaults to /var/lib/polyweather and can be overridden in .env.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
# Keep /app/data compatibility for existing cache/state defaults.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log # 挂载日志文件
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
# Windows / macOS can usually keep the fallback values.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_web:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_web
|
||||
restart: unless-stopped
|
||||
command: python web/app.py
|
||||
volumes:
|
||||
# Web service shares the same runtime data directory as bot/state tasks.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_prewarm:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_prewarm
|
||||
restart: unless-stopped
|
||||
profiles: ["workers"]
|
||||
command: python scripts/prewarm_dashboard_worker.py --include-detail --include-market
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_prometheus:
|
||||
image: prom/prometheus:v3.4.1
|
||||
container_name: polyweather_prometheus
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_web
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/prometheus:/prometheus
|
||||
ports:
|
||||
- "${POLYWEATHER_PROMETHEUS_PORT:-9090}:9090"
|
||||
|
||||
polyweather_alertmanager:
|
||||
image: prom/alertmanager:v0.28.1
|
||||
container_name: polyweather_alertmanager
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_alert_relay
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
volumes:
|
||||
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/alertmanager:/alertmanager
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERTMANAGER_PORT:-9093}:9093"
|
||||
|
||||
polyweather_alert_relay:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_alert_relay
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
command: python scripts/alertmanager_telegram_relay.py
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERT_RELAY_PORT:-9099}:9099"
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_grafana:
|
||||
image: grafana/grafana-oss:12.0.2
|
||||
container_name: polyweather_grafana
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_prometheus
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_BOT_CPUS:-0.75}
|
||||
env_file: &id001
|
||||
- .env
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${POLYWEATHER_GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${POLYWEATHER_GRAFANA_ADMIN_PASSWORD:-polyweather}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_BOT_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_BOT_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/grafana:/var/lib/grafana
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log
|
||||
polyweather_frontend:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
container_name: polyweather_frontend
|
||||
environment:
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- -qO-
|
||||
- http://localhost:3000
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- "${POLYWEATHER_GRAFANA_PORT:-3001}:3000"
|
||||
- 3001:3000
|
||||
restart: unless-stopped
|
||||
polyweather_web:
|
||||
command: python web/app.py
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
container_name: polyweather_web
|
||||
env_file: *id001
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- from urllib.request import urlopen; urlopen('http://localhost:8000/healthz')
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 8000:8000
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 机场高频数据接入市场监控频道方案
|
||||
|
||||
## 背景
|
||||
|
||||
### 现有数据
|
||||
|
||||
| 城市 | 站点 | ICAO/站点 | 数据类型 | 数据源 | 刷新频率 |
|
||||
|------|------|-----------|---------|--------|---------|
|
||||
| 首尔 | 仁川国际 | RKSI | 跑道对温度(2 对) | AMOS | 1 分钟 |
|
||||
| 釜山 | 金海国际 | RKPK | 跑道对温度(1 对) | AMOS | 1 分钟 |
|
||||
| 东京 | 羽田 | RJTT | 机场站点实时温度 | JMA AMeDAS | 10 分钟 |
|
||||
| 安卡拉 | Esenboğa | 17128 | 机场站点实时温度 | MGM | 不定,约 5-15 分钟 |
|
||||
|
||||
### 现有 Telegram 推送系统
|
||||
|
||||
- **循环**: `start_trade_alert_push_loop`,默认每 30 分钟跑一轮
|
||||
- **覆盖城市**: `TELEGRAM_ALERT_CITIES`(默认全部 51 城)
|
||||
- **3 条规则**: Ankara Center DEB 命中、预报突破、暖平流
|
||||
- **门禁**: 严重度/触发数/冷却期 多层过滤
|
||||
- **消息**: 中英双语,包含触发类型、实况温度
|
||||
|
||||
### 问题
|
||||
|
||||
四座机场城市的实时数据已就绪,但现有推送系统 30 分钟一轮对所有城市一视同仁。1-10 分钟级高频数据在接近交易高峰期时,温度变化可能比 30 分钟窗口更快,需要更灵敏的监控。
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 核心思路
|
||||
|
||||
在现有 30 分钟主循环之上叠加高频通道,对四座机场城市用 10 分钟间隔独立检测温度急变。温度波动达到阈值时推送告警,包含当前温度 + DEB 预测最高温。不做市场分析、不输出 AI 建议、不约定时快照。
|
||||
|
||||
### 1. 高频机场城市快速通道
|
||||
|
||||
在现有 30 分钟主循环之外,为 `{seoul, busan, tokyo, ankara}` 单独跑一个 10 分钟间隔的子循环,每个城市独立检测温度急变。
|
||||
|
||||
**配置(写死在代码中)**:
|
||||
```python
|
||||
HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara"}
|
||||
HIGH_FREQ_PUSH_INTERVAL_SEC = 600 # 10 分钟
|
||||
HIGH_FREQ_MOMENTUM_THRESHOLD_C = 0.5 # 比默认 0.8°C 更灵敏
|
||||
HIGH_FREQ_COOLDOWN_SEC = 7200 # 同一城市冷却 2 小时
|
||||
```
|
||||
|
||||
**逻辑**:
|
||||
- 主循环 30 分钟照常跑全部城市(不变)
|
||||
- 每 10 分钟对四座机场城市各检查一次温度急变
|
||||
- 高频轮次仅检查 `airport_rapid_temp_change` 一条规则
|
||||
- 各城市独立冷却,触发后 2 小时内同一城市不再重复推送
|
||||
- **最高温已锁定则跳过**:当日最高已过且持续下降,不再推送
|
||||
|
||||
### 2. 机场观测积累与趋势检测
|
||||
|
||||
**新增数据库表**: `airport_obs_log`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS airport_obs_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
icao TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
temp_c REAL,
|
||||
wind_kt REAL,
|
||||
pressure_hpa REAL,
|
||||
obs_time TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_airport_obs_log_icao_time
|
||||
ON airport_obs_log(icao, created_at DESC);
|
||||
```
|
||||
|
||||
**写入**: 在 AMOS/JMA/MGM 成功获取数据后自动调用 `append_airport_obs()` 写入。自动清理 2 小时前的旧数据。
|
||||
|
||||
**读取**: `get_airport_obs_recent(icao, minutes=30)` 返回最近 N 分钟观测列表,用于计算温度变化斜率。
|
||||
|
||||
### 3. 温度突变即时告警
|
||||
|
||||
基于积累的观测日志,新增告警规则 `airport_rapid_temp_change`。每条告警 per-city 独立推送。
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 滑动窗口 | 20 分钟 | 取最近 20 分钟内的观测 |
|
||||
| 最少样本 | 3 条 | 确保有足够数据点 |
|
||||
| 触发阈值 | > 0.5°C/10min | 比默认 0.8°C/30min 更灵敏 |
|
||||
| 冷却期 | 2 小时 | 同城市两次推送最小间隔 |
|
||||
| 锁定跳过 | 最高温已锁定 | 当日最高已过且持续下降,不推送 |
|
||||
|
||||
**告警消息示例**:
|
||||
|
||||
首尔/釜山(跑道对温度):
|
||||
```
|
||||
🚨 首尔/仁川 温度急变
|
||||
|
||||
15L/33R 14.6°C
|
||||
15R/33L 15.2°C
|
||||
DEB 预测最高 18.2°C
|
||||
```
|
||||
|
||||
东京/安卡拉(站点实时温度):
|
||||
```
|
||||
🚨 东京/羽田 温度急变
|
||||
|
||||
当前 24.1°C
|
||||
DEB 预测最高 26.5°C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 改动文件清单
|
||||
|
||||
| 优先级 | 文件 | 改动 |
|
||||
|--------|------|------|
|
||||
| 1 | `src/database/db_manager.py` | 新增 `airport_obs_log` 表、`append_airport_obs()`、`get_airport_obs_recent()` |
|
||||
| 2 | `src/data_collection/weather_sources.py` | AMOS/JMA/MGM 成功后调用 `append_airport_obs()` 写日志 |
|
||||
| 3 | `src/analysis/market_alert_engine.py` | 新增 `airport_rapid_temp_change` 规则 |
|
||||
| 4 | `src/utils/telegram_push.py` | 10 分钟高频子循环、温度急变告警推送、最高温锁定跳过 |
|
||||
| 5 | `src/bot/runtime_coordinator.py` | 注册机场高频推送循环 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. **Phase 1 — DB 层**: `airport_obs_log` 表 + 读写方法
|
||||
2. **Phase 2 — 采集层**: AMOS/JMA/MGM 成功后自动写日志,部署观察 1-2 天确认数据积累正常
|
||||
3. **Phase 3 — 告警引擎**: `airport_rapid_temp_change` 规则 + 单元测试
|
||||
4. **Phase 4 — 推送层**: 高频快速通道,直接推送市场监控频道
|
||||
5. **Phase 5 — 调参**: 观察 3-7 天调整阈值
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
- **AMOS/JMA/MGM 站点可用性**: 各数据源可能偶发性不可用,需容错处理
|
||||
- **告警频率控制**: 高频循环可能产生过多告警,需要严格的冷却期和去重机制
|
||||
- **数据库体积**: `airport_obs_log` 每 1-10 分钟写入 4 条记录,2 小时约 48-480 条,自动清理后体积可控
|
||||
- **安卡拉 MGM 刷新频率**: `servis.mgm.gov.tr` 实测更新间隔 5-15 分钟不等,非固定周期
|
||||
@@ -0,0 +1,94 @@
|
||||
# 机场高频实时数据源
|
||||
|
||||
## 已接入城市
|
||||
|
||||
| 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 |
|
||||
|------|------|-----------|--------|------|------|------|
|
||||
| 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 |
|
||||
| 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 |
|
||||
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 赫尔辛基 | Vantaa | EFHK | FMI (`opendata.fmi.fi`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 阿姆斯特丹 | Schiphol | EHAM | KNMI (`dataplatform.knmi.nl`) | 10 分钟 | 机场站点实时温度 | 免费(需注册) |
|
||||
| 巴黎 | Le Bourget | LFPB | AROME HD (`api.open-meteo.com`) | 15 分钟 | 模型预报(非实测) | 免费 |
|
||||
| 新加坡 | Changi | WSSS | Singapore MSS (`api.data.gov.sg`) | 1 分钟 | 机场站点实时温度 (S24 站) | 免费 |
|
||||
| 纽约 | LaGuardia | KLGA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 洛杉矶 | LAX | KLAX | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 芝加哥 | O'Hare | KORD | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 丹佛 | Buckley | KBKF | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 亚特兰大 | Hartsfield | KATL | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 迈阿密 | MIA | KMIA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 旧金山 | SFO | KSFO | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 休斯顿 | Hobby | KHOU | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 达拉斯 | Love Field | KDAL | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 奥斯汀 | Bergstrom | KAUS | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 西雅图 | SeaTac | KSEA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
|
||||
> **Singapore MSS**: 新加坡气象局(MSS)通过 data.gov.sg 开放数据平台提供全国 15 个站点
|
||||
> 的干球温度(1 分钟均值),更新频率 ~1 分钟。选取 S24 Upper Changi Road North 站
|
||||
> 作为樟宜机场 (WSSS) 的实时温度锚点。数据公开免费,无需 API 密钥。
|
||||
> 后端通过 `singapore_mss_sources.py` 拉取并注入 `airport_primary`。
|
||||
|
||||
> **NOAA MADIS HFMETAR**: 美国 11 个城市的机场高频实时数据通过 NOAA MADIS 公共档案获取。
|
||||
> 数据源为 NetCDF 格式(`madis-data.ncep.noaa.gov/madisPublic1/data/LDAD/hfmetar/`),
|
||||
> 每 5 分钟全量更新一次,温度保留一位小数。匿名公开访问,无需 API 密钥。
|
||||
> 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过
|
||||
> `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。
|
||||
|
||||
## 推送机制
|
||||
|
||||
- 每城按原生频率独立推送,不捆绑
|
||||
- 首尔/釜山 60s,其余 600s
|
||||
- 循环轮询 60s 以匹配最快频率
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
## 前端实时同步与 SSE Patch 机制
|
||||
|
||||
为了向用户提供秒级实况响应并降低服务器负载,系统已从定时轮询架构全面迁移至 **Server-Sent Events (SSE) 增量更新(SSE Patch)** 架构。
|
||||
|
||||
### 1. 数据推送链路 (Data Pipeline)
|
||||
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 接收到 Patch 后,将其推入 `sse_manager` 进行全局广播,事件被包装为 `city_patch` 增量包,包含自增的全局 `revision` 和最新的 `changes`。
|
||||
3. **BFF 代理流**:浏览器前端通过 BFF (Next.js rewrites) 建立与 `/api/events` 的持久连接,从而无需定时轮询。
|
||||
|
||||
### 2. 前端消费与刷新规则 (Frontend Freshness Rules)
|
||||
- **扫描列表免轮询更新**:`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。
|
||||
- **详情图表增量合并**:`LiveTemperatureThresholdChart.tsx` 使用 `useLatestPatch(city)` 钩子订阅当前选中城市的增量 Patch。当收到 Patch 时,前端会将最新温度与时间戳以增量形式直接合并(Merge)入本地的 `hourly` 状态中,避免重新加载完整的 City Detail JSON。
|
||||
- **双重降级兜底 (Safe Fallback Guard)**:
|
||||
- **无 Patch 轮询兜底**:为了防止 SSE 连接断开或长时间无 patch 导致界面卡死,所有**可见图表**(即 active 槽位、compact 栅格槽位或 maximized 视图)会启动一个 60 秒的检测定时器。
|
||||
- **触发条件**:若当前可见城市在连续 **2 分钟** 内没有收到任何 SSE patch,前端将自动发起主动请求:
|
||||
1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。
|
||||
2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。
|
||||
- **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。
|
||||
|
||||
## 消息模板
|
||||
|
||||
```
|
||||
Seoul / Incheon 16:03
|
||||
|
||||
15L/33R 14.6°C
|
||||
15R/33L 15.2°C
|
||||
今日DEB预报最高:18.2°C
|
||||
今日实测最高:16.5°C(15:30)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `TELEGRAM_PUSH_LANGUAGE` | Telegram 自动推送的全局语言,可选 `both`/`en`/`zh` | `both` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_ENABLED` | 启用机场推送 | `true` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
|
||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||
|
||||
## 未接入城市
|
||||
|
||||
| 城市 | 原因 |
|
||||
|------|------|
|
||||
| 马德里/Barajas | AEMET 注册页面失效 |
|
||||
| 伦敦/Heathrow | Met Office 仅 1 小时更新 |
|
||||
| 慕尼黑 | DWD 延迟 ~1 小时 |
|
||||
| 米兰/华沙/莫斯科 | 无已知实时源 |
|
||||
@@ -1,4 +1,4 @@
|
||||
# PolyWeather API 文档(v1.5.4)
|
||||
# PolyWeather API 文档(v1.8.0)
|
||||
|
||||
最后更新:`2026-04-27`
|
||||
|
||||
@@ -126,18 +126,14 @@ SSE 事件:
|
||||
|
||||
#### 2. `probabilities`
|
||||
|
||||
概率层现在按“校准模型概率”对外解释,而不是直接把模型票数或市场价格当成概率。
|
||||
概率层基于 legacy 高斯分桶,以 DEB 融合预测 μ 和 ensemble spread σ 生成 1°C 粒度概率分布。
|
||||
|
||||
新增 / 重点字段:
|
||||
概率字段:
|
||||
|
||||
- `engine`:概率引擎名称,例如 `lgbm_calibrated`、`emos`、`legacy`
|
||||
- `calibration_mode`:校准运行模式
|
||||
- `calibration_version`:校准产物版本
|
||||
- `raw_mu` / `raw_sigma`:原始分布参数
|
||||
- `calibrated_mu` / `calibrated_sigma`:校准后分布参数
|
||||
- `shadow_distribution`:shadow / 对照分布,供回归与灰度验证
|
||||
|
||||
当前前端展示 `probabilities.engine` 对应的生产概率分布;`EMOS` / `LGBM` 只有在评估通过、显式启用或 shadow 对照时才进入展示/解释层。模型共识与市场价格只作为辅助参考,不再作为主结论。
|
||||
- `engine`:固定为 `legacy`
|
||||
- `mu`:DEB 融合预测中心值
|
||||
- `distribution`:当天合约桶概率分布
|
||||
- `distribution_all`:包含外围桶的完整分布
|
||||
|
||||
#### 3. `detail_depth`
|
||||
|
||||
@@ -206,7 +202,7 @@ SSE 事件:
|
||||
|
||||
- 多数机场市场以 `METAR` / 机场主站实况为结算锚点。
|
||||
- `Wunderground` 是历史页面或参考入口,不应在产品文案里被描述成“站”。
|
||||
- `MGM / NMC / JMA / KMA / HKO / CWA` 等官方站网属于增强层或明确官方站点层;只有合约规则明确指定时,才作为最终结算站点。
|
||||
- `MGM / NMC / JMA / AMOS / HKO / CWA` 等官方站网属于增强层或明确官方站点层;只有合约规则明确指定时,才作为最终结算站点。
|
||||
|
||||
## 4. 鉴权与账户接口
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# 城市实时数据源总览
|
||||
|
||||
> 最后更新: 2026-05-26 | 51 城市
|
||||
|
||||
## 数据源分级
|
||||
|
||||
### Tier 1 — ≤1 分钟高频
|
||||
|
||||
| 城市 | 来源 | 频率 | 备注 |
|
||||
|------|------|------|------|
|
||||
| seoul | AMOS 跑道传感器 (RKSI) | ~1 min | global.amo.go.kr, 站号 113 |
|
||||
| busan | AMOS 跑道传感器 (RKPK) | ~1 min | global.amo.go.kr, 站号 153 |
|
||||
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
|
||||
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
|
||||
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
|
||||
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
| 城市 | 来源 | 频率 | 备注 |
|
||||
|------|------|------|------|
|
||||
| new york | MADIS HFMETAR (KLGA) | 5 min | madis-data.ncep.noaa.gov |
|
||||
| los angeles | MADIS HFMETAR (KLAX) | 5 min | |
|
||||
| san francisco | MADIS HFMETAR (KSFO) | 5 min | |
|
||||
| denver | MADIS HFMETAR (KBKF) | 5 min | |
|
||||
| austin | MADIS HFMETAR (KAUS) | 5 min | |
|
||||
| houston | MADIS HFMETAR (KHOU) | 5 min | |
|
||||
| chicago | MADIS HFMETAR (KORD) | 5 min | |
|
||||
| dallas | MADIS HFMETAR (KDAL) | 5 min | |
|
||||
| miami | MADIS HFMETAR (KMIA) | 5 min | |
|
||||
| atlanta | MADIS HFMETAR (KATL) | 5 min | |
|
||||
| seattle | MADIS HFMETAR (KSEA) | 5 min | |
|
||||
|
||||
### Tier 3 — 准实时国家级站网
|
||||
|
||||
| 城市 | 来源 | 频率 | 国家/地区 |
|
||||
|------|------|------|------|
|
||||
| tokyo | JMA AMeDAS (44166) | 10 min | 日本 |
|
||||
| ankara | MGM (17128) | 5-15 min | 土耳其 |
|
||||
| istanbul | MGM (17058) | 5-15 min | 土耳其 |
|
||||
| helsinki | FMI 开放数据 | 10 min | 芬兰 |
|
||||
| amsterdam | KNMI 数据平台 | 10 min | 荷兰 |
|
||||
| shenzhen | HKO 官方 CSV (LFS) | ~10 min | 香港天文台流浮山自动站 |
|
||||
| taipei | CWA 开放数据 (466920) | ~10 min | 台湾 |
|
||||
| tel aviv | IMS Lod (225) | 实时 | 以色列 |
|
||||
| paris | AEROWEB 实况 / AROME HD | 实时/15min | 法国 (AROME是15分钟临近预报) |
|
||||
|
||||
### Tier 4 — 仅 METAR(10 分钟缓存)
|
||||
|
||||
| 城市 | ICAO | 备注 |
|
||||
|------|------|------|
|
||||
| london | EGLC | Met Office 仅 1 小时更新 |
|
||||
| jeddah | OEJN | NCM 数据源目前不可用 |
|
||||
| moscow | UUWW | 仅 UUWW METAR 单站 |
|
||||
| shenzhen | ZGSZ | 已接入 HKO 流浮山 10 分钟数据,见 Tier 3 |
|
||||
| munich | EDDM | DWD 延迟约 1 小时 |
|
||||
| milan | LIMC | 无已知实时源 |
|
||||
| warsaw | EPWA | 含 IMGW 附近站 |
|
||||
| madrid | LEMD | AEMET 注册已失效 |
|
||||
| toronto | CYYZ | |
|
||||
| mexico city | MMMX | |
|
||||
| buenos aires | SAEZ | |
|
||||
| sao paulo | SBGR | |
|
||||
| panama city | MPMG | |
|
||||
| kuala lumpur | WMKK | |
|
||||
| jakarta | WIHH | |
|
||||
| manila | RPLL | |
|
||||
| karachi | OPKC | |
|
||||
| lucknow | VILK | |
|
||||
| wellington | NZWN | |
|
||||
| cape town | FACT | |
|
||||
|
||||
## 高频推送覆盖
|
||||
|
||||
31 个城市在 `HIGH_FREQ_AIRPORT_CITIES`(Telegram 推送循环):
|
||||
所有 Tier 1-3 城市 + shenzhen
|
||||
|
||||
19 个城市在 `HIGH_FREQ_AIRPORT_ANALYSIS_CITIES`(日内分析):
|
||||
seoul, busan, hong kong, lau fau shan, singapore, beijing, shanghai,
|
||||
guangzhou, chengdu, chongqing, wuhan, qingdao, shenzhen, tokyo,
|
||||
ankara, istanbul, helsinki, amsterdam, paris
|
||||
|
||||
## 温度观测优先级链
|
||||
|
||||
`country_networks.py:_airport_primary_from_raw()` 按以下顺序解析:
|
||||
|
||||
1. MADIS HFMETAR(美国 11 城)
|
||||
2. AMOS 跑道传感器(首尔/釜山)
|
||||
3. MGM current(安卡拉/伊斯坦布尔)
|
||||
4. JMA AMeDAS current(东京)
|
||||
5. FMI current(赫尔辛基)
|
||||
6. KNMI current(阿姆斯特丹)
|
||||
7. CoWIN 6087(香港 1min 参考站)
|
||||
8. AEROWEB current(巴黎)
|
||||
9. IMS current(特拉维夫)
|
||||
10. NCM current(吉达)
|
||||
11. Singapore MSS current(新加坡)
|
||||
12. 纯 METAR(默认兜底)
|
||||
|
||||
## 对日内偏差修正的影响
|
||||
|
||||
- **Tier 1 城市**(1 分钟级):修正权重可以更激进,数据噪声低
|
||||
- **Tier 2 城市**(5 分钟级):修正效果良好,MADIS 更新稳定
|
||||
- **Tier 3 城市**(10-15 分钟级):修正可用但滞后较大
|
||||
- **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖
|
||||
|
||||
|
||||
## 关于网站终端图表的数据曲线展示逻辑
|
||||
|
||||
### 1. 实测数据(默认全开,突出核心)
|
||||
|
||||
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、首尔等城市的跑道实测数据,默认全量开启,无需手动勾选。
|
||||
- **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下:
|
||||
- 北京:19/01
|
||||
- 上海:17L/35R
|
||||
- 广州:02L/20R
|
||||
- 成都:02L/20R
|
||||
- 重庆:20R/02L
|
||||
- 武汉:04/22
|
||||
- 首尔:15R/33L
|
||||
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
|
||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。
|
||||
|
||||
### 2. 核心预测数据(默认展示)
|
||||
|
||||
- **DEB 模型融合**:作为平台核心的高精度智能融合预测曲线,默认始终展示给用户。
|
||||
|
||||
### 3. 多模型原始数据(默认隐藏,按需自选)
|
||||
|
||||
- **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。
|
||||
- **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。
|
||||
- **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。
|
||||
@@ -8,7 +8,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
|
||||
核心价值:
|
||||
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、KMA 等作为增强层)
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、AMOS 等作为增强层)
|
||||
- 结算导向(DEB + 校准概率桶)
|
||||
- 气象判断优先(证据链、失效条件、下一观测点)
|
||||
- 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺
|
||||
@@ -18,7 +18,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
@@ -32,13 +32,13 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
- Pro 用户:
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 专业气象结论条、证据链、失效条件、确认条件
|
||||
- LGBM / EMOS 等校准概率层
|
||||
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(5 USDC / 30 天)
|
||||
- 套餐:`pro_monthly`(10 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
|
||||
|
||||
@@ -105,13 +105,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED`
|
||||
|
||||
### 4.3 L3:运行调优项
|
||||
|
||||
@@ -131,29 +127,12 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
- `POLYWEATHER_PREWARM_INTERVAL_SEC`
|
||||
- `POLYWEATHER_PREWARM_JITTER_SEC`
|
||||
- `POLYWEATHER_PREWARM_CITIES`
|
||||
- `POLYWEATHER_PREWARM_INCLUDE_DETAIL`
|
||||
- `POLYWEATHER_PREWARM_INCLUDE_MARKET`
|
||||
- `POLYWEATHER_PREWARM_FORCE_REFRESH`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_MODEL`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC`
|
||||
|
||||
策略:
|
||||
|
||||
- 先用默认值
|
||||
- 出现性能或运维问题时再调
|
||||
|
||||
当前默认预热名单优先覆盖:
|
||||
|
||||
- 亚洲:Shanghai、Beijing、Shenzhen、Wuhan、Chengdu、Chongqing、Hong Kong、Taipei、Singapore、Tokyo、Seoul、Busan
|
||||
- 中东:Ankara、Istanbul
|
||||
- 欧洲:London、Paris、Madrid
|
||||
|
||||
默认不再包含美国城市;如果线上 `.env` 已手动设置 `POLYWEATHER_PREWARM_CITIES`,则会以你的显式配置为准。
|
||||
|
||||
### 4.4 L4:敏感项
|
||||
|
||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||
@@ -164,10 +143,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `METEOBLUE_API_KEY`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYMARKET_SECRET_KEY`
|
||||
- `GROQ_API_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
@@ -259,17 +235,7 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
@@ -290,47 +256,14 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
|
||||
- `POLYWEATHER_BACKEND_URL` 仅在独立 `polyweather_prewarm` worker 容器中使用,建议设为 `http://polyweather_web:8000`,不要写 `127.0.0.1`。
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED=false` 表示默认仍走规则文案;只有在确实配置了 `GROQ_API_KEY` 时才建议开启。
|
||||
|
||||
### 6.3 Dashboard 预热 worker 推荐变量
|
||||
|
||||
```env
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_PREWARM_FORCE_REFRESH=false
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。
|
||||
- 如果使用独立 `polyweather_prewarm` 容器,`POLYWEATHER_BACKEND_URL` 必须指向容器网络中的 `polyweather_web`。
|
||||
|
||||
### 6.4 Groq 解读增强层
|
||||
|
||||
```env
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=true
|
||||
GROQ_API_KEY=...
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
|
||||
- Groq 调用失败时,系统会自动回退到规则文案。
|
||||
|
||||
### 6.5 机器人市场监控建议配置
|
||||
|
||||
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
- `关键提醒`:实时错价/触发条件满足时发送
|
||||
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
||||
@@ -348,7 +281,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -356,7 +288,6 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
||||
|
||||
## 7. 当前建议的运维规则
|
||||
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
# EMOS + LGBM 系统说明(中文)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
|
||||
本文档用于完整说明 PolyWeather 当前的两条统计/机器学习链路:
|
||||
|
||||
- `EMOS`:概率后处理与校准链路
|
||||
- `LGBM`:日最高温点预测辅助模型
|
||||
|
||||
重点不只是“模型怎么训练”,还包括:
|
||||
|
||||
- 这些模型依赖什么历史数据
|
||||
- 真值和训练特征现在如何长期保存
|
||||
- 为什么过去样本一直不够
|
||||
- 当前线上到底运行在哪个模式
|
||||
- 现在能做什么,不能做什么
|
||||
|
||||
本文档基于仓库当前实现与最近一轮重建结果,适合作为:
|
||||
|
||||
- 项目内部模型说明
|
||||
- 运维与数据治理说明
|
||||
- 未来继续扩展 EMOS/LGBM 的基线文档
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览
|
||||
|
||||
PolyWeather 当前不是“用一个模型替代所有东西”,而是多层结构:
|
||||
|
||||
1. 多源天气采集层
|
||||
2. `DEB` 业务主预测层
|
||||
3. `LGBM` 轻量点预测辅助层
|
||||
4. `EMOS` 概率校准层
|
||||
5. 市场概率/桶命中评估层
|
||||
|
||||
可以简化理解为:
|
||||
|
||||
```text
|
||||
天气源 / 观测 / 历史真值
|
||||
↓
|
||||
DEB 主预测
|
||||
↓
|
||||
LGBM 辅助点预测
|
||||
↓
|
||||
EMOS 对概率分布做后处理
|
||||
↓
|
||||
市场概率 / shadow / rollout 门禁
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `DEB` 仍然是当前业务主路径
|
||||
- `LGBM` 是辅助预测源,不是主路径
|
||||
- `EMOS` 是概率后处理,不是基础天气模型
|
||||
|
||||
---
|
||||
|
||||
## 2. 两条链路各自负责什么
|
||||
|
||||
### 2.1 EMOS 负责什么
|
||||
|
||||
`EMOS` 的全称通常指 Ensemble Model Output Statistics。
|
||||
|
||||
在本项目里,它的角色不是重新预测温度,而是:
|
||||
|
||||
- 把已有的预测结果做概率后处理
|
||||
- 让输出分布更“可校准”
|
||||
- 让桶概率和市场评估更稳定
|
||||
|
||||
EMOS 关注的是:
|
||||
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ens_median`
|
||||
- `ensemble_spread`
|
||||
- `max_so_far_gap`
|
||||
- `peak_flag`
|
||||
- 最终真实 `actual_high`
|
||||
|
||||
它最终输出的是一套“经过校准的概率分布”,而不是单一温度值。
|
||||
|
||||
所以 EMOS 的核心衡量指标不是单纯 MAE,而更看重:
|
||||
|
||||
- `CRPS`
|
||||
- `bucket_hit_rate`
|
||||
- `bucket_brier`
|
||||
|
||||
### 2.2 LGBM 负责什么
|
||||
|
||||
`LGBM` 是一个轻量级的回归模型,用来预测:
|
||||
|
||||
- `actual_high`(日最高温)
|
||||
|
||||
它吃的是:
|
||||
|
||||
- 历史真值 lag 特征
|
||||
- 多模型 forecast
|
||||
- `deb_prediction`
|
||||
- 当前观测特征
|
||||
- 时间特征
|
||||
|
||||
它输出的是:
|
||||
|
||||
- 一个点预测 `actual_high`
|
||||
|
||||
然后这个点预测可以作为:
|
||||
|
||||
- 额外 forecast 源
|
||||
- 供 DEB / 运营 / 研究参考
|
||||
|
||||
所以它和 EMOS 的区别非常重要:
|
||||
|
||||
- `LGBM`:做点预测
|
||||
- `EMOS`:做概率校准
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前代码结构
|
||||
|
||||
### 3.1 EMOS 相关
|
||||
|
||||
核心文件:
|
||||
|
||||
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
|
||||
- [probability_rollout.py](/E:/web/PolyWeather/src/analysis/probability_rollout.py)
|
||||
- [fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
|
||||
- [evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
|
||||
- [build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
|
||||
- [judge_probability_rollout.py](/E:/web/PolyWeather/scripts/judge_probability_rollout.py)
|
||||
|
||||
核心产物:
|
||||
|
||||
- [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
- [rollout_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/rollout_report.json)
|
||||
- [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 3.2 LGBM 相关
|
||||
|
||||
核心文件:
|
||||
|
||||
- [lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
|
||||
- [lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
|
||||
- [train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
|
||||
- [report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
|
||||
|
||||
核心产物:
|
||||
|
||||
- [lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- [lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
---
|
||||
|
||||
## 4. 为什么之前样本总是上不去
|
||||
|
||||
这件事是理解当前状态的关键。
|
||||
|
||||
过去项目里有一个结构性问题:
|
||||
|
||||
- `daily_records_store` 同时承担了
|
||||
- 运行态缓存
|
||||
- 历史训练数据来源
|
||||
|
||||
但运行态层会把 `daily_records` 硬裁成最近 14 天。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 对线上运行来说没问题
|
||||
- 对训练来说,历史监督样本会不断被删掉
|
||||
|
||||
结果就是:
|
||||
|
||||
- 城市越来越多
|
||||
- 训练历史反而越来越稀
|
||||
- `LGBM` 很容易只有二十几条样本
|
||||
- `EMOS` 也只能靠有限 snapshot/daily_record 拼起来
|
||||
|
||||
这不是“模型太差”,而是“数据主存设计不对”。
|
||||
|
||||
---
|
||||
|
||||
## 5. 这次历史真值治理做了什么
|
||||
|
||||
现在已经把“运行态缓存”和“长期训练主存”拆开了。
|
||||
|
||||
### 5.1 `daily_records_store`
|
||||
|
||||
继续保留,但只作为:
|
||||
|
||||
- 最近 14 天运行态缓存
|
||||
|
||||
它不再承担长期训练历史职责。
|
||||
|
||||
### 5.2 `truth_records_store`
|
||||
|
||||
新增永久真值表,作为长期训练真值主存。
|
||||
|
||||
当前核心字段包括:
|
||||
|
||||
- `city`
|
||||
- `target_date`
|
||||
- `actual_high`
|
||||
- `settlement_source`
|
||||
- `settlement_station_code`
|
||||
- `settlement_station_label`
|
||||
- `truth_version`
|
||||
- `updated_by`
|
||||
- `updated_at`
|
||||
- `source_payload_json`
|
||||
- `is_final`
|
||||
|
||||
这张表的意义是:
|
||||
|
||||
- 长期保存监督真值
|
||||
- 不再被 14 天缓存裁剪
|
||||
- 真值来源变得可追溯
|
||||
|
||||
### 5.3 `truth_revisions_store`
|
||||
|
||||
新增真值修订审计表。
|
||||
|
||||
它记录:
|
||||
|
||||
- 老值是什么
|
||||
- 新值是什么
|
||||
- 来源怎么变了
|
||||
- 谁改的
|
||||
- 为什么改
|
||||
- 什么时候改
|
||||
|
||||
所以现在回填不会再是“静默覆盖”。
|
||||
|
||||
### 5.4 `training_feature_records_store`
|
||||
|
||||
新增长期训练特征表。
|
||||
|
||||
它长期留存:
|
||||
|
||||
- forecasts
|
||||
- deb_prediction
|
||||
- mu
|
||||
- probability_features
|
||||
- prob_snapshot
|
||||
- shadow_prob_snapshot
|
||||
- calibration 摘要
|
||||
|
||||
它的作用是:
|
||||
|
||||
- 从现在开始,不再继续丢失历史训练特征
|
||||
- 让未来 EMOS/LGBM 样本自然累积
|
||||
|
||||
---
|
||||
|
||||
## 6. 训练数据现在怎么来
|
||||
|
||||
### 6.1 EMOS 训练样本
|
||||
|
||||
EMOS 训练不只是需要真值,还要有“当时那一刻的预测快照”。
|
||||
|
||||
所以一条 EMOS 样本,本质上需要两部分:
|
||||
|
||||
1. 历史预测特征
|
||||
2. 对应日期最终真值
|
||||
|
||||
当前导出的 EMOS 样本里,核心字段包括:
|
||||
|
||||
- `city`
|
||||
- `date`
|
||||
- `actual_high`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ens_median`
|
||||
- `ensemble_spread`
|
||||
- `max_so_far_gap`
|
||||
- `peak_flag`
|
||||
- `sample_source`
|
||||
- `settlement_source`
|
||||
- `settlement_station_code`
|
||||
- `truth_version`
|
||||
- `truth_updated_by`
|
||||
- `truth_updated_at`
|
||||
|
||||
也就是说,EMOS 训练样本现在已经带了真值 provenance。
|
||||
|
||||
### 6.2 LGBM 训练样本
|
||||
|
||||
LGBM 训练样本会优先从:
|
||||
|
||||
1. 永久真值表取监督目标
|
||||
2. 长期训练特征表取历史特征
|
||||
3. 再回退到必要的运行态/快照补充
|
||||
|
||||
当前 LGBM 样本会用到:
|
||||
|
||||
- 历史 `actual_high` lag
|
||||
- 历史均值/趋势
|
||||
- 多模型 forecast
|
||||
- `deb_prediction`
|
||||
- 当前观测
|
||||
- 时间特征
|
||||
|
||||
---
|
||||
|
||||
## 7. Wunderground 历史回填为什么重要
|
||||
|
||||
这次治理里一个重点是:
|
||||
|
||||
- `Taipei`
|
||||
- `Shenzhen`
|
||||
|
||||
这两个城市配置了 `Wunderground` 历史页面作为历史观测取数入口。
|
||||
|
||||
这里要注意产品文案口径:
|
||||
|
||||
- `Wunderground` 不是物理观测站
|
||||
- 它只是历史页面 / 数据入口
|
||||
- 机场类市场仍应以 METAR / 机场主站作为结算锚点
|
||||
- 明确官方站点市场才以规则指定的官方站点作为最终结算锚点
|
||||
|
||||
之前的问题是:
|
||||
|
||||
- 城市注册表已经写成 `wunderground`
|
||||
- 但历史回填链路还没有真正支持按指定历史日期抓 WU 历史页
|
||||
|
||||
所以过去它们的 `actual_high` 可能:
|
||||
|
||||
- 没有被正确回填
|
||||
- 或者被错误来源污染
|
||||
|
||||
现在已经补了正式历史回填函数:
|
||||
|
||||
- [wunderground_sources.py](/E:/web/PolyWeather/src/data_collection/wunderground_sources.py)
|
||||
|
||||
它会:
|
||||
|
||||
1. 按 `city + target_date` 拼出对应历史页
|
||||
2. 解析该日观测序列
|
||||
3. 取当日最高温
|
||||
4. 按市场规则做整度结算
|
||||
5. 写入永久真值表
|
||||
6. 记录来源与审计信息
|
||||
|
||||
这一步对 `Taipei/Shenzhen` 尤其关键,因为它们的历史页面取数和普通 METAR bootstrap 不同。
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前线上/离线运行模式
|
||||
|
||||
### 8.1 概率引擎模式
|
||||
|
||||
当前生产主概率应保持:
|
||||
|
||||
- `legacy`
|
||||
|
||||
如果需要观察 EMOS 对照,可切:
|
||||
|
||||
- `emos_shadow`
|
||||
|
||||
而不是:
|
||||
|
||||
- `emos_primary`
|
||||
|
||||
原因不是工程没接好,而是主概率发布必须由离线评估结果决定。VPS 轻量训练候选未通过门禁;本地训练候选虽通过门禁,但仍建议先 shadow 观察,再人工决定是否切主。
|
||||
|
||||
### 8.2 LGBM 角色
|
||||
|
||||
当前 `LGBM` 仍然只能算:
|
||||
|
||||
- 辅助预测源
|
||||
- 研究/观测链路
|
||||
- 校准概率层的一个可用引擎输入
|
||||
|
||||
不适合替代 `DEB` 主路径。
|
||||
|
||||
前端展示上,`LGBM 校准概率` 代表概率层已使用 LGBM 上下文生成桶分布;它不是把模型四舍五入票数直接当成概率。模型共识仍只是解释层,市场价格也只作为参考层。
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前最新状态
|
||||
|
||||
以下状态来自最近一轮恢复、回填和重训产物。
|
||||
|
||||
### 9.1 永久真值
|
||||
|
||||
当前永久真值表已恢复到长期历史:
|
||||
|
||||
- `truth_records_store`
|
||||
- 最早:`2023-01-01`
|
||||
- 最晚:`2026-04-02`
|
||||
- 行数:约 `35138`
|
||||
- 城市数:`30`
|
||||
|
||||
运行态缓存仍然只有近 14 天:
|
||||
|
||||
- `daily_records_store`
|
||||
- 仍然是近两周范围
|
||||
|
||||
这说明:
|
||||
|
||||
- 长期真值主存已经从运行态缓存里分离出来了
|
||||
|
||||
### 9.2 真值修订
|
||||
|
||||
当前已有 revision 审计记录:
|
||||
|
||||
- `truth_revisions_store`
|
||||
- 行数:`2`
|
||||
|
||||
这说明审计链路已经在工作。
|
||||
|
||||
### 9.3 Wunderground 回填
|
||||
|
||||
`Taipei` 与 `Shenzhen` 已按 WU 历史页完成回填。
|
||||
|
||||
当前这两城已经补到:
|
||||
|
||||
- `2026-04-02`
|
||||
|
||||
### 9.4 长期训练特征
|
||||
|
||||
当前 `training_feature_records_store` 已经接通,但历史上真正留存下来的特征仍然很少。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 从现在开始不会继续丢
|
||||
- 但过去没留下的那部分特征,不会凭空恢复
|
||||
|
||||
这也是为什么:
|
||||
|
||||
- 真值恢复了
|
||||
- `EMOS` 样本量却没有同步大幅增长
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前 EMOS 结果怎么理解
|
||||
|
||||
最近两轮评估给出了更清晰的结论。
|
||||
|
||||
VPS 轻量训练候选:
|
||||
|
||||
- 版本:`emos-auto-20260418204203`
|
||||
- `sample_count = 791`
|
||||
- `delta_crps = +0.004652`
|
||||
- `delta_mae = +0.102623`
|
||||
- `delta_bucket_hit_rate = -0.137800`
|
||||
- 结论:`hold`
|
||||
|
||||
本地训练候选:
|
||||
|
||||
- 版本:`emos-auto-20260418212046`
|
||||
- `sample_count = 847`
|
||||
- `delta_crps = -0.036170`
|
||||
- `delta_mae = -0.007896`
|
||||
- `delta_bucket_hit_rate = -0.009445`
|
||||
- 结论:`promote`
|
||||
|
||||
这说明:
|
||||
|
||||
- EMOS 工程链路有效,本地用更多 snapshot 训练时可以超过 legacy 的 CRPS/MAE。
|
||||
- 低配 VPS 不适合做主训练环境。
|
||||
- 通过门禁不等于立即默认主用,仍应先 `emos_shadow` 观察。
|
||||
|
||||
当前生产策略仍然是:
|
||||
|
||||
- 用户主概率默认 `legacy`
|
||||
- EMOS 通过本地训练产生候选
|
||||
- 通过门禁后先以 `emos_shadow` 灰度
|
||||
- 连续稳定后才考虑 `emos_primary`
|
||||
|
||||
这不是“EMOS 无效”,而是:
|
||||
|
||||
- 它还没有稳定到能切主路径
|
||||
|
||||
### 10.1 当前阻塞点
|
||||
|
||||
主要阻塞仍然是:
|
||||
|
||||
- 有效样本仍然不大,城市级样本分布不均
|
||||
- 桶概率容易受结算边界影响
|
||||
- 需要避免 VPS 训练消耗线上资源
|
||||
- `emos_primary` 发布需要明确人工门禁
|
||||
|
||||
也就是说,当前 EMOS 状态可以总结成:
|
||||
|
||||
- 工程链路完整
|
||||
- 数据治理大幅改善
|
||||
- 本地训练可通过门禁
|
||||
- 生产主用仍需 shadow 观察与人工发布
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前 LGBM 结果怎么理解
|
||||
|
||||
最近一轮 LGBM 训练后,样本数已经从以前更少的状态提升到:
|
||||
|
||||
- `sample_count = 54`
|
||||
- `train_count = 42`
|
||||
- `validation_count = 12`
|
||||
|
||||
验证集指标大致为:
|
||||
|
||||
- `lgbm_mae = 1.349`
|
||||
- `deb_mae = 0.875`
|
||||
|
||||
这说明:
|
||||
|
||||
- LGBM 比以前样本更充足了
|
||||
- 但在验证集上仍然不如 DEB
|
||||
|
||||
所以当前它的定位仍然应该是:
|
||||
|
||||
- 辅助参考
|
||||
- 不替代 DEB
|
||||
|
||||
---
|
||||
|
||||
## 12. 为什么现在 EMOS 没有像 LGBM 那样明显涨样本
|
||||
|
||||
这点很容易误解。
|
||||
|
||||
答案不是“恢复失败”,而是两条链路对数据要求不一样。
|
||||
|
||||
### 12.1 LGBM
|
||||
|
||||
LGBM 更依赖:
|
||||
|
||||
- 长期真值
|
||||
- 基础 forecast 特征
|
||||
|
||||
这部分通过:
|
||||
|
||||
- `truth_records_store`
|
||||
- `training_feature_records_store`
|
||||
|
||||
已经改善很多。
|
||||
|
||||
### 12.2 EMOS
|
||||
|
||||
EMOS 更依赖:
|
||||
|
||||
- 某一时刻的概率快照/分布特征
|
||||
|
||||
如果过去那些 snapshot 没有长期保存下来,那么即使今天把真值补齐了:
|
||||
|
||||
- 也无法凭空重建完整 EMOS 样本
|
||||
|
||||
所以当前现实是:
|
||||
|
||||
- 真值问题已经大幅改善
|
||||
- 未来特征不会再继续丢
|
||||
- 但过去缺失的 EMOS 快照历史仍然限制样本增长
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前最重要的工程判断
|
||||
|
||||
### 13.1 已经完成的
|
||||
|
||||
这些现在可以认为已经完成:
|
||||
|
||||
- 真值主存从运行态缓存里拆出
|
||||
- 真值 provenance 落库
|
||||
- revision 审计表落地
|
||||
- Wunderground 历史回填接通
|
||||
- `Taipei/Shenzhen` 真值口径修正
|
||||
- 长期训练特征表接通
|
||||
- `/ops` 已能可视化 truth / feature / EMOS / LGBM 覆盖情况
|
||||
|
||||
### 13.2 还没完成的
|
||||
|
||||
这些仍然是后续重点:
|
||||
|
||||
- EMOS 样本继续自然积累
|
||||
- shadow bucket brier 稳定下来
|
||||
- LGBM 验证效果超过 DEB
|
||||
- 让更多城市开始持续积累训练特征
|
||||
|
||||
---
|
||||
|
||||
## 14. 运维怎么看当前状态
|
||||
|
||||
现在最直接的入口是:
|
||||
|
||||
- `/ops`
|
||||
|
||||
这页已经能看到:
|
||||
|
||||
- 历史真值主表统计
|
||||
- 真值来源分布
|
||||
- 真值修订数量
|
||||
- 长期训练特征统计
|
||||
- `Taipei/Shenzhen` 的 WU 回填状态
|
||||
- 城市覆盖缺口
|
||||
- 模型城市覆盖
|
||||
- 城市覆盖矩阵
|
||||
|
||||
因此,运维现在可以快速回答:
|
||||
|
||||
- 哪些城市真值已经长期化
|
||||
- 哪些城市还没有特征积累
|
||||
- 哪些城市已经能支撑 EMOS/LGBM
|
||||
- 哪些城市目前仍然只能主要依赖 DEB
|
||||
|
||||
---
|
||||
|
||||
## 15. 推荐工作流
|
||||
|
||||
### 15.1 日常
|
||||
|
||||
1. 查看 `/ops`
|
||||
2. 看 `truth / feature / EMOS / LGBM` 覆盖有没有继续增长
|
||||
3. 看 `Taipei/Shenzhen` 的 WU 行数是否继续更新
|
||||
4. 看本地 EMOS 候选是否通过门禁
|
||||
5. 看 VPS 是否只加载已批准参数,不在低配机器上训练
|
||||
|
||||
### 15.2 周期性重训
|
||||
|
||||
建议在本地开发机执行,不建议在低配 VPS 上执行:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
只有 `auto_retrain_report.json` 里 `ready_for_promotion=true` 时,才允许把候选 `default.json` 传回 VPS。
|
||||
|
||||
### 15.3 真值恢复/补数
|
||||
|
||||
当有新的历史真值补数或回填需要时:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/restore_training_truth_history.py
|
||||
./venv/Scripts/python.exe scripts/restore_training_feature_history.py
|
||||
./venv/Scripts/python.exe scripts/backfill_recent_daily_actuals_from_metar.py --cities taipei shenzhen --lookback-days 14
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 脚本名里虽然还保留 `from_metar`
|
||||
- 但当前实现已经会按 `settlement_source` 自动分发
|
||||
- `wunderground` 会走 WU 历史回填分支
|
||||
|
||||
---
|
||||
|
||||
## 16. 当前最务实的结论
|
||||
|
||||
如果只用一句话概括当前状态:
|
||||
|
||||
**EMOS 和 LGBM 的工程基础已经补齐,但生产主概率仍必须由评估门禁控制;当前最正确的策略是继续以 `DEB/legacy` 为主路径,在本地训练 EMOS 候选,VPS 只加载已批准参数。**
|
||||
|
||||
更具体一点:
|
||||
|
||||
- `EMOS`
|
||||
- 已接好
|
||||
- 可训练
|
||||
- 可评估
|
||||
- 可 shadow
|
||||
- 通过门禁后可灰度
|
||||
- 不应在低配 VPS 上自动训练或自动主用
|
||||
|
||||
- `LGBM`
|
||||
- 已接好
|
||||
- 样本比以前更多
|
||||
- 但验证集还不如 DEB
|
||||
- 目前只能做辅助参考
|
||||
|
||||
- 数据层
|
||||
- 这次治理的真正价值,是防止未来继续丢历史
|
||||
- 这对两条模型链路都比继续“微调参数”更关键
|
||||
|
||||
---
|
||||
|
||||
## 17. 相关文档
|
||||
|
||||
若需要看更细分的历史说明,可继续参考:
|
||||
|
||||
- [EMOS_TRAINING_REPORT_ZH.md](/E:/web/PolyWeather/docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- [LGBM_DAILY_HIGH_ZH.md](/E:/web/PolyWeather/docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- [PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](/E:/web/PolyWeather/docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- [deep-research-report.md](/E:/web/PolyWeather/docs/deep-research-report.md)
|
||||
@@ -1,263 +0,0 @@
|
||||
# EMOS 训练与发布报告(2026-04-19)
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
- `EMOS` 工程链路已经接通:可以训练、评估、生成候选参数,并在前端以校准概率层展示。
|
||||
- 生产主概率当前不应默认使用 `emos_primary`。默认建议为 `legacy`;需要观察时使用 `emos_shadow`。
|
||||
- `emos_primary` 只允许在本地离线训练通过门禁、人工复核后手动灰度。
|
||||
- 低配 VPS(例如 1 vCPU / 2GB RAM)不适合做 EMOS 全量训练;VPS 只负责采集、服务和加载已批准的参数文件。
|
||||
- `LGBM` 当前仍不建议作为主路径,继续保持 `POLYWEATHER_LGBM_ENABLED=false`。
|
||||
|
||||
## 2. 最近两次训练结果
|
||||
|
||||
### 2.1 VPS 轻量训练:不通过
|
||||
|
||||
VPS 使用最近 `5000` 条 snapshot 训练的候选:
|
||||
|
||||
- 版本:`emos-auto-20260418204203`
|
||||
- 样本数:`791`
|
||||
- 结论:`hold`
|
||||
|
||||
| 指标 | 变化 |
|
||||
| :-- | --: |
|
||||
| `delta_crps` | `+0.004652` |
|
||||
| `delta_mae` | `+0.102623` |
|
||||
| `delta_bucket_hit_rate` | `-0.137800` |
|
||||
|
||||
解读:CRPS、MAE、桶命中全部弱于 legacy,因此不能晋级。
|
||||
|
||||
### 2.2 本地训练:通过门禁,但仍需灰度
|
||||
|
||||
本地电脑使用生产 SQLite 副本与最近 `50000` 条 snapshot 训练的候选:
|
||||
|
||||
- 版本:`emos-auto-20260418212046`
|
||||
- 样本数:`847`
|
||||
- 结论:`promote`
|
||||
|
||||
| 指标 | 变化 |
|
||||
| :-- | --: |
|
||||
| `delta_crps` | `-0.036170` |
|
||||
| `delta_mae` | `-0.007896` |
|
||||
| `delta_bucket_hit_rate` | `-0.009445` |
|
||||
|
||||
解读:
|
||||
|
||||
- CRPS 与 MAE 有改善,候选通过当前门禁。
|
||||
- 桶命中率轻微下降,虽然在门禁允许范围内,但仍建议先以 `emos_shadow` 观察,再决定是否切 `emos_primary`。
|
||||
|
||||
## 3. 生产运行策略
|
||||
|
||||
推荐生产 `.env`:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=legacy
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
观察 EMOS 时:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=emos_shadow
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
只有在候选连续通过评估、前端展示稳定、业务侧确认后,才切:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=emos_primary
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
验证线上加载状态:
|
||||
|
||||
```bash
|
||||
docker compose exec -T polyweather_web python - <<'PY'
|
||||
from src.analysis.probability_calibration import load_calibration, resolve_probability_engine_mode
|
||||
cal = load_calibration()
|
||||
print("engine_mode =", resolve_probability_engine_mode())
|
||||
print("loaded_version =", cal.get("version"))
|
||||
print("sample_count =", (cal.get("metrics") or {}).get("sample_count"))
|
||||
print("has_global =", bool(cal.get("global")))
|
||||
PY
|
||||
```
|
||||
|
||||
## 4. 本地训练 SOP
|
||||
|
||||
### 4.1 拉取生产 SQLite 副本
|
||||
|
||||
推荐先在 VPS 上用 SQLite 在线备份生成快照:
|
||||
|
||||
```bash
|
||||
sqlite3 /var/lib/polyweather/polyweather.db ".backup '/var/lib/polyweather/polyweather-train-copy.db'"
|
||||
```
|
||||
|
||||
本地 PowerShell 拉取:
|
||||
|
||||
```powershell
|
||||
cd E:\web\PolyWeather
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather-train-copy.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
```
|
||||
|
||||
如果生产库写入压力很低,也可以直接拉主库副本:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
```
|
||||
|
||||
### 4.2 本地训练
|
||||
|
||||
```powershell
|
||||
cd E:\web\PolyWeather
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
如果本地机器仍然较慢,可先降到:
|
||||
|
||||
```powershell
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 20000
|
||||
```
|
||||
|
||||
训练报告:
|
||||
|
||||
```powershell
|
||||
Get-Content E:\web\PolyWeather\artifacts\local_runtime\probability_calibration\auto_retrain_report.json
|
||||
```
|
||||
|
||||
候选目录:
|
||||
|
||||
```text
|
||||
E:\web\PolyWeather\artifacts\local_runtime\probability_calibration\candidates\<version>\
|
||||
```
|
||||
|
||||
### 4.3 晋级判断
|
||||
|
||||
只有报告满足以下条件时,候选才可进入部署流程:
|
||||
|
||||
```json
|
||||
"ready_for_promotion": true
|
||||
```
|
||||
|
||||
同时人工检查:
|
||||
|
||||
- `delta_crps <= 0`
|
||||
- `delta_mae <= 0.05`
|
||||
- `delta_bucket_hit_rate >= -0.05`
|
||||
- 城市级结果没有出现关键城市大幅退化
|
||||
- 前端概率分布没有明显过度摊平或异常偏桶
|
||||
|
||||
## 5. 部署通过的候选
|
||||
|
||||
把本地候选上传到 VPS:
|
||||
|
||||
```powershell
|
||||
scp E:\web\PolyWeather\artifacts\local_runtime\probability_calibration\candidates\<version>\default.json root@38.54.27.70:/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
VPS 上优先设置为 `emos_shadow`:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=emos_shadow
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
重启:
|
||||
|
||||
```bash
|
||||
cd /root/PolyWeather
|
||||
docker compose up -d polyweather_web
|
||||
```
|
||||
|
||||
观察稳定后再考虑 `emos_primary`。
|
||||
|
||||
## 6. VPS 定时训练策略
|
||||
|
||||
当前策略:**不在 VPS 上做 EMOS 定时训练**。
|
||||
|
||||
原因:
|
||||
|
||||
- 生产 SQLite 的 `probability_training_snapshots_store` 会持续增长。
|
||||
- 低配 VPS 全量扫描会造成 CPU/IO 飙升,严重时影响 SSH 和线上服务。
|
||||
- VPS 训练用较小 `--snapshot-limit` 虽然安全,但训练效果可能弱于本地。
|
||||
|
||||
如果曾经加过 cron,应删除:
|
||||
|
||||
```bash
|
||||
crontab -l | grep -v 'auto_retrain_probability_calibration.py' | crontab -
|
||||
```
|
||||
|
||||
确认:
|
||||
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
## 7. 自动重训脚本说明
|
||||
|
||||
脚本:
|
||||
|
||||
```text
|
||||
python scripts\auto_retrain_probability_calibration.py
|
||||
```
|
||||
|
||||
默认行为:
|
||||
|
||||
- 生成新的 EMOS candidate。
|
||||
- 对 candidate 跑离线评估。
|
||||
- 写入候选目录和门禁报告。
|
||||
- 不覆盖线上 `default.json`。
|
||||
|
||||
重要参数:
|
||||
|
||||
- `--verbose`:输出训练/评估进度。
|
||||
- `--snapshot-limit N`:只使用最近 N 条 snapshot。
|
||||
- `--promote-if-passed`:门禁通过后覆盖目标参数文件。
|
||||
- `--run-tests`:晋级前跑测试。
|
||||
|
||||
当前不建议在 VPS 使用 `--promote-if-passed`。本地训练通过后,仍优先人工上传并使用 `emos_shadow`。
|
||||
|
||||
## 8. 门禁阈值
|
||||
|
||||
默认阈值:
|
||||
|
||||
- `POLYWEATHER_EMOS_AUTO_MIN_SAMPLES=50`
|
||||
- `POLYWEATHER_EMOS_AUTO_MAX_DELTA_CRPS=0`
|
||||
- `POLYWEATHER_EMOS_AUTO_MAX_DELTA_MAE=0.05`
|
||||
- `POLYWEATHER_EMOS_AUTO_MIN_DELTA_BUCKET_HIT_RATE=-0.05`
|
||||
|
||||
解释:
|
||||
|
||||
- `CRPS` 不允许比 legacy 更差。
|
||||
- `MAE` 最多允许轻微退化 `0.05`。
|
||||
- `bucket_hit_rate` 是业务参考指标,但对结算边界敏感,不单独作为唯一判断。
|
||||
|
||||
## 9. 前端说明
|
||||
|
||||
今日日内分析中的概率区展示的是当前生产概率引擎输出:
|
||||
|
||||
- `legacy`:展示现有动态概率。
|
||||
- `emos_shadow`:用户主概率仍为 legacy,EMOS 仅用于对照和评估。
|
||||
- `emos_primary`:用户主概率使用 EMOS 校准分布。
|
||||
|
||||
对外文案应避免暗示“EMOS 一定更准”。推荐解释为:
|
||||
|
||||
> EMOS 是 PolyWeather 基于 DEB 路径、多模型集合、METAR 实测进度和历史误差结构生成的统计校准概率,不是外部天气模型,也不是直接 API 结果。
|
||||
|
||||
## 10. 已验证
|
||||
|
||||
本地训练链路已验证:
|
||||
|
||||
```text
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
测试链路已验证:
|
||||
|
||||
```text
|
||||
python -m pytest tests\test_auto_retrain_probability_calibration.py tests\test_probability_calibration.py tests\test_probability_rollout.py
|
||||
```
|
||||
|
||||
当前工程结论:
|
||||
|
||||
**EMOS 可以继续本地训练与 shadow 观察,但生产主概率不应因为“机制接好”而默认切到 `emos_primary`。**
|
||||
@@ -93,7 +93,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
# LightGBM 日最高温模型(中文)
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
这套 `LightGBM` 模型是给 PolyWeather 增加一个轻量级的统计学习预测源。
|
||||
|
||||
它的定位不是替代:
|
||||
|
||||
- `DEB`
|
||||
- `EMOS`
|
||||
- `ECMWF / GFS / GEM / JMA / ICON / Open-Meteo / MGM / NWS`
|
||||
|
||||
而是作为一个新的点预测源:
|
||||
|
||||
`现有模型 + 观测特征 -> LGBM -> 并入 current_forecasts -> DEB -> EMOS`
|
||||
|
||||
第一版只做:
|
||||
|
||||
- `D0` 当日最高温预测
|
||||
|
||||
不做:
|
||||
|
||||
- `D1-D3`
|
||||
- 小时级曲线
|
||||
- 原始独立概率分布
|
||||
- 独立结算源
|
||||
|
||||
注意:前端出现的“LGBM 校准概率”不是把 LGBM 模型票数直接当成概率,而是概率层基于 LGBM / DEB / 观测上下文输出的校准分布。模型共识只保留为解释性参考。
|
||||
|
||||
## 2. 适用场景
|
||||
|
||||
这条链路是为低资源 VPS 准备的。
|
||||
|
||||
当前项目线上环境只有 `2GB RAM` 时,不适合引入 `TimesFM` 这类大模型,但适合用 `LightGBM` 做轻量推理。
|
||||
|
||||
当前方案是:
|
||||
|
||||
1. 训练离线完成
|
||||
2. 训练产物直接提交到仓库
|
||||
3. VPS 线上只加载模型文件并推理
|
||||
4. VPS 不训练,不起额外服务
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
核心文件如下:
|
||||
|
||||
- 运行时推理:
|
||||
- [src/models/lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
|
||||
- 特征构建:
|
||||
- [src/models/lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
|
||||
- 训练脚本:
|
||||
- [scripts/train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
|
||||
- 训练报告脚本:
|
||||
- [scripts/report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
|
||||
- 模型文件:
|
||||
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- 模型 schema / 指标:
|
||||
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
接入链路位置:
|
||||
|
||||
- Web API 聚合:
|
||||
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
|
||||
- 共享趋势引擎:
|
||||
- [src/analysis/trend_engine.py](/E:/web/PolyWeather/src/analysis/trend_engine.py)
|
||||
|
||||
## 4. 特征说明
|
||||
|
||||
第一版特征固定为以下几组。
|
||||
|
||||
### 4.1 历史日高温特征
|
||||
|
||||
- `actual_high_lag_1`
|
||||
- `actual_high_lag_2`
|
||||
- `actual_high_lag_3`
|
||||
- `actual_high_lag_7`
|
||||
- `actual_high_mean_7`
|
||||
- `actual_high_mean_14`
|
||||
- `actual_high_trend_3`
|
||||
|
||||
### 4.2 当天模型特征
|
||||
|
||||
- `Open-Meteo`
|
||||
- `ECMWF`
|
||||
- `GFS`
|
||||
- `GEM`
|
||||
- `JMA`
|
||||
- `ICON`
|
||||
- `MGM`
|
||||
- `NWS`
|
||||
- `deb_prediction`
|
||||
- `model_median`
|
||||
- `model_spread`
|
||||
|
||||
### 4.3 当前观测特征
|
||||
|
||||
- `current_temp`
|
||||
- `max_so_far`
|
||||
- `humidity`
|
||||
- `wind_speed_kt`
|
||||
- `visibility_mi`
|
||||
|
||||
### 4.4 时间与状态特征
|
||||
|
||||
- `local_hour`
|
||||
- `month`
|
||||
- `weekday`
|
||||
- `peak_status_code`
|
||||
|
||||
其中:
|
||||
|
||||
- `before = 0`
|
||||
- `in_window = 1`
|
||||
- `past = 2`
|
||||
|
||||
## 5. 训练数据来源
|
||||
|
||||
训练数据主要来自两份运行时历史文件:
|
||||
|
||||
- [data/daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
- [data/probability_training_snapshots.jsonl](/E:/web/PolyWeather/data/probability_training_snapshots.jsonl)
|
||||
|
||||
作用分工:
|
||||
|
||||
- `daily_records.json`
|
||||
- 提供 `actual_high`
|
||||
- 提供当天各模型 forecast
|
||||
- 提供历史 `deb_prediction`
|
||||
|
||||
- `probability_training_snapshots.jsonl`
|
||||
- 提供 `max_so_far`
|
||||
- 提供 `peak_status`
|
||||
- 提供观测特征快照
|
||||
|
||||
为后续重训,概率快照归档现在还会额外写入:
|
||||
|
||||
- `current_temp`
|
||||
- `humidity`
|
||||
- `wind_speed_kt`
|
||||
- `visibility_mi`
|
||||
- `local_hour`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [src/analysis/probability_snapshot_archive.py](/E:/web/PolyWeather/src/analysis/probability_snapshot_archive.py)
|
||||
|
||||
## 6. 训练流程
|
||||
|
||||
训练脚本:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
训练流程如下:
|
||||
|
||||
1. 从历史文件构造监督样本
|
||||
2. 目标值固定为 `actual_high`
|
||||
3. 按日期做简单的时间顺序切分
|
||||
4. 最后约 20% 做验证集
|
||||
5. 先训练并评估验证集
|
||||
6. 再用全量样本训练最终模型
|
||||
7. 输出模型文件和 schema 文件
|
||||
|
||||
输出产物:
|
||||
|
||||
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
## 7. 如何看训练结果
|
||||
|
||||
查看训练报告:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
这个脚本会读取 schema,并打印:
|
||||
|
||||
- `Sample Count`
|
||||
- `Train Count`
|
||||
- `Valid Count`
|
||||
- `LGBM MAE`
|
||||
- `DEB MAE`
|
||||
- `Best Single MAE`
|
||||
- `Median MAE`
|
||||
- `Winner`
|
||||
|
||||
当前这版训练结果是:
|
||||
|
||||
- `sample_count = 29`
|
||||
- `validation_count = 12`
|
||||
- `validation.lgbm_mae = 2.975`
|
||||
- `validation.deb_mae = 2.267`
|
||||
- `validation.best_single_mae = 1.167`
|
||||
|
||||
这说明:
|
||||
|
||||
- 当前 `LGBM` 链路已经可用
|
||||
- 但现阶段验证集表现还没有超过 `DEB`
|
||||
- 所以默认配置仍建议保持关闭
|
||||
|
||||
## 8. 线上运行逻辑
|
||||
|
||||
运行时推理逻辑不是“直接替代 DEB”,而是:
|
||||
|
||||
1. 先收集现有模型 forecast
|
||||
2. 先算一版基线 `DEB`
|
||||
3. 把这版 `DEB` 当作 `LGBM` 的一个输入特征
|
||||
4. 输出 `LGBM` 点预测
|
||||
5. 把 `LGBM` 注入 `current_forecasts`
|
||||
6. 重新计算最终 `DEB`
|
||||
|
||||
这样做的原因是:
|
||||
|
||||
- `LGBM` 需要吃到 `deb_prediction` 特征
|
||||
- 但最终 `DEB` 又要把 `LGBM` 当成一个新的输入模型
|
||||
|
||||
## 8.1 前端概率展示口径
|
||||
|
||||
当前网页的概率区按以下顺序解释:
|
||||
|
||||
1. 如果后端 `probabilities.engine` 表示 LGBM 校准概率可用,则标题显示为 `LGBM 校准概率`。
|
||||
2. 如果 LGBM 不可用,但 EMOS / legacy 概率可用,则显示为 `校准模型概率`。
|
||||
3. 模型舍入票数只保留为“模型共识参考”,用于说明哪些模型四舍五入后落在同一温度档,不作为最终命中概率。
|
||||
4. 市场价格只保留为“市场参考”,不和校准概率混成同一结论。
|
||||
|
||||
这能避免用户把 `4/8 模型支持 82°F` 误读成 `82°F 有 50% 概率`。模型共识是解释层,概率引擎才是结论层。
|
||||
|
||||
## 9. 环境变量
|
||||
|
||||
示例配置见:
|
||||
|
||||
- [.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
相关变量:
|
||||
|
||||
```env
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
|
||||
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_LGBM_ENABLED`
|
||||
- 是否启用运行时推理
|
||||
- `POLYWEATHER_LGBM_MODEL_PATH`
|
||||
- 模型文件路径
|
||||
- `POLYWEATHER_LGBM_SCHEMA_PATH`
|
||||
- schema 文件路径
|
||||
- `POLYWEATHER_LGBM_MIN_HISTORY_POINTS`
|
||||
- 某城市最低历史样本门槛
|
||||
|
||||
默认是 `3`,原因不是最理想,而是当前整体样本仍然偏少。
|
||||
|
||||
如果门槛设太高,很多城市现在根本不会触发 `LGBM`。
|
||||
|
||||
## 10. VPS 部署建议
|
||||
|
||||
如果你的 VPS 只有 `2GB RAM`:
|
||||
|
||||
- 可以跑这套 `LightGBM`
|
||||
- 不要在 VPS 上训练
|
||||
- 不要起额外模型服务
|
||||
|
||||
推荐方式:
|
||||
|
||||
1. 在本地或开发环境训练
|
||||
2. 提交模型产物
|
||||
3. VPS 拉代码
|
||||
4. 开启 `POLYWEATHER_LGBM_ENABLED=true`
|
||||
5. 重启主服务
|
||||
|
||||
不推荐:
|
||||
|
||||
- 在 VPS 上跑训练脚本
|
||||
- 把 `LightGBM` 当成长任务服务单独部署
|
||||
- 同时引入大模型推理
|
||||
|
||||
## 11. 当前结论
|
||||
|
||||
这条链路已经完成了:
|
||||
|
||||
- 离线训练
|
||||
- 模型产物固化
|
||||
- 运行时懒加载
|
||||
- Web / 共享分析链路注入
|
||||
- 前端模型类型兼容
|
||||
|
||||
但当前样本量仍偏少,所以建议运营策略是:
|
||||
|
||||
1. 先继续积累历史 `actual_high`
|
||||
2. 继续积累概率快照观测字段
|
||||
3. 定期重训
|
||||
4. 只有当验证集 `MAE` 持续接近或优于 `DEB` 时,再考虑默认线上开启
|
||||
|
||||
## 12. 常用命令
|
||||
|
||||
### 训练
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 查看训练报告
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 本地测试
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe -m pytest tests/test_lgbm_features.py tests/test_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 编译检查
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe -m compileall src web scripts tests
|
||||
```
|
||||
@@ -99,7 +99,7 @@ Web API 会把这部分元数据挂到:
|
||||
- RDPS
|
||||
- HRDPS
|
||||
|
||||
亚洲城市更依赖本地观测增强层,例如 JMA、KMA、NMC、HKO、CWA、METAR、TAF。
|
||||
亚洲城市更依赖本地观测增强层,例如 JMA、AMOS(首尔/釜山)、NMC、HKO、CWA、METAR、TAF。
|
||||
|
||||
## 4. DEB 家族去重
|
||||
|
||||
@@ -151,7 +151,6 @@ HRDPS > RDPS > GDPS > GEM
|
||||
- MGM
|
||||
- NWS
|
||||
- HKO
|
||||
- LGBM
|
||||
- Open-Meteo
|
||||
|
||||
ECMWF IFS 与 ECMWF AIFS 分开保留,因为前者是传统 NWP,后者是 AIFS 模型。
|
||||
@@ -219,7 +218,6 @@ raw current_forecasts
|
||||
当前前端把三层拆开展示:
|
||||
|
||||
- `模型区间与分歧`:解释不同模型当前给出的最高温范围和分歧,不直接等于命中概率。
|
||||
- `校准模型概率`:由当前生产概率引擎输出温度桶概率;默认可保持 legacy,EMOS / LGBM 只在评估通过、显式启用或 shadow 对照时进入展示。
|
||||
- `市场参考`:只展示市场价格和错价背景,不再作为主判断,也不默认输出 BUY YES / BUY NO。
|
||||
|
||||
模型票数只用于解释“哪些模型支持某个档位”,不等于最终概率。最终概率应优先读取 `probabilities.engine` 对应的校准分布。
|
||||
@@ -230,7 +228,6 @@ raw current_forecasts
|
||||
|
||||
- `tests/test_multi_model_sources.py`
|
||||
- `tests/test_deb_model_family.py`
|
||||
- `tests/test_lgbm_features.py`
|
||||
|
||||
重点覆盖:
|
||||
|
||||
|
||||
@@ -114,12 +114,6 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
|
||||
目前已覆盖:
|
||||
|
||||
- `prewarm` worker 是否启用、线程 / heartbeat 是否活着
|
||||
- 最近一轮 prewarm 的:
|
||||
- `cycle_count`
|
||||
- `success_count / failure_count`
|
||||
- `last_started_at / last_finished_at`
|
||||
- `last_summary_ok / last_detail_ok / last_market_ok`
|
||||
- 缓存桶条目数:
|
||||
- `api_cache`
|
||||
- `metar`
|
||||
|
||||
@@ -25,7 +25,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 系统健康
|
||||
- SQLite / rollout / metrics 摘要
|
||||
- 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 当前会员
|
||||
- 周榜
|
||||
@@ -101,11 +100,9 @@ python scripts/reconcile_subscription_by_email.py --email <user_email>
|
||||
|
||||
## 7. 备注
|
||||
|
||||
### 7.1 当前 prewarm / 缓存观测项
|
||||
|
||||
`/ops` 里的系统状态卡目前已额外展示:
|
||||
|
||||
- `prewarm` 是否启用
|
||||
- `thread_alive` / `heartbeat_age_sec`
|
||||
- 最近一轮:
|
||||
- `cycle_count`
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
# 概率训练样本归档说明(中文)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
|
||||
## 1. 目的
|
||||
|
||||
这份文档说明两件事:
|
||||
|
||||
1. 为什么 `EMOS` 训练不能只依赖历史实测天气
|
||||
2. 未来如何持续沉淀“历史预测记录”,让概率引擎越训越稳
|
||||
|
||||
一句话结论:
|
||||
|
||||
- 历史实测天气只能补 `actual_high`
|
||||
- 真正决定 `EMOS` 训练质量的是“当时那一刻的预测快照”
|
||||
|
||||
## 2. 什么是“历史预测记录”
|
||||
|
||||
对 PolyWeather 来说,一条可训练的历史预测记录,至少应该包含这些字段:
|
||||
|
||||
- `city`
|
||||
- `timestamp`
|
||||
- `date`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ensemble p10 / p50 / p90`
|
||||
- `multi-model forecasts`
|
||||
- `max_so_far`
|
||||
- `peak_status`
|
||||
- `prob_snapshot`
|
||||
- `probability_engine`
|
||||
- `calibration_mode`
|
||||
- `calibration_version`
|
||||
- `raw_mu / raw_sigma`
|
||||
- `calibrated_mu / calibrated_sigma`
|
||||
- `shadow_distribution`
|
||||
- 当天最终 `actual_high`
|
||||
- 当天最终 `settlement bucket`
|
||||
|
||||
这类记录的核心价值是:
|
||||
|
||||
- 还原“当时系统实际看到什么”
|
||||
- 再对照“后来真实发生了什么”
|
||||
|
||||
只有这两者成对,`EMOS` 才能学习偏差。
|
||||
|
||||
## 3. 为什么不能只用历史天气实测
|
||||
|
||||
历史天气 CSV 只能告诉你:
|
||||
|
||||
- 当天最高温是多少
|
||||
- 某小时温度是多少
|
||||
|
||||
但它不能告诉你:
|
||||
|
||||
- 当天早上 09:00 时,系统的 `mu` 是多少
|
||||
- 当时的 `ensemble spread` 是多少
|
||||
- 当时 `DEB` 怎么看
|
||||
- 当时的 top bucket 是什么
|
||||
|
||||
所以:
|
||||
|
||||
- 历史实测天气是标签
|
||||
- 历史预测记录才是训练输入
|
||||
|
||||
缺少后者,EMOS 只能学到很有限的东西。
|
||||
|
||||
## 4. 当前项目里已经有的基础
|
||||
|
||||
### 4.1 已有历史日记录
|
||||
|
||||
文件:
|
||||
|
||||
- [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
当前已经保存了一部分训练相关字段,例如:
|
||||
|
||||
- `forecasts`
|
||||
- `actual_high`
|
||||
- `deb_prediction`
|
||||
- `mu`
|
||||
- `prob_snapshot`
|
||||
- `shadow_prob_snapshot`
|
||||
- `probability_calibration`
|
||||
- `probability_features`
|
||||
|
||||
这已经是“历史预测记录”的雏形。
|
||||
|
||||
### 4.2 已有历史天气 CSV
|
||||
|
||||
目录:
|
||||
|
||||
- [data/historical](/E:/web/PolyWeather/data/historical)
|
||||
|
||||
它们可以帮助补:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement history`
|
||||
|
||||
但不能替代预测快照归档。
|
||||
|
||||
## 5. 未来应该怎么存历史预测记录
|
||||
|
||||
推荐做法是:
|
||||
|
||||
### 5.1 固定时点归档
|
||||
|
||||
每天为每个重点城市固定存几次快照,例如:
|
||||
|
||||
- 当地 `09:00`
|
||||
- 当地 `12:00`
|
||||
- 当地 `15:00`
|
||||
|
||||
这样能确保每个交易日都有稳定可比样本。
|
||||
|
||||
### 5.2 关键变化时补充归档
|
||||
|
||||
除了固定时点,还应该在以下情况额外存一次:
|
||||
|
||||
- `max_so_far` 创新高
|
||||
- `mu` 变化超过阈值
|
||||
- `top bucket` 发生变化
|
||||
- `shadow top bucket` 发生变化
|
||||
|
||||
这样能捕捉真正有训练价值的转折点。
|
||||
|
||||
### 5.3 建议的存储格式
|
||||
|
||||
建议新增一个文件,例如:
|
||||
|
||||
- `data/probability_training_snapshots.jsonl`
|
||||
|
||||
每一行保存一条 JSON 记录。
|
||||
|
||||
优点:
|
||||
|
||||
- 追加写入简单
|
||||
- 后续导出训练集方便
|
||||
- 不容易因为单个大 JSON 文件损坏而全盘受影响
|
||||
|
||||
## 6. 一条建议的快照结构
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "ankara",
|
||||
"timestamp": "2026-03-20T12:00:00+03:00",
|
||||
"date": "2026-03-20",
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2,
|
||||
"deb_prediction": 15.4,
|
||||
"ensemble": {
|
||||
"p10": 14.8,
|
||||
"median": 15.8,
|
||||
"p90": 17.9
|
||||
},
|
||||
"multi_model": {
|
||||
"ECMWF": 15.8,
|
||||
"GFS": 14.1,
|
||||
"ICON": 15.9,
|
||||
"GEM": 16.5,
|
||||
"JMA": 14.5
|
||||
},
|
||||
"max_so_far": 15.0,
|
||||
"peak_status": "before",
|
||||
"prob_snapshot": [
|
||||
{"v": 15, "p": 0.552},
|
||||
{"v": 16, "p": 0.377}
|
||||
],
|
||||
"shadow_prob_snapshot": [
|
||||
{"v": 15, "p": 0.324},
|
||||
{"v": 16, "p": 0.238}
|
||||
],
|
||||
"probability_engine": "legacy",
|
||||
"probability_mode": "emos_shadow",
|
||||
"calibration_mode": "emos_shadow",
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibrated_mu": 15.4,
|
||||
"calibrated_sigma": 1.1
|
||||
}
|
||||
```
|
||||
|
||||
当天结束后,再由后处理脚本回填:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement_bucket`
|
||||
|
||||
当前前端把这类快照解释为“校准模型概率”。如果 `probability_engine` 为 LGBM 相关值,则显示为 LGBM 校准概率;模型舍入票数和市场价格只用于解释,不直接作为最终概率。
|
||||
|
||||
## 7. 现阶段你可以执行的命令
|
||||
|
||||
### 7.1 回填历史天气 CSV
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 补全 30 城市历史天气时序 CSV
|
||||
|
||||
### 7.2 从历史 CSV 构建日级结算标签
|
||||
|
||||
```bash
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
|
||||
|
||||
### 7.3 导出当前训练样本
|
||||
|
||||
```bash
|
||||
python scripts/export_probability_training_dataset.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 7.4 重训 EMOS
|
||||
|
||||
推荐在本地电脑使用生产 SQLite 副本训练,不建议在低配 VPS 上训练:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成新的候选 `default.json`
|
||||
- 同时生成 `evaluation_report.json` 与 `auto_retrain_report.json`
|
||||
- 不自动覆盖线上参数
|
||||
|
||||
### 7.5 离线评估训练效果
|
||||
|
||||
```bash
|
||||
python scripts/evaluate_probability_calibration.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
|
||||
### 7.6 回填 shadow 结果到历史记录
|
||||
|
||||
```bash
|
||||
python scripts/backfill_probability_shadow_history.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 把 `shadow_prob_snapshot` 和 `probability_calibration` 回填到 [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
### 7.7 生成线上 shadow 滚动报表
|
||||
|
||||
```bash
|
||||
python scripts/build_probability_shadow_report.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
## 8. 推荐的一整套重训流程
|
||||
|
||||
如果过了十天、半个月,想重新训练一次,当前推荐流程是:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
只有 `auto_retrain_report.json` 中 `ready_for_promotion=true`,才把候选参数传回 VPS,并优先用 `emos_shadow` 观察。
|
||||
|
||||
如果只是做历史真值补数,才需要额外执行:
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
```
|
||||
|
||||
## 9. 怎么判断这次训练有没有进步
|
||||
|
||||
重训后,不要只看一个指标。
|
||||
|
||||
至少看这 4 个:
|
||||
|
||||
1. `CRPS`
|
||||
- 越低越好
|
||||
|
||||
2. `MAE`
|
||||
- 越低越好
|
||||
- 至少不要明显变差
|
||||
|
||||
3. `Bucket Hit Rate`
|
||||
- 越高越好
|
||||
- 这是业务上非常关键的指标
|
||||
|
||||
4. `Bucket Brier`
|
||||
- 越低越好
|
||||
- 反映概率分布质量
|
||||
|
||||
当前自动门禁至少要求:
|
||||
|
||||
- `CRPS` 下降
|
||||
- `MAE` 最多轻微退化 `0.05`
|
||||
- `Bucket Hit Rate` 退化不超过 `0.05`
|
||||
|
||||
人工复核还应看城市级结果,避免少数关键城市大幅退化。`Bucket Hit Rate` 受整数结算边界影响大,不能单独作为唯一判断。
|
||||
|
||||
## 10. 当前最重要的现实判断
|
||||
|
||||
过去的“完整历史预测记录”通常没法完全补出来,除非:
|
||||
|
||||
1. 你之前就存过
|
||||
2. 你接入了支持 forecast archive 的商业数据源
|
||||
|
||||
所以现实里最重要的不是“把过去全补齐”,而是:
|
||||
|
||||
- 从现在开始系统化归档
|
||||
- 每天稳定沉淀可训练样本
|
||||
- 定期离线重训
|
||||
|
||||
## 11. 推荐的下一步
|
||||
|
||||
最值得做的改造是:
|
||||
|
||||
1. 新增 `probability_training_snapshots.jsonl`
|
||||
2. 每次分析时自动追加一条快照
|
||||
3. 当天结束后自动回填 `actual_high`
|
||||
4. 每 1-2 周在本地电脑重新训练一次
|
||||
5. VPS 只加载通过评估的参数文件,不做全量训练
|
||||
|
||||
## 12. 总结
|
||||
|
||||
如果只记住一句话,就记这个:
|
||||
|
||||
**EMOS 要想越训越好,关键不是多下载一点历史天气,而是持续保存“当时系统看到的预测快照”。**
|
||||
@@ -0,0 +1,53 @@
|
||||
# 外部服务依赖总览
|
||||
|
||||
最后更新:`2026-05-23`
|
||||
|
||||
项目调用了 20 个外部服务,按状态分为三类。
|
||||
|
||||
## 核心(必须有,挂了服务不可用)
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ---------------------- | --------------------- | ---- |
|
||||
| Open-Meteo | 52 城天气预报 | ✅ |
|
||||
| AviationWeather (NOAA) | METAR/TAF 航空观测 | ✅ |
|
||||
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证 + 订阅 | ✅ |
|
||||
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须)
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| -------------------- | --------------------- | ----------- |
|
||||
| JMA (日本) | Tokyo | ✅ |
|
||||
| KMA + AMOS (韩国) | Seoul, Busan | ✅ |
|
||||
| AMSC AWOS (中国) | 北京/上海/广州等 6 城 | ✅ |
|
||||
| MGM (土耳其) | Ankara, Istanbul | ✅ |
|
||||
| FMI (芬兰) | Helsinki | ✅ |
|
||||
| HKO (香港) | Hong Kong | ✅ |
|
||||
| CWA (台湾) | Taipei | ✅ |
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
| Russia pogodaiklimat | Moscow | ❌ 已移除 |
|
||||
|
||||
## 可选 / 已禁用
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| -------------- | ------------- | ----------- |
|
||||
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
|
||||
## AI / 其他
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ----------------- | ---------------- | ----------- |
|
||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||
| DeepSeek | AI fallback | - 备用 |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
| Polygon RPC | 链上支付 | ✅ |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
||||
|
||||
## 合计
|
||||
|
||||
15 个在用,3 个可选/未配置,3 个已移除。
|
||||
@@ -1,4 +1,4 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.5.1)
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.0)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
@@ -92,20 +92,7 @@ POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
```
|
||||
|
||||
## 5. 钱包异动频道拆分(推荐)
|
||||
|
||||
如果要把“钱包异动监控”发到独立频道:
|
||||
|
||||
```env
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
|
||||
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。
|
||||
|
||||
## 6. 验证步骤
|
||||
## 5. 验证步骤
|
||||
|
||||
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
||||
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 技术债与工程待办(v1.6.0)
|
||||
# 技术债与工程待办(v1.8.0)
|
||||
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
@@ -29,7 +29,6 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph S["状态与概率"]
|
||||
S1["EMOS 本地训练与 shadow 发布门禁"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
@@ -47,16 +46,13 @@ flowchart TD
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已接通;生产主概率保持 `legacy` 或 `emos_shadow`,`emos_primary` 只允许本地训练通过门禁后人工灰度。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| EMOS 发布门禁 | 低配 VPS 不适合训练,主概率不能绕过评估 | 本地拉生产 SQLite 训练,`ready_for_promotion=true` 后先 `emos_shadow` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
@@ -64,7 +60,7 @@ flowchart TD
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
@@ -77,6 +73,5 @@ flowchart TD
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 固化 EMOS 本地训练流程,禁止低配 VPS 自动训练和自动主用。
|
||||
2. 补外部监控抓取与告警阈值。
|
||||
3. 评估并推进支付合约 V2 升级。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# PolyWeather 数据链路架构审查
|
||||
|
||||
> 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态
|
||||
>
|
||||
> **修复状态:8/8 已完成**
|
||||
|
||||
## 一、数据架构总览
|
||||
|
||||
```
|
||||
外部数据源 Python 后端 Next.js 前端
|
||||
=========== ========== ===========
|
||||
|
||||
Open-Meteo (预报+多模型) ─┐
|
||||
METAR/TAF (航空气象) ─┤
|
||||
NWS (美国) / MGM (土耳其) ─┤
|
||||
JMA/AMOS/NMC/HKO/CWA ─┤
|
||||
Wunderground / NOAA ─┤
|
||||
Polymarket Gamma/CLOB ─┤
|
||||
├─ WeatherDataCollector ├─ dashboard-client.ts
|
||||
│ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR)
|
||||
│ │
|
||||
├─ _analyze() ├─ useDashboardStore
|
||||
│ ├─ DEB 融合 (11模型加权) │ (双Context拆分)
|
||||
│ └─ 趋势引擎 │ (扫描数据预加载)
|
||||
│ │
|
||||
├─ scan_terminal_service.py ├─ 扫描终端查询
|
||||
│ ├─ ThreadPoolExecutor(4) │ (120s TTL)
|
||||
│ └─ AI 增强层 (DeepSeek) │
|
||||
│ │
|
||||
└─ FastAPI routes └─ API代理 (Next.js rewrites)
|
||||
(36个端点 + ETag 304)
|
||||
```
|
||||
|
||||
## 二、数据采集层
|
||||
|
||||
### 源端(14个外部源)
|
||||
|
||||
| 源 | 类型 | 覆盖 | TTL |
|
||||
|------|------|---------|------|
|
||||
| Open-Meteo | 预报 + 多模型集合 | 全球 | 300s |
|
||||
| METAR | 机场观测 | 全球 ICAO | 60s |
|
||||
| TAF | 机场预报 | 全球 ICAO | 600s |
|
||||
| NWS | 国家预报 | 美国 | 按请求 |
|
||||
| MGM | 国家官方 | 土耳其 | 300s |
|
||||
| ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s |
|
||||
| HKO/CWA/NOAA/AMOS/NMC | 结算观测 | 特定国家 | 60s (AMOS) / 300s |
|
||||
| Wunderground | 个人气象站 | 全球备用 | 按请求 |
|
||||
| Polymarket Gamma | 市场发现 | 所有温度市场 | 60s |
|
||||
| Polymarket CLOB | 订单簿 | 匹配市场 | 30s |
|
||||
|
||||
### 待改进
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 1 | 无源端健康状态检测 | 🟡 |
|
||||
| 2 | METAR TTL 60s 过于激进(机场每小时发一次) | 🟡 |
|
||||
| 3 | 无请求重试(`POLYWEATHER_HTTP_RETRY_COUNT` 默认 0) | 🟡 |
|
||||
|
||||
## 三、分析层
|
||||
|
||||
### DEB 动态集成混合
|
||||
|
||||
自适应加权:11 模型按过去 7 天 MAE 动态分配权重。回退链完善。
|
||||
|
||||
|
||||
### 概率校准
|
||||
|
||||
|
||||
**已修复:校准漂移检测** — `check_calibration_drift()` 对比最近 CRPS 与基线,漂移 >15% 时告警,集成在 `/api/system/status` 的 `probability.drift` 字段。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 4 | 校准系数静态 JSON 文件,数据分布变化需手动重新训练 | 🟡 |
|
||||
|
||||
## 四、API 与缓存层
|
||||
|
||||
**已修复:ETag 304** — 后端 `_etag_middleware` 对 GET /api/* 自动返回 ETag (MD5),支持 `If-None-Match`,匹配返回 304 + `Cache-Control: private, max-age=30`。
|
||||
|
||||
**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
|
||||
|
||||
**已实现:SSE 增量推送(SSE Patch)与按需刷新** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 5 | 缓存键过粗(city::mode),微小变化也触发完整重算 | 🟡 |
|
||||
|
||||
## 五、前端状态管理
|
||||
|
||||
**已修复:sessionStorage 限制** — 只保留最近 3 个城市的详情,避免 3-10MB JSON 序列化阻塞主线程。
|
||||
|
||||
**已修复:Context 拆分** — `CityDetailsContext` 独立管理 `cityDetailsByName` 变更,新增 `useCityDetails` hook。只读详情数据的组件不因其他状态变化而重渲染。
|
||||
|
||||
**已修复:Stale-while-revalidate** — `ensureCityDetail` 过期缓存立即返回 + 后台异步刷新,用户不再看到 loading spinner。
|
||||
|
||||
**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
|
||||
|
||||
**已实现:SSE 订阅与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch` 与 `useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断或 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。
|
||||
|
||||
## 六、待办
|
||||
|
||||
| # | 问题 | 优先级 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | 校准系数需手动重新训练 | 🟡 | 漂移检测已有,但自动触发重训练需要 GPU/算力资源 |
|
||||
| 2 | 缓存键过粗 — `city::mode` 粒度 | 🟢 | 微小温度变化触发完整重算,可考虑内容 hash 键 |
|
||||
|
||||
> 注:原审查中 METAR TTL 60s 实际为 600s(误诊);扫描终端轮询已有 `AbortController` + `requestSeq` 保护(误诊)。
|
||||
@@ -2,22 +2,20 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
从工程实现看,截至 `2026-04-27`,项目已经完成一轮更明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存迁移到 SQLite 主路径,以及最小外部监控链路(`/healthz`、`/api/system/status`、`/metrics` + Prometheus + Alertmanager + Grafana + Telegram relay)。除此之外,项目还补上了**历史真值治理**:`daily_records` 继续只保留近 14 天运行态缓存,但新增了永久真值表、真值 revision 审计表和长期训练特征表,并开始把监督真值与训练特征从“短期缓存”正式拆到“长期可追溯存储”。2026-04 下旬新增的前端城市决策卡把“多模型 + METAR + 市场桶”进一步组合成面向单城点击的解释层:AI 机场报文解读、最高温中枢、完整市场桶匹配与“模型-市场差”已成为 Scan Terminal 的核心决策入口。
|
||||
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py` 与 `web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段;更重要的是,监督真值与训练特征不再只能附着在 14 天运行态缓存上。
|
||||
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已进一步收敛:**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化,同时历史长期特征仍处在“刚开始积累”的阶段。SQLite 迁移方面,运行态主读切换和核心离线训练/回填链路已经完成验收:在移除 `data/*.json` / `data/*.jsonl` 后,训练、评估、shadow report 与关键 backfill 脚本仍可仅依赖运行时数据库正常执行;当前保留的 legacy 文件路径主要用于迁移、导出、校验和显式回退输入。历史真值治理方面,新增的永久真值表、revision 审计表与长期训练特征表已经落地,`Taipei` / `Shenzhen` 的历史页面回填也已接通,因此当前缺口已从“历史真值是否会继续丢失”转为“历史特征是否能持续增长并支撑 EMOS/LGBM 评估”。可观测性方面,最小外部监控链路已经补齐:Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram 告警 relay 与巡检脚本均已落地;当前剩余缺口已从“有没有外部监控”转为“监控覆盖深度是否足够”,例如节点级资源、数据库体积趋势、支付细粒度指标、按城市/来源拆分的业务 SLA。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
|
||||
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**EMOS 上线门禁稳定化、长期训练特征持续积累、监控覆盖深挖、城市决策卡可观测性、支付合约防护升级**这五条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→前端决策→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
|
||||
> **2026-05-23 更新(v1.7.0)**:Polymarket 价格拉取与 UI 层(MarketDecisionLine)已删除,`market_scan` 当前返回空;LGBM 已完全移除,概率引擎仅保留 legacy 高斯 + EMOS/CRPS;Groq、Meteoblue、NMC、pogodaiklimat 数据源和 prewarm 预热系统已移除。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为五层:
|
||||
**天气层(数据源/采集)**:聚合 52 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、韩国 AMOS 跑道级观测(首尔/釜山)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**城市决策层(Scan Terminal / AI 机场报文解读)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment`、`metar_read`、`reasoning`、`model_cluster_note`、`risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 或当前实测。
|
||||
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB(`py-clob-client` 或 REST 回退)读取价格/盘口,并用完整 `all_buckets` 对目标温度桶做 exact/range/“or higher”/“or lower” 严格匹配,计算“模型-市场差”(模型概率 − 市场隐含概率)生成信号标签。
|
||||
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
|
||||
@@ -33,9 +31,8 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/scan/terminal/ai-city/stream`,城市 AI 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
|
||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`nws_open_meteo_sources.py`。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`、`country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
||||
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态、永久真值与训练特征仓储 | 已接入 `daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 持久缓存,并新增永久真值表、真值修订审计表、长期训练特征表。 |
|
||||
@@ -69,11 +66,10 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
OM[Open-Meteo Forecast/Ensemble/Multi-model]
|
||||
AW[AviationWeather Data API<br/>METAR]
|
||||
MGM[MGM Turkey]
|
||||
AMOS[global.amo.go.kr<br/>AMOS runway sensors]
|
||||
NWS[api.weather.gov]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
PM_G[Polymarket Gamma API]
|
||||
PM_C[Polymarket CLOB API]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
end
|
||||
@@ -95,12 +91,11 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
WX --> OM
|
||||
WX --> AW
|
||||
WX --> MGM
|
||||
WX --> AMOS
|
||||
WX --> NWS
|
||||
WX --> HKO
|
||||
WX --> CWA
|
||||
|
||||
FAST --> PM_G
|
||||
FAST --> PM_C
|
||||
FAST --> LLM
|
||||
|
||||
FAST --> SB
|
||||
@@ -118,7 +113,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
|
||||
|
||||
|
||||
### 城市决策卡工作流(2026-04 更新)
|
||||
### 城市决策卡工作流(2026-05 更新)
|
||||
|
||||
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
|
||||
|
||||
@@ -148,13 +143,12 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
|
||||
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。前端侧已通过 `npm run build` 验证 Scan Terminal 改动可以编译;后续仍建议为城市决策卡补固定 fixture,覆盖 `all_buckets` 匹配、温度单位渲染、AI 缓存 key 与 stream 队列行为。
|
||||
**CI/CD**:已补齐 GitHub Actions 工作流,至少覆盖 Python lint/test、前端 build、Docker build 三条门禁;当前缺口不再是“有没有 CI”,而是“是否已在 GitHub 分支保护中强制执行”。
|
||||
**运维验收**:除 `scripts/validate_frontend_cache.sh` 外,现已新增配置校验、运行态迁移/核验、EMOS rollout 判断等脚本,并提供 `/healthz`、`/api/system/status`、`/metrics` 作为基础观测入口。
|
||||
**部署/更新**:Compose 用于启动服务;另有 `update.sh` 通过 `pkill` + `nohup` 重启 bot 与 web。
|
||||
## 优势与薄弱点
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
@@ -164,41 +158,35 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example`、`.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
|
||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
||||
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||
**历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了历史页面回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前临时 MiMo)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz`、`/api/system/status`、`/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势图和运营日报。
|
||||
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
|
||||
> **v1.7.0 更新**:Polymarket(Gamma/CLOB)API 依赖已随市场价格拉取层一并移除。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
## 对标分析
|
||||
|
||||
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
||||
1)**AI 气象预报模型**(GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
|
||||
2)**概率后处理方法**(EMOS):作为 PolyWeather 概率引擎的更标准化替代/对照;
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型。
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):*[v1.7.0 后已不适用]* 市场价格拉取层已移除,此对标仅作历史参考。
|
||||
### 关键对比表
|
||||
|
||||
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→校准概率桶→错价扫描→城市决策卡→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、LGBM/EMOS 校准概率、死盘判定、市场扫描;当前覆盖 52 城市,并已补齐真值治理、后台运维视图、AI 机场报文解读与 full bucket 决策映射。 | 主要依赖外部 API 与城市 AI provider;Docker Compose 一键启动。 | 仓库 `AGPL-3.0-only`;品牌、生产私有数据与运营规则不随代码许可证授权。 |
|
||||
| **GraphCast**(google-deepmind/graphcast) | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
|
||||
| **FourCastNet**(NVlabs/FourCastNet) | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
|
||||
| **Pangu-Weather**(198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
|
||||
| **EMOS**(Gneiting & Raftery 等) | 集合预报校准:纠偏与解决 underdispersion | 统计后处理方法 | 提出用回归形式输出概率分布(常见为高斯),并以 CRPS 等指标拟合,属于成熟的气象概率校准路线。 | 易落地:对 PolyWeather 而言只需“历史库+拟合器”。 | 方法论(论文);可自行实现,无额外许可约束(注意论文版权)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | *[2026-05 起不再使用]* 官方 SDK,支持 read-only 与交易接口。 | 曾用作 PolyWeather 市场层参考。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | *[2026-05 起不再使用]* 类型安全(Pydantic)、自动分页、重试与 backoff。 | 曾用作市场层升级候选。 | 以仓库许可为准。 |
|
||||
|
||||
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-04-27` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
|
||||
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
|
||||
| 高 | **持续积累长期训练特征,验证 SQLite 真值治理后的样本增长** | 1–2 周 | 让 EMOS/LGBM 的重训真正建立在长期可信样本上,而不是继续被短期特征缺口卡住 | 当前真值已长期化,但历史长期特征仍偏少,EMOS/LGBM 样本增长会滞后 | 1) 持续写入 `training_feature_records_store` → 2) 每日检查 `/ops` 训练数据与 `/ops/truth-history` → 3) 定期对 `Taipei` / `Shenzhen` 的历史页面回填做抽查 → 4) 观察样本是否自然增长后再重训 |
|
||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
||||
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
|
||||
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
| 低 | **引入外部神经天气模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||
@@ -206,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||
|
||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
@@ -221,34 +209,33 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
**指标**
|
||||
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
|
||||
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
|
||||
3)概率质量:Brier Score(对离散温度桶),以及建议补充 CRPS(连续变量概率评分,EMOS 体系常用)。
|
||||
4)校准曲线:预测概率分箱的可靠性图(reliability diagram)与 Sharpness(分布集中度)。
|
||||
**基线**
|
||||
|
||||
- Baseline A:Open-Meteo 当日最高温(或 forecast median)作为点预测;
|
||||
- Baseline B:等权平均(DEB 在历史少时也会回退此策略);
|
||||
- Baseline C:当前 DEB;
|
||||
- Baseline D:EMOS(以 ensemble 均值/方差为输入,拟合 μ 与 σ,优化 CRPS)。
|
||||
**预期结果(定性)**
|
||||
|
||||
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
||||
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“52 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“51 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准 *[v1.7.0 已暂停]*
|
||||
|
||||
> **2026-05-23 更新**:Polymarket 价格拉取层与市场扫描(`market_scan`)已于 v1.7.0 移除。本节基准评测方案暂不适用,留待未来若重新引入市场数据层时参考。
|
||||
|
||||
**数据集**
|
||||
|
||||
- 保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 API:Gamma/Data/CLOB)。
|
||||
- 若恢复:保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签。
|
||||
**指标**
|
||||
|
||||
- Signal 覆盖率:能否找到正确 market / bucket;
|
||||
- Edge 稳健性:不同流动性分位的 edge 分布;
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益。
|
||||
**基线**
|
||||
|
||||
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
||||
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
||||
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
|
||||
- 改进策略:引入”流动性/盘口深度/波动”作为信号置信度。
|
||||
**算力**:CPU 即可;关键在于数据采样与回放。
|
||||
## 路线图与风险缓解
|
||||
|
||||
@@ -257,7 +244,6 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
|
||||
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 |
|
||||
| 第 3–4 周 | EMOS 与长期特征继续收口 | 持续积累 `training_feature_records_store`;定期生成 evaluation/shadow/rollout report;继续观察 `bucket_brier` 是否退出 hold | 数据工程为主 |
|
||||
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
|
||||
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
|
||||
|
||||
@@ -270,6 +256,8 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||
## 参考链接
|
||||
|
||||
> **v1.7.0 注**:以下 Polymarket 相关链接已不再被项目使用,保留作为历史参考。
|
||||
|
||||
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
||||
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
||||
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
||||
|
||||
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 947 KiB |
@@ -1,4 +1,4 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.5.1)
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.8.0)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# PolyWeather 前端产品审查报告
|
||||
|
||||
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程
|
||||
|
||||
## 一、产品概览
|
||||
|
||||
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
|
||||
|
||||
### 产品分层
|
||||
|
||||
| 层级 | 功能 | 门槛 |
|
||||
|------|------|------|
|
||||
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
|
||||
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
|
||||
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) |
|
||||
|
||||
### 页面结构(9 个路由)
|
||||
|
||||
| 路由 | 功能 | 是否必需登录 |
|
||||
|------|------|-------------|
|
||||
| `/` | 主看板 — AI 天气决策台 | 否 |
|
||||
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
|
||||
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
|
||||
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
|
||||
| `/subscription-help` | 订阅 FAQ(双语) | 否 |
|
||||
| `/entitlement-required` | 访问被拒页面 | 否 |
|
||||
| `/ops` | 运营管理后台 | 是(管理员) |
|
||||
| `/ops/truth-history` | 真值历史查看器 | 是(管理员) |
|
||||
|
||||
## 二、用户流程分析
|
||||
|
||||
### 主看板的两个视图
|
||||
|
||||
```
|
||||
┌─ 分布视图(地图) ──────────────────────────────┐
|
||||
│ Leaflet 交互式地图,城市彩色气泡 │
|
||||
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
|
||||
│ 免费用户和 Pro 用户均可使用 │
|
||||
├─ 决策卡(分析) ──────────────────────────────┤
|
||||
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表 │
|
||||
│ 需要 Pro 订阅 │
|
||||
└──────────────────────────────────────────────┘
|
||||
└── 右侧栏:城市简报面板(始终可见,免费可用)
|
||||
```
|
||||
|
||||
### 关键用户路径
|
||||
|
||||
1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明)
|
||||
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向
|
||||
|
||||
## 三、做得好的地方
|
||||
|
||||
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
|
||||
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
|
||||
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
|
||||
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
|
||||
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
|
||||
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
|
||||
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
|
||||
8. **浅色/深色主题都有** — 两个主题都经过完整设计
|
||||
9. **Ops 面板功能齐全** — 系统健康、转化漏斗、缓存状态、支付异常、用户管理一览无余
|
||||
|
||||
## 四、存在的问题与待办
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 |
|
||||
| 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 |
|
||||
| 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 |
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# AMSC AWOS Runway Observation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a China-only AMSC AWOS runway observation source and expose it as a runway observation tab next to Market Monitor.
|
||||
|
||||
**Architecture:** Backend fetches and normalizes AMSC `getWindPlate?cccc=...` payloads into the existing `amos`/`runway_obs` shape so current dashboard consumers can reuse runway display logic. Frontend adds a dedicated `runway` scan terminal tab that fetches a domestic city whitelist and renders runway TDZ/MID/END air temperatures without changing settlement anchors.
|
||||
|
||||
**Tech Stack:** Python data collection + pytest, Next.js/React TypeScript, existing business-state test runner.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend parser and source
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data_collection/amsc_awos_sources.py`
|
||||
- Create: `tests/test_amsc_awos_sources.py`
|
||||
- Modify: `src/data_collection/weather_sources.py`
|
||||
- Modify: `src/data_collection/country_networks.py`
|
||||
|
||||
- [ ] **Step 1: Write failing parser tests**
|
||||
|
||||
Add tests that import `_amsc_parse_wind_plate_payload`, `_amsc_supported_city_codes`, and `AmscAwosSourceMixin`, parse a ZBAA-style sample, assert runway point temperatures, UTC observation conversion, `runway_temp_range`, and unauthorized/no-data fallback.
|
||||
|
||||
- [ ] **Step 2: Run red test**
|
||||
|
||||
Run: `python -m pytest tests/test_amsc_awos_sources.py -q`
|
||||
Expected: FAIL because `src.data_collection.amsc_awos_sources` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal backend source**
|
||||
|
||||
Create a source module with China whitelist: `shanghai=ZSPD`, `beijing=ZBAA`, `guangzhou=ZGGG`, `shenzhen=ZGSZ`, `chengdu=ZUUU`, `chongqing=ZUCK`, `wuhan=ZHHH`, `qingdao=ZSQD`. Fetch `https://www.amsc.net.cn/gateway/api/saas/rest/amc/AwosController/getWindPlate?cccc=<ICAO>`, optionally using `POLYWEATHER_AMSC_COOKIE` or `POLYWEATHER_AMSC_SESSION_ID`, and return existing-compatible `amos` payload with `source="amsc_awos"`.
|
||||
|
||||
- [ ] **Step 4: Run green backend tests**
|
||||
|
||||
Run: `python -m pytest tests/test_amsc_awos_sources.py tests/test_amos_station_sources.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Backend integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data_collection/weather_sources.py`
|
||||
- Modify: `src/data_collection/country_networks.py`
|
||||
|
||||
- [ ] **Step 1: Attach AMSC after AMOS**
|
||||
|
||||
Add `AmscAwosSourceMixin` to `WeatherDataCollector`, call `_attach_china_amsc_awos_data` in both Open-Meteo and fallback paths, and persist aggregate plus first runway rows to `airport_obs_log` like AMOS.
|
||||
|
||||
- [ ] **Step 2: Normalize airport primary source labels**
|
||||
|
||||
Teach `_airport_primary_from_raw` that `raw["amos"].source == "amsc_awos"` should use `source_code="amsc_awos"`, `source_label="AMSC AWOS"`.
|
||||
|
||||
- [ ] **Step 3: Compile check**
|
||||
|
||||
Run: `python -m py_compile src/data_collection/amsc_awos_sources.py src/data_collection/weather_sources.py src/data_collection/country_networks.py`
|
||||
Expected: exit 0.
|
||||
|
||||
### Task 3: Frontend runway tab
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx`
|
||||
- Create: `frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts`
|
||||
- Modify: `frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx`
|
||||
- Modify: `frontend/components/dashboard/ScanTerminalDashboard.tsx`
|
||||
- Modify: `frontend/lib/dashboard-types.ts`
|
||||
- Modify: `frontend/components/dashboard/monitoring/monitor-temperature.ts`
|
||||
- Modify: `frontend/components/dashboard/monitoring/MonitorPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing frontend business-state test**
|
||||
|
||||
Add a source-scan test asserting `ScanTerminalContentView` includes `runway`, dashboard has a `跑道观测` tab, and the panel includes `AMSC AWOS` plus TDZ/MID/END labels.
|
||||
|
||||
- [ ] **Step 2: Run red frontend test**
|
||||
|
||||
Run: `cd frontend; npm run test:business`
|
||||
Expected: FAIL because the runway tab/panel strings do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement tab and panel**
|
||||
|
||||
Add `runway` view next to Monitor. The panel fetches domestic whitelist details with `ensureCityDetail(key, false, "panel")`, displays city cards with runway rows and local-time labels, and uses a not-available message for cities without AMSC data.
|
||||
|
||||
- [ ] **Step 4: Run green frontend checks**
|
||||
|
||||
Run: `cd frontend; npm run test:business; npm run typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Final verification and publish
|
||||
|
||||
**Files:**
|
||||
- All changed files from Tasks 1-3.
|
||||
|
||||
- [ ] **Step 1: Full verification**
|
||||
|
||||
Run backend tests, Python compile, frontend business tests, typecheck, and build.
|
||||
|
||||
- [ ] **Step 2: Completion audit**
|
||||
|
||||
Map user requirement “国内几个城市的机场跑道温度,放在市场监控旁边 Tab” to changed backend source, frontend tab, tests, and build evidence.
|
||||
|
||||
- [ ] **Step 3: Commit/push/deploy**
|
||||
|
||||
If verification passes, commit, push `main`, and rely on configured deployment.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Telegram Group Pricing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
|
||||
|
||||
**Goal:** Add Telegram Login verification so backend decides Pro price: group member 5U, non-member 10U.
|
||||
|
||||
**Architecture:** Keep Supabase as the website account session, add Telegram Login as an identity link/price verification step. Backend verifies Telegram Login hash, checks getChatMember, stores the Telegram link in existing supabase_bindings, and payment intent creation recalculates price server-side.
|
||||
|
||||
**Tech Stack:** FastAPI, existing DBManager bindings, Telegram Bot HTTP API, Next.js proxy routes, React account center, pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Telegram auth service
|
||||
|
||||
**Files:**
|
||||
- Create: src/auth/telegram_group_pricing.py
|
||||
- Test: ests/test_telegram_group_pricing.py
|
||||
|
||||
- [ ] Verify Telegram Login payload HMAC using bot token.
|
||||
- [ ] Call Telegram getChatMember and treat member, dministrator, creator as group members.
|
||||
- [ ] Return 5U/10U pricing payload.
|
||||
|
||||
### Task 2: Backend auth route
|
||||
|
||||
**Files:**
|
||||
- Modify: web/core.py
|
||||
- Modify: web/services/auth_api.py
|
||||
- Modify: web/routers/auth.py
|
||||
|
||||
- [ ] Add TelegramLoginRequest model.
|
||||
- [ ] Add POST /api/auth/telegram/login requiring Supabase identity.
|
||||
- [ ] Link Telegram id to current Supabase user via DBManager.bind_supabase_identity.
|
||||
- [ ] Return Telegram member status and effective price.
|
||||
|
||||
### Task 3: Payment dynamic price
|
||||
|
||||
**Files:**
|
||||
- Modify: src/payments/contract_checkout.py
|
||||
- Modify: web/services/payment_api.py
|
||||
|
||||
- [ ] At payment intent creation, check linked Telegram id and group membership.
|
||||
- [ ] Override pro_monthly amount to 5U for members, 10U otherwise.
|
||||
- [ ] Store pricing source in metadata.
|
||||
|
||||
### Task 4: Frontend Telegram Login entry
|
||||
|
||||
**Files:**
|
||||
- Modify: rontend/components/account/AccountCenter.tsx
|
||||
- Create: rontend/app/api/auth/telegram/login/route.ts
|
||||
|
||||
- [ ] Load Telegram Login widget with configured bot username.
|
||||
- [ ] Send payload to backend proxy.
|
||||
- [ ] Show group/member price status before checkout.
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Commands:**
|
||||
- python -m pytest tests\test_telegram_group_pricing.py tests\test_direct_payment.py tests\test_payments_runtime.py -q
|
||||
- python -m py_compile src\auth\telegram_group_pricing.py src\payments\contract_checkout.py web\core.py web\services\auth_api.py
|
||||
- python -m ruff check src\auth\telegram_group_pricing.py src\payments\contract_checkout.py web\core.py web\services\auth_api.py tests\test_telegram_group_pricing.py
|
||||
- cd frontend; npm run typecheck
|
||||
@@ -0,0 +1,929 @@
|
||||
# 终端城市表格按大洲分组 — 实施计划
|
||||
|
||||
> **For agentic workers:** 使用 superpowers:subagent-driven-development 或 superpowers:executing-plans 按任务逐步实施。
|
||||
|
||||
**目标:** 将扫描终端的平铺城市表格重构为按时区分组 + Active Signals 虚拟分组的金融终端风格,桌面端 9 列可折叠分组表格,移动端 Tab 切换 + 卡片流。
|
||||
|
||||
**架构:** 抽取分组逻辑到 `continent-grouping.ts`,新增 `ContinentGroupHeader`、`MobileCityCard`、`MobileRegionTabs` 三个子组件,重构 `ScanTerminalDashboard.tsx` 中的 `KoyfinWeatherTerminal` 和 `MarketTable`。数据字段全部就绪,后端零改动。
|
||||
|
||||
**技术栈:** React 19 + TypeScript + Tailwind CSS 3 + CSS Modules
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 创建 continent-grouping.ts 分组逻辑
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/continent-grouping.ts`
|
||||
|
||||
这个文件负责所有分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理、Gap 颜色映射。
|
||||
|
||||
- [ ] **Step 1: 写入 continent-grouping.ts**
|
||||
|
||||
```typescript
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
||||
// 7 trading regions from backend scan_terminal_filters.py
|
||||
export const TRADING_REGIONS = [
|
||||
{ key: "east_asia", labelEn: "East Asia", labelZh: "东亚", sort: 1 },
|
||||
{ key: "southeast_asia", labelEn: "Southeast Asia", labelZh: "东南亚", sort: 2 },
|
||||
{ key: "central_asia", labelEn: "Central / South Asia", labelZh: "中亚 / 南亚", sort: 3 },
|
||||
{ key: "west_asia", labelEn: "West Asia / Middle East", labelZh: "西亚 / 中东", sort: 4 },
|
||||
{ key: "europe_africa", labelEn: "Europe / Africa", labelZh: "欧洲 / 非洲", sort: 5 },
|
||||
{ key: "south_america", labelEn: "Latin America", labelZh: "拉美", sort: 6 },
|
||||
{ key: "north_america", labelEn: "North America", labelZh: "北美", sort: 7 },
|
||||
] as const;
|
||||
|
||||
export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"];
|
||||
|
||||
export interface ContinentGroup {
|
||||
key: string; // "active_signals" | TradingRegionKey
|
||||
labelEn: string;
|
||||
labelZh: string;
|
||||
sort: number;
|
||||
rows: ScanOpportunityRow[];
|
||||
activeCount: number;
|
||||
watchCount: number;
|
||||
hotCity: string | null;
|
||||
localTimeRange: string | null;
|
||||
}
|
||||
|
||||
export function isActiveSignal(row: ScanOpportunityRow): boolean {
|
||||
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
|
||||
if (decision.includes("approve")) return true;
|
||||
if (row.tradable && row.active) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isWatchSignal(row: ScanOpportunityRow): boolean {
|
||||
const decision = String(row.ai_decision || row.v4_metar_decision || row.signal_status || "").toLowerCase();
|
||||
if (decision.includes("watch")) return true;
|
||||
if (decision.includes("monitor")) return true;
|
||||
if (!row.tradable && row.active) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isDeadSignal(row: ScanOpportunityRow): boolean {
|
||||
if (row.closed) return true;
|
||||
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
|
||||
if (decision.includes("veto")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getSignalState(row: ScanOpportunityRow): "active" | "watch" | "closed" | "data" {
|
||||
if (isDeadSignal(row)) return "closed";
|
||||
if (isActiveSignal(row)) return "active";
|
||||
if (isWatchSignal(row)) return "watch";
|
||||
return "data";
|
||||
}
|
||||
|
||||
export function getSignalLabel(state: ReturnType<typeof getSignalState>, isEn: boolean): string {
|
||||
switch (state) {
|
||||
case "active": return isEn ? "◆ Active" : "◆ 活跃";
|
||||
case "watch": return isEn ? "● Watch" : "● 观察";
|
||||
case "closed": return isEn ? "○ Closed" : "○ 关闭";
|
||||
case "data": return isEn ? "! Data" : "! 数据";
|
||||
}
|
||||
}
|
||||
|
||||
export type GapColor = "green" | "orange" | "slate" | "gray" | "red";
|
||||
|
||||
export function getGapColor(row: ScanOpportunityRow): GapColor {
|
||||
const gap = Number(row.signed_gap ?? row.gap_to_target);
|
||||
const edge = Number(row.edge_percent || 0);
|
||||
const spread = Number(row.spread || 0);
|
||||
const liq = Number(row.book_liquidity || row.market_liquidity || 0);
|
||||
|
||||
if (!Number.isFinite(gap)) return "gray";
|
||||
if (liq <= 0 || spread > 20) return "red";
|
||||
if (gap >= 2) return "green";
|
||||
if (gap >= 0 && edge > 5) return "orange";
|
||||
if (gap >= 0) return "slate";
|
||||
if (gap < -5 || edge < -10) return "gray";
|
||||
return "slate";
|
||||
}
|
||||
|
||||
export const GAP_COLOR_MAP: Record<GapColor, string> = {
|
||||
green: "text-emerald-600",
|
||||
orange: "text-amber-600",
|
||||
slate: "text-slate-500",
|
||||
gray: "text-slate-400",
|
||||
red: "text-red-500",
|
||||
};
|
||||
|
||||
export function formatPrice(midpoint?: number | null, ask?: number | null, bid?: number | null): string {
|
||||
const m = Number(midpoint);
|
||||
if (Number.isFinite(m) && m > 0) {
|
||||
const cents = Math.round(m * 100);
|
||||
return `Y ${cents}¢`;
|
||||
}
|
||||
const a = Number(ask);
|
||||
if (Number.isFinite(a) && a > 0) {
|
||||
const cents = Math.round(a * 100);
|
||||
return `Y ${cents}¢`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
export function formatSpreadLiquidity(spread?: number | null, liquidity?: number | null): string {
|
||||
const sp = Number(spread);
|
||||
const liq = Number(liquidity);
|
||||
const spStr = Number.isFinite(sp) ? `${Math.round(sp)}¢` : "--";
|
||||
const liqStr = Number.isFinite(liq)
|
||||
? liq >= 1000
|
||||
? `$${(liq / 1000).toFixed(1)}K`
|
||||
: `$${Math.round(liq)}`
|
||||
: "--";
|
||||
return `${spStr} / ${liqStr}`;
|
||||
}
|
||||
|
||||
export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): ContinentGroup[] {
|
||||
const regionMap = new Map<string, ScanOpportunityRow[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const region = String(row.trading_region || "unknown").toLowerCase();
|
||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||
regionMap.get(region)!.push(row);
|
||||
}
|
||||
|
||||
const groups: ContinentGroup[] = [];
|
||||
|
||||
// Active Signals virtual group
|
||||
const activeRows = rows.filter((r) => isActiveSignal(r));
|
||||
if (activeRows.length > 0) {
|
||||
const hotRow = activeRows.reduce((best, r) =>
|
||||
Number(r.edge_percent || 0) > Number(best.edge_percent || 0) ? r : best
|
||||
);
|
||||
groups.push({
|
||||
key: "active_signals",
|
||||
labelEn: "Active Signals",
|
||||
labelZh: "活跃信号",
|
||||
sort: 0,
|
||||
rows: activeRows,
|
||||
activeCount: activeRows.filter((r) => isActiveSignal(r)).length,
|
||||
watchCount: activeRows.filter((r) => isWatchSignal(r)).length,
|
||||
hotCity: hotRow?.city_display_name || hotRow?.city || null,
|
||||
localTimeRange: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const region of TRADING_REGIONS) {
|
||||
const regionRows = regionMap.get(region.key) || [];
|
||||
if (regionRows.length === 0) continue;
|
||||
|
||||
const activeCount = regionRows.filter((r) => isActiveSignal(r)).length;
|
||||
const watchCount = regionRows.filter((r) => isWatchSignal(r)).length;
|
||||
const sorted = [...regionRows].sort((a, b) =>
|
||||
Number(b.final_score || 0) - Number(a.final_score || 0)
|
||||
);
|
||||
const hotCity = sorted[0]?.city_display_name || sorted[0]?.city || null;
|
||||
|
||||
// Compute local time range for this region
|
||||
const times = regionRows.map((r) => String(r.local_time || "").trim()).filter(Boolean);
|
||||
const ltRange = times.length >= 2
|
||||
? `${times[0]}-${times[times.length - 1]}`
|
||||
: times[0] || null;
|
||||
|
||||
groups.push({
|
||||
key: region.key,
|
||||
labelEn: region.labelEn,
|
||||
labelZh: region.labelZh,
|
||||
sort: region.sort,
|
||||
rows: regionRows,
|
||||
activeCount,
|
||||
watchCount,
|
||||
hotCity,
|
||||
localTimeRange: ltRange,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: active_signals first, then by trading_region_sort
|
||||
groups.sort((a, b) => a.sort - b.sort);
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function getDefaultExpanded(groups: ContinentGroup[]): Set<string> {
|
||||
const expanded = new Set<string>();
|
||||
for (const g of groups) {
|
||||
if (g.key === "active_signals") {
|
||||
expanded.add(g.key);
|
||||
} else if (g.activeCount > 0 || g.watchCount > 0) {
|
||||
expanded.add(g.key);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 类型**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit src/components/dashboard/scan-terminal/continent-grouping.ts 2>&1 || true
|
||||
```
|
||||
|
||||
预期:类型通过(可能有模块路径的错误,这些在组件集成后解决)。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/continent-grouping.ts
|
||||
git commit -m "新增终端大洲分组逻辑模块"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建 ContinentGroupHeader.tsx 分组标题行组件
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx`
|
||||
|
||||
- [ ] **Step 1: 写入组件**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
export function ContinentGroupHeader({
|
||||
group,
|
||||
isExpanded,
|
||||
isEn,
|
||||
onToggle,
|
||||
}: {
|
||||
group: ContinentGroup;
|
||||
isExpanded: boolean;
|
||||
isEn: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const label = isEn ? group.labelEn : group.labelZh;
|
||||
const parts: string[] = [`${group.rows.length}`];
|
||||
|
||||
if (group.activeCount > 0) {
|
||||
parts.push(isEn ? `Active ${group.activeCount}` : `活跃 ${group.activeCount}`);
|
||||
}
|
||||
if (group.watchCount > 0) {
|
||||
parts.push(isEn ? `Watch ${group.watchCount}` : `观察 ${group.watchCount}`);
|
||||
}
|
||||
if (group.localTimeRange) {
|
||||
parts.push(`LT ${group.localTimeRange}`);
|
||||
}
|
||||
if (group.hotCity) {
|
||||
parts.push(isEn ? `Hot: ${group.hotCity}` : `热门: ${group.hotCity}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="group flex w-full items-center gap-2 border-b border-slate-200 bg-[#eef2f6] px-3 py-2 text-left hover:bg-[#e2e8f0] transition-colors"
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center text-slate-400 group-hover:text-slate-600">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="text-xs font-black uppercase tracking-wide text-slate-600">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx
|
||||
git commit -m "新增终端大洲分组标题行组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 创建 MobileCityCard.tsx 移动端卡片组件
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/MobileCityCard.tsx`
|
||||
|
||||
- [ ] **Step 1: 写入组件**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import {
|
||||
formatPrice,
|
||||
formatSpreadLiquidity,
|
||||
GAP_COLOR_MAP,
|
||||
getGapColor,
|
||||
getSignalLabel,
|
||||
getSignalState,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
function rowName(row: ScanOpportunityRow) {
|
||||
return row.city_display_name || row.display_name || row.city || "--";
|
||||
}
|
||||
|
||||
function tempVal(value?: number | null, symbol?: string | null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "--";
|
||||
return `${n.toFixed(1)}${symbol || "°"}`;
|
||||
}
|
||||
|
||||
export function MobileCityCard({
|
||||
isEn,
|
||||
onClick,
|
||||
row,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
onClick: (row: ScanOpportunityRow) => void;
|
||||
row: ScanOpportunityRow;
|
||||
}) {
|
||||
const signal = getSignalState(row);
|
||||
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(row)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm hover:bg-blue-50/70 transition-colors"
|
||||
>
|
||||
{/* Row 1: City + Signal */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-bold text-slate-900">
|
||||
{rowName(row)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-black">
|
||||
<span className={signal === "active" ? "text-emerald-600" : signal === "watch" ? "text-amber-600" : signal === "closed" ? "text-slate-400" : "text-red-500"}>
|
||||
{getSignalLabel(signal, isEn)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Obs · Gap · Market */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="font-mono font-bold">
|
||||
Obs {tempVal(row.current_temp, row.temp_symbol)}
|
||||
</span>
|
||||
<span className={gapColor}>
|
||||
Gap {tempVal(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
|
||||
</span>
|
||||
<span className="text-slate-600">
|
||||
{formatPrice(row.midpoint, row.ask, row.bid)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: High · DEB */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||
<span>
|
||||
High {tempVal(row.current_max_so_far, row.temp_symbol)}
|
||||
</span>
|
||||
<span>
|
||||
DEB {tempVal(row.deb_prediction, row.temp_symbol)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/MobileCityCard.tsx
|
||||
git commit -m "新增终端移动端城市卡片组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 MobileRegionTabs.tsx 移动端 Tab 栏
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx`
|
||||
|
||||
- [ ] **Step 1: 写入组件**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
export function MobileRegionTabs({
|
||||
activeTab,
|
||||
groups,
|
||||
isEn,
|
||||
onSelectTab,
|
||||
}: {
|
||||
activeTab: string;
|
||||
groups: ContinentGroup[];
|
||||
isEn: boolean;
|
||||
onSelectTab: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-x-auto border-b border-slate-200 bg-white px-2 no-scrollbar">
|
||||
{groups.map((g) => {
|
||||
const label = isEn ? g.labelEn : g.labelZh;
|
||||
const isActive = activeTab === g.key;
|
||||
return (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
onClick={() => onSelectTab(g.key)}
|
||||
className={clsx(
|
||||
"shrink-0 px-3 py-2.5 text-xs font-bold whitespace-nowrap border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-blue-600 text-blue-700"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span className="ml-1 text-[10px] text-slate-400">
|
||||
{g.activeCount > 0 ? `${g.activeCount}A` : g.rows.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx
|
||||
git commit -m "新增终端移动端大洲 Tab 栏组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 重构 ScanTerminalDashboard.tsx — 重写 MarketTable 为分组表格
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (替换 `MarketTable` 函数)
|
||||
|
||||
这个步骤将现有的 `MarketTable` 替换为支持分组标题行的新版本 `GroupedMarketTable`。
|
||||
|
||||
- [ ] **Step 1: 替换 MarketTable 组件 (第 245-323 行)**
|
||||
|
||||
删除旧 `MarketTable`,替换为:
|
||||
|
||||
```tsx
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
ContinentGroup,
|
||||
buildContinentGroups,
|
||||
formatPrice,
|
||||
formatSpreadLiquidity,
|
||||
GAP_COLOR_MAP,
|
||||
getDefaultExpanded,
|
||||
getGapColor,
|
||||
getSignalLabel,
|
||||
getSignalState,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
function GroupedMarketTable({
|
||||
groups,
|
||||
isEn,
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: {
|
||||
groups: ContinentGroup[];
|
||||
isEn: boolean;
|
||||
onSelect: (row: ScanOpportunityRow) => void;
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||
const c = new Set<string>();
|
||||
const defaultExpanded = getDefaultExpanded(groups);
|
||||
for (const g of groups) {
|
||||
if (!defaultExpanded.has(g.key)) c.add(g.key);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[800px] border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-[#f5f7fa] text-left text-[11px] uppercase text-slate-500">
|
||||
<th className="px-3 py-2 font-black">City</th>
|
||||
<th className="px-2 py-2 text-right font-black">Obs</th>
|
||||
<th className="px-2 py-2 text-right font-black">High</th>
|
||||
<th className="px-2 py-2 text-right font-black">DEB</th>
|
||||
<th className="px-2 py-2 text-right font-black">Gap</th>
|
||||
<th className="px-2 py-2 text-right font-black">Market</th>
|
||||
<th className="px-2 py-2 text-right font-black">Edge</th>
|
||||
<th className="px-2 py-2 text-right font-black">Spr/Liq</th>
|
||||
<th className="px-3 py-2 font-black">Signal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group) => {
|
||||
const isExpanded = !collapsed.has(group.key);
|
||||
const label = isEn ? group.labelEn : group.labelZh;
|
||||
return (
|
||||
<Fragment key={group.key}>
|
||||
{/* Group header row */}
|
||||
<tr className="border-b border-slate-200 bg-[#eef2f6]">
|
||||
<td colSpan={9} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-[#e2e8f0] transition-colors"
|
||||
>
|
||||
<span className="grid h-4 w-4 place-items-center text-slate-400">
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<span className="text-[11px] font-black uppercase tracking-wide text-slate-600">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{group.rows.length} · {isEn ? "Active" : "活跃"} {group.activeCount} · {isEn ? "Watch" : "观察"} {group.watchCount}
|
||||
{group.localTimeRange ? ` · LT ${group.localTimeRange}` : ""}
|
||||
{group.hotCity ? ` · Hot: ${group.hotCity}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Data rows */}
|
||||
{isExpanded &&
|
||||
group.rows.map((row) => {
|
||||
const signal = getSignalState(row);
|
||||
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={clsx(
|
||||
"cursor-pointer border-b border-slate-100 hover:bg-blue-50/70",
|
||||
selectedId === row.id && "bg-blue-50"
|
||||
)}
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-bold text-slate-900">{rowName(row)}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">
|
||||
{row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono font-bold">
|
||||
{temp(row.current_temp, row.temp_symbol)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{temp(row.current_max_so_far, row.temp_symbol)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{temp(row.deb_prediction, row.temp_symbol)}
|
||||
</td>
|
||||
<td className={clsx("px-2 py-2 text-right font-mono font-bold", gapColor)}>
|
||||
{temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{formatPrice(row.midpoint, row.ask, row.bid)}
|
||||
</td>
|
||||
<td className={clsx("px-2 py-2 text-right font-mono font-bold", edgeClass(row.edge_percent))}>
|
||||
{pct(row.edge_percent)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono text-[11px]">
|
||||
{formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx(
|
||||
"text-[11px] font-black",
|
||||
signal === "active" ? "text-emerald-600" :
|
||||
signal === "watch" ? "text-amber-600" :
|
||||
signal === "closed" ? "text-slate-400" : "text-red-500"
|
||||
)}>
|
||||
{getSignalLabel(signal, isEn)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
需要在本文件顶部新增 `import { Fragment, ... }` from react。
|
||||
|
||||
- [ ] **Step 2: 更新 import 语句 (文件第 1 行)**
|
||||
|
||||
将 `import { useEffect, useMemo, useState } from "react";` 改为:
|
||||
```tsx
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
|
||||
git commit -m "终端表格重构为9列时区分组布局:新增 City/Obs/High/DEB/Gap/Market/Edge/SprLiq/Signal 列"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 重构 KoyfinWeatherTerminal — 接入分组逻辑
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal`)
|
||||
|
||||
在 `KoyfinWeatherTerminal` 中引入 `buildContinentGroups`,将左列 `MarketTable` 替换为 `GroupedMarketTable`,中列 `MarketTable` 替换为 `GroupedMarketTable`,右列 Watchlist 保留列表展示。
|
||||
|
||||
- [ ] **Step 1: 修改 KoyfinWeatherTerminal 中的左列表格部分 (第 460-491 行)**
|
||||
|
||||
找到左列 `<Panel title="Weather Contracts">` 内的 `<MarketTable>`,替换为:
|
||||
|
||||
```tsx
|
||||
<Panel title={isEn ? "Weather Contracts" : "天气合约"}>
|
||||
<div className="grid grid-cols-3 border-b border-slate-200 text-center">
|
||||
<div className="p-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-500">
|
||||
{isEn ? "Rows" : "行数"}
|
||||
</div>
|
||||
<div className="font-mono text-xl font-black">{rows.length}</div>
|
||||
</div>
|
||||
<div className="border-x border-slate-200 p-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-500">
|
||||
Avg Edge
|
||||
</div>
|
||||
<div className={clsx("font-mono text-xl font-black", edgeClass(avgEdge))}>
|
||||
{pct(avgEdge)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-500">
|
||||
Liquidity
|
||||
</div>
|
||||
<div className="font-mono text-xl font-black">
|
||||
{money(totalLiquidity)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GroupedMarketTable
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
selectedId={selectedRow?.id}
|
||||
onSelect={setSelectedRow}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换中列 MarketTable (第 600-606 行)**
|
||||
|
||||
```tsx
|
||||
<Panel title={isEn ? "All Contracts" : "全部合约"}>
|
||||
<GroupedMarketTable
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
selectedId={selectedRow?.id}
|
||||
onSelect={setSelectedRow}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 KoyfinWeatherTerminal 中计算 continentGroups**
|
||||
|
||||
在组件内部,紧接 `const selectedLabel = ...` 之后添加:
|
||||
|
||||
```tsx
|
||||
const continentGroups = useMemo(
|
||||
() => buildContinentGroups(rows, isEn),
|
||||
[rows, isEn]
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
|
||||
git commit -m "终端三列布局接入大洲分组数据"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 实现移动端响应式布局 — Tab + 卡片流
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal` main 区域)
|
||||
|
||||
在 `<main>` 内,用 `useMediaQuery` 或 Tailwind 响应式类区分桌面/移动端布局。移动端显示 `MobileRegionTabs` + `MobileCityCard` 列表。
|
||||
|
||||
- [ ] **Step 1: 在 KoyfinWeatherTerminal 中添加移动端状态和渲染**
|
||||
|
||||
在组件顶部添加:
|
||||
```tsx
|
||||
const [mobileTab, setMobileTab] = useState<string>("active_signals");
|
||||
```
|
||||
|
||||
在 `continentGroups` 计算后添加:
|
||||
```tsx
|
||||
const mobileActiveGroup = useMemo(
|
||||
() => continentGroups.find((g) => g.key === mobileTab) || continentGroups[0],
|
||||
[continentGroups, mobileTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (continentGroups.length > 0 && !continentGroups.find((g) => g.key === mobileTab)) {
|
||||
setMobileTab(continentGroups[0].key);
|
||||
}
|
||||
}, [continentGroups, mobileTab]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 main 区域为响应式双布局 (第 458-673 行)**
|
||||
|
||||
在 `<main>` 中包裹条件渲染:
|
||||
|
||||
```tsx
|
||||
<main className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{/* Mobile layout */}
|
||||
<div className="flex flex-col gap-2 lg:hidden">
|
||||
<MobileRegionTabs
|
||||
activeTab={mobileTab}
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
onSelectTab={setMobileTab}
|
||||
/>
|
||||
<div className="space-y-2 px-1">
|
||||
{mobileActiveGroup?.rows.map((row) => (
|
||||
<MobileCityCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
isEn={isEn}
|
||||
onClick={setSelectedRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout (existing 3-column grid) */}
|
||||
<div className="hidden min-h-full grid-cols-1 gap-2 lg:grid xl:grid-cols-[1.12fr_1.6fr_1.1fr]">
|
||||
{/* ... existing 3 columns unchanged ... */}
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
现有的三列布局代码放入 `{/* Desktop layout */}` 块中,用 `hidden lg:grid` 控制显示。
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
|
||||
git commit -m "终端新增移动端 Tab+卡片流响应式布局"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 添加 CSS 样式 — 分组行和移动端卡片
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/ScanTerminalContinent.module.css`
|
||||
- 修改: `frontend/components/dashboard/scan-root-styles.ts`
|
||||
|
||||
- [ ] **Step 1: 写入 CSS Module**
|
||||
|
||||
```css
|
||||
/* ScanTerminalContinent.module.css */
|
||||
|
||||
.root {
|
||||
--gap-green: #16a34a;
|
||||
--gap-orange: #ea580c;
|
||||
--gap-slate: #475569;
|
||||
--gap-gray: #94a3b8;
|
||||
--gap-red: #dc2626;
|
||||
}
|
||||
|
||||
/* Group header */
|
||||
.groupHeader {
|
||||
background: #eef2f6;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
.groupHeader:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile: hide scrollbar on tab bar */
|
||||
.mobileTabs {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.mobileTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile card */
|
||||
.mobileCard {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mobileCard:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
/* Signal badge */
|
||||
.signalActive {
|
||||
color: #059669;
|
||||
}
|
||||
.signalWatch {
|
||||
color: #d97706;
|
||||
}
|
||||
.signalClosed {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.signalData {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:global(html.light) .groupHeader {
|
||||
background: #f8fafc;
|
||||
border-bottom-color: #e2e8f0;
|
||||
}
|
||||
:global(html.light) .groupHeader:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
:global(html.light) .mobileCard {
|
||||
background: #ffffff;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册到 scan-root-styles.ts barrel**
|
||||
|
||||
读取 `frontend/components/dashboard/scan-root-styles.ts`,追加:
|
||||
```typescript
|
||||
import sContinent from "@/components/dashboard/ScanTerminalContinent.module.css";
|
||||
// ... add sContinent.root to the barrel export
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalContinent.module.css frontend/components/dashboard/scan-root-styles.ts
|
||||
git commit -m "新增终端大洲分组与移动端卡片样式"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 最终验证与构建
|
||||
|
||||
- [ ] **Step 1: TypeScript 类型检查**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
预期:0 errors。
|
||||
|
||||
- [ ] **Step 2: 生产构建**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
预期:✓ Compiled successfully,66 pages generated。
|
||||
|
||||
- [ ] **Step 3: 启动 dev server 验证 UI**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
打开 http://localhost:3000/terminal 验证:
|
||||
- 桌面端:Active Signals 展开显示,时区分组折叠/展开,9 列表格
|
||||
- 移动端:Tab 切换流畅,卡片流显示正常
|
||||
- 双主题切换无样式错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "终端大洲分组功能验证通过"
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# Live Temperature Chart Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Split `LiveTemperatureThresholdChart.tsx` so chart data generation and SSE patch merge logic live in focused pure modules, while the visible chart behavior stays unchanged.
|
||||
|
||||
**Architecture:** Keep the React component as the orchestration/rendering layer. Move types, constants, time helpers, series builders, fallback rules, and patch merge into adjacent modules under `frontend/components/dashboard/scan-terminal/`. Preserve existing test exports through the component file during the first split to avoid changing test callers.
|
||||
|
||||
**Tech Stack:** Next.js/React, TypeScript, Recharts, existing business-state test runner.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts`
|
||||
- Modify: `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
|
||||
- Modify: `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
|
||||
- Verify: `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add a failing architecture test that rejects keeping all chart data builders inside `LiveTemperatureThresholdChart.tsx`.
|
||||
- [ ] Extract pure chart logic into `temperature-chart-logic.ts`.
|
||||
- [ ] Import extracted functions/types back into `LiveTemperatureThresholdChart.tsx`.
|
||||
- [ ] Keep the current test-only exports stable from `LiveTemperatureThresholdChart.tsx`.
|
||||
- [ ] Run `npm run test:business`, `npm run typecheck`, and `npm run build`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Production Realtime SSE Patch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade PolyWeather terminal charts from in-process best-effort SSE patches to a replayable, city-scoped, versioned realtime observation stream for PM highest-temperature prediction workflows.
|
||||
|
||||
**Architecture:** Keep HTTP APIs as the full snapshot/source-of-truth layer. Add a short-window SQLite event log for SSE replay, version observations as `city_observation_patch.v1`, fan out live events through the existing SSE manager, and let the frontend subscribe only to visible cities with `since_revision` reconnect replay.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLite/WAL via `DBManager`, in-process `asyncio.Queue` SSE fanout, React/Next.js `EventSource`, TypeScript external store hooks.
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- Default replay retention is 6 hours because this event log is not the business history store.
|
||||
- The retention can be raised with `POLYWEATHER_PATCH_EVENT_RETENTION_HOURS`, but the product does not need all-day patch retention.
|
||||
- First production step is SQLite-only; Redis/Postgres pub/sub remains a later multi-instance extension.
|
||||
- Existing legacy `city_patch` ingest and frontend handling must keep working during rollout.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Backend schema tests
|
||||
- Add tests proving legacy collector payloads normalize to `city_observation_patch.v1`.
|
||||
- Cover runway point conversion from `amos.runway_obs.point_temperatures`.
|
||||
- Cover invalid payload rejection when city and useful observation data are missing.
|
||||
|
||||
- [ ] Backend schema implementation
|
||||
- Add `web/realtime_patch_schema.py`.
|
||||
- Normalize city/source/obs time/temp/max/runway payload fields.
|
||||
- Keep payload small and JSON-serializable.
|
||||
|
||||
- [ ] Event store tests
|
||||
- Add tests for monotonic SQLite revisions.
|
||||
- Add city-filtered replay tests.
|
||||
- Add retention cleanup tests for stale replay rows.
|
||||
|
||||
- [ ] Event store implementation
|
||||
- Add `observation_patch_events` table and indexes in `DBManager`.
|
||||
- Add `web/realtime_event_store.py` for append, replay, latest revision, and cleanup.
|
||||
- Use `POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6` as the default.
|
||||
|
||||
- [ ] SSE replay tests
|
||||
- Add tests for `/api/events?cities=...&since_revision=...`.
|
||||
- Verify replay only returns subscribed cities.
|
||||
- Verify replay over limit emits `resync_required`.
|
||||
|
||||
- [ ] SSE implementation
|
||||
- Update router to parse `cities`, `since_revision`, and bounded `replay_limit`.
|
||||
- Write normalized events to SQLite before broadcasting.
|
||||
- Update manager to track per-connection city subscriptions while keeping heartbeat behavior.
|
||||
|
||||
- [ ] Frontend SSE tests
|
||||
- Extend architecture tests for v1 schema, `cities`, `since_revision`, replay/resync handling.
|
||||
- Add chart merge coverage for v1 runway point payloads.
|
||||
|
||||
- [ ] Frontend SSE implementation
|
||||
- Update `use-sse-patches.ts` to normalize v1 and legacy patch events.
|
||||
- Track global `lastRevision`.
|
||||
- Reconnect with visible-city `cities` and `since_revision`.
|
||||
- Expose a resync signal for charts when the server cannot replay.
|
||||
|
||||
- [ ] Chart/list integration
|
||||
- Ensure visible charts register their city subscription.
|
||||
- Keep terminal row patching lightweight: current temp, current max, local time, revision.
|
||||
- Append v1 temp/runway points into existing chart series without forcing full-detail polling.
|
||||
|
||||
- [ ] Verification
|
||||
- Run targeted backend pytest files.
|
||||
- Run `npm run test:business`.
|
||||
- Run `npm run typecheck`.
|
||||
- Run `npm run build`.
|
||||
@@ -0,0 +1,137 @@
|
||||
# 终端城市表格按大洲分组 — 设计文档
|
||||
|
||||
日期:2026-05-25
|
||||
状态:已确认
|
||||
|
||||
## 目标
|
||||
|
||||
将扫描终端(Scan Terminal)的城市列表从平铺表格改造为按时区分组、按交易信号优先的金融终端风格布局。
|
||||
|
||||
## 数据
|
||||
|
||||
所有字段已在 `/api/scan/terminal` 响应的 `ScanOpportunityRow` 中就绪,无需后端改动。
|
||||
|
||||
### 列 → 字段映射
|
||||
|
||||
| 列名 | 字段 | 类型 |
|
||||
|------|------|------|
|
||||
| City | `city`, `airport`, `local_time` | `string` |
|
||||
| Obs | `current_temp` | `number \| null` |
|
||||
| High | `current_max_so_far` | `number \| null` |
|
||||
| DEB | `deb_prediction` | `number \| null` |
|
||||
| Gap | `signed_gap` / `gap_to_target` | `number \| null` |
|
||||
| Market | `midpoint`, `bid`, `ask` | `number \| null` |
|
||||
| Edge | `edge_percent` | `number \| null` |
|
||||
| Spr/Liq | `spread`, `book_liquidity` | `number \| null` |
|
||||
| Signal | `signal_status`, `ai_decision` | `string \| null` |
|
||||
|
||||
## 时区分组
|
||||
|
||||
后端 `scan_terminal_filters.py` 按 UTC 偏移量划分 7 个交易区域:
|
||||
|
||||
| 排序 | 区域键 | 英文 | 中文 | UTC 条件 |
|
||||
|------|--------|------|------|----------|
|
||||
| 1 | east_asia | East Asia | 东亚 | >= +8 |
|
||||
| 2 | southeast_asia | Southeast Asia | 东南亚 | >= +7 |
|
||||
| 3 | central_asia | Central / South Asia | 中亚/南亚 | >= +5 |
|
||||
| 4 | west_asia | West Asia / Middle East | 西亚/中东 | >= +3 |
|
||||
| 5 | europe_africa | Europe / Africa | 欧洲/非洲 | >= 0 |
|
||||
| 6 | south_america | Latin America | 拉美 | >= -5 |
|
||||
| 7 | north_america | North America | 北美 | < -5 |
|
||||
|
||||
## 桌面端布局(≥ 768px)
|
||||
|
||||
### 虚拟分组:"Active Signals"
|
||||
|
||||
在 7 个时区分组上方插入一个虚拟分组,聚合当前最重要的信号:
|
||||
|
||||
- 筛选条件:`ai_decision === "approve"`、`tradable === true`、`peak`、`rising`、`超预期`
|
||||
- 标题行显示:城市数量、Hot 城市、更新时间
|
||||
- 格式:`▼ Active Signals 活跃信号 6 · Hot: Tokyo · Updated 14:32`
|
||||
|
||||
### 时区分组标题行
|
||||
|
||||
- 显示:折叠箭头、中英文名、城市数、active 数、watch 数、本地时间
|
||||
- 热门城市标注
|
||||
- 格式:`▼ East Asia 东亚 5 · Active 2 · Watch 1 · LT 14:20`
|
||||
- 可折叠,默认规则:
|
||||
- Active Signals 始终展开
|
||||
- 包含 active/watch 的时区展开
|
||||
- 无机会的时区折叠
|
||||
|
||||
### 表格行
|
||||
|
||||
- 9 列:City | Obs | High | DEB | Gap | Market | Edge | Spr/Liq | Signal
|
||||
- Market 列显示价格格式:`Y 72¢` / `N 28¢`
|
||||
- Signal 列显示:`◆ Active` / `● Watch` / `○ Closed` (文字+图标)
|
||||
|
||||
### Gap 列颜色语义
|
||||
|
||||
| 状态 | 颜色 | 含义 |
|
||||
|------|------|------|
|
||||
| 已突破/超预期 | 绿色 `#22c55e` | 对市场方向有利 |
|
||||
| 接近目标未突破 | 橙色 `#f59e0b` | Peak Watch |
|
||||
| 明显低于目标 | 蓝灰 `#64748b` | 尚不到位 |
|
||||
| 追不上/时间不够 | 灰色 `#94a3b8` | 机会减弱 |
|
||||
| 数据异常/风险 | 红色 `#ef4444` | 需关注 |
|
||||
|
||||
## 移动端布局(< 768px)
|
||||
|
||||
### Tab 栏
|
||||
|
||||
横向滚动,包含:`All | Active | 东亚 | 东南亚 | 中亚南亚 | 西亚 | 欧洲非洲 | 北美 | 拉美`
|
||||
|
||||
当前时区 Tab 默认选中。
|
||||
|
||||
### 信号卡片流
|
||||
|
||||
每张卡片两行信息:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Tokyo · ◆ Active │
|
||||
│ Obs 28.1°C · Gap -0.8°C · Y 72¢ │
|
||||
│ High 29.3°C · DEB 30.0°C │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
卡片字段:City、Signal、Obs、Gap、Market(第一行),High、DEB(第二行)
|
||||
|
||||
## 文件变更范围
|
||||
|
||||
### 主要文件
|
||||
|
||||
| 文件 | 变更 | 说明 |
|
||||
|------|------|------|
|
||||
| `components/dashboard/ScanTerminalDashboard.tsx` | 重构 | 拆分 `MarketTable`、`KoyfinWeatherTerminal`,新增分组和卡片组件 |
|
||||
| `components/dashboard/scan-terminal/` | 新增 | `ContinentGroupHeader.tsx`、`ActiveSignalsGroup.tsx`、`MobileCityCard.tsx`、`MobileRegionTabs.tsx` |
|
||||
| `components/dashboard/scan-terminal/continent-grouping.ts` | 新增 | 分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理 |
|
||||
| `components/dashboard/scan-terminal/decision-utils.ts` | 修改 | 补充排序逻辑,Active Signals 置顶 |
|
||||
| `components/dashboard/ScanTerminalDashboard.module.css` | 修改 | 新增分组标题行、卡片流、Tab 栏样式 |
|
||||
| `components/dashboard/ScanTerminalLightTheme.module.css` | 新增/修改 | 浅色主题适配 |
|
||||
|
||||
### 不改的文件
|
||||
|
||||
- 后端 API:所有字段已就绪
|
||||
- `ScanOpportunityRow` 类型定义:字段充足
|
||||
- 数据获取 hooks:`useScanTerminalQuery` 保持不变
|
||||
|
||||
## 信号状态枚举
|
||||
|
||||
```
|
||||
◆ Active — approve / tradable + active
|
||||
● Watch — watch / monitor / peak_watch
|
||||
○ Closed — veto / closed / inactive
|
||||
! Data — 数据异常/缺失/延迟
|
||||
```
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 桌面端:7 时区 + Active Signals 分组,可折叠,9 列表格
|
||||
2. 移动端:Tab 切换 + 卡片流
|
||||
3. Gap 颜色按语义映射,非简单正负色
|
||||
4. Market 列显示价格格式(Y XX¢)
|
||||
5. 默认折叠规则:Active Signals 展开、有机遇展开、无机遇折叠
|
||||
6. 双主题(dark/light)CSS 适配
|
||||
7. TypeScript 类型检查通过
|
||||
8. `npm run build` 通过
|
||||
@@ -0,0 +1,319 @@
|
||||
# Production Realtime SSE Patch Design
|
||||
|
||||
> 日期: 2026-05-26
|
||||
> 范围: PolyWeather 网站终端图表的生产级实时观测增量层
|
||||
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
|
||||
|
||||
## 背景
|
||||
|
||||
当前网站已经有 SSE patch 通道,但它更像一个进程内即时广播层:
|
||||
|
||||
- 后端 `web/sse_manager.py` 用进程内 `asyncio.Queue` 管连接。
|
||||
- 采集器通过 `/api/internal/collector-patch` 推送城市温度变化。
|
||||
- 前端 `use-sse-patches.ts` 用 `EventSource` 接收 patch,并把温度合并到终端列表和图表。
|
||||
|
||||
这个结构能做轻量实时更新,但还不是生产级“像股票行情一样”的实时架构。主要缺口是:
|
||||
|
||||
- 断线后没有 replay event log。
|
||||
- 连接不能按城市订阅,只能广播后让前端筛选。
|
||||
- patch schema 没有版本化,后续扩展跑道、站点、最高温字段风险较高。
|
||||
- 多实例共享 pub/sub 还没有抽象边界。
|
||||
|
||||
## 目标
|
||||
|
||||
把当前实时层升级为“HTTP 快照基线 + SSE 增量观测事件 + 可重放事件日志”的架构:
|
||||
|
||||
- 页面首屏仍通过 HTTP snapshot 加载完整 terminal rows / city detail。
|
||||
- 实测温度、站点观测、跑道观测通过 SSE 增量追加到图表。
|
||||
- 浏览器断线重连后可以通过 `since_revision` 补发错过的事件。
|
||||
- 前端只订阅当前可见城市,例如 2x2 图表中的四个城市。
|
||||
- patch payload 明确使用 `city_observation_patch.v1` schema。
|
||||
- 第一版用 SQLite event log 适配当前服务器规格,预留 Redis/Postgres event bus 接口。
|
||||
|
||||
## 非目标
|
||||
|
||||
第一版不做这些事:
|
||||
|
||||
- 不把全部 terminal state 改成 event sourcing。
|
||||
- 不用 SSE 取代 `/api/scan/terminal` 或 `/api/city/{name}/detail`。
|
||||
- 不在第一版强依赖 Redis。
|
||||
- 不把 DEB、多模型、概率分布改成分钟级实时更新。
|
||||
- 不做 WebSocket;继续使用 SSE,因为浏览器端简单、代理友好、适合单向行情流。
|
||||
|
||||
## 推荐架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Collector["WeatherDataCollector"] --> Ingest["POST /api/internal/collector-patch"]
|
||||
Ingest --> Normalize["Patch schema normalizer"]
|
||||
Normalize --> Log["SQLite observation_patch_events"]
|
||||
Normalize --> Bus["PatchEventBus"]
|
||||
Bus --> SSE["GET /api/events?cities=...&since_revision=..."]
|
||||
API["HTTP snapshot APIs"] --> FE["Frontend terminal/chart state"]
|
||||
SSE --> FE
|
||||
FE -->|gap / stale / reconnect| API
|
||||
```
|
||||
|
||||
## 事件存储
|
||||
|
||||
新增 SQLite 表 `observation_patch_events`,由 `DBManager` 初始化。
|
||||
|
||||
字段:
|
||||
|
||||
- `revision INTEGER PRIMARY KEY AUTOINCREMENT`
|
||||
- `schema_type TEXT NOT NULL`
|
||||
- `schema_version INTEGER NOT NULL`
|
||||
- `city TEXT NOT NULL`
|
||||
- `source TEXT NOT NULL`
|
||||
- `obs_time TEXT`
|
||||
- `payload_json TEXT NOT NULL`
|
||||
- `created_at TEXT NOT NULL`
|
||||
|
||||
索引:
|
||||
|
||||
- `idx_observation_patch_events_city_revision(city, revision)`
|
||||
- `idx_observation_patch_events_created_at(created_at)`
|
||||
|
||||
保留策略:
|
||||
|
||||
- 默认保留 6 小时。
|
||||
- event log 只用于 SSE 断线 replay,不作为日内历史曲线归档。
|
||||
- 完整日内曲线仍由 `/api/city/{name}/detail` 从现有观测存储和 city cache 构建。
|
||||
- 6 小时窗口覆盖浏览器短线重连、页面休眠恢复和用户午后交易窗口内的 replay;超过窗口直接触发 HTTP resync。
|
||||
- 每次写入后低频触发清理,避免每条事件都扫表。
|
||||
- 环境变量:`POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6`。
|
||||
|
||||
## Patch Schema v1
|
||||
|
||||
事件类型固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "city_observation_patch.v1",
|
||||
"revision": 12345,
|
||||
"city": "shanghai",
|
||||
"source": "amsc_awos",
|
||||
"obs_time": "2026-05-26T10:02:00Z",
|
||||
"ts": 1780000000000,
|
||||
"payload": {
|
||||
"temp": 30.4,
|
||||
"max_so_far": 31.0,
|
||||
"station_code": "ZSPD",
|
||||
"station_label": "Shanghai Pudong",
|
||||
"series_key": "airport_primary",
|
||||
"unit": "celsius",
|
||||
"runway_points": [
|
||||
{
|
||||
"runway": "17L/35R",
|
||||
"temp": 30.8,
|
||||
"tdz_temp": 30.5,
|
||||
"mid_temp": 30.6,
|
||||
"end_temp": 30.8,
|
||||
"is_settlement": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
兼容要求:
|
||||
|
||||
- 前端仍接受旧 `city_patch` 一段时间,避免部署顺序问题。
|
||||
- 新事件统一转换成前端内部 `CityPatch`。
|
||||
- 后端 ingest 接受旧字段 `changes`,但写入 event log 时规范化成 v1。
|
||||
|
||||
## SSE API
|
||||
|
||||
`GET /api/events`
|
||||
|
||||
查询参数:
|
||||
|
||||
- `cities`: 逗号分隔城市 key,例如 `shanghai,hong kong,taipei`
|
||||
- `since_revision`: 客户端上次处理过的最大 revision
|
||||
- `replay_limit`: 最大补发数量,默认 500,上限 2000
|
||||
|
||||
连接行为:
|
||||
|
||||
1. 建立连接。
|
||||
2. 返回 `connected` event,包含当前 server revision。
|
||||
3. 如果传入 `since_revision`,先从 SQLite 补发匹配城市的历史事件。
|
||||
4. 进入 live stream,只推送匹配城市的事件。
|
||||
5. 每 30 秒发送 heartbeat。
|
||||
|
||||
gap 处理:
|
||||
|
||||
- 如果 `since_revision` 太旧,超过保留窗口或补发上限,服务端发送:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "resync_required",
|
||||
"reason": "replay_window_exceeded",
|
||||
"latest_revision": 12345
|
||||
}
|
||||
```
|
||||
|
||||
- 前端收到后对当前可见城市调用 HTTP full detail resync。
|
||||
|
||||
## 后端模块边界
|
||||
|
||||
新增或调整模块:
|
||||
|
||||
- `web/realtime_patch_schema.py`
|
||||
- 负责规范化旧 `city_patch` 输入为 `city_observation_patch.v1`。
|
||||
- 负责校验城市、温度、时间、source。
|
||||
|
||||
- `web/realtime_event_store.py`
|
||||
- 封装 SQLite event log 写入、查询、保留清理。
|
||||
- 使用 `DBManager` 的 DB path。
|
||||
|
||||
- `web/sse_manager.py`
|
||||
- 从纯进程队列升级为基于 `PatchEventBus` 的连接管理。
|
||||
- 保留进程内 live fanout。
|
||||
- 每个连接记录订阅城市集合。
|
||||
|
||||
- `web/routers/sse_router.py`
|
||||
- 解析 `cities`、`since_revision`、`replay_limit`。
|
||||
- ingest 后先写 event log,再广播 live event。
|
||||
|
||||
第一版 `PatchEventBus`:
|
||||
|
||||
- `SQLitePatchEventBus`
|
||||
- 写入 SQLite event log。
|
||||
- 进程内广播给当前 worker 的连接。
|
||||
|
||||
后续可新增:
|
||||
|
||||
- `RedisPatchEventBus`
|
||||
- `PostgresPatchEventBus`
|
||||
|
||||
## 前端模块边界
|
||||
|
||||
调整:
|
||||
|
||||
- `frontend/hooks/use-sse-patches.ts`
|
||||
- 支持 `city_observation_patch.v1`。
|
||||
- 记录 `lastRevision`。
|
||||
- 支持按可见城市集合建立 SSE URL。
|
||||
- 收到 `resync_required` 时通知图表重拉。
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts`
|
||||
- 继续负责 terminal rows 的 HTTP snapshot。
|
||||
- 消费 patch 时只更新轻量字段:当前温度、最高温、观测时间。
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
|
||||
- 消费 patch 时追加分钟级观测点。
|
||||
- 对 runway patch 更新对应 runway series。
|
||||
- resync required 或长时间无 patch 时重拉 `/api/city/{name}/detail`。
|
||||
|
||||
城市订阅策略:
|
||||
|
||||
- 2x2 / 3x3 图表只订阅当前 slots 中的城市。
|
||||
- 移动端只订阅当前主图城市。
|
||||
- 切换布局或城市后重建 SSE 连接。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 首屏加载
|
||||
|
||||
1. 前端调用 `/api/scan/terminal` 得到 terminal rows。
|
||||
2. 每个可见图表调用 `/api/city/{name}/detail` 得到完整基线曲线。
|
||||
3. 前端用可见城市列表连接 `/api/events?cities=...&since_revision=...`。
|
||||
|
||||
### 新观测进入
|
||||
|
||||
1. `WeatherDataCollector` 发现温度或跑道观测变化。
|
||||
2. POST `/api/internal/collector-patch`。
|
||||
3. 后端规范化为 `city_observation_patch.v1`。
|
||||
4. 写入 SQLite event log,生成 revision。
|
||||
5. 广播给订阅该城市的 SSE 连接。
|
||||
6. 前端追加图表点并更新 terminal row。
|
||||
|
||||
### 断线重连
|
||||
|
||||
1. 前端保留 `lastRevision`。
|
||||
2. EventSource 断线后指数退避重连。
|
||||
3. 重连 URL 带 `since_revision=lastRevision`。
|
||||
4. 后端 replay missed events。
|
||||
5. 如果 replay 不完整,返回 `resync_required`,前端 HTTP 重拉。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- ingest payload 无 city 或无有效 temp/runway point:返回 400。
|
||||
- event log 写入失败:返回 500,不广播,避免前端看到不可 replay 的事件。
|
||||
- SSE replay 查询失败:发送 `resync_required`,然后继续 heartbeat/live stream。
|
||||
- 前端收到未知 schema:忽略并记录 debug log。
|
||||
- 前端 revision 倒退:忽略。
|
||||
- 前端 revision 跳号:标记 gap,并触发 HTTP resync。
|
||||
|
||||
## 测试策略
|
||||
|
||||
后端:
|
||||
|
||||
- `tests/test_realtime_patch_schema.py`
|
||||
- 旧 `city_patch` 输入能规范化成 `city_observation_patch.v1`。
|
||||
- 跑道 payload 保留 runway point。
|
||||
- 无效 city/temp 返回校验失败。
|
||||
|
||||
- `tests/test_realtime_event_store.py`
|
||||
- append event 生成单调 revision。
|
||||
- replay 按 city 过滤。
|
||||
- retention cleanup 删除旧事件。
|
||||
|
||||
- `tests/test_sse_replay.py`
|
||||
- `/api/events?cities=...&since_revision=...` 只 replay 匹配城市。
|
||||
- replay window 超限返回 `resync_required`。
|
||||
|
||||
前端:
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
|
||||
- 确认 v1 schema、city subscription、since_revision URL 存在。
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
|
||||
- 确认 patch 追加分钟级观测点。
|
||||
- 确认 runway patch 更新对应 runway series。
|
||||
|
||||
验收命令:
|
||||
|
||||
```powershell
|
||||
pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
|
||||
cd frontend
|
||||
npm run test:business
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 部署与容量
|
||||
|
||||
当前服务器 2 vCPU / 8 GB / 50 GB 足够第一版 SQLite-first:
|
||||
|
||||
- 30 个高频城市,每分钟 1 条事件,约 43,200 条/天。
|
||||
- 默认只保留 6 小时,约 10,800 条事件。
|
||||
- payload 控制在 1-3 KB,SQLite event log 体积预计维持在几十 MB 内。
|
||||
- PM 最高温预测市场不需要把当天所有 patch 都保留在 event log;超过 replay 窗口时用 HTTP detail 重建当前画面。
|
||||
|
||||
运行建议:
|
||||
|
||||
- SQLite 使用 WAL。
|
||||
- event log 与现有 `POLYWEATHER_DB_PATH` 同库。
|
||||
- 先保持单 backend worker,避免进程内 fanout 跨 worker 不一致。
|
||||
- 多实例或多 worker 时再启用 Redis bus。
|
||||
|
||||
## 后续 Redis 扩展
|
||||
|
||||
当部署变成多实例或多 worker 后,新增 `RedisPatchEventBus`:
|
||||
|
||||
- SQLite 仍作为 replay log。
|
||||
- Redis Pub/Sub 或 Redis Stream 负责跨实例 live fanout。
|
||||
- 每个 worker 从 Redis 订阅 live event,再投递给本进程 SSE 连接。
|
||||
|
||||
这个扩展不改变前端协议。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 页面加载后可见图表只订阅当前城市。
|
||||
- 高频城市新观测进入后,图表在无需手动刷新下追加新点。
|
||||
- 浏览器断线重连后,能补齐断线期间的事件。
|
||||
- replay 超窗时,前端自动 HTTP resync。
|
||||
- 未订阅城市的事件不会推送到该连接。
|
||||
- 旧 `city_patch` 输入在迁移期仍可用。
|
||||
- 所有新增行为有自动化测试覆盖。
|
||||
@@ -0,0 +1,189 @@
|
||||
# PolyWeather UX 研究员审查报告
|
||||
|
||||
> 审查日期:2026-06 | 视角:UX 研究员 | 范围:用户理解修正逻辑、第一眼认知、图表误导风险、普通用户语言、天气异常体验
|
||||
|
||||
## 一、修正逻辑:用户是否看懂"上修/下修/维持"?
|
||||
|
||||
### 当前状态
|
||||
|
||||
AI 后端提示词明确要求 AI 使用**上修 / 下修 / 维持**三个方向词(`scan_city_ai_prompt.py:47-51`)。但前端 `WeatherDecisionBand` 完全不用这三个词,而是用:
|
||||
|
||||
| AI 判断方向 | 前端实际展示 | 中文原文 |
|
||||
|------------|------------|---------|
|
||||
| 上修(偏暖) | "Watch hotter range" | "关注偏高温区间" |
|
||||
| 下修(偏冷) | "Avoid chasing high" | "暂不追高温" |
|
||||
| 维持(中性) | "Wait for peak-window confirmation" | "等待峰值窗口确认" |
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 1 | **AI 输出与前端展示词汇不一致** — AI 用"上修/下修/维持",用户看到的是"关注偏高温/暂不追/等待确认"。这是两套完全不同的语言体系,用户读完 AI 解读再看决策条可能对不上号 | 🔴 |
|
||||
| 2 | **没有"维持/不变"标签** — 中性状态被表述为动作("等待确认"),而不是状态("维持不变")。用户想知道"现在是什么判断"而不是"现在该做什么" | 🟡 |
|
||||
| 3 | **"偏高温区间" vs "暂不追高温" 不对称** — 上修方向指向一个温度区间,下修方向指向一个行为。一个说 where,一个说 what。逻辑结构不一致 | 🟡 |
|
||||
| 4 | **颜色语义双重解读** — warm=红色边框=偏暖(上修),cold=绿色边框=偏冷(下修)。天气直觉是"热=红、冷=蓝/绿",但金融直觉是"红=跌、绿=涨"。两类用户可能得出相反的解读 | 🟡 |
|
||||
|
||||
### 建议
|
||||
|
||||
统一词汇体系,前端和 AI 使用同一套语言:
|
||||
|
||||
| 方向 | 建议前端标签 | 建议说明 |
|
||||
|------|------------|---------|
|
||||
| 上修 | **"预计最高温上修"** / "Revise upward" | 比 DEB/模型集群基准偏高 |
|
||||
| 下修 | **"预计最高温下修"** / "Revise downward" | 比 DEB/模型集群基准偏低 |
|
||||
| 维持 | **"维持模型基准"** / "Stay with model base" | 无需显著上修或下修 |
|
||||
|
||||
---
|
||||
|
||||
## 二、第一眼理解:用户打开卡片看到什么?
|
||||
|
||||
### 视觉层级
|
||||
|
||||
```
|
||||
1. CityCardHeader
|
||||
├─ Kicker: "城市深度分析" / "Deep analysis"
|
||||
├─ 城市名(大号标题)
|
||||
├─ 状态标签(实测突破 / METAR 过旧 / 模型高度一致 等)
|
||||
├─ 数据新鲜度条(METAR / 模型 / 市场 / AI 各自的新鲜度)
|
||||
└─ 三指标:当前温度 | 预计最高温 | 峰值时间
|
||||
|
||||
2. WeatherDecisionBand(修正条)
|
||||
├─ Kicker: "天气优先判断 · 市场价格另列"
|
||||
├─ 主体判断(大号粗体): "关注偏高温区间" / "暂不追高温" / "等待峰值窗口确认"
|
||||
├─ 原因文字(高亮 pill 内)
|
||||
└─ 三指标:天气区间 | 路径偏差 | 报价状态
|
||||
|
||||
3. 图表 + AI 证据 + 模型证据
|
||||
```
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 5 | **Kicker 说了两遍"市场"** — Header 的 kicker 是"城市深度分析",Decision band 的 kicker 是"天气优先判断 · 市场价格另列"。两个 kicker 都提到了市场,但新手不知道"市场"指的是 Polymarket 温度合约。这个词对圈外人完全无意义 | 🟡 |
|
||||
| 6 | **"预计最高温"没有解释来源** — 用户看到这个数字,不知道它是 AI 独立判断的、还是 DEB 融合的、还是某一个模型给的。只有一个数字,没有可信度标签 | 🟡 |
|
||||
| 7 | **状态标签过多** — 一个卡片可能同时显示 3-4 个标签:实测突破 + 峰值窗口已过 + METAR 过旧 + 模型高度一致。这些标签颜色不同、含义各异,用户需要逐一解码 | 🟢 |
|
||||
|
||||
---
|
||||
|
||||
## 三、图表是否误导?
|
||||
|
||||
### 日内温度图表(`AiCityTemperatureChart`)
|
||||
|
||||
三条线:
|
||||
- 灰色虚线:DEB 原始路径(过去+未来都是虚线)
|
||||
- 蓝色实线:METAR 修正路径
|
||||
- 绿色散点:METAR 实测
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 8 | **DEB 路径永远是虚线** — 即使过去部分(已发生的几小时)也是虚线。虚线在图形语言中普遍表示"不确定/预测",但过去几小时 DEB 已经是根据已知观测计算的,不应该看起来不确定 | 🟡 |
|
||||
| 9 | **没有"现在"标记** — 图表上没有任何竖线或标记指示当前时间。用户不知道图表上哪里是"现在",哪里是"未来" | 🔴 |
|
||||
| 10 | **X 轴标签稀疏** — 每 4 个小时才显示一个标签,最多 6 个。用户可能以为数据只在标记的小时上有,实际上每个小时都有数据 | 🟢 |
|
||||
| 11 | **HistoryChart 缺 °C/°F** — 历史图 tooltip 只显示 `°`,没有 `C` 或 `F`。在混合温度单位的环境下可能混淆 | 🟡 |
|
||||
| 12 | **没有轴标题** — Y 轴只有数字 + °C,没有"温度"标签。对新手来说不够自解释(虽然常见于仪表盘类产品) | 🟢 |
|
||||
|
||||
### 建议
|
||||
|
||||
| 建议 | 实现 |
|
||||
|------|------|
|
||||
| 添加"现在"竖线 | `chart-utils.ts` 中在 `currentIndex` 位置画一条 annotation line |
|
||||
| 过去 DEB 改为实线 | `segment: { borderDash: past=[], future=[6,4] }` 给过去和未来不同样式 |
|
||||
| HistoryChart tooltip 补全单位 | 使用 `data.temp_symbol` 替代硬编码 `°` |
|
||||
|
||||
---
|
||||
|
||||
## 四、普通用户能看懂多少?
|
||||
|
||||
### 高频出现的专业术语
|
||||
|
||||
| 术语 | 出现次数 | 用户理解难度 | 是否有解释 |
|
||||
|------|---------|------------|----------|
|
||||
| **METAR** | ~50+ 处 | 高 — 只有飞行员/气象人员知道 | ❌ 无 |
|
||||
| **DEB** | ~30+ 处 | 高 — 项目内部术语 | ❌ 无 |
|
||||
| **TAF** | ~15 处 | 高 — 航空术语 | ❌ 无 |
|
||||
| **峰值窗口** | ~20 处 | 中 — 可推测含义 | ❌ 无 |
|
||||
| **模型集群** | ~10 处 | 中 — 可推测含义 | ❌ 无 |
|
||||
| **边界层** | 3 处 | 极高 — 气象学专业术语 | ❌ 无 |
|
||||
| **冷平流/暖平流** | 2 处 | 极高 — 气象学专业术语 | ❌ 无 |
|
||||
| **中枢** | ~8 处 | 中 — 在这个语境下表示中心值 | ❌ 无 |
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 13 | **所有专业术语都没有 tooltip 或解释** — METAR、DEB、TAF 在全站出现数十次,没有任何地方解释它们是什么。用户的唯一学习途径是 `/docs` 页面,但需要主动离开看板去查阅 | 🔴 |
|
||||
| 14 | **AI 输出的"最终判断"字段直接暴露给用户** — 如果 AI 提到了"冷平流支撑"、"边界层逆温"等术语,前端不做任何改写或解释,直接原样展示。用户要么读懂,要么跳过 | 🟡 |
|
||||
| 15 | **"DEB 融合"对普通用户完全无意义** — 这是一个内部算法名称。用户需要的是"综合预报"或"多模型加权平均",而不是一个缩写 | 🟡 |
|
||||
|
||||
### 建议
|
||||
|
||||
| 建议 | 实现 |
|
||||
|------|------|
|
||||
| 核心术语加 tooltip | 首次出现的 METAR/DEB/TAF 加 `title` 属性或悬浮解释 |
|
||||
| DEB 改用用户语言 | "DEB 融合" → "多模型综合预报" 或保留 DEB 但加括号说明 |
|
||||
| AI 术语过滤 | 在后端 `scan_city_ai_fallback.py` 或前端展示层过滤掉过于专业的术语 |
|
||||
|
||||
---
|
||||
|
||||
## 五、天气异常时的体验
|
||||
|
||||
### 当前异常信号和用户看到的反馈
|
||||
|
||||
| 异常事件 | 前端展示 | 评估 |
|
||||
|---------|---------|------|
|
||||
| 实测温度突破模型上沿 | 红色标签"实测突破" + "Observation has broken above the model range" | ✅ 清晰 |
|
||||
| METAR 数据过旧(超过一天) | 黄色标签"METAR 过旧" + "已过旧,仅作背景参考" + 数据新鲜度条标红 | ✅ 清晰 |
|
||||
| 峰值窗口已过 | 灰色标签"峰值窗口已过" + 温度图表可能显示下降趋势 | ⚠️ 图表不标注窗口起止 |
|
||||
| 模型数据不足(<2个模型) | "等待模型补齐" | ⚠️ 没说为什么模型少、什么时候能补上 |
|
||||
| 市场价格不可用 | "市场价暂不可用" + "天气证据可参考,但暂无可交易价格" | ✅ 清晰 |
|
||||
| 快速判断与完整解读不一致 | 先显示"快速判断已完成",再异步合并完整 AI 解读 | ⚠️ 两种状态切换可能让用户困惑 |
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 16 | **异常没有"下一步"指引** — 当实测突破模型上沿时,用户看到"Observation has broken above the model range",但不知道这意味着该做什么。是应该买入?卖出?等待?系统不给建议 | 🔴 |
|
||||
| 17 | **"快速判断"和"完整解读"的切换可能造成 flicker** — 卡片先显示快速判断文案,然后完整 AI 返回后替换。如果两者结论一致,用户感知不到变化;如果不一致,用户会困惑"刚才不是这么说的" | 🟡 |
|
||||
| 18 | **极端天气没有特别处理** — 如果城市出现 40°C+ 或 -10°C 以下的极端温度,卡片和普通温度展示方式完全一样,没有视觉强调 | 🟢 |
|
||||
|
||||
### 建议
|
||||
|
||||
| 建议 | 实现 |
|
||||
|------|------|
|
||||
| 异常加行动建议 | 在 `primaryReason` 后追加一句行动建议("建议等待下一报文后再做判断" / "建议关注更高温区间") |
|
||||
| 极端温度视觉强化 | 温度超过历史极值时加大字号或添加红色脉冲高亮 |
|
||||
| 快速→完整过渡加标记 | 文案更新时显示 "✓ 已更新" 小标记,避免用户以为信息没变 |
|
||||
|
||||
---
|
||||
|
||||
## 六、优先级总结
|
||||
|
||||
### P0 — 用户理解障碍
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|------|------|
|
||||
| 1 | AI 用"上修/下修",前端用"偏高温/暂不追" | 统一为"上修/下修/维持"三词 |
|
||||
| 9 | 图表没有"现在"标记 | 在 currentIndex 位置画竖线 |
|
||||
| 13 | METAR/DEB/TAF 全站无解释 | 加 title tooltip + 首次出现时加括号说明 |
|
||||
| 16 | 异常没有行动建议 | 在 primaryReason 后追加引导文字 |
|
||||
|
||||
### P1 — 体验提升
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|------|------|
|
||||
| 3 | 上修/下修文案不对称 | 统一用"方向 + 幅度"格式 |
|
||||
| 4 | 红/绿颜色双重解读 | 保留但加文字标签确认 |
|
||||
| 8 | DEB 路径全是虚线 | 过去部分用实线,未来部分用虚线 |
|
||||
| 15 | "DEB 融合"对普通用户无意义 | 改为"多模型综合预报" |
|
||||
|
||||
### P2 — 优化打磨
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|------|------|
|
||||
| 11 | HistoryChart 缺 °C/°F | 使用 temp_symbol |
|
||||
| 12 | 图表无轴标题 | 可加可不加(仪表盘惯例) |
|
||||
| 17 | 快速→完整 flicker | 加过渡标记 |
|
||||
| 18 | 极端温度无强调 | 加视觉强化 |
|
||||
@@ -1,12 +1,12 @@
|
||||
# PolyWeather Side Panel
|
||||
|
||||
`PolyWeather Side Panel` 是一个面向天气交易场景的 Chrome / Edge 浏览器侧边栏工具。
|
||||
`PolyWeather Side Panel` 是一个面向天气交易市场的 Chrome / Edge 浏览器侧边栏工具。
|
||||
|
||||
## 功能
|
||||
|
||||
1. 自动识别当前 Polymarket 页面中的城市,也支持手动切换。
|
||||
2. 展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。
|
||||
3. 展示今日日内走势(简版):`DEB` 走势与官方观测(`METAR / HKO / CWA / NOAA`)对照,可悬停查看时间与温度。
|
||||
3. 展示今日日内走势(简版):`DEB` 走势与机场/官方观测对照,可悬停查看时间与温度。
|
||||
4. 展示多日最高温预报(简版),当前以 `DEB` 优先。
|
||||
5. 支持一键刷新,强制拉取最新温度数据。
|
||||
6. 支持本地缓存,提升打开速度;插件版本更新时会刷新城市列表缓存。
|
||||
@@ -15,8 +15,9 @@
|
||||
## 数据说明
|
||||
|
||||
- 香港使用 `HKO`(香港天文台)结算源。
|
||||
- 其他城市按配置使用 `METAR / NOAA / 官方数据源`。
|
||||
- 其他城市按配置使用 `METAR / NOAA / AMOS / JMA / MGM / FMI / KNMI` 等官方数据源。
|
||||
- 城市展示名以主站返回值为准,例如 `aurora` 市场在插件中会显示为 `Denver`。
|
||||
- 首尔/釜山/东京/安卡拉/伊斯坦布尔/赫尔辛基/阿姆斯特丹已接入高频机场实时数据(1-10 分钟级)。
|
||||
|
||||
## 权限说明
|
||||
|
||||
@@ -33,14 +34,14 @@
|
||||
1. 打开 Chrome/Edge 扩展页面:
|
||||
- Chrome:`chrome://extensions`
|
||||
- Edge:`edge://extensions`
|
||||
2. 打开“开发者模式”。
|
||||
3. 选择“加载已解压的扩展程序”。
|
||||
2. 打开"开发者模式"。
|
||||
3. 选择"加载已解压的扩展程序"。
|
||||
4. 选择目录:`extension/`。
|
||||
5. 点击扩展图标,侧边栏会打开。
|
||||
|
||||
## 设置
|
||||
|
||||
首次建议打开扩展“选项页”并确认:
|
||||
首次建议打开扩展"选项页"并确认:
|
||||
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
|
||||
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
|
||||
@@ -48,9 +49,9 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前版本仍是轻量产品,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
|
||||
- 当前版本仍是轻量产品,重点是"监控 + 基础判断 + 导流回站",未接入支付链路。
|
||||
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
|
||||
- 插件城市列表来自主站 `/api/cities`,不是插件内置静态列表;本版已升级缓存版本,安装更新后会重新拉取 Manila、Karachi、Masroor Air Base 等最新城市。
|
||||
- 插件走势图与主站保持一致:`Wunderground / weather.com` 是历史参考页,不是物理实测站;机场市场统一显示机场 `METAR` / 官方观测点位。
|
||||
- 点击“打开网站查看更多”会回到主站继续查看完整分析。
|
||||
- 插件城市列表来自主站 `/api/cities`,非内置静态列表;安装更新后自动刷新最新城市。
|
||||
- 机场市场统一显示机场 `METAR` / 官方观测点位,部分城市已覆盖 1 分钟级实时数据。
|
||||
- 点击"打开网站查看更多"会回到主站继续查看完整分析。
|
||||
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "PolyWeather Side Panel",
|
||||
"description": "Weather side panel for Polymarket.",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
|
||||
> polyweather-frontend@1.5.4 start
|
||||
> next start -p 3002
|
||||
|
||||
▲ Next.js 15.5.12
|
||||
- Local: http://localhost:3002
|
||||
- Network: http://172.23.64.1:3002
|
||||
|
||||
✓ Starting...
|
||||
✓ Ready in 541ms
|
||||
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
@@ -25,10 +30,6 @@ POLYWEATHER_AUTH_ENABLED=false
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
@@ -39,4 +40,5 @@ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
|
||||
@@ -1 +1,52 @@
|
||||
# PolyWeather 前端最小配置(本地 / Vercel)
|
||||
# 只部署天气看板时,先填下面 4 项即可。
|
||||
|
||||
# 必填:后端 FastAPI 基础地址
|
||||
# 默认供 Next.js API Route 在服务端代理后端使用。
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
# 可选:浏览器直连后端 FastAPI 基础地址。
|
||||
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||
# 例如:https://api.example.com
|
||||
# 本地开发时注释掉,让 Next.js API Route 代理请求,避免 CORS 问题
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
|
||||
# 关闭本地开发鉴权绕过
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY --from=deps /app/frontend/node_modules ./node_modules
|
||||
COPY . ./
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY --from=builder /app/frontend/public ./public
|
||||
COPY --from=builder /app/frontend/.next/standalone ./
|
||||
COPY --from=builder /app/frontend/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -21,8 +21,8 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF、结算来源和历史对账
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
@@ -31,9 +31,6 @@ PolyWeather Pro 的生产前端工程。
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 历史对账支持:
|
||||
- `DEB / 最佳单模型 / 实测最高温` 对比
|
||||
- 峰值前 12 小时 `DEB` 参考(近似)
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
@@ -43,8 +40,8 @@ PolyWeather Pro 的生产前端工程。
|
||||
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
||||
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出;EMOS / LGBM 只在评估通过或 shadow 对照时进入解释层,模型共识和市场价格只作为辅助说明
|
||||
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -98,7 +95,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
# 社群入口
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
# 推荐默认关闭的前端观测 / 预热开关
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
@@ -117,7 +114,6 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
- `GET /api/city/[name]`
|
||||
- `GET /api/city/[name]/summary`
|
||||
- `GET /api/city/[name]/detail`
|
||||
- `GET /api/history/[name]`
|
||||
|
||||
鉴权:
|
||||
|
||||
@@ -154,7 +150,6 @@ Ops:
|
||||
|
||||
- 系统状态
|
||||
- SQLite / rollout / 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 用户查询
|
||||
- 当前会员
|
||||
@@ -205,4 +200,4 @@ Ops:
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
最后更新:`2026-05-23`
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
|
||||
function AccountErrorContent({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Account page error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem",
|
||||
gap: "1rem",
|
||||
backgroundColor: "var(--color-bg-base, #0B1220)",
|
||||
color: "var(--color-text-primary, #E6EDF3)",
|
||||
fontFamily: "var(--font-data, Inter, sans-serif)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
}}
|
||||
>
|
||||
{isEn ? "Account Page Error" : "账户页面出错"}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-secondary, #9FB2C7)",
|
||||
fontSize: "0.875rem",
|
||||
margin: 0,
|
||||
maxWidth: 420,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{isEn
|
||||
? "If this happened during payment or wallet binding, the most common cause is conflicting wallet extensions (e.g. MetaMask and Rabby open at the same time). Try disabling other wallet extensions and refresh."
|
||||
: "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "var(--radius-md, 10px)",
|
||||
border: "1px solid var(--color-border-default, rgba(159,178,199,0.16))",
|
||||
backgroundColor: "var(--color-bg-raised, #111A2E)",
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isEn ? "Retry" : "重试"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountErrorContent error={error} reset={reset} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 542 KiB |
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!ANALYTICS_ENABLED) {
|
||||
@@ -26,7 +26,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.text();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/bind-by-token`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to bind Telegram account",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,33 +7,22 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const fetchOptions = {
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/bot-bind-link`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 60 },
|
||||
} as const;
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
@@ -41,16 +30,11 @@ export async function GET(
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
);
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch history",
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create Telegram bot bind link",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.text();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/login`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to verify Telegram login",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
@@ -21,30 +12,10 @@ export async function GET(req: NextRequest) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"no-store, max-age=0",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch cities",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,9 +7,29 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function normalizeCityDetailPayload(data: unknown) {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const payload = data as Record<string, any>;
|
||||
|
||||
// Backend v2 nests hourly under timeseries; chart expects it at top level.
|
||||
if (!payload.hourly && payload.timeseries?.hourly) {
|
||||
payload.hourly = payload.timeseries.hourly;
|
||||
}
|
||||
|
||||
if (!payload.market_scan && payload.market_scan_payload) {
|
||||
return {
|
||||
...payload,
|
||||
market_scan: payload.market_scan_payload,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
@@ -24,9 +44,11 @@ export async function GET(
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
const resolution = req.nextUrl.searchParams.get("resolution");
|
||||
const searchParams = new URLSearchParams({
|
||||
force_refresh: forceRefresh,
|
||||
});
|
||||
@@ -39,21 +61,32 @@ export async function GET(
|
||||
if (targetDate) {
|
||||
searchParams.set("target_date", targetDate);
|
||||
}
|
||||
if (resolution) {
|
||||
searchParams.set("resolution", resolution);
|
||||
}
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const params = new URLSearchParams();
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
params.set("force_refresh", forceRefresh);
|
||||
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
|
||||
const lite = req.nextUrl.searchParams.get("lite");
|
||||
if (lite) {
|
||||
params.set("lite", lite);
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 800,
|
||||
error: "Backend city market scan failed",
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city market scan",
|
||||
status: 502,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -64,6 +66,9 @@ function buildFallbackCityDetail(name: string, depth: string, summary: Record<st
|
||||
sunshine_hours: null,
|
||||
},
|
||||
multi_model: {},
|
||||
multi_model_daily: {},
|
||||
source_forecasts: {},
|
||||
hourly: { times: [], temps: [], radiation: [] },
|
||||
probabilities: {
|
||||
mu: null,
|
||||
distribution: [],
|
||||
@@ -104,6 +109,12 @@ function buildFallbackCityDetail(name: string, depth: string, summary: Record<st
|
||||
function normalizeCityDetailPayload(data: unknown) {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const payload = data as Record<string, any>;
|
||||
|
||||
// Backend v2 nests hourly under timeseries; chart expects it at top level.
|
||||
if (!payload.hourly && payload.timeseries?.hourly) {
|
||||
payload.hourly = payload.timeseries.hourly;
|
||||
}
|
||||
|
||||
if (!payload.market_scan && payload.market_scan_payload) {
|
||||
return {
|
||||
...payload,
|
||||
@@ -128,29 +139,38 @@ export async function GET(
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const summaryUrl = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
const summaryRes = await fetch(summaryUrl, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: 10 } }),
|
||||
});
|
||||
if (summaryRes.ok) {
|
||||
const summaryData = await summaryRes.json();
|
||||
const response = NextResponse.json(buildFallbackCityDetail(name, depth, summaryData), {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-PolyWeather-Fallback": "summary",
|
||||
},
|
||||
});
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
buildFallbackCityDetail(name, depth, summaryData),
|
||||
cachePolicy.fetchMode === "no-store"
|
||||
? cachePolicy.responseCacheControl
|
||||
: "public, max-age=0, s-maxage=10, stale-while-revalidate=30",
|
||||
);
|
||||
response.headers.set("X-PolyWeather-Fallback", "summary");
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -158,7 +178,11 @@ export async function GET(
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const response = NextResponse.json(data);
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -25,50 +18,15 @@ export async function GET(
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const bypassCache = forceRefresh === "true";
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20);
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const fetchOptions =
|
||||
bypassCache
|
||||
? {
|
||||
headers: auth.headers,
|
||||
cache: "no-store" as const,
|
||||
}
|
||||
: {
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 20 },
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (bypassCache) {
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city summary",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city summary",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamUrl = new URL(`${API_BASE.replace(/\/+$/, "")}/api/events`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstreamUrl.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const upstream = await fetch(upstreamUrl.toString(), {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Cookie: req.headers.get("cookie") || "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return NextResponse.json(
|
||||
{ error: `SSE upstream failed with HTTP ${upstream.status}` },
|
||||
{ status: upstream.status || 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config fetch failed" }); }
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config update failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Health check failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) url.searchParams.set("days", days);
|
||||
const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Growth fetch failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
|
||||
method: "POST", headers, body, cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription extend failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function parseAdminEmails() {
|
||||
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getBearerEmail(req: NextRequest) {
|
||||
const auth = String(req.headers.get("authorization") || "").trim();
|
||||
const token = auth.replace(/^bearer\s+/i, "").trim();
|
||||
const supabaseUrl = String(process.env.NEXT_PUBLIC_SUPABASE_URL || "").trim();
|
||||
const anonKey = String(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "").trim();
|
||||
if (!token || !supabaseUrl || !anonKey) return "";
|
||||
const res = await fetch(`${supabaseUrl.replace(/\/$/, "")}/auth/v1/user`, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
const data = (await res.json()) as { email?: string };
|
||||
return String(data.email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function findSupabaseUserIdByEmail(email: string) {
|
||||
const supabaseUrl = String(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
users?: Array<{ id?: string }>;
|
||||
};
|
||||
if (!res.ok) throw new Error(`Supabase user lookup failed: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
const userId = String(data.users?.[0]?.id || "").trim();
|
||||
if (!userId) {
|
||||
const error = new Error(`user not found: ${email}`);
|
||||
(error as Error & { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return { supabaseUrl, serviceRoleKey, userId };
|
||||
}
|
||||
|
||||
async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, authEmail?: string | null) {
|
||||
const adminEmail = String(authEmail || (await getBearerEmail(req)) || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const allowedEmails = parseAdminEmails();
|
||||
if (!adminEmail) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!allowedEmails.includes(adminEmail)) {
|
||||
return NextResponse.json({ error: "ops admin required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = JSON.parse(bodyText || "{}") as {
|
||||
email?: string;
|
||||
plan_code?: string;
|
||||
days?: number;
|
||||
};
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const planCode = String(body.plan_code || "pro_monthly").trim();
|
||||
const days = Math.max(1, Math.min(365, Number(body.days || 30)));
|
||||
if (!email) return NextResponse.json({ error: "email is required" }, { status: 400 });
|
||||
if (planCode !== "pro_monthly") {
|
||||
return NextResponse.json({ error: "invalid plan_code" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { supabaseUrl, serviceRoleKey, userId } = await findSupabaseUserIdByEmail(email);
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + days * 86_400_000);
|
||||
const payload = {
|
||||
user_id: userId,
|
||||
email,
|
||||
plan_code: planCode,
|
||||
"status": "active",
|
||||
starts_at: now.toISOString(),
|
||||
expires_at: expires.toISOString(),
|
||||
source: "ops_manual_grant_next_fallback",
|
||||
created_at: now.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
};
|
||||
const insert = await fetch(`${supabaseUrl}/rest/v1/subscriptions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await insert.text();
|
||||
if (!insert.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase insert failed", detail: raw.slice(0, 300) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user_id: userId,
|
||||
plan_code: planCode,
|
||||
days,
|
||||
expires_at: expires.toISOString(),
|
||||
fallback: "next_supabase_direct",
|
||||
});
|
||||
} catch (error) {
|
||||
const status = Number((error as Error & { status?: number }).status || 500);
|
||||
return NextResponse.json({ error: String(error) }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
|
||||
method: "POST",
|
||||
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" },
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (res.status === 404) {
|
||||
const fallback = await grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
return applyAuthResponseCookies(fallback, auth.response);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) {
|
||||
return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Telegram audit failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Training accuracy fetch failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
if (level) url.searchParams.set("level", level);
|
||||
if (lines) url.searchParams.set("lines", lines);
|
||||
const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Log fetch failed" }); }
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,28 +10,12 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/config`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
|
||||
detailLimit: 350,
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
revalidateSeconds: 300,
|
||||
url: `${API_BASE}/api/payments/config`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,29 +14,11 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment intent",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
detailLimit: 350,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch payment intent",
|
||||
url: `${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
@@ -37,8 +40,14 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/validate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to validate payment tx",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -18,28 +11,13 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/runtime`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 500,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "no-store",
|
||||
conditionalResponse: false,
|
||||
detailLimit: 500,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
url: `${API_BASE}/api/payments/runtime`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
proxyBackendJsonGet,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
@@ -17,29 +19,15 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch wallets",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "no-store",
|
||||
conditionalResponse: false,
|
||||
detailLimit: 350,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch wallets",
|
||||
url: `${API_BASE}/api/payments/wallets`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
@@ -57,6 +45,8 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
|
||||