File size: 109,335 Bytes
fb490d1 1be1ff5 fb490d1 33b81e9 fb490d1 5afd3a9 fb490d1 5afd3a9 fb490d1 33b81e9 fb490d1 5afd3a9 fb490d1 5afd3a9 fb490d1 75c221a fb490d1 33b81e9 fb490d1 33b81e9 fb490d1 931437f fb490d1 c5c1733 931437f bce97d9 fb490d1 0386c5f e1cd978 fb490d1 e1cd978 0386c5f 1597754 0386c5f 1597754 0386c5f 1597754 fb490d1 931437f fb490d1 c5c1733 931437f bce97d9 fb490d1 0386c5f e1cd978 fb490d1 e1cd978 0386c5f 1597754 0386c5f 1597754 0386c5f 1597754 fb490d1 1597754 fb490d1 e1cd978 fb490d1 bce97d9 fb490d1 0386c5f fb490d1 e1cd978 0386c5f 1597754 0386c5f 1597754 0386c5f fb490d1 7aaaedc fb490d1 7aaaedc fb490d1 7aaaedc fb490d1 7aaaedc fb490d1 33b81e9 fb490d1 27a56bb fb490d1 c5c1733 27a56bb c5c1733 fb490d1 8127b85 56d36d7 0386c5f 56d36d7 e1cd978 3519853 c9fa753 fb490d1 1be1ff5 c9b6df8 27a56bb fb490d1 c5c1733 fb490d1 c5c1733 fb490d1 c9b6df8 fb490d1 c9b6df8 fb490d1 2f1e6e0 fb490d1 56d36d7 fb490d1 1be1ff5 c9b6df8 fb490d1 c9b6df8 fb490d1 0386c5f b5c330d f612cc5 b5c330d f612cc5 b5c330d 0386c5f fb490d1 fd2d086 fb490d1 56d36d7 fb490d1 56d36d7 fb490d1 0386c5f fb490d1 0386c5f fb490d1 56d36d7 fb490d1 56d36d7 0b22581 56d36d7 fb490d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 | #!/usr/bin/env python3
"""
1-Month Stock Price & Gain Predictor — Full Alpha Vantage Intelligence
=======================================================================
Uses EVERY available Alpha Vantage API to build the most comprehensive
30-day price forecast possible for a given ticker.
Data Sources (16 API endpoints):
1. TIME_SERIES_DAILY_ADJUSTED — 5yr OHLCV history
2. GLOBAL_QUOTE — latest realtime price
3. OVERVIEW — fundamentals (P/E, EPS, margins, etc.)
4. INCOME_STATEMENT — revenue, net income, EBITDA
5. BALANCE_SHEET — assets, debt, equity, cash
6. CASH_FLOW — FCF, operating cash flow, capex
7. EARNINGS — EPS history, surprises
8. INSIDER_TRANSACTIONS — smart money buy/sell
9. REALTIME_OPTIONS — put/call ratio, IV, options sentiment
10. NEWS_SENTIMENT — news sentiment scores
11. ANALYTICS_FIXED_WINDOW — statistical return analytics
12. TREASURY_YIELD — 10Y yield (macro)
13. FEDERAL_FUNDS_RATE — interest rate environment
14. CPI — inflation
15. UNEMPLOYMENT — labor market
16. SPY daily — market context
Prediction Methods:
A. ML ensemble (RF + XGB + GBM + Elastic Net + MLP) — 21d return + quantile tails
B. Technical momentum model — trend + mean-reversion signals
C. Fundamental valuation model — fair value vs current price
D. Sentiment composite — news + insider + options + earnings
E. Macro overlay — applies sector/leverage multiplier to blended return
F. Optional 3-model LLM debate — blended 45% into final expected return
Final: Confidence-weighted blend of technical, fundamental, sentiment, and ML.
Usage:
python3 predict_monthly.py AAPL
python3 predict_monthly.py MSFT --months 2
python3 predict_monthly.py AAPL --llm
"""
from llm_compat import claude_create # SDK-drift shim for Claude calls
import argparse
import json
import os
from collections import Counter
os.environ.setdefault("PYTHONWARNINGS", "ignore") # suppress joblib/sklearn thread warnings
import warnings
warnings.filterwarnings("ignore")
import csv
import io
import re
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import requests
from sklearn.decomposition import PCA
from sklearn.inspection import permutation_importance
from sklearn.ensemble import (
GradientBoostingRegressor,
RandomForestRegressor,
)
from sklearn.linear_model import ElasticNet, RidgeCV
from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler
from scipy.stats import spearmanr
import sklearn_parallel_hotfix # noqa: F401
from xgboost import XGBRegressor
try:
from predict_weekly import (
check_earnings_proximity,
compute_post_earnings_drift_score,
_raw_gpt_judge,
_raw_kimi_judge,
_raw_claude_judge,
_parse_judge_response_weekly,
_JUDGE_USER_DEBATE,
_JUDGE_SYSTEM_DEBATE,
)
except ImportError:
def check_earnings_proximity(data):
return None, "unknown", ""
def compute_post_earnings_drift_score(*_a, **_k):
return 0.0, "NEUTRAL", {}
def _raw_gpt_judge(*_a, **_k):
return None, 0, 0
def _raw_kimi_judge(*_a, **_k):
return None, 0, 0
def _raw_claude_judge(*_a, **_k):
return None, 0, 0
def _parse_judge_response_weekly(*_a, **_k):
return None
_JUDGE_USER_DEBATE = "{ticker}"
_JUDGE_SYSTEM_DEBATE = ""
# ─── Config ───────────────────────────────────────────────────────────────────
# Alpha Vantage key: environment only — never hardcode (the old literal key was
# committed to git and must be treated as compromised/revoked). Set
# ALPHAVANTAGE_API_KEY (or ALPHA_VANTAGE_API_KEY) in the shell or in .env.
# NOTE: .env is loaded further down this module (load_dotenv), so the value is
# re-read after that block; api_call() raises a clear error if it is still empty.
API_KEY = (os.environ.get("ALPHAVANTAGE_API_KEY", "")
or os.environ.get("ALPHA_VANTAGE_API_KEY", "")).strip()
BASE_URL = "https://www.alphavantage.co/query"
CACHE_DIR = Path(__file__).resolve().parent / "cache"
CACHE_DIR.mkdir(exist_ok=True)
# ─── FOMC / CPI (shared schedule; monthly uses wider detection window) ───────
_FOMC_ANNOUNCEMENT_DATES = [
"2025-01-29", "2025-03-19", "2025-05-07", "2025-06-18",
"2025-07-30", "2025-09-17", "2025-10-29", "2025-12-10",
"2026-01-28", "2026-03-18", "2026-05-06", "2026-06-17",
"2026-07-29", "2026-09-16", "2026-10-28", "2026-12-09",
]
_CPI_RELEASE_DATES = [
"2025-01-15", "2025-02-12", "2025-03-12", "2025-04-10",
"2025-05-13", "2025-06-11", "2025-07-11", "2025-08-12",
"2025-09-10", "2025-10-14", "2025-11-12", "2025-12-10",
"2026-01-14", "2026-02-11", "2026-03-11", "2026-04-10",
"2026-05-12", "2026-06-10", "2026-07-14", "2026-08-12",
"2026-09-10", "2026-10-13", "2026-11-12", "2026-12-10",
]
_SECTOR_MAP = {
"Technology": 0,
"Healthcare": 1,
"Financials": 2,
"Financial Services": 2,
"Consumer Cyclical": 3,
"Consumer Defensive": 4,
"Industrials": 5,
"Energy": 6,
"Utilities": 7,
"Real Estate": 8,
"Basic Materials": 9,
"Communication Services": 10,
"": 11,
}
def _moonshot_assistant_text(message) -> str:
for attr in ("content", "reasoning_content", "reasoning"):
v = getattr(message, attr, None)
if isinstance(v, str) and v.strip():
return v.strip()
return ""
def _get_llm_calibration_monthly(ticker, daily_df=None):
cal_file = CACHE_DIR / f"{ticker}_llm_cal_monthly.json"
if not cal_file.exists() or daily_df is None:
return 1.0
try:
history = json.loads(cal_file.read_text())
if not history:
return 1.0
cutoff = datetime.now() - timedelta(days=60)
correct = total = 0
for rec in history:
pred_date = datetime.fromisoformat(rec["date"])
if pred_date < cutoff:
continue
pred_ts = pd.Timestamp(pred_date.date())
future = daily_df.index[daily_df.index >= pred_ts]
if len(future) < 22:
continue
entry_ts = future[0]
exit_ts = future[21]
actual_ret = float(daily_df.loc[exit_ts, "adj_close"]) / float(
daily_df.loc[entry_ts, "adj_close"]
) - 1
pred_dir = rec.get("direction", "NEUTRAL")
if pred_dir == "BULLISH":
correct += actual_ret > 0
total += 1
elif pred_dir == "BEARISH":
correct += actual_ret < 0
total += 1
if total < 3:
return 1.0
accuracy = correct / total
return float(np.clip((accuracy - 0.5) * 2.0 + 1.0, 0.5, 1.2))
except Exception:
return 1.0
_call_count = 0
_call_window = time.time()
_last_call_ts = 0.0
# AlphaVantage rejects >5 requests/second with a "Burst pattern detected" Information payload.
# The per-minute cap below doesn't prevent sub-second bursts, so enforce a per-call floor too.
_MIN_CALL_INTERVAL = 0.25 # ~4 req/s — safely under the 5/s burst limit
import threading as _threading
_rate_lock = _threading.Lock() # serialize the throttle so concurrent fetchers still space calls
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
BLUE = "\033[94m"
WHITE = "\033[97m"
RESET = "\033[0m"
BG_GREEN = "\033[42m"
BG_RED = "\033[41m"
BG_YELLOW = "\033[43m"
def _rate_limit():
global _call_count, _call_window, _last_call_ts
# Lock so concurrent fetchers (prefetch pool) still space their dispatches and
# share one honest per-minute counter instead of racing past AV's burst limit.
with _rate_lock:
# Per-second floor: space calls so a tight loop can't trip AV's 5-req/s burst limit.
_since_last = time.time() - _last_call_ts
if _since_last < _MIN_CALL_INTERVAL:
time.sleep(_MIN_CALL_INTERVAL - _since_last)
_last_call_ts = time.time()
_call_count += 1
elapsed = time.time() - _call_window
if elapsed < 60 and _call_count >= 73: # premium: 75/min, leave 2 margin
wait = 62 - elapsed
print(f" {DIM}(rate limit pause {wait:.0f}s){RESET}", end="", flush=True)
time.sleep(wait)
_call_count = 0
_call_window = time.time()
elif elapsed >= 60:
_call_count = 1
_call_window = time.time()
def safe_float(v, default=0.0):
"""Convert to float, handling 'None' strings and other garbage."""
try:
return float(v)
except (ValueError, TypeError):
return default
def _sigmoid_prob(quantile_value, threshold, scale=25.0, cap=0.80):
"""Smooth mapping from quantile prediction to tail probability."""
x = (quantile_value - threshold) * scale
return float(np.clip(cap / (1.0 + np.exp(-x)), 0.0, cap))
def _buyback_rate_yoy_from_shares(rows):
"""Negative = shares shrinking (buybacks). Expect list of {date, outstandingShares} or similar."""
if not rows or len(rows) < 2:
return 0.0
try:
parsed = []
for r in rows:
d = r.get("date") or r.get("reportedDate", "")
sh = safe_float(r.get("sharesOutstanding", r.get("outstandingShares", r.get("shares", 0))))
if d and sh > 0:
parsed.append((pd.Timestamp(d), sh))
if len(parsed) < 2:
return 0.0
parsed.sort(key=lambda x: x[0])
oldest, newest = parsed[0][1], parsed[-1][1]
if oldest <= 0:
return 0.0
return float((newest - oldest) / oldest)
except Exception:
return 0.0
def _iv_percentile_60d(hist_opts):
"""Rank current IV vs recent history if payload exposes implied volatility series."""
if not hist_opts:
return 0.5
ivs = []
cur = None
for r in hist_opts if isinstance(hist_opts, list) else []:
iv = safe_float(r.get("impliedVolatility", r.get("implied_volatility", r.get("iv", 0))))
if iv > 0:
ivs.append(iv)
cur = iv
if len(ivs) < 5 or cur is None:
return 0.5
arr = np.array(ivs[-60:])
return float(np.mean(arr <= cur))
def _is_throttle_or_error_json(data):
"""True if an AlphaVantage JSON response is a throttle / error / empty payload that must
NOT be cached or treated as data. AV signals rate limits via 'Information' (burst) and
'Note' (daily cap) — neither was previously caught, so error payloads poisoned the cache
for the full TTL (e.g. 7-day OVERVIEW caches filled with 'Burst pattern detected')."""
if not isinstance(data, dict):
return True
if "Information" in data or "Note" in data:
return True
if str(data.get("Error Message") or "").strip():
return True
# Empty or metadata-only payloads (e.g. {"_ts": ...}) carry no usable data.
if not any(k for k in data.keys() if not str(k).startswith("_")):
return True
return False
def _is_throttle_or_error_csv(text):
"""CSV-mode equivalent: AV returns the throttle/error as a JSON-ish blurb instead of rows."""
head = (text or "")[:300]
return (not head.strip()) or any(
marker in head for marker in ("Error Message", "Information", "Note", "Thank you for using Alpha Vantage")
)
def api_call(params, cache_key=None, cache_hours=4, no_cache=False, csv_response=False):
"""If csv_response=True, request CSV (datatype=csv) and return raw text (or cached string)."""
# NB: throttle moved below the cache check — a cache hit hits no network, so it
# must not pay the per-call floor (that was ~5s of dead time on a warm run).
if csv_response:
params = dict(params)
params.setdefault("datatype", "csv")
if cache_key and not no_cache:
cf = CACHE_DIR / f"{cache_key}.json"
if cf.exists():
try:
d = json.loads(cf.read_text())
if (datetime.now() - datetime.fromisoformat(d["_ts"])).total_seconds() < cache_hours * 3600:
d2 = d.copy()
d2.pop("_ts", None)
if csv_response and "_csv" in d2:
if not _is_throttle_or_error_csv(d2["_csv"]):
return d2["_csv"]
elif not _is_throttle_or_error_json(d2):
# Ignore a previously-poisoned cache entry and refetch.
return d2
except Exception:
pass
if not API_KEY:
# Raised only when a live request is actually needed (cache hits above still work).
raise RuntimeError(
"Alpha Vantage API key is not configured. Set ALPHAVANTAGE_API_KEY "
"(or ALPHA_VANTAGE_API_KEY) in the environment or in the .env file next "
"to this script. The hardcoded key fallback was removed for security."
)
params["apikey"] = API_KEY
_rate_limit() # only real network requests are throttled (cache hits returned above)
try:
r = requests.get(BASE_URL, params=params, timeout=30)
if csv_response:
text = r.text or ""
if cache_key and not no_cache and not _is_throttle_or_error_csv(text):
(CACHE_DIR / f"{cache_key}.json").write_text(
json.dumps({"_csv": text, "_ts": datetime.now().isoformat()}, default=str)
)
return text
data = r.json()
# Only cache genuine data — never AV throttle/error/empty payloads (prevents cache poisoning).
if cache_key and not no_cache and not _is_throttle_or_error_json(data):
to_save = data.copy(); to_save["_ts"] = datetime.now().isoformat()
(CACHE_DIR / f"{cache_key}.json").write_text(json.dumps(to_save, default=str))
return data
except Exception as e:
return {"_error": str(e)} if not csv_response else ""
def fetch_intraday_volume_profile(ticker, no_cache=False):
"""15-min bars → volume profile features (entitlement=delayed)."""
raw = api_call(
{
"function": "TIME_SERIES_INTRADAY",
"symbol": ticker,
"interval": "15min",
"outputsize": "full",
"entitlement": "delayed",
},
f"{ticker}_intraday_15min_monthly",
1,
no_cache=no_cache,
)
ts = raw.get("Time Series (15min)", {})
if not ts or len(ts) < 20:
return None
prices = []
volumes = []
for d, v in ts.items():
typical = (float(v["2. high"]) + float(v["3. low"]) + float(v["4. close"])) / 3
vol = float(v["5. volume"])
prices.append(typical)
volumes.append(vol)
if not prices:
return None
prices = np.array(prices)
volumes = np.array(volumes)
current_price = prices[0]
n_bins = 30
price_min, price_max = prices.min(), prices.max()
if price_max - price_min < 0.01:
return None
bin_edges = np.linspace(price_min, price_max, n_bins + 1)
bin_volumes = np.zeros(n_bins)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
for p, v in zip(prices, volumes):
idx = min(int((p - price_min) / (price_max - price_min) * n_bins), n_bins - 1)
bin_volumes[idx] += v
poc_idx = int(np.argmax(bin_volumes))
poc_price = float(bin_centers[poc_idx])
poc_distance = (current_price - poc_price) / (current_price + 1e-12)
total_vol = bin_volumes.sum()
target_vol = total_vol * 0.70
lo, hi = poc_idx, poc_idx
area_vol = bin_volumes[poc_idx]
while area_vol < target_vol and (lo > 0 or hi < n_bins - 1):
expand_lo = bin_volumes[lo - 1] if lo > 0 else 0
expand_hi = bin_volumes[hi + 1] if hi < n_bins - 1 else 0
if expand_lo >= expand_hi and lo > 0:
lo -= 1
area_vol += bin_volumes[lo]
elif hi < n_bins - 1:
hi += 1
area_vol += bin_volumes[hi]
else:
if lo > 0:
lo -= 1
area_vol += bin_volumes[lo]
else:
break
va_low = float(bin_edges[lo])
va_high = float(bin_edges[hi + 1])
if current_price > va_high:
va_position = 1.0
elif current_price < va_low:
va_position = -1.0
else:
va_position = 0.0
avg_bin_vol = total_vol / n_bins
hvn_mask = bin_volumes > 1.5 * avg_bin_vol
hvn_centers = bin_centers[hvn_mask]
hvn_support = 0.0
if len(hvn_centers) > 0:
below = hvn_centers[hvn_centers < current_price]
above = hvn_centers[hvn_centers > current_price]
nearest_below = float(below.max()) if len(below) > 0 else None
nearest_above = float(above.min()) if len(above) > 0 else None
if nearest_below is not None and nearest_above is not None:
dist_below = current_price - nearest_below
dist_above = nearest_above - current_price
hvn_support = 1.0 if dist_below < dist_above else -1.0
elif nearest_below is not None:
hvn_support = 1.0
elif nearest_above is not None:
hvn_support = -1.0
return {
"poc_distance": float(poc_distance),
"value_area_position": float(va_position),
"hvn_support": float(hvn_support),
}
# ─── Data Collection (all 16 endpoints) ──────────────────────────────────────
# ─── Analysis Modules ─────────────────────────────────────────────────────────
def _build_qualitative_assessments(signals, de_thresh=None):
"""Build qualitative Good/Neutral/Bad assessment for each fundamental metric.
Returns list of (category, metric_name, value_str, assessment, note) tuples.
Assessment is one of: STRONG, GOOD, NEUTRAL, WEAK, BAD, N/A.
"""
if de_thresh is None:
de_thresh = _SECTOR_DE_THRESHOLDS["DEFAULT"]
assessments = []
def _add(cat, name, key, fmt, thresholds, invert=False):
"""Helper: thresholds = [(val, "STRONG"), (val, "GOOD"), ...] descending.
If invert=True, lower is better (e.g., P/E, D/E)."""
val = signals.get(key)
if val is None:
assessments.append((cat, name, "N/A", "N/A", ""))
return
val_str = fmt(val)
assessment = "NEUTRAL"
note = ""
for threshold, label in thresholds:
if invert:
if val <= threshold:
assessment = label
break
else:
if val >= threshold:
assessment = label
break
else:
# Fell through — worst case for both inverted and non-inverted metrics
# Inverted (lower is better): value exceeded all thresholds → too expensive → worst label
# Non-inverted (higher is better): value below all thresholds → too low → worst label
assessment = thresholds[-1][1]
assessments.append((cat, name, val_str, assessment, note))
pct = lambda v: f"{v*100:.1f}%"
dec1 = lambda v: f"{v:.1f}"
dec2 = lambda v: f"{v:.2f}"
money = lambda v: f"${v/1e9:.1f}B" if abs(v) >= 1e9 else f"${v/1e6:.0f}M"
# ── Valuation ──
_add("Valuation", "P/E Ratio", "pe_ratio", dec1,
[(15, "STRONG"), (20, "GOOD"), (25, "NEUTRAL"), (35, "WEAK")], invert=True)
_add("Valuation", "PEG Ratio", "peg_ratio", dec2,
[(1.0, "STRONG"), (1.5, "GOOD"), (2.0, "NEUTRAL"), (3.0, "WEAK")], invert=True)
_add("Valuation", "P/FCF", "p_fcf", dec1,
[(15, "STRONG"), (20, "GOOD"), (30, "NEUTRAL"), (50, "WEAK")], invert=True)
_add("Valuation", "P/S Ratio", "price_to_sales", dec1,
[(2, "STRONG"), (5, "GOOD"), (10, "NEUTRAL"), (20, "WEAK")], invert=True)
_add("Valuation", "P/B Ratio", "price_to_book", dec1,
[(1.5, "STRONG"), (3, "GOOD"), (5, "NEUTRAL"), (10, "WEAK")], invert=True)
_add("Valuation", "EV/EBITDA", "ev_to_ebitda", dec1,
[(10, "STRONG"), (15, "GOOD"), (20, "NEUTRAL"), (30, "WEAK")], invert=True)
_add("Valuation", "EV/Revenue", "ev_to_revenue", dec1,
[(2, "STRONG"), (5, "GOOD"), (10, "NEUTRAL"), (20, "WEAK")], invert=True)
_add("Valuation", "52W High Drop", "52w_high_drop", pct,
[(0.50, "STRONG"), (0.25, "GOOD"), (0.10, "NEUTRAL")], invert=False)
_add("Valuation", "Analyst Upside", "analyst_upside", pct,
[(0.30, "STRONG"), (0.15, "GOOD"), (0.05, "NEUTRAL"), (-0.05, "WEAK")], invert=False)
# ── Growth ──
_add("Growth", "Revenue Growth YoY", "revenue_growth_yoy", pct,
[(0.20, "STRONG"), (0.10, "GOOD"), (0.05, "NEUTRAL"), (0, "WEAK")], invert=False)
_add("Growth", "Earnings Growth YoY", "earnings_growth_yoy", pct,
[(0.20, "STRONG"), (0.10, "GOOD"), (0.05, "NEUTRAL"), (0, "WEAK")], invert=False)
_add("Growth", "FCF Growth YoY", "fcf_growth_yoy", pct,
[(0.20, "STRONG"), (0.10, "GOOD"), (0, "NEUTRAL"), (-0.20, "WEAK")], invert=False)
# ── Profitability ──
_add("Profitability", "ROIC", "roic", pct,
[(0.20, "STRONG"), (0.15, "GOOD"), (0.10, "NEUTRAL"), (0.05, "WEAK")], invert=False)
_add("Profitability", "ROE", "roe", pct,
[(0.20, "STRONG"), (0.15, "GOOD"), (0.10, "NEUTRAL"), (0, "WEAK")], invert=False)
_add("Profitability", "Operating Margin", "operating_margin", pct,
[(0.25, "STRONG"), (0.15, "GOOD"), (0.10, "NEUTRAL"), (0, "WEAK")], invert=False)
_add("Profitability", "Profit Margin", "profit_margin", pct,
[(0.20, "STRONG"), (0.10, "GOOD"), (0.05, "NEUTRAL"), (0, "WEAK")], invert=False)
_add("Profitability", "Gross Margin", "gross_margin", pct,
[(0.60, "STRONG"), (0.40, "GOOD"), (0.25, "NEUTRAL"), (0.10, "WEAK")], invert=False)
# ── Financial Health ──
# D/E thresholds are sector-dependent — financials/utilities carry structural leverage
_add("Health", "Debt/Equity", "debt_to_equity", dec2,
[(de_thresh["strong"], "STRONG"), (de_thresh["good"], "GOOD"),
(de_thresh["neutral"], "NEUTRAL"), (de_thresh["neutral"] * 2, "WEAK")], invert=True)
_add("Health", "Interest Coverage", "interest_coverage",
lambda v: "Debt-free" if v == float('inf') else ("N/A" if v is None else f"{v:.1f}x"),
[(10, "STRONG"), (5, "GOOD"), (3, "NEUTRAL"), (1.5, "WEAK")], invert=False)
_add("Health", "Current Ratio", "current_ratio", dec2,
[(2.0, "STRONG"), (1.5, "GOOD"), (1.0, "NEUTRAL"), (0.8, "WEAK")], invert=False)
# ── Cash Flow ──
_add("Cash Flow", "FCF Margin", "fcf_margin", pct,
[(0.15, "STRONG"), (0.10, "GOOD"), (0.05, "NEUTRAL"), (0, "WEAK")], invert=False)
_add("Cash Flow", "FCF Conversion", "fcf_conversion", pct,
[(1.0, "STRONG"), (0.80, "GOOD"), (0.50, "NEUTRAL"), (0, "WEAK")], invert=False)
ocf_gt_ni = signals.get("ocf_gt_ni")
if ocf_gt_ni is not None:
assessments.append(("Cash Flow", "OCF > Net Income", "Yes" if ocf_gt_ni else "No",
"GOOD" if ocf_gt_ni else "BAD", "earnings quality"))
# ── Dividends ──
div_y = signals.get("dividend_yield", 0)
payout = signals.get("payout_ratio", 0)
if div_y > 0:
_add("Dividends", "Dividend Yield", "dividend_yield", pct,
[(0.04, "STRONG"), (0.02, "GOOD"), (0.01, "NEUTRAL")], invert=False)
_add("Dividends", "Payout Ratio", "payout_ratio", pct,
[(0.75, "BAD"), (0.60, "NEUTRAL"), (0.40, "GOOD"), (0, "STRONG")], invert=True)
else:
assessments.append(("Dividends", "Dividend", "None", "NEUTRAL", "no dividend"))
# ── Risk ──
_add("Risk", "Beta", "beta", dec2,
[(1.5, "BAD"), (1.2, "WEAK"), (0.8, "NEUTRAL"), (0.5, "GOOD")], invert=True)
# ── Earnings Quality ──
br = signals.get("beat_rate")
if br is not None:
label = "STRONG" if br >= 0.75 else "GOOD" if br >= 0.50 else "WEAK" if br >= 0.25 else "BAD"
assessments.append(("Earnings", "Beat Rate (last 4 qtrs)", f"{br*100:.0f}%", label, ""))
surprise = signals.get("avg_earnings_surprise")
if surprise is not None:
label = "STRONG" if surprise > 5 else "GOOD" if surprise > 0 else "WEAK" if surprise > -5 else "BAD"
assessments.append(("Earnings", "Avg Surprise", f"{surprise:+.1f}%", label, ""))
return assessments
# D/E thresholds by sector — financial companies operate with structural leverage
_SECTOR_DE_THRESHOLDS = {
"Financials": {"strong": 6.0, "good": 10.0, "neutral": 15.0},
"Financial Services": {"strong": 5.0, "good": 9.0, "neutral": 14.0},
"Real Estate": {"strong": 1.5, "good": 2.5, "neutral": 4.0},
"Utilities": {"strong": 1.2, "good": 2.0, "neutral": 3.0},
"DEFAULT": {"strong": 0.3, "good": 0.5, "neutral": 1.0},
}
def analyze_fundamentals(data):
"""Comprehensive quant scorecard — 6 categories, 32 max points."""
ov = data.get("overview", {})
if not ov:
return None
signals = {}
scorecard = {} # category → {metric: points}
MAX_POINTS = 32.0
sector = (ov.get("Sector") or ov.get("GicsSector") or "").strip()
de_thresh = _SECTOR_DE_THRESHOLDS.get(sector, _SECTOR_DE_THRESHOLDS["DEFAULT"])
# ── helper: extract TTM from quarterly reports ──
def _ttm(reports, field, n=4):
"""Sum last n quarterly values for trailing-twelve-months."""
total = 0.0
count = 0
for r in (reports or [])[:n]:
v = safe_float(r.get(field, 0))
if v != 0:
total += v
count += 1
return total if count >= max(n - 1, 1) else None
def _latest(reports, field):
"""Most recent quarterly value."""
if not reports:
return None
return safe_float(reports[0].get(field, 0)) or None
def _prev(reports, field, offset=4):
"""Same-quarter prior year value for YoY comparison."""
if not reports or len(reports) <= offset:
return None
return safe_float(reports[offset].get(field, 0)) or None
# Raw data sources
inc_q = data.get("income", {}).get("quarterlyReports", [])
bs_q = data.get("balance", {}).get("quarterlyReports", [])
cf_q = data.get("cashflow", {}).get("quarterlyReports", [])
# Get current price from daily data or overview
daily_df_price = data.get("daily", None)
if daily_df_price is not None and not daily_df_price.empty:
close_col_p = "adj_close" if "adj_close" in daily_df_price.columns else \
"adjusted_close" if "adjusted_close" in daily_df_price.columns else daily_df_price.columns[0]
current_price = float(daily_df_price[close_col_p].iloc[-1])
else:
current_price = safe_float(ov.get("AnalystTargetPrice", 0)) # rough fallback
# ══════════════════════════════════════════════════════════════
# 1. VALUATION (Max 8 points)
# ══════════════════════════════════════════════════════════════
val_pts = {}
# 52-week high drop (from daily DataFrame)
daily_df = data.get("daily", None)
if daily_df is not None and not daily_df.empty and current_price > 0:
close_col = "adj_close" if "adj_close" in daily_df.columns else \
"adjusted_close" if "adjusted_close" in daily_df.columns else daily_df.columns[0]
recent_252 = daily_df[close_col].dropna().tail(252)
if len(recent_252) > 50:
high_52w = recent_252.max()
if high_52w > 0:
drop_pct = (high_52w - current_price) / high_52w
signals["52w_high_drop"] = drop_pct
if drop_pct > 0.50:
val_pts["52w_drop"] = 2
elif drop_pct > 0.25:
val_pts["52w_drop"] = 1
else:
val_pts["52w_drop"] = 0
# P/E Ratio
pe = safe_float(ov.get("PERatio", 0))
fwd_pe = safe_float(ov.get("ForwardPE", 0))
if pe > 0:
signals["pe_ratio"] = pe
if pe < 15:
val_pts["pe"] = 2
elif pe <= 25:
val_pts["pe"] = 1
else:
val_pts["pe"] = 0
if fwd_pe > 0 and pe > 0:
signals["pe_fwd_discount"] = (pe - fwd_pe) / pe
# PEG Ratio
peg = safe_float(ov.get("PEGRatio", 0))
if peg > 0:
signals["peg_ratio"] = peg
if peg < 1.0:
val_pts["peg"] = 2
elif peg <= 1.5:
val_pts["peg"] = 1
else:
val_pts["peg"] = 0
# Price to Free Cash Flow (P/FCF)
ttm_fcf = None
ttm_ocf = _ttm(cf_q, "operatingCashflow")
ttm_capex = _ttm(cf_q, "capitalExpenditures")
if ttm_ocf is not None and ttm_capex is not None:
ttm_fcf = ttm_ocf - abs(ttm_capex) # capex often reported negative
signals["ttm_fcf"] = ttm_fcf
shares = safe_float(ov.get("SharesOutstanding", 0))
if ttm_fcf > 0 and current_price > 0 and shares > 0:
p_fcf = (current_price * shares) / ttm_fcf
signals["p_fcf"] = p_fcf
if p_fcf < 20:
val_pts["p_fcf"] = 2
elif p_fcf <= 30:
val_pts["p_fcf"] = 1
else:
val_pts["p_fcf"] = 0
scorecard["Valuation"] = (sum(val_pts.values()), 8, val_pts)
# ══════════════════════════════════════════════════════════════
# 2. GROWTH (Max 6 points)
# ══════════════════════════════════════════════════════════════
gro_pts = {}
# Revenue Growth YoY
rev_growth = safe_float(ov.get("QuarterlyRevenueGrowthYOY", 0))
if rev_growth != 0:
signals["revenue_growth_yoy"] = rev_growth
if rev_growth > 0.20:
gro_pts["rev_growth"] = 2
elif rev_growth > 0.10:
gro_pts["rev_growth"] = 1
else:
gro_pts["rev_growth"] = 0
# Earnings Growth YoY
earn_growth = safe_float(ov.get("QuarterlyEarningsGrowthYOY", 0))
if earn_growth != 0:
signals["earnings_growth_yoy"] = earn_growth
if earn_growth > 0.20:
gro_pts["earn_growth"] = 2
elif earn_growth > 0.10:
gro_pts["earn_growth"] = 1
else:
gro_pts["earn_growth"] = 0
# Free Cash Flow Growth YoY
if ttm_fcf is not None:
prev_ocf = _ttm(cf_q[4:], "operatingCashflow") if len(cf_q) > 4 else None
prev_capex = _ttm(cf_q[4:], "capitalExpenditures") if len(cf_q) > 4 else None
if prev_ocf is not None and prev_capex is not None:
prev_fcf = prev_ocf - abs(prev_capex)
if prev_fcf > 0:
fcf_growth = (ttm_fcf - prev_fcf) / abs(prev_fcf)
signals["fcf_growth_yoy"] = fcf_growth
if fcf_growth > 0.20:
gro_pts["fcf_growth"] = 2
elif fcf_growth > 0.10:
gro_pts["fcf_growth"] = 1
else:
gro_pts["fcf_growth"] = 0
scorecard["Growth"] = (sum(gro_pts.values()), 6, gro_pts)
# ══════════════════════════════════════════════════════════════
# 3. PROFITABILITY (Max 8 points)
# ══════════════════════════════════════════════════════════════
prof_pts = {}
# ROIC = NOPAT / Invested Capital
ttm_ebit = _ttm(inc_q, "ebit")
tax_rate = 0.21 # assume statutory
latest_total_debt = _latest(bs_q, "shortLongTermDebtTotal") or (
(_latest(bs_q, "shortTermDebt") or 0) + (_latest(bs_q, "longTermDebt") or 0))
latest_equity = _latest(bs_q, "totalShareholderEquity")
if ttm_ebit and latest_equity:
nopat = ttm_ebit * (1 - tax_rate)
invested_capital = (latest_equity or 0) + (latest_total_debt or 0)
if invested_capital > 0:
roic = nopat / invested_capital
signals["roic"] = roic
if roic > 0.15:
prof_pts["roic"] = 2
elif roic > 0.10:
prof_pts["roic"] = 1
else:
prof_pts["roic"] = 0
# ROE
roe = safe_float(ov.get("ReturnOnEquityTTM", 0))
if roe != 0:
signals["roe"] = roe
if roe > 0.20:
prof_pts["roe"] = 2
elif roe > 0.15:
prof_pts["roe"] = 1
else:
prof_pts["roe"] = 0
# Operating Margin
op_margin = safe_float(ov.get("OperatingMarginTTM", 0))
profit_margin = safe_float(ov.get("ProfitMargin", 0))
signals["profit_margin"] = profit_margin
if op_margin != 0:
signals["operating_margin"] = op_margin
if op_margin > 0.20:
prof_pts["op_margin"] = 2
elif op_margin > 0.15:
prof_pts["op_margin"] = 1
else:
prof_pts["op_margin"] = 0
elif profit_margin != 0:
# fallback to profit margin if operating margin unavailable
if profit_margin > 0.20:
prof_pts["op_margin"] = 2
elif profit_margin > 0.15:
prof_pts["op_margin"] = 1
else:
prof_pts["op_margin"] = 0
# Gross Margin (bonus context, not scored but tracked)
gross_margin = safe_float(ov.get("GrossProfitTTM", 0))
ttm_rev = _ttm(inc_q, "totalRevenue")
if gross_margin > 0 and ttm_rev and ttm_rev > 0:
signals["gross_margin"] = gross_margin / ttm_rev
scorecard["Profitability"] = (sum(prof_pts.values()), 8, prof_pts)
# ══════════════════════════════════════════════════════════════
# 4. FINANCIAL HEALTH (Max 4 points)
# ══════════════════════════════════════════════════════════════
health_pts = {}
# Debt to Equity — thresholds depend on sector (financials have structural leverage)
if latest_equity and latest_equity > 0 and latest_total_debt is not None:
d_e = latest_total_debt / latest_equity
signals["debt_to_equity"] = d_e
if d_e < de_thresh["strong"]:
health_pts["d_e"] = 2
elif d_e <= de_thresh["good"]:
health_pts["d_e"] = 1
else:
health_pts["d_e"] = 0
# Interest Coverage = EBIT / Interest Expense
# Try multiple field names — AV uses different names by sector
ttm_interest = (_ttm(inc_q, "interestExpense") or
_ttm(inc_q, "interestAndDebtExpense") or
_ttm(inc_q, "totalInterestExpense"))
# Sanity check: if company has meaningful debt, can't be "Debt-free"
has_debt = (signals.get("debt_to_equity", 0) or 0) > 0.1
if ttm_ebit and ttm_ebit > 0:
if ttm_interest and ttm_interest > 0:
int_coverage = ttm_ebit / ttm_interest
signals["interest_coverage"] = int_coverage
if int_coverage > 8:
health_pts["int_cov"] = 2
elif int_coverage >= 5:
health_pts["int_cov"] = 1
else:
health_pts["int_cov"] = 0
elif not has_debt:
# Truly debt-free: no interest expense AND no meaningful debt
signals["interest_coverage"] = float('inf')
health_pts["int_cov"] = 2
else:
# Has debt but interest expense not found in income statement
# (common for financial companies that report it differently)
# Score 0 — cannot verify coverage, treat as unknown/risky
signals["interest_coverage"] = None
health_pts["int_cov"] = 0
scorecard["Financial Health"] = (sum(health_pts.values()), 4, health_pts)
# ══════════════════════════════════════════════════════════════
# 5. DIVIDENDS (Max 2 points)
# ══════════════════════════════════════════════════════════════
div_pts = {}
div_yield = safe_float(ov.get("DividendYield", 0))
signals["dividend_yield"] = div_yield
payout_ratio = safe_float(ov.get("PayoutRatio")) or 0.0
# Fall back to DividendPerShare / EPS when Overview API returns None
if payout_ratio == 0.0:
_dps = safe_float(ov.get("DividendPerShare")) or 0.0
_eps = safe_float(ov.get("EPS")) or 0.0
if _dps > 0 and _eps > 0:
payout_ratio = _dps / _eps
signals["payout_ratio"] = payout_ratio
if payout_ratio > 0 and payout_ratio <= 0.75:
div_pts["payout"] = 2
elif payout_ratio == 0 and div_yield == 0:
div_pts["payout"] = 0 # no dividend, neutral
else:
div_pts["payout"] = 0 # payout > 75% or negative
scorecard["Dividends"] = (sum(div_pts.values()), 2, div_pts)
# ══════════════════════════════════════════════════════════════
# 6. CASH FLOW QUALITY (Max 4 points)
# ══════════════════════════════════════════════════════════════
cf_pts = {}
# Positive FCF
if ttm_fcf is not None:
if ttm_fcf > 0:
cf_pts["positive_fcf"] = 1
else:
cf_pts["positive_fcf"] = 0
# Earnings Quality: OCF > Net Income
ttm_net_income = _ttm(inc_q, "netIncome")
if ttm_ocf is not None and ttm_net_income is not None:
signals["ttm_net_income"] = ttm_net_income
if ttm_ocf > ttm_net_income:
cf_pts["earnings_quality"] = 1
signals["ocf_gt_ni"] = True
else:
cf_pts["earnings_quality"] = 0
signals["ocf_gt_ni"] = False
# FCF Margin = FCF / Revenue
if ttm_fcf is not None and ttm_rev and ttm_rev > 0:
fcf_margin = ttm_fcf / ttm_rev
signals["fcf_margin"] = fcf_margin
if fcf_margin > 0.10:
cf_pts["fcf_margin"] = 1
else:
# Check if improving YoY
prev_fcf_for_margin = None
prev_rev = _ttm(inc_q[4:], "totalRevenue") if len(inc_q) > 4 else None
prev_ocf_m = _ttm(cf_q[4:], "operatingCashflow") if len(cf_q) > 4 else None
prev_capex_m = _ttm(cf_q[4:], "capitalExpenditures") if len(cf_q) > 4 else None
if prev_ocf_m is not None and prev_capex_m is not None and prev_rev and prev_rev > 0:
prev_fcf_for_margin = prev_ocf_m - abs(prev_capex_m)
prev_fcf_margin = prev_fcf_for_margin / prev_rev
if fcf_margin > prev_fcf_margin:
cf_pts["fcf_margin"] = 0.5
signals["fcf_margin_improving"] = True
else:
cf_pts["fcf_margin"] = 0
else:
cf_pts["fcf_margin"] = 0
# FCF Conversion = FCF / Net Income
if ttm_fcf is not None and ttm_net_income and ttm_net_income > 0:
fcf_conversion = ttm_fcf / ttm_net_income
signals["fcf_conversion"] = fcf_conversion
if fcf_conversion > 0.80:
cf_pts["fcf_conversion"] = 1
else:
# Check if improving YoY
if "fcf_growth_yoy" in signals: # we have prior year FCF
prev_ocf_c = _ttm(cf_q[4:], "operatingCashflow") if len(cf_q) > 4 else None
prev_capex_c = _ttm(cf_q[4:], "capitalExpenditures") if len(cf_q) > 4 else None
prev_ni = _ttm(inc_q[4:], "netIncome") if len(inc_q) > 4 else None
if prev_ocf_c is not None and prev_capex_c is not None and prev_ni and prev_ni > 0:
prev_conv = (prev_ocf_c - abs(prev_capex_c)) / prev_ni
if fcf_conversion > prev_conv:
cf_pts["fcf_conversion"] = 0.5
signals["fcf_conversion_improving"] = True
else:
cf_pts["fcf_conversion"] = 0
else:
cf_pts["fcf_conversion"] = 0
else:
cf_pts["fcf_conversion"] = 0
scorecard["Cash Flow Quality"] = (sum(cf_pts.values()), 4, cf_pts)
# ══════════════════════════════════════════════════════════════
# BONUS: Analyst consensus + earnings surprises (context, not in 32pts)
# ══════════════════════════════════════════════════════════════
analyst_bonus = 0.0
# Analyst target
try:
target = safe_float(ov.get("AnalystTargetPrice", 0))
shares = safe_float(ov.get("SharesOutstanding", 0))
if target > 0 and current_price > 0:
upside = (target - current_price) / current_price
signals["analyst_upside"] = upside
analyst_bonus += np.clip(upside * 0.15, -0.08, 0.08)
except (ValueError, TypeError, ZeroDivisionError):
pass
# Earnings surprises
if "earnings" in data and "quarterlyEarnings" in data["earnings"]:
qe = data["earnings"]["quarterlyEarnings"][:4]
surprises = []
for q in qe:
try:
surprises.append(safe_float(q.get("surprisePercentage", 0)))
except Exception:
pass
if surprises:
avg_surprise = np.mean(surprises)
beats = sum(1 for s in surprises if s > 0)
signals["avg_earnings_surprise"] = avg_surprise
signals["beat_rate"] = beats / len(surprises)
analyst_bonus += np.clip(avg_surprise / 50, -0.05, 0.05)
# ══════════════════════════════════════════════════════════════
# FINAL SCORE → expected return
# ══════════════════════════════════════════════════════════════
total_pts = sum(cat[0] for cat in scorecard.values())
pct_score = total_pts / MAX_POINTS # 0.0 to 1.0
# Map score to expected return:
# 0% → -8% (terrible company)
# 50% → 0% (average)
# 100% → +10% (exceptional)
base_return = (pct_score - 0.5) * 0.18 # range: -9% to +9%
# Scale analyst bonus by scorecard quality so a poor score can't be
# fully overridden by analyst consensus
quality_scale = 0.2 + 0.8 * pct_score
analyst_bonus *= quality_scale
expected_return = np.clip(base_return + analyst_bonus, -0.10, 0.10)
# Confidence: based on data completeness (how many metrics we could calculate)
total_metrics_possible = 14 # total sub-metrics across all categories
total_metrics_scored = sum(len(cat[2]) for cat in scorecard.values())
data_coverage = min(total_metrics_scored / total_metrics_possible, 1.0)
# ══════════════════════════════════════════════════════════════
# ADDITIONAL METRICS (not scored, but tracked for LLM context)
# ══════════════════════════════════════════════════════════════
ps = safe_float(ov.get("PriceToSalesRatioTTM", 0))
if ps > 0:
signals["price_to_sales"] = ps
pb = safe_float(ov.get("PriceToBookRatio", 0))
if pb > 0:
signals["price_to_book"] = pb
ev_ebitda = safe_float(ov.get("EVToEBITDA", 0))
if ev_ebitda > 0:
signals["ev_to_ebitda"] = ev_ebitda
ev_rev = safe_float(ov.get("EVToRevenue", 0))
if ev_rev > 0:
signals["ev_to_revenue"] = ev_rev
beta = safe_float(ov.get("Beta", 0))
if beta > 0:
signals["beta"] = beta
eps = safe_float(ov.get("EPS", 0))
if eps != 0:
signals["eps"] = eps
rev_ttm = safe_float(ov.get("RevenueTTM", 0))
if rev_ttm > 0:
signals["revenue_ttm"] = rev_ttm
ebitda = safe_float(ov.get("EBITDA", 0))
if ebitda > 0:
signals["ebitda"] = ebitda
# Current Ratio from balance sheet
current_assets = _latest(bs_q, "totalCurrentAssets")
current_liabilities = _latest(bs_q, "totalCurrentLiabilities")
if current_assets and current_liabilities and current_liabilities > 0:
signals["current_ratio"] = current_assets / current_liabilities
# ══════════════════════════════════════════════════════════════
# QUALITATIVE ASSESSMENT TABLE (for LLM consumption)
# ══════════════════════════════════════════════════════════════
assessments = _build_qualitative_assessments(signals, de_thresh)
return {
"expected_return": expected_return,
"score": pct_score,
"total_points": total_pts,
"max_points": MAX_POINTS,
"scorecard": scorecard,
"signals": signals,
"assessments": assessments,
"confidence": max(data_coverage, 0.20), # floor at 20% — sparse data should carry less weight
}
def _compute_enhanced_news_sentiment(data):
"""Enhanced news sentiment with recency weighting, relevance filtering,
and momentum (trend) detection.
Returns dict with keys: weighted_avg, raw_avg, momentum, n_articles,
recent_avg (last 7d), older_avg (8-30d), or None if no data.
"""
news_items = data.get("news_items", [])
if not news_items:
# Fallback to legacy scores if news_items not available
scores = data.get("news_scores", [])
if not scores:
return None
return {
"weighted_avg": float(np.mean(scores)),
"raw_avg": float(np.mean(scores)),
"momentum": 0.0,
"n_articles": len(scores),
"recent_avg": float(np.mean(scores)),
"older_avg": float(np.mean(scores)),
}
now = datetime.now()
scored = []
for item in news_items:
sent = item["score"]
rel = item.get("relevance", 0.5)
tp = item.get("time_published", "")
# Parse Alpha Vantage timestamp: "20260329T120000"
days_ago = 15 # default if unparseable
if tp:
try:
pub_dt = datetime.strptime(tp[:15], "%Y%m%dT%H%M%S")
days_ago = max((now - pub_dt).total_seconds() / 86400, 0.1)
except (ValueError, IndexError):
pass
# Exponential recency decay: half-life 7 days
recency_weight = np.exp(-0.693 * days_ago / 7.0) # ln(2)/7 ≈ 0.099
# Relevance filter: articles below 0.2 relevance get down-weighted
rel_weight = max(rel, 0.1)
combined_weight = recency_weight * rel_weight
scored.append((sent, combined_weight, days_ago))
if not scored:
return None
# Weighted average sentiment
total_w = sum(w for _, w, _ in scored)
weighted_avg = sum(s * w for s, w, _ in scored) / total_w if total_w > 0 else 0
# Raw average (for comparison)
raw_avg = float(np.mean([s for s, _, _ in scored]))
# Recent vs older split for momentum
recent = [s for s, _, d in scored if d <= 7]
older = [s for s, _, d in scored if d > 7]
recent_avg = float(np.mean(recent)) if recent else weighted_avg
older_avg = float(np.mean(older)) if older else weighted_avg
# Momentum: positive = sentiment improving, negative = deteriorating
momentum = recent_avg - older_avg
return {
"weighted_avg": weighted_avg,
"raw_avg": raw_avg,
"momentum": momentum,
"n_articles": len(scored),
"n_recent": len(recent),
"n_older": len(older),
"recent_avg": recent_avg,
"older_avg": older_avg,
}
def _insider_score_from_rows(insider, lookback_days=90, return_counts=False):
"""Insider buy/sell pressure ∈ [-1, +1] from INSIDER_TRANSACTIONS rows over the last
`lookback_days`. -1 = pure selling, +1 = pure buying. Returns None if no usable rows.
Shared by analyze_sentiment (Stage 2) and the Stage-1.5 distribution pre-screen so both
use an identical definition. Blends transaction-count ratio (0.4) and dollar-value ratio (0.6).
return_counts=True returns (score, buys, sells) for callers that need the breakdown;
otherwise returns the scalar score (or None / (None,0,0) when there are no usable rows).
"""
if not insider:
return (None, 0, 0) if return_counts else None
now = datetime.now()
buys, sells, buy_val, sell_val = 0, 0, 0.0, 0.0
# Filter by the date window across ALL rows (not a fixed [:100] slice): high-volume names
# (AAPL/IBM have thousands of filings) could otherwise have a 90-day window truncated mid-
# window, biasing the score toward whatever the newest cluster happens to be.
for txn in insider:
try:
td = datetime.strptime(txn.get("transaction_date", ""), "%Y-%m-%d")
except ValueError:
continue
if (now - td).days > lookback_days:
continue
shares = safe_float(txn.get("shares", 0))
price = safe_float(txn.get("share_price", 0))
if txn.get("acquisition_or_disposal") == "A":
buys += 1; buy_val += shares * price
elif txn.get("acquisition_or_disposal") == "D":
sells += 1; sell_val += shares * price
total = buys + sells
if total == 0:
return (None, 0, 0) if return_counts else None
ratio = buys / total
val_ratio = buy_val / (buy_val + sell_val) if (buy_val + sell_val) > 0 else 0.5
score = (ratio * 0.4 + val_ratio * 0.6) * 2 - 1
return (score, buys, sells) if return_counts else score
def analyze_sentiment(data, ticker):
"""Composite sentiment from news + insider + options + earnings.
News component uses recency-weighted, relevance-filtered scores with
momentum detection (recent vs older sentiment trend).
"""
components = {}
score = 0.0
weight_sum = 0.0
# ── News sentiment (enhanced) ──
news = _compute_enhanced_news_sentiment(data)
if news:
components["news_sentiment"] = news["weighted_avg"]
components["news_raw_avg"] = news["raw_avg"]
components["news_momentum"] = news["momentum"]
components["news_n_articles"] = news["n_articles"]
components["news_recent_avg"] = news["recent_avg"]
components["news_older_avg"] = news["older_avg"]
# Blend: 70% weighted sentiment + 30% momentum signal
news_signal = news["weighted_avg"] * 0.7 + news["momentum"] * 0.3
score += news_signal * 0.3
weight_sum += 0.3
# ── Insider transactions (90 day) ──
insider = data.get("insider", [])
if insider:
insider_score, buys, sells = _insider_score_from_rows(insider, return_counts=True)
if insider_score is not None:
components["insider_score"] = insider_score
score += insider_score * 0.3
weight_sum += 0.3
components["insider_detail"] = f"buys:{buys} sells:{sells}"
# ── Options put/call ──
options = data.get("options", [])
if options:
call_vol = sum(safe_float(o.get("volume", 0)) for o in options if o.get("type", "").lower() == "call")
put_vol = sum(safe_float(o.get("volume", 0)) for o in options if o.get("type", "").lower() == "put")
if call_vol > 0:
pc_ratio = put_vol / call_vol
options_score = np.clip(-(pc_ratio - 1.0) * 2, -1, 1)
components["options_pc_ratio"] = pc_ratio
components["options_score"] = options_score
score += options_score * 0.2
weight_sum += 0.2
# IV level
ivs = [safe_float(o.get("implied_volatility", 0)) for o in options if safe_float(o.get("implied_volatility", 0)) > 0]
if ivs:
components["avg_iv"] = np.mean(ivs)
# ── Earnings momentum ──
if "earnings" in data and "quarterlyEarnings" in data["earnings"]:
qe = data["earnings"]["quarterlyEarnings"][:4]
surprises = [safe_float(q.get("surprisePercentage", 0)) for q in qe
if q.get("surprisePercentage") not in (None, "None", "")]
if surprises:
earn_score = np.clip(np.mean(surprises) / 10, -1, 1) * 0.5 + (sum(1 for s in surprises if s > 0) / len(surprises) - 0.5)
components["earnings_score"] = earn_score
score += earn_score * 0.2
weight_sum += 0.2
if weight_sum == 0:
return None
norm_score = score / weight_sum
expected_return = np.clip(norm_score * 0.06, -0.10, 0.10)
# Dampen confidence when sentiment components disagree internally
_component_vals = []
for _ck in ("news_sentiment", "insider_score", "options_score", "earnings_score"):
_cv = components.get(_ck)
if isinstance(_cv, (int, float)) and abs(_cv) > 0.05:
_component_vals.append(float(_cv))
if len(_component_vals) >= 2:
_pos = sum(1 for v in _component_vals if v > 0)
_neg = sum(1 for v in _component_vals if v < 0)
_agreement = max(_pos, _neg) / len(_component_vals)
_disagreement_penalty = 0.5 + 0.5 * _agreement
weight_sum *= _disagreement_penalty
return {
"expected_return": expected_return,
"score": norm_score,
"components": components,
"confidence": min(weight_sum / 1.0, 1.0),
}
# ─── ML Monthly Return Predictor ─────────────────────────────────────────────
# Filled by build_ml_features — used by train_ml_model for dropna(subset=core)
MONTHLY_NULLABLE_BASES = set()
_SCALED_MODELS = {"enet", "mlp"} # models requiring StandardScaler'd input
# Stacked meta-learner: floor weights so no model is dropped; fall back if meta inverts sign vs base models.
_META_MIN_WEIGHT = 0.05
# Stacked meta is fit on few OOS folds and can extrapolate far past the base-model consensus.
# Constrain its output to within this band (absolute 21d return) of the dir-acc-gated weighted avg.
_META_DIVERGENCE_TOL = 0.04
# Cap any single base learner's ensemble weight so one degenerate model cannot dominate.
_MAX_MODEL_WEIGHT = 0.35
# Features excluded from the ML training matrix despite being economically motivated, because a
# feature-dominance audit (per-ticker permutation importance vs forward-21d rank-IC across 12
# names) flagged them as overfit surfaces: meaningful model importance but near-zero, sign-
# inconsistent IC across tickers (trees memorizing per-ticker noise, not generalizable signal).
# • days_to_next_earnings — ~0.32 importance, IC −0.10..+0.02 (smooth 0–90 earnings-cycle
# sawtooth). Earnings-proximity risk is already handled explicitly (alpha penalties +
# risk_score + check_earnings_proximity); the coarse binary `earnings_in_next_21d` is kept.
# • skew_63d — 5.1% importance but mean|IC| 0.033 with coin-flip sign (signCons 0.50): a
# higher-moment return-shape feature with no stable cross-ticker direction.
_ML_EXCLUDE_FEATURES = {"days_to_next_earnings", "skew_63d"}
# ─── LLM Judge (GPT-5.5 / Gemini Flash) ─────────────────────────────────────
# Load .env if available (keeps keys out of shell history)
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"), override=True)
except ImportError:
pass
# Re-read the Alpha Vantage key now that .env has been loaded (the module-top read
# at the Config section only sees the shell environment).
API_KEY = (os.environ.get("ALPHAVANTAGE_API_KEY", "")
or os.environ.get("ALPHA_VANTAGE_API_KEY", "")
or API_KEY).strip()
if not API_KEY:
print(f"{YELLOW}⚠ ALPHAVANTAGE_API_KEY is not set (env or .env) — Alpha Vantage "
f"requests will fail until it is configured.{RESET}")
try:
from config import (
OPENAI_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, KIMI_API_KEY,
)
except ImportError:
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
KIMI_API_KEY = os.environ.get("KIMI_API_KEY", "")
# Per-call LLM network timeout. SDK defaults are ~600s, so a single unreachable provider hangs
# a debate round for 10 minutes before giving up; 120s fails fast on network trouble while
# leaving ample headroom for a legitimately slow multi-round response.
_LLM_TIMEOUT = 120.0
_LLM_SYSTEM_PROMPT = """You are an expert quantitative equity analyst. You are given the outputs of multiple quantitative models that predict a stock's 1-month return. Your job is to synthesize these signals, identify conflicts, assess which sources are most credible for this specific stock, and produce a FINAL predicted price and a BUY/HOLD/SELL recommendation.
Key principles:
- ML models extrapolate from historical patterns and can be wildly wrong for stocks in unusual regimes (parabolic runs, near-bankruptcy, post-M&A)
- Technical signals are most reliable for mean-reversion in normal regimes, less so for momentum crashes
- Fundamental signals are most reliable for mature companies, less so for growth/speculative names
- Insider selling is one of the strongest bearish signals, especially when unanimous (0 buys)
- When analyst targets diverge strongly from ML predictions, investigate why — analysts have forward info the model doesn't
- Be skeptical of extreme predictions (>10% monthly return). Most stocks don't move that much.
- Your confidence should be LOW when sources strongly disagree
Recommendation guidelines:
- STRONG BUY: High-conviction upside >5% in 1 month with strong fundamental + technical alignment
- BUY: Expected upside >2% with reasonable confidence
- HOLD: Expected return -2% to +2%, or high uncertainty
- SELL: Expected downside >2% with reasonable confidence
- STRONG SELL: High-conviction downside >5% with multiple bearish signals confirming
You MUST respond with valid JSON only. No markdown, no explanation outside JSON."""
_LLM_USER_TEMPLATE = """Analyze {ticker} ({months}-month forecast) and synthesize these quantitative signals:
CURRENT: ${current_price:.2f}
{signals_text}
Respond with ONLY this JSON (no markdown):
{{
"expected_return_pct": <float: your predicted return in percent, e.g. 2.5 for +2.5%>,
"predicted_price": <float: your predicted price target, e.g. 185.50>,
"recommendation": "<STRONG BUY|BUY|HOLD|SELL|STRONG SELL>",
"confidence": <float: 0.0 to 1.0>,
"direction": "<BULLISH|BEARISH|NEUTRAL>",
"reasoning": "<2-3 sentences explaining your synthesis and which signals you weighted most>",
"risk_flags": ["<list of key risks>"],
"override_ml": <boolean: true if you think ML is significantly wrong>
}}"""
_BEAR_SYSTEM_PROMPT = """You are a senior risk manager at a top-tier hedge fund. Your mandate is capital preservation.
Your role: make the STRONGEST POSSIBLE BEAR CASE for why this stock will underperform or decline.
- Stress-test every bullish assumption. Assume quant models are overfit to recent momentum.
- Focus on: downside risks, valuation traps, insider selling (what was traded, never why), technical exhaustion, macro headwinds.
- NEVER recommend STRONG BUY. If risk/reward is genuinely exceptional, the best you can say is HOLD.
- Be specific — every claim must cite an actual number from the data provided.
- Look beyond the financials: if a SMART-MONEY FLOW block (13F super-investors / insider Form-4)
or RECENT NEWS HEADLINES block is present, you MUST address it — what positions and trades do the
filings show (they record holdings, never intent or motive), are the "catalysts" hype? If it cuts
against your thesis, concede it explicitly.
- Build a COMPREHENSIVE thesis, not just a numbers read. Explicitly weigh: (a) business & growth
outlook — demand trend, TAM, product cycle, guidance credibility; (b) competitive landscape &
moat — share loss, pricing power erosion, disruptors, substitution; (c) business/operational
risks — customer/geographic concentration, regulation, litigation, execution, balance-sheet
fragility; (d) management & capital allocation — track record, dilution, buybacks at high prices,
incentive alignment. Ground each in the earnings-call excerpt, news, and fundamentals trends
provided plus well-established facts about the company — never fabricate specifics you can't support.
- A missed risk is a career-ending failure. A missed opportunity cost is noise.
Recommendation guidelines (from your perspective):
- STRONG SELL: high-conviction downside >5%, multiple confirming signals
- SELL: expected downside >2% with reasonable confidence
- HOLD: risk/reward is balanced but tilted bearish; not worth adding
You MUST respond with valid JSON only. No markdown, no explanation outside JSON."""
_BEAR_USER_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
{signals_text}
As the RISK MANAGER, make the strongest possible bear case.
In "key_points", include ONLY the topics that are material (drop any you have nothing specific for);
each point states its conclusion first, then the number that proves it (1-2 sentences).
Respond with ONLY this JSON:
{{
"expected_return_pct": <float: your bear-case return estimate, typically negative or low positive>,
"predicted_price": <float>,
"recommendation": "<HOLD|SELL|STRONG SELL>",
"confidence": <float: 0.0-1.0>,
"direction": "<BEARISH|NEUTRAL>",
"reasoning": "<your bear thesis in 2-3 sentences: the claim, the strongest reason, what the market is missing>",
"key_points": [
{{"topic": "Business outlook", "point": "<demand / growth / guidance credibility, figures only as [[TOKENS]]>"}},
{{"topic": "Competitive landscape", "point": "<moat erosion, share loss, pricing, disruptors>"}},
{{"topic": "Business risks", "point": "<concentration, regulation, execution, balance sheet>"}},
{{"topic": "Management", "point": "<track record, dilution, buybacks at highs, alignment>"}},
{{"topic": "Valuation", "point": "<REQUIRED — INTRINSIC VALUE vs PRICE: cite the Damodaran DCF/story value and the market-composite price from the signals, state the gap, and say whether your case needs the market to be right or wrong about it — plus multiples vs growth/peers, figures only as [[TOKENS]]>"}},
{{"topic": "Smart money & news", "point": "<13F/insider positioning; real catalyst vs hype>"}}
],
"risk_flags": ["<top 4-5 specific risks, figures only as [[TOKENS]]>"],
"override_ml": <boolean>
}}"""
_BULL_SYSTEM_PROMPT = """You are a growth equity analyst at a top-tier asset manager. Your mandate is alpha generation.
Your role: make the STRONGEST POSSIBLE BULL CASE for why this stock will outperform.
- Challenge every bearish assumption. Assume the market is underpricing the growth trajectory.
- Focus on: momentum catalysts, earnings power, changes in reported institutional positions, sector tailwinds, quality metrics.
- NEVER recommend STRONG SELL. If the setup is genuinely terrible, the worst you say is HOLD.
- Be specific — every claim must cite an actual number from the data provided.
- Look beyond the financials: if a SMART-MONEY FLOW block (13F super-investors / insider Form-4)
or RECENT NEWS HEADLINES block is present, you MUST address it — name the tracked investors whose
reported positions rose (positions, never intent), cite real catalysts. If it cuts against your
thesis, concede it explicitly.
- Build a COMPREHENSIVE thesis, not just a numbers read. Explicitly weigh: (a) business & growth
outlook — demand drivers, TAM expansion, product/roadmap momentum, operating leverage; (b)
competitive landscape & moat — durable share gains, pricing power, network effects, switching
costs; (c) key business risks and why they are manageable or already priced in; (d) management &
capital allocation — execution track record, founder/insider alignment, smart reinvestment or
buybacks. Ground each in the earnings-call excerpt, news, and fundamentals trends provided plus
well-established facts about the company — never fabricate specifics you can't support.
- A missed 10% move is a failure. A 2% drawdown is noise.
Recommendation guidelines (from your perspective):
- STRONG BUY: high-conviction upside >5% in 1 month with strong alignment
- BUY: expected upside >2% with reasonable confidence
- HOLD: opportunity exists but timing or entry is not yet ideal
You MUST respond with valid JSON only. No markdown, no explanation outside JSON."""
_BULL_USER_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
{signals_text}
As the GROWTH ANALYST, make the strongest possible bull case.
In "key_points", include ONLY the topics that are material (drop any you have nothing specific for);
each point states its conclusion first, then the number that proves it (1-2 sentences).
Respond with ONLY this JSON:
{{
"expected_return_pct": <float: your bull-case return estimate, typically positive>,
"predicted_price": <float>,
"recommendation": "<HOLD|BUY|STRONG BUY>",
"confidence": <float: 0.0-1.0>,
"direction": "<BULLISH|NEUTRAL>",
"reasoning": "<your bull thesis in 2-3 sentences: the claim, the strongest reason, what the market is missing>",
"key_points": [
{{"topic": "Business outlook", "point": "<demand drivers / TAM / operating leverage, figures only as [[TOKENS]]>"}},
{{"topic": "Competitive landscape", "point": "<moat, share gains, pricing power, switching costs>"}},
{{"topic": "Business risks", "point": "<why the key risks are manageable or priced in>"}},
{{"topic": "Management", "point": "<execution record, founder/insider alignment, capital allocation>"}},
{{"topic": "Valuation", "point": "<REQUIRED — INTRINSIC VALUE vs PRICE: cite the Damodaran DCF/story value and the market-composite price from the signals, state the gap, and argue why the market is right to pay more than intrinsic value (or why the value estimate is too low) — plus multiples vs growth/peers and the re-rating path, figures only as [[TOKENS]]>"}},
{{"topic": "Smart money & news", "point": "<13F/insider accumulation; real catalysts>"}}
],
"risk_flags": ["<top 4-5 upside catalysts, figures only as [[TOKENS]]>"],
"override_ml": <boolean>
}}"""
_PM_BLIND_SYSTEM_PROMPT = """You are a portfolio manager responsible for a long-only equity book.
Make an initial independent assessment of this stock based purely on the quantitative data provided.
Be decisive — HOLD should reflect genuine balance, not uncertainty avoidance.
Every claim must rest on a specific figure from the data, written as its [[TOKEN]], never typed.
You MUST respond with valid JSON only. No markdown, no explanation outside JSON."""
_PM_BLIND_USER_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
{signals_text}
As the PORTFOLIO MANAGER, make your initial independent assessment before hearing from your analysts.
Respond with ONLY this JSON:
{{
"expected_return_pct": <float>,
"predicted_price": <float>,
"recommendation": "<STRONG BUY|BUY|HOLD|SELL|STRONG SELL>",
"confidence": <float: 0.0-1.0>,
"direction": "<BULLISH|BEARISH|NEUTRAL>",
"reasoning": "<2-3 sentences: your synthesis and key conviction drivers>",
"risk_flags": ["<key risks>"],
"override_ml": <boolean>
}}"""
_PM_SYNTHESIS_SYSTEM_PROMPT = """You are a portfolio manager. You have heard from your risk manager (bear case) and growth analyst (bull case).
Your task: assign probability weights to each scenario — NOT pick a side outright.
Rules:
- p_bear + p_bull + p_neutral = 1.0 (p_neutral = 1 - p_bear - p_bull)
- p_bear must be between 0.10 and 0.65 (you cannot dismiss the bear entirely or let it dominate)
- p_bull must be between 0.10 and 0.65 (same constraint)
- The final expected return will be computed MECHANICALLY from your probabilities — do NOT engineer a target number
- Your reasoning is the decision thesis: state what you concluded and the evidence that decided it — not a recap of each analyst
Do NOT simply average. Make a genuine judgment about which case is better supported by the data.
You MUST respond with valid JSON only. No markdown, no explanation outside JSON."""
_PM_SYNTHESIS_USER_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
YOUR INITIAL VIEW (made before hearing analysts):
Return: {pm_r1_return:+.2f}% | Recommendation: {pm_r1_rec} | Direction: {pm_r1_direction}
Reasoning: {pm_r1_reasoning}
RISK MANAGER (Bear) says:
Return: {bear_return:+.2f}% → ${bear_price:.2f} | Recommendation: {bear_rec} | Confidence: {bear_conf:.0f}%
Thesis: {bear_reasoning}
Key Risks: {bear_risks}
GROWTH ANALYST (Bull) says:
Return: {bull_return:+.2f}% → ${bull_price:.2f} | Recommendation: {bull_rec} | Confidence: {bull_conf:.0f}%
Thesis: {bull_reasoning}
Key Catalysts: {bull_catalysts}
Assign probability weights to each scenario.
Constraints: p_bear ∈ [0.10, 0.65], p_bull ∈ [0.10, 0.65], p_neutral = 1 - p_bear - p_bull ≥ 0.05
Respond with ONLY this JSON:
{{
"p_bear": <float>,
"p_bull": <float>,
"p_neutral": <float>,
"conviction": <float: 0.0-1.0, confidence in these weights>,
"reasoning": "<2-3 sentences: which specific arguments moved your probabilities and why>",
"risk_flags": ["<combined key risks from both cases>"],
"override_ml": <boolean>
}}"""
_NEUTRAL_SYSTEM_PROMPT = """You are an independent equity research analyst. Your mandate is objectivity — no bull or bear bias.
Synthesize the quantitative signals and report what the data actually shows. Give equal weight to bullish and bearish evidence. Do not advocate. Do not anchor to any prior view. Be specific — every claim must cite an actual number from the data. Do not stop at valuation and financials: if SMART-MONEY FLOW (13F super-investor / insider Form-4 positioning) or RECENT NEWS HEADLINES are present in the data, weigh them alongside the fundamentals and say what they show. Cover the whole business objectively: (a) business & growth outlook, (b) competitive landscape & moat, (c) key business/operational risks, (d) management & capital allocation — grounding each in the earnings-call excerpt, news, and fundamentals trends plus well-established facts about the company, without fabricating specifics you can't support.
Recommendation guidelines (apply them neutrally):
- STRONG BUY: overwhelming multi-signal bullish confluence, low downside risk
- BUY: majority of signals bullish with manageable risk
- HOLD: mixed signals, high uncertainty, or balanced risk/reward
- SELL: majority of signals bearish with confirmed downside risk
- STRONG SELL: overwhelming multi-signal bearish confluence
You MUST respond with valid JSON only. No markdown, no explanation outside JSON."""
_NEUTRAL_USER_TEMPLATE = """Analyze {ticker} ({months}-month forecast) and provide an objective, unbiased assessment of these quantitative signals:
CURRENT: ${current_price:.2f}
{signals_text}
As the NEUTRAL ANALYST, report what the data shows without advocacy.
In "key_points", include ONLY the topics that are material (drop any you have nothing specific for);
each point is 1-2 sentences citing actual numbers. Keep "reasoning" to a single-sentence net call.
Respond with ONLY this JSON:
{{
"expected_return_pct": <float: your data-driven return estimate>,
"predicted_price": <float>,
"recommendation": "<STRONG BUY|BUY|HOLD|SELL|STRONG SELL>",
"confidence": <float: 0.0-1.0>,
"direction": "<BULLISH|BEARISH|NEUTRAL>",
"reasoning": "<net assessment in 2-3 sentences: where the evidence points and the one thing each side gets right>",
"key_points": [
{{"topic": "Business outlook", "point": "<bull & bear evidence on growth/demand, figures only as [[TOKENS]]>"}},
{{"topic": "Competitive landscape", "point": "<moat strength vs threats>"}},
{{"topic": "Business risks", "point": "<the most material risks, objectively>"}},
{{"topic": "Management", "point": "<execution & capital allocation, both sides>"}},
{{"topic": "Valuation", "point": "<REQUIRED — INTRINSIC VALUE vs PRICE: cite the Damodaran DCF/story value and the market-composite price from the signals, state the gap, and say whether your case needs the market to be right or wrong about it — plus multiples vs growth/peers, figures only as [[TOKENS]]>"}},
{{"topic": "Smart money & news", "point": "<what 13F/insider positioning and news actually show>"}}
],
"risk_flags": ["<top signals on both sides>"],
"override_ml": <boolean>
}}"""
_BEAR_REBUTTAL_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
NEUTRAL ANALYST VIEW (objective baseline):
Return: {neutral_return:+.2f}% | Recommendation: {neutral_rec} | Confidence: {neutral_conf:.0f}%
Reasoning: {neutral_reasoning}
YOUR BEAR POSITION (Round 2):
Return: {my_return:+.2f}% → ${my_price:.2f} | Recommendation: {my_rec}
Thesis: {my_reasoning}
GROWTH ANALYST (Bull) counters with:
Return: {bull_return:+.2f}% → ${bull_price:.2f} | Recommendation: {bull_rec} | Confidence: {bull_conf:.0f}%
Thesis: {bull_reasoning}
Key Catalysts: {bull_catalysts}
Rebut ONLY the specific points where you disagree with the bull — do NOT restate your whole case.
List the 2-3 sharpest contested points. Each key_point: "topic" = the bull's claim you're
challenging (quote it briefly), "point" = your one-sentence counter with a hard number.
Respond with ONLY this JSON:
{{
"expected_return_pct": <float>,
"predicted_price": <float>,
"recommendation": "<HOLD|SELL|STRONG SELL>",
"confidence": <float: 0.0-1.0>,
"direction": "<BEARISH|NEUTRAL>",
"reasoning": "<ONE line: did you hold or revise, and the single biggest reason>",
"key_points": [
{{"topic": "<bull claim you contest>", "point": "<one-sentence rebuttal with a number>"}},
{{"topic": "<bull claim you contest>", "point": "<one-sentence rebuttal with a number>"}}
],
"risk_flags": ["<key risks>"],
"override_ml": <boolean>,
"changed_view": <boolean>
}}"""
_BULL_REBUTTAL_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
NEUTRAL ANALYST VIEW (objective baseline):
Return: {neutral_return:+.2f}% | Recommendation: {neutral_rec} | Confidence: {neutral_conf:.0f}%
Reasoning: {neutral_reasoning}
YOUR BULL POSITION (Round 2):
Return: {my_return:+.2f}% → ${my_price:.2f} | Recommendation: {my_rec}
Thesis: {my_reasoning}
RISK MANAGER (Bear) counters with:
Return: {bear_return:+.2f}% → ${bear_price:.2f} | Recommendation: {bear_rec} | Confidence: {bear_conf:.0f}%
Thesis: {bear_reasoning}
Key Risks: {bear_risks}
Rebut ONLY the specific points where you disagree with the bear — do NOT restate your whole case.
List the 2-3 sharpest contested points. Each key_point: "topic" = the bear's claim you're
challenging (quote it briefly), "point" = your one-sentence counter with a hard number.
Respond with ONLY this JSON:
{{
"expected_return_pct": <float>,
"predicted_price": <float>,
"recommendation": "<HOLD|BUY|STRONG BUY>",
"confidence": <float: 0.0-1.0>,
"direction": "<BULLISH|NEUTRAL>",
"reasoning": "<ONE line: did you hold or revise, and the single biggest reason>",
"key_points": [
{{"topic": "<bear claim you contest>", "point": "<one-sentence rebuttal with a number>"}},
{{"topic": "<bear claim you contest>", "point": "<one-sentence rebuttal with a number>"}}
],
"risk_flags": ["<key catalysts>"],
"override_ml": <boolean>,
"changed_view": <boolean>
}}"""
_PM_SYNTHESIS_USER_TEMPLATE = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: {months}-month
NEUTRAL ANALYST VIEW (objective baseline — no advocacy):
Return: {neutral_return:+.2f}% | Recommendation: {neutral_rec} | Confidence: {neutral_conf:.0f}%
Reasoning: {neutral_reasoning}
RISK MANAGER (Bear) final position after debate:
Return: {bear_return:+.2f}% → ${bear_price:.2f} | Recommendation: {bear_rec} | Confidence: {bear_conf:.0f}%
Thesis: {bear_reasoning}
Key Risks: {bear_risks}
GROWTH ANALYST (Bull) final position after debate:
Return: {bull_return:+.2f}% → ${bull_price:.2f} | Recommendation: {bull_rec} | Confidence: {bull_conf:.0f}%
Thesis: {bull_reasoning}
Key Catalysts: {bull_catalysts}
Assign probability weights to each scenario. The neutral view is your anchor — use it to judge which advocate is better supported by the data.
Constraints: p_bear ∈ [0.10, 0.65], p_bull ∈ [0.10, 0.65], p_neutral = 1 - p_bear - p_bull ≥ 0.05
Respond with ONLY this JSON:
{{
"p_bear": <float>,
"p_bull": <float>,
"p_neutral": <float>,
"conviction": <float: 0.0-1.0>,
"reasoning": "<2-3 sentences: which specific arguments moved your probabilities, referencing the neutral baseline>",
"risk_flags": ["<combined key risks>"],
"override_ml": <boolean>
}}"""
def _dedup_flags(flags):
seen, out = set(), []
for f in flags:
k = f.lower().strip()
if k not in seen:
seen.add(k)
out.append(f)
return out
def _repair_truncated_json(text):
"""Best-effort close of a truncated JSON object (the one containing p_bear).
Tracks the open-bracket STACK so closers are appended in correct nesting order.
Returns a dict or None."""
start = text.find('"p_bear"')
if start == -1:
return None
open_at = text.rfind("{", 0, start)
if open_at == -1:
return None
frag = text[open_at:]
# candidate cut points: the full fragment, then progressively earlier safe points
cuts = [len(frag)]
for tok in ('"}', '},', '"]', '",', '}', '"'):
i = frag.rfind(tok)
if i > 0:
cuts.append(i + len(tok))
for c in cuts:
body = frag[:c].rstrip().rstrip(",")
stack = []; in_str = False; esc = False
for ch in body:
if in_str:
if esc: esc = False
elif ch == "\\": esc = True
elif ch == '"': in_str = False
continue
if ch == '"': in_str = True
elif ch in "{[": stack.append(ch)
elif ch in "}]":
if stack: stack.pop()
if in_str:
body += '"'
# a dangling key ("topic": ) or trailing colon can't be closed — drop it
body = re.sub(r',?\s*"[^"]*"\s*:\s*$', "", body)
body = body.rstrip().rstrip(",")
while stack:
body += "}" if stack.pop() == "{" else "]"
try:
d = json.loads(body)
if isinstance(d, dict) and "p_bear" in d:
return d
except Exception:
continue
return None
def _parse_pm_synthesis(raw, tokens_in, tokens_out):
"""Parse PM synthesis JSON — different schema from standard analyst responses."""
cleaned = raw
if "```" in cleaned:
cleaned = re.sub(r"```(?:json)?\s*", "", cleaned)
cleaned = cleaned.strip()
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
data = None
# Tolerate a preamble/trailing prose around the object, and nested braces
# (the flat [^{}] regex fails when reasoning contains braces): scan for the
# first balanced {...} that mentions p_bear.
start = cleaned.find('"p_bear"')
if start != -1:
open_at = cleaned.rfind("{", 0, start)
if open_at != -1:
depth = 0
for i in range(open_at, len(cleaned)):
if cleaned[i] == "{":
depth += 1
elif cleaned[i] == "}":
depth -= 1
if depth == 0:
try:
data = json.loads(cleaned[open_at:i + 1])
except Exception:
data = None
break
if data is None:
# TRUNCATION REPAIR: a long synthesis can hit max_tokens mid-JSON (seen on PLTR:
# the raw head held valid p_bear/p_bull/p_neutral but the tail was cut). Throwing
# the whole thing away collapses the capstone to an equal-weight blend, so try to
# close the object: drop the trailing partial token, then append the closers the
# nesting still needs. If the scalar fields survive, the synthesis is usable even
# when the last key_point is lost.
data = _repair_truncated_json(cleaned)
if data is None:
return None
return {
"p_bear": float(np.clip(_num(data.get("p_bear")) if _num(data.get("p_bear")) is not None else 0.33, 0.10, 0.65)),
"p_bull": float(np.clip(_num(data.get("p_bull")) if _num(data.get("p_bull")) is not None else 0.33, 0.10, 0.65)),
"p_neutral": float(np.clip(_num(data.get("p_neutral")) if _num(data.get("p_neutral")) is not None else 0.34, 0.05, 0.80)),
"conviction": float(np.clip(_num(data.get("conviction")) if _num(data.get("conviction")) is not None else 0.5, 0.1, 1.0)),
"reasoning": str(data.get("reasoning", "") or (data.get("thesis") if isinstance(data.get("thesis"), str) else "")),
"key_points": _coerce_key_points(data.get("key_points")),
"structured": _structured_fields(data),
"tipping_point": (str(data.get("tipping_point"))[:300] if data.get("tipping_point") else None),
# structured flip conditions travel with the synthesis: a fixed key list here was silently
# dropping them, which is what forced the renderer back onto free-prose tipping points
"triggers": (data.get("triggers") if isinstance(data.get("triggers"), list) else []),
# evidence lineage and any quantitative tilt: dropped here before, which left the evidence
# ledger and the scenario-probability decomposition with no input in production
"evidence_used": [str(x) for x in (data.get("evidence_used") or []) if isinstance(x, (str, int))][:12],
"quant_tilt": ({k: float(np.clip(float(v), -0.2, 0.2)) for k, v in data["quant_tilt"].items()
if k in ("bear", "neutral", "bull") and isinstance(v, (int, float))}
if isinstance(data.get("quant_tilt"), dict) else {}),
"risk_flags": list(data.get("risk_flags", [])),
"override_ml": bool(data.get("override_ml", False)),
"tokens_in": tokens_in,
"tokens_out": tokens_out,
}
def _call_claude_synthesis(user_msg, verbose=False):
"""Call Claude for PM synthesis — uses a higher token budget."""
if not ANTHROPIC_API_KEY:
return None
import anthropic
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY, timeout=_LLM_TIMEOUT)
response = claude_create(client,
model="claude-sonnet-5",
max_tokens=4500, # long reasoning truncated the JSON (PLTR); parser now also repairs truncation
system=_PM_SYNTHESIS_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_msg}],
temperature=1.0,
)
raw = "".join(b.text for b in response.content if getattr(b, "type", "") == "text").strip()
if verbose:
print(f" {DIM}Claude synthesis tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out{RESET}")
parsed = _parse_pm_synthesis(raw, response.usage.input_tokens, response.usage.output_tokens)
if parsed is None:
# Surface WHY the capstone failed instead of silently collapsing to equal-weight.
print(f" {DIM}PM parse failed; raw head: {raw[:160].replace(chr(10), ' ')}{RESET}", flush=True)
return parsed
# ─── 3-Panel Signal Judge (scan Stage 4 — PASS/FLAG/REJECT; matches weekly pattern) ─
_JUDGE_SYSTEM_ROUND1_MONTHLY = """You are an expert equity risk analyst evaluating a QUANTITATIVE 1-month trade signal for a human operator.
Your job is NOT to predict returns. You assess whether the quantitative signal is trustworthy and assign a CONFIDENCE ADJUSTMENT — a percentage by which you would modify the quant model's pick confidence.
Key calibration for 1-MONTH horizon:
- Earnings within 10 days is a RISK FACTOR that widens uncertainty. It is NOT an automatic rejection. Many stocks drift predictably in the 10-20 days before earnings (pre-earnings drift). Only reject if earnings combine with multiple OTHER severe risks.
- RSI 65-75 is normal momentum, not overbought. RSI 75-85 is elevated. RSI > 85 is extreme.
- A stock selected by a momentum scanner WILL have positive momentum. Don't penalize the trait that selected it — instead check whether that momentum is sustainable or exhausted.
- Positive directional accuracy (>55%) and signal stability (>30%) from walk-forward are STRONG positive signals. Weight these heavily.
- For 1-month horizon, fundamental quality matters more than at 1-week. Check if high expected returns are supported by fundamentals.
- Macro regime (rising rates, widening credit spreads) can impair momentum strategies — flag if detected.
Your confidence adjustment should be:
+5 to +15: Signal looks clean, risks are minimal → boosts pick confidence
-5 to -15: Minor risks present but signal is tradeable → slight reduction
-15 to -30: Significant risks, trade with reduced size → moderate reduction
-30 to -50: Severe risks, likely better to wait → strong reduction
Below -50: Critical risks, do not trade → near-rejection
REJECT is reserved for: data quality issues, circuit breaker / halted stock, known fraud / SEC investigation, or signal that is clearly broken (e.g., dir_acc 20% on high confidence).
Respond with valid JSON only. No markdown."""
_JUDGE_USER_ROUND1_MONTHLY = """CANDIDATE PROFILE (1-month horizon):
{ticker} @ ${current_price:.2f}
Blended expected return: {blend_ret_pct:+.2f}%
Walk-forward prediction (OOS mean): {wf_pred_pct:+.2f}%
Direction: {direction} | Confidence: {confidence:.0f}%
Risk score (0-1, higher=worse): {risk_score:.2f}
Pick confidence (walk-forward): {pick_confidence:.1%}
Dir accuracy (historical windows): {dir_accuracy:.1%}
Signal stability: {signal_stability:.1%}
Walk-forward Rank IC: {rank_ic:.3f}
OOS samples: {oos_samples}
P(return>5% in 21d): {p5:.1%} | P(return>0%): {ppos:.1%}
Earnings: {earn_str}
History: {history_days} days of price data
Key technicals: RSI {rsi:.0f} | BB%B {bb:.2f} | Mom1m {mom1m:+.2f}%
{extra_context}
Respond with ONLY this JSON:
{{
"confidence_adjustment_pct": <int: -50 to +15, how much to adjust pick_confidence>,
"verdict": "<PASS|FLAG|REJECT>",
"severity": <int: 1-5, where 1=trivial and 5=critical>,
"risk_flags": ["<short flag>", "..."],
"reasoning": "<1-2 sentences>",
"confidence": <float 0-1, your subjective confidence in this assessment>
}}"""
_JUDGE_USER_DEBATE_MONTHLY = """STOCK: {ticker}
YOUR ROUND-1 POSITION:
Verdict: {my_verdict} | Adjustment: {my_adjustment:+d}% | Severity: {my_severity}/5
Reasoning: {my_reasoning}
OTHER ANALYSTS:
{other_positions}
Respond with ONLY this JSON:
{{
"confidence_adjustment_pct": <int: -50 to +15>,
"verdict": "<PASS|FLAG|REJECT>",
"severity": <int: 1-5>,
"risk_flags": ["..."],
"reasoning": "<1-2 sentences>",
"confidence": <float 0-1>
}}"""
# ─── Monthly LLM Selector (single-call, all candidates) ──────────────────────
_SELECTOR_SYSTEM_MONTHLY = """You are an expert portfolio selector evaluating quantitative 1-month trade candidates.
Your role is to assess SIGNAL RELIABILITY — not to re-predict price direction (the ML ensemble already does that).
VERDICT CRITERIA:
PASS — signal is reliable; no disqualifying risk flags
FLAG — tradeable but with elevated risk (e.g. earnings proximity, overbought RSI, low OOS samples); reduce size
REJECT — signal is unreliable or risk is unacceptable
ADJUSTMENT: integer −50 to +20 scaling the pick_confidence.
Negative = penalty (FLAG/REJECT). Positive = modest boost (rare; only high-conviction PASS).
SEVERITY: 1 (minor concern) to 5 (disqualifying) for the worst risk flag.
1-MONTH horizon calibration:
- RSI 65-75 is normal momentum; RSI >85 is extreme. Don't penalise the trait that selected it.
- Earnings within 10 days is a risk factor — NOT auto-rejection. Only reject if combined with multiple other severe risks.
- Positive dir_acc >55% and stability >30% from walk-forward are STRONG positives. Weight heavily.
- High prob_gt_5pct (>15%) combined with strong dir_acc is a quality signal.
- rank_ic > 0.20 indicates good cross-sectional predictability.
- For 1-month horizon, fundamental quality matters more than at 1-week.
When calibration data is provided, use it to anchor severity.
Respond ONLY with valid JSON. No markdown fences, no explanation outside the JSON."""
_SELECTOR_USER_TEMPLATE_MONTHLY = """SCAN DATE: {scan_date}
{calibration_context}
MACRO CONTEXT (use to adjust sector/leverage risk — do not over-weight):
{macro_context}
MONTHLY CANDIDATES ({n} total, ordered by pick_confidence descending):
{candidates_text}
Return JSON exactly:
{{
"assessments": [
{{
"ticker": "<ticker>",
"verdict": "PASS|FLAG|REJECT",
"adjustment": <-50 to +20>,
"severity": <1-5>,
"risk_flags": ["concise flag", ...],
"reasoning": "<one sentence>"
}}
],
"ranked": ["ticker1", "ticker2", ...],
"best_pick": "<ticker>",
"best_pick_reasoning": "<two sentences max>"
}}"""
# ─── Monthly scan outcome tracker ─────────────────────────────────────────────
# Logs every Stage-3-validated pick → resolves actual 21d return after 35 days
# → feeds per-confidence-bucket calibration stats into the Stage 4 selector.
_SCAN_PICK_LOG_MONTHLY = CACHE_DIR / "scan_pick_log_monthly.jsonl"
# ─── LLM Call Functions ──────────────────────────────────────────────────────
def _call_openai(user_msg, system=None, verbose=False):
"""Call OpenAI GPT-5.5."""
if not OPENAI_API_KEY:
print(f" {YELLOW}OPENAI_API_KEY not set{RESET}")
return None
from openai import OpenAI
client = OpenAI(api_key=OPENAI_API_KEY, timeout=_LLM_TIMEOUT)
response = client.chat.completions.create(
model="gpt-5.1",
messages=[
{"role": "system", "content": system or _LLM_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=1.0,
max_completion_tokens=4000,
)
raw = response.choices[0].message.content.strip()
tokens_in = response.usage.prompt_tokens
tokens_out = response.usage.completion_tokens
if verbose:
print(f" {DIM}GPT-5.5 tokens: {tokens_in} in / {tokens_out} out{RESET}")
return _parse_llm_response(raw, tokens_in, tokens_out, "gpt-5.1")
def _call_kimi(user_msg, system=None, verbose=False):
"""Call Moonshot AI Kimi K2.6 with thinking-mode fallback (matches weekly / signal judge)."""
if not KIMI_API_KEY:
print(f" {YELLOW}KIMI_API_KEY not set{RESET}")
return None
from openai import OpenAI
import time as _time
# Dedicated shorter timeout: Kimi/Moonshot thinking-mode can stall, and this caller
# retries internally — a tighter per-request bound keeps the debate from freezing.
client = OpenAI(api_key=KIMI_API_KEY, base_url="https://api.moonshot.ai/v1", timeout=75.0)
def _try_once(extra_body=None, temperature=1.0, max_completion_tokens=4096):
kwargs = dict(
model="kimi-k2.6",
messages=[
{"role": "system", "content": system or _LLM_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=temperature,
max_completion_tokens=max_completion_tokens,
)
if extra_body is not None:
try:
return client.chat.completions.create(**kwargs, extra_body=extra_body)
except TypeError:
pass
except Exception as e:
err = str(e)
if "thinking" in err.lower() or "400" in err:
pass
else:
raise
return client.chat.completions.create(**kwargs)
max_retries = 3
_deadline = _time.monotonic() + 200.0 # hard wall-clock cap — never stall the debate
for attempt in range(max_retries):
if _time.monotonic() > _deadline:
print(f" {DIM}Kimi (Bear) time budget exhausted — skipping{RESET}", flush=True)
return None
if attempt > 0:
print(f" {DIM}Kimi (Bear) retry {attempt+1}/{max_retries}...{RESET}", flush=True)
try:
response = None
last_err = None
for cfg in (
# kimi-k2.6 enforces mode-specific temperature: thinking-DISABLED requires
# temp 0.6, thinking-ENABLED requires temp 1.0. cfg1 previously used 0.3 →
# rejected with HTTP 400 on every call, forcing the slow thinking path (cfg2)
# and stalling / dropping the Bear. cfg1 at 0.6 makes the fast path work (~10s).
{"extra_body": {"thinking": {"type": "disabled"}}, "temperature": 0.6, "max_completion_tokens": 4096},
{"extra_body": None, "temperature": 1.0, "max_completion_tokens": 6144},
):
try:
response = _try_once(
extra_body=cfg["extra_body"],
temperature=cfg["temperature"],
max_completion_tokens=cfg["max_completion_tokens"],
)
break
except Exception as e:
last_err = e
err = str(e)
if "429" in err:
raise
continue
if response is None:
raise last_err if last_err else RuntimeError("Kimi: no response")
msg = response.choices[0].message
raw = _moonshot_assistant_text(msg)
tokens_in = response.usage.prompt_tokens if response.usage else 0
tokens_out = response.usage.completion_tokens if response.usage else 0
finish = getattr(response.choices[0], "finish_reason", None)
if verbose:
print(
f" {DIM}Kimi K2.6 tokens: {tokens_in} in / {tokens_out} out (finish: {finish}){RESET}"
)
if not raw or not raw.strip():
if attempt < max_retries - 1:
_time.sleep(min(3 * (attempt + 1), 8))
continue
return None
raw = raw.strip()
result = _parse_llm_response(raw, tokens_in, tokens_out, "kimi-k2.6")
if result is None and attempt < max_retries - 1:
_time.sleep(min(3 * (attempt + 1), 8))
continue
return result
except Exception as e:
err_str = str(e)
if "401" in err_str:
raise Exception("Kimi K2.6: invalid/expired API key (401)")
if "429" in err_str:
wait = min(10 * (2 ** attempt), 30)
if attempt >= 2:
print(f" {DIM}Kimi rate limited — skipping after {attempt+1} attempts{RESET}")
return None
print(f" {DIM}Kimi rate limited, retrying in {wait}s...{RESET}")
_time.sleep(wait)
continue
if attempt < max_retries - 1:
_time.sleep(min(4 * (attempt + 1), 10))
continue
return None
return None
def _call_gemini(user_msg, system=None, verbose=False):
"""Call Google Gemini Flash 2.5 Lite (fast/cheap for scanning)."""
if not GEMINI_API_KEY:
print(f" {YELLOW}GEMINI_API_KEY not set{RESET}")
return None
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
safety_off = [
{"category": c, "threshold": "BLOCK_NONE"}
for c in ["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]
]
model = genai.GenerativeModel(
"gemini-3-flash-preview-lite",
system_instruction=system or _LLM_SYSTEM_PROMPT,
)
response = model.generate_content(
user_msg,
generation_config=genai.GenerationConfig(temperature=0.3, max_output_tokens=2200),
safety_settings=safety_off,
request_options={"timeout": _LLM_TIMEOUT},
)
raw = response.text.strip()
tokens_in = response.usage_metadata.prompt_token_count if hasattr(response, 'usage_metadata') else 0
tokens_out = response.usage_metadata.candidates_token_count if hasattr(response, 'usage_metadata') else 0
if verbose:
print(f" {DIM}Gemini Flash tokens: {tokens_in} in / {tokens_out} out{RESET}")
return _parse_llm_response(raw, tokens_in, tokens_out, "gemini-3-flash-preview-lite")
def _call_gemini_3_pro(user_msg, system=None, verbose=False):
"""Call Google Gemini 3 Flash — used as Neutral Analyst in Round 1."""
if not GEMINI_API_KEY:
print(f" {YELLOW}GEMINI_API_KEY not set{RESET}")
return None
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
safety_off = [
{"category": c, "threshold": "BLOCK_NONE"}
for c in ["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]
]
# gemini-3-flash-preview: production-stable; uses internal thinking so needs high max_output_tokens
model = genai.GenerativeModel(
"gemini-3-flash-preview",
system_instruction=system or _LLM_SYSTEM_PROMPT,
)
response = model.generate_content(
user_msg,
generation_config=genai.GenerationConfig(temperature=0.3, max_output_tokens=8192),
safety_settings=safety_off,
request_options={"timeout": _LLM_TIMEOUT},
)
raw = response.text.strip()
tokens_in = response.usage_metadata.prompt_token_count if hasattr(response, "usage_metadata") else 0
tokens_out = response.usage_metadata.candidates_token_count if hasattr(response, "usage_metadata") else 0
if verbose:
print(f" {DIM}Gemini 3 Flash tokens: {tokens_in} in / {tokens_out} out{RESET}")
print(f" {DIM}Gemini raw: {raw[:200]}{RESET}")
parsed = _parse_llm_response(raw, tokens_in, tokens_out, "gemini-3-flash-preview")
if parsed is None and not verbose:
import sys
print(f" {YELLOW}Gemini parse failed. raw[:400]={raw[:400]!r}{RESET}", file=sys.stderr)
return parsed
def _call_claude(user_msg, system=None, verbose=False):
"""Call Anthropic Claude Opus 4.7."""
if not ANTHROPIC_API_KEY:
print(f" {YELLOW}ANTHROPIC_API_KEY not set{RESET}")
return None
import anthropic
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY, timeout=_LLM_TIMEOUT)
response = claude_create(client,
model="claude-sonnet-5",
max_tokens=2000,
system=system or _LLM_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_msg}],
temperature=1.0,
)
raw = response.content[0].text.strip()
tokens_in = response.usage.input_tokens
tokens_out = response.usage.output_tokens
if verbose:
print(f" {DIM}Claude Opus tokens: {tokens_in} in / {tokens_out} out{RESET}")
return _parse_llm_response(raw, tokens_in, tokens_out, "claude-sonnet-5")
def _coerce_key_points(kp):
"""Normalize an analyst's key_points into a clean [{topic, point}] list.
Tolerates dicts, bare strings, or 'Topic: text' strings; returns [] if unusable
so the UI falls back to the prose reasoning (no regression)."""
out = []
if isinstance(kp, list):
for it in kp[:8]:
if isinstance(it, dict):
topic = str(it.get("topic", "") or "").strip()
point = str(it.get("point", it.get("text", "")) or "").strip()
elif isinstance(it, str) and it.strip():
s = it.strip()
topic, sep, rest = s.partition(":")
topic, point = (topic.strip(), rest.strip()) if (sep and len(topic) < 40) else ("", s)
else:
continue
if point:
def _trunc(t, n):
# keep whole sentences only: break at the last sentence end inside the limit. Authored prose is
# never cut mid-sentence (a cut sentence is a different, unaudited claim); a single over-long
# first sentence is kept whole and left to prose ingestion
if len(t) <= n:
return t
ends = [m.end() for m in re.finditer(r"[.!?](?=\s)", t[:n])]
if ends:
return t[:ends[-1]].strip()
first = re.search(r"[.!?](?=\s|$)", t)
return t[:first.end()].strip() if first else t
out.append({"topic": _trunc(topic, 72), "point": _trunc(point, 600)})
return out
def _parse_llm_response(raw, tokens_in, tokens_out, model_name):
"""Parse LLM JSON response into prediction dict."""
# Strip markdown fences if present
cleaned = raw
if "```" in cleaned:
cleaned = re.sub(r"```(?:json)?\s*", "", cleaned)
cleaned = cleaned.strip()
try:
data = json.loads(cleaned)
except json.JSONDecodeError as _jde:
_parse_err = str(_jde)
# Find the outermost { ... } block that contains expected_return_pct.
# This handles models that prepend plain text before the JSON.
data = None
pos = 0
while pos < len(cleaned):
start = cleaned.find('{', pos)
if start == -1:
break
depth, i = 0, start
while i < len(cleaned):
if cleaned[i] == '{':
depth += 1
elif cleaned[i] == '}':
depth -= 1
if depth == 0:
candidate = cleaned[start:i + 1]
if '"expected_return_pct"' in candidate:
try:
data = json.loads(candidate)
except json.JSONDecodeError:
pass
break
i += 1
if isinstance(data, dict):
break
pos = start + 1
if not isinstance(data, dict):
# Last resort: a truncated-but-well-formed prefix (max_tokens cut) can usually be
# closed by the bracket-stack repairer used for the PM synthesis.
try:
data = _repair_truncated_json(cleaned)
except Exception:
data = None
if not isinstance(data, dict):
import sys
print(f" {YELLOW} json_err={getattr(locals(), '_parse_err', '?')!r} cleaned[:200]={cleaned[:200]!r}{RESET}", file=sys.stderr)
return None
_er_raw = data.get("expected_return_pct")
has_return = _num(_er_raw) is not None
expected_return = (_num(_er_raw) or 0.0) / 100.0
_cf = _num(data.get("confidence"))
_cf = 0.5 if _cf is None else (_cf / 100.0 if _cf > 1.0 else _cf)
confidence = min(max(_cf, 0.1), 1.0)
direction = data.get("direction", "NEUTRAL")
reasoning = data.get("reasoning", "") or (data.get("thesis") if isinstance(data.get("thesis"), str) else "")
risk_flags = data.get("risk_flags", [])
override_ml = data.get("override_ml", False)
changed_view = data.get("changed_view", False)
predicted_price = data.get("predicted_price")
recommendation = data.get("recommendation", "HOLD")
key_points = _coerce_key_points(data.get("key_points"))
# Normalize recommendation
if recommendation:
recommendation = recommendation.upper().strip()
valid_recs = {"STRONG BUY", "BUY", "HOLD", "SELL", "STRONG SELL"}
if recommendation not in valid_recs:
recommendation = "HOLD"
return {
"expected_return": expected_return,
"confidence": confidence,
"direction": direction,
"reasoning": reasoning,
"key_points": key_points,
"risk_flags": risk_flags,
"override_ml": override_ml,
"changed_view": changed_view,
"predicted_price": predicted_price,
"recommendation": recommendation,
"model": model_name,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
# structured-argument fields (crucible.args/2) pass through untouched for engine/arguments.py; a reply
# without a numeric expected_return_pct is marked so a rebuttal keeps its opening numbers
"structured": _structured_fields(data),
"has_return": has_return,
}
def _num(x):
"""A number from a JSON scalar ("-12.5", "−12.5%", 12) or None."""
if isinstance(x, bool):
return None
if isinstance(x, (int, float)):
return float(x)
if isinstance(x, str):
t = x.strip().replace("\u2212", "-").replace("%", "").replace(",", "")
try:
return float(t)
except ValueError:
return None
return None
def _structured_fields(data):
"""The structured-argument keys of a persona reply, copied as JSON (validated downstream, never here)."""
import copy as _copy
out = {k: _copy.deepcopy(data[k]) for k in ("thesis", "thesis_rests_on", "points", "stance", "answers", "factors",
"summary") if k in data}
kp = data.get("key_points")
if "points" not in out and isinstance(kp, list) and any(isinstance(x, dict) and ("view" in x or "kind" in x)
for x in kp):
out["points"] = _copy.deepcopy(kp)
return out
# ─── Final Blend & Display ───────────────────────────────────────────────────
# ─── Main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
main()
|