quantwise 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/.claude/skills/README.md +80 -0
  2. package/.claude/skills/backtest-expert/SKILL.md +206 -0
  3. package/.claude/skills/backtest-expert/references/failed_tests.md +236 -0
  4. package/.claude/skills/backtest-expert/references/methodology.md +227 -0
  5. package/.claude/skills/breadth-chart-analyst/SKILL.md +583 -0
  6. package/.claude/skills/breadth-chart-analyst/assets/SP500_Breadth_Index_200MA_8MA.jpeg +0 -0
  7. package/.claude/skills/breadth-chart-analyst/assets/US_Stock_Market_Uptrend_Ratio.jpeg +0 -0
  8. package/.claude/skills/breadth-chart-analyst/assets/breadth_analysis_template.md +558 -0
  9. package/.claude/skills/breadth-chart-analyst/references/breadth_chart_methodology.md +590 -0
  10. package/.claude/skills/canslim-screener/SKILL.md +599 -0
  11. package/.claude/skills/canslim-screener/references/canslim_methodology.md +606 -0
  12. package/.claude/skills/canslim-screener/references/fmp_api_endpoints.md +707 -0
  13. package/.claude/skills/canslim-screener/references/interpretation_guide.md +516 -0
  14. package/.claude/skills/canslim-screener/references/scoring_system.md +597 -0
  15. package/.claude/skills/canslim-screener/scripts/calculators/earnings_calculator.py +343 -0
  16. package/.claude/skills/canslim-screener/scripts/calculators/growth_calculator.py +334 -0
  17. package/.claude/skills/canslim-screener/scripts/calculators/institutional_calculator.py +347 -0
  18. package/.claude/skills/canslim-screener/scripts/calculators/leadership_calculator.py +380 -0
  19. package/.claude/skills/canslim-screener/scripts/calculators/market_calculator.py +244 -0
  20. package/.claude/skills/canslim-screener/scripts/calculators/new_highs_calculator.py +194 -0
  21. package/.claude/skills/canslim-screener/scripts/calculators/supply_demand_calculator.py +221 -0
  22. package/.claude/skills/canslim-screener/scripts/finviz_stock_client.py +227 -0
  23. package/.claude/skills/canslim-screener/scripts/fmp_client.py +393 -0
  24. package/.claude/skills/canslim-screener/scripts/report_generator.py +405 -0
  25. package/.claude/skills/canslim-screener/scripts/scorer.py +625 -0
  26. package/.claude/skills/canslim-screener/scripts/screen_canslim.py +361 -0
  27. package/.claude/skills/canslim-screener/scripts/test_institutional_endpoint.py +109 -0
  28. package/.claude/skills/chart/SKILL.md +20 -0
  29. package/.claude/skills/dividend-growth-pullback-screener/SKILL.md +322 -0
  30. package/.claude/skills/dividend-growth-pullback-screener/references/dividend_growth_compounding.md +400 -0
  31. package/.claude/skills/dividend-growth-pullback-screener/references/fmp_api_guide.md +642 -0
  32. package/.claude/skills/dividend-growth-pullback-screener/references/rsi_oversold_strategy.md +333 -0
  33. package/.claude/skills/dividend-growth-pullback-screener/scripts/screen_dividend_growth_rsi.py +1155 -0
  34. package/.claude/skills/earnings-calendar/SKILL.md +721 -0
  35. package/.claude/skills/earnings-calendar/assets/earnings_report_template.md +102 -0
  36. package/.claude/skills/earnings-calendar/references/fmp_api_guide.md +590 -0
  37. package/.claude/skills/earnings-calendar/scripts/fetch_earnings_fmp.py +443 -0
  38. package/.claude/skills/earnings-calendar/scripts/generate_report.py +366 -0
  39. package/.claude/skills/economic-calendar-fetcher/SKILL.md +365 -0
  40. package/.claude/skills/economic-calendar-fetcher/references/fmp_api_documentation.md +345 -0
  41. package/.claude/skills/economic-calendar-fetcher/scripts/get_economic_calendar.py +267 -0
  42. package/.claude/skills/ftd-detector/SKILL.md +147 -0
  43. package/.claude/skills/ftd-detector/references/ftd_methodology.md +188 -0
  44. package/.claude/skills/ftd-detector/references/post_ftd_guide.md +185 -0
  45. package/.claude/skills/ftd-detector/scripts/fmp_client.py +158 -0
  46. package/.claude/skills/ftd-detector/scripts/ftd_detector.py +280 -0
  47. package/.claude/skills/ftd-detector/scripts/post_ftd_monitor.py +404 -0
  48. package/.claude/skills/ftd-detector/scripts/rally_tracker.py +508 -0
  49. package/.claude/skills/ftd-detector/scripts/report_generator.py +341 -0
  50. package/.claude/skills/ftd-detector/scripts/tests/conftest.py +9 -0
  51. package/.claude/skills/ftd-detector/scripts/tests/helpers.py +107 -0
  52. package/.claude/skills/ftd-detector/scripts/tests/test_post_ftd_monitor.py +311 -0
  53. package/.claude/skills/ftd-detector/scripts/tests/test_rally_tracker.py +302 -0
  54. package/.claude/skills/institutional-flow-tracker/README.md +362 -0
  55. package/.claude/skills/institutional-flow-tracker/SKILL.md +357 -0
  56. package/.claude/skills/institutional-flow-tracker/references/13f_filings_guide.md +383 -0
  57. package/.claude/skills/institutional-flow-tracker/references/institutional_investor_types.md +580 -0
  58. package/.claude/skills/institutional-flow-tracker/references/interpretation_framework.md +573 -0
  59. package/.claude/skills/institutional-flow-tracker/scripts/analyze_single_stock.py +457 -0
  60. package/.claude/skills/institutional-flow-tracker/scripts/track_institution_portfolio.py +108 -0
  61. package/.claude/skills/institutional-flow-tracker/scripts/track_institutional_flow.py +450 -0
  62. package/.claude/skills/macro-regime-detector/SKILL.md +86 -0
  63. package/.claude/skills/macro-regime-detector/references/historical_regimes.md +124 -0
  64. package/.claude/skills/macro-regime-detector/references/indicator_interpretation_guide.md +144 -0
  65. package/.claude/skills/macro-regime-detector/references/regime_detection_methodology.md +138 -0
  66. package/.claude/skills/macro-regime-detector/scripts/calculators/__init__.py +1 -0
  67. package/.claude/skills/macro-regime-detector/scripts/calculators/concentration_calculator.py +165 -0
  68. package/.claude/skills/macro-regime-detector/scripts/calculators/credit_conditions_calculator.py +124 -0
  69. package/.claude/skills/macro-regime-detector/scripts/calculators/equity_bond_calculator.py +198 -0
  70. package/.claude/skills/macro-regime-detector/scripts/calculators/sector_rotation_calculator.py +123 -0
  71. package/.claude/skills/macro-regime-detector/scripts/calculators/size_factor_calculator.py +131 -0
  72. package/.claude/skills/macro-regime-detector/scripts/calculators/utils.py +347 -0
  73. package/.claude/skills/macro-regime-detector/scripts/calculators/yield_curve_calculator.py +279 -0
  74. package/.claude/skills/macro-regime-detector/scripts/fmp_client.py +134 -0
  75. package/.claude/skills/macro-regime-detector/scripts/macro_regime_detector.py +278 -0
  76. package/.claude/skills/macro-regime-detector/scripts/report_generator.py +327 -0
  77. package/.claude/skills/macro-regime-detector/scripts/scorer.py +574 -0
  78. package/.claude/skills/macro-regime-detector/scripts/tests/conftest.py +9 -0
  79. package/.claude/skills/macro-regime-detector/scripts/tests/test_concentration.py +78 -0
  80. package/.claude/skills/macro-regime-detector/scripts/tests/test_credit_conditions.py +59 -0
  81. package/.claude/skills/macro-regime-detector/scripts/tests/test_equity_bond.py +74 -0
  82. package/.claude/skills/macro-regime-detector/scripts/tests/test_helpers.py +90 -0
  83. package/.claude/skills/macro-regime-detector/scripts/tests/test_scorer.py +439 -0
  84. package/.claude/skills/macro-regime-detector/scripts/tests/test_sector_rotation.py +78 -0
  85. package/.claude/skills/macro-regime-detector/scripts/tests/test_size_factor.py +59 -0
  86. package/.claude/skills/macro-regime-detector/scripts/tests/test_utils.py +126 -0
  87. package/.claude/skills/macro-regime-detector/scripts/tests/test_yield_curve.py +64 -0
  88. package/.claude/skills/market-breadth-analyzer/SKILL.md +121 -0
  89. package/.claude/skills/market-breadth-analyzer/references/breadth_analysis_methodology.md +168 -0
  90. package/.claude/skills/market-breadth-analyzer/scripts/calculators/__init__.py +1 -0
  91. package/.claude/skills/market-breadth-analyzer/scripts/calculators/bearish_signal_calculator.py +150 -0
  92. package/.claude/skills/market-breadth-analyzer/scripts/calculators/cycle_calculator.py +168 -0
  93. package/.claude/skills/market-breadth-analyzer/scripts/calculators/divergence_calculator.py +119 -0
  94. package/.claude/skills/market-breadth-analyzer/scripts/calculators/historical_context_calculator.py +120 -0
  95. package/.claude/skills/market-breadth-analyzer/scripts/calculators/ma_crossover_calculator.py +115 -0
  96. package/.claude/skills/market-breadth-analyzer/scripts/calculators/trend_level_calculator.py +103 -0
  97. package/.claude/skills/market-breadth-analyzer/scripts/csv_client.py +225 -0
  98. package/.claude/skills/market-breadth-analyzer/scripts/market_breadth_analyzer.py +307 -0
  99. package/.claude/skills/market-breadth-analyzer/scripts/report_generator.py +330 -0
  100. package/.claude/skills/market-breadth-analyzer/scripts/scorer.py +271 -0
  101. package/.claude/skills/market-environment-analysis/SKILL.md +139 -0
  102. package/.claude/skills/market-environment-analysis/references/analysis_patterns.md +124 -0
  103. package/.claude/skills/market-environment-analysis/references/indicators.md +99 -0
  104. package/.claude/skills/market-environment-analysis/scripts/market_utils.py +127 -0
  105. package/.claude/skills/market-news-analyst/SKILL.md +714 -0
  106. package/.claude/skills/market-news-analyst/references/corporate_news_impact.md +446 -0
  107. package/.claude/skills/market-news-analyst/references/geopolitical_commodity_correlations.md +499 -0
  108. package/.claude/skills/market-news-analyst/references/market_event_patterns.md +393 -0
  109. package/.claude/skills/market-news-analyst/references/trusted_news_sources.md +510 -0
  110. package/.claude/skills/market-top-detector/SKILL.md +159 -0
  111. package/.claude/skills/market-top-detector/references/distribution_day_guide.md +100 -0
  112. package/.claude/skills/market-top-detector/references/historical_tops.md +142 -0
  113. package/.claude/skills/market-top-detector/references/market_top_methodology.md +167 -0
  114. package/.claude/skills/market-top-detector/scripts/calculators/__init__.py +17 -0
  115. package/.claude/skills/market-top-detector/scripts/calculators/breadth_calculator.py +116 -0
  116. package/.claude/skills/market-top-detector/scripts/calculators/defensive_rotation_calculator.py +127 -0
  117. package/.claude/skills/market-top-detector/scripts/calculators/distribution_day_calculator.py +161 -0
  118. package/.claude/skills/market-top-detector/scripts/calculators/index_technical_calculator.py +254 -0
  119. package/.claude/skills/market-top-detector/scripts/calculators/leading_stock_calculator.py +198 -0
  120. package/.claude/skills/market-top-detector/scripts/calculators/sentiment_calculator.py +213 -0
  121. package/.claude/skills/market-top-detector/scripts/fmp_client.py +158 -0
  122. package/.claude/skills/market-top-detector/scripts/market_top_detector.py +349 -0
  123. package/.claude/skills/market-top-detector/scripts/report_generator.py +314 -0
  124. package/.claude/skills/market-top-detector/scripts/scorer.py +473 -0
  125. package/.claude/skills/market-top-detector/scripts/tests/conftest.py +9 -0
  126. package/.claude/skills/market-top-detector/scripts/tests/helpers.py +49 -0
  127. package/.claude/skills/market-top-detector/scripts/tests/test_breadth.py +62 -0
  128. package/.claude/skills/market-top-detector/scripts/tests/test_defensive_rotation.py +56 -0
  129. package/.claude/skills/market-top-detector/scripts/tests/test_distribution_day.py +92 -0
  130. package/.claude/skills/market-top-detector/scripts/tests/test_index_technical.py +73 -0
  131. package/.claude/skills/market-top-detector/scripts/tests/test_leading_stock.py +57 -0
  132. package/.claude/skills/market-top-detector/scripts/tests/test_scorer.py +180 -0
  133. package/.claude/skills/market-top-detector/scripts/tests/test_sentiment.py +64 -0
  134. package/.claude/skills/options-strategy-advisor/README.md +469 -0
  135. package/.claude/skills/options-strategy-advisor/SKILL.md +959 -0
  136. package/.claude/skills/options-strategy-advisor/scripts/black_scholes.py +495 -0
  137. package/.claude/skills/pair-trade-screener/README.md +389 -0
  138. package/.claude/skills/pair-trade-screener/SKILL.md +622 -0
  139. package/.claude/skills/pair-trade-screener/references/cointegration_guide.md +745 -0
  140. package/.claude/skills/pair-trade-screener/references/methodology.md +853 -0
  141. package/.claude/skills/pair-trade-screener/scripts/analyze_spread.py +394 -0
  142. package/.claude/skills/pair-trade-screener/scripts/find_pairs.py +535 -0
  143. package/.claude/skills/portfolio-manager/README.md +394 -0
  144. package/.claude/skills/portfolio-manager/SKILL.md +750 -0
  145. package/.claude/skills/portfolio-manager/references/alpaca-mcp-setup.md +367 -0
  146. package/.claude/skills/portfolio-manager/references/asset-allocation.md +502 -0
  147. package/.claude/skills/portfolio-manager/references/diversification-principles.md +553 -0
  148. package/.claude/skills/portfolio-manager/references/portfolio-risk-metrics.md +603 -0
  149. package/.claude/skills/portfolio-manager/references/position-evaluation.md +477 -0
  150. package/.claude/skills/portfolio-manager/references/rebalancing-strategies.md +715 -0
  151. package/.claude/skills/portfolio-manager/references/risk-profile-questionnaire.md +608 -0
  152. package/.claude/skills/portfolio-manager/references/target-allocations.md +558 -0
  153. package/.claude/skills/portfolio-manager/scripts/test_alpaca_connection.py +286 -0
  154. package/.claude/skills/scenario-analyzer/SKILL.md +317 -0
  155. package/.claude/skills/scenario-analyzer/references/headline_event_patterns.md +264 -0
  156. package/.claude/skills/scenario-analyzer/references/scenario_playbooks.md +320 -0
  157. package/.claude/skills/scenario-analyzer/references/sector_sensitivity_matrix.md +217 -0
  158. package/.claude/skills/sector-analyst/SKILL.md +206 -0
  159. package/.claude/skills/sector-analyst/assets/industory_performance_1.jpeg +0 -0
  160. package/.claude/skills/sector-analyst/assets/industory_performance_2.jpeg +0 -0
  161. package/.claude/skills/sector-analyst/assets/sector_performance.jpeg +0 -0
  162. package/.claude/skills/sector-analyst/references/sector_rotation.md +170 -0
  163. package/.claude/skills/stanley-druckenmiller-investment/SKILL.md +84 -0
  164. package/.claude/skills/stanley-druckenmiller-investment/references/case-studies.md +148 -0
  165. package/.claude/skills/stanley-druckenmiller-investment/references/investment-philosophy.md +80 -0
  166. package/.claude/skills/stanley-druckenmiller-investment/references/market-analysis-guide.md +146 -0
  167. package/.claude/skills/stock/NOTION_SETUP.md +33 -0
  168. package/.claude/skills/stock/SKILL.md +38 -0
  169. package/.claude/skills/technical-analyst/SKILL.md +238 -0
  170. package/.claude/skills/technical-analyst/assets/analysis_template.md +183 -0
  171. package/.claude/skills/technical-analyst/references/technical_analysis_framework.md +282 -0
  172. package/.claude/skills/theme-detector/SKILL.md +320 -0
  173. package/.claude/skills/theme-detector/assets/report_template.md +155 -0
  174. package/.claude/skills/theme-detector/references/cross_sector_themes.md +252 -0
  175. package/.claude/skills/theme-detector/references/finviz_industry_codes.md +403 -0
  176. package/.claude/skills/theme-detector/references/thematic_etf_catalog.md +333 -0
  177. package/.claude/skills/theme-detector/references/theme_detection_methodology.md +430 -0
  178. package/.claude/skills/theme-detector/scripts/calculators/__init__.py +1 -0
  179. package/.claude/skills/theme-detector/scripts/calculators/heat_calculator.py +123 -0
  180. package/.claude/skills/theme-detector/scripts/calculators/industry_ranker.py +98 -0
  181. package/.claude/skills/theme-detector/scripts/calculators/lifecycle_calculator.py +172 -0
  182. package/.claude/skills/theme-detector/scripts/calculators/theme_classifier.py +195 -0
  183. package/.claude/skills/theme-detector/scripts/calculators/theme_discoverer.py +280 -0
  184. package/.claude/skills/theme-detector/scripts/config_loader.py +142 -0
  185. package/.claude/skills/theme-detector/scripts/default_theme_config.py +254 -0
  186. package/.claude/skills/theme-detector/scripts/etf_scanner.py +609 -0
  187. package/.claude/skills/theme-detector/scripts/finviz_performance_client.py +131 -0
  188. package/.claude/skills/theme-detector/scripts/report_generator.py +490 -0
  189. package/.claude/skills/theme-detector/scripts/representative_stock_selector.py +673 -0
  190. package/.claude/skills/theme-detector/scripts/scorer.py +87 -0
  191. package/.claude/skills/theme-detector/scripts/tests/README.md +21 -0
  192. package/.claude/skills/theme-detector/scripts/tests/conftest.py +9 -0
  193. package/.claude/skills/theme-detector/scripts/tests/test_config_loader.py +239 -0
  194. package/.claude/skills/theme-detector/scripts/tests/test_etf_scanner.py +810 -0
  195. package/.claude/skills/theme-detector/scripts/tests/test_heat_calculator.py +245 -0
  196. package/.claude/skills/theme-detector/scripts/tests/test_industry_ranker.py +256 -0
  197. package/.claude/skills/theme-detector/scripts/tests/test_lifecycle_calculator.py +301 -0
  198. package/.claude/skills/theme-detector/scripts/tests/test_report_generator.py +624 -0
  199. package/.claude/skills/theme-detector/scripts/tests/test_representative_stock_selector.py +898 -0
  200. package/.claude/skills/theme-detector/scripts/tests/test_scorer.py +185 -0
  201. package/.claude/skills/theme-detector/scripts/tests/test_theme_classifier.py +534 -0
  202. package/.claude/skills/theme-detector/scripts/tests/test_theme_detector_e2e.py +467 -0
  203. package/.claude/skills/theme-detector/scripts/tests/test_theme_discoverer.py +458 -0
  204. package/.claude/skills/theme-detector/scripts/tests/test_uptrend_client.py +76 -0
  205. package/.claude/skills/theme-detector/scripts/theme_detector.py +815 -0
  206. package/.claude/skills/theme-detector/scripts/themes.yaml +168 -0
  207. package/.claude/skills/theme-detector/scripts/uptrend_client.py +241 -0
  208. package/.claude/skills/uptrend-analyzer/SKILL.md +108 -0
  209. package/.claude/skills/uptrend-analyzer/references/uptrend_methodology.md +215 -0
  210. package/.claude/skills/uptrend-analyzer/scripts/calculators/__init__.py +1 -0
  211. package/.claude/skills/uptrend-analyzer/scripts/calculators/historical_context_calculator.py +122 -0
  212. package/.claude/skills/uptrend-analyzer/scripts/calculators/market_breadth_calculator.py +145 -0
  213. package/.claude/skills/uptrend-analyzer/scripts/calculators/momentum_calculator.py +183 -0
  214. package/.claude/skills/uptrend-analyzer/scripts/calculators/sector_participation_calculator.py +204 -0
  215. package/.claude/skills/uptrend-analyzer/scripts/calculators/sector_rotation_calculator.py +218 -0
  216. package/.claude/skills/uptrend-analyzer/scripts/data_fetcher.py +236 -0
  217. package/.claude/skills/uptrend-analyzer/scripts/report_generator.py +329 -0
  218. package/.claude/skills/uptrend-analyzer/scripts/scorer.py +276 -0
  219. package/.claude/skills/uptrend-analyzer/scripts/uptrend_analyzer.py +219 -0
  220. package/.claude/skills/us-market-bubble-detector/CHANGELOG.md +118 -0
  221. package/.claude/skills/us-market-bubble-detector/SKILL.md +545 -0
  222. package/.claude/skills/us-market-bubble-detector/references/bubble_framework.md +335 -0
  223. package/.claude/skills/us-market-bubble-detector/references/historical_cases.md +327 -0
  224. package/.claude/skills/us-market-bubble-detector/references/implementation_guide.md +473 -0
  225. package/.claude/skills/us-market-bubble-detector/references/quick_reference.md +354 -0
  226. package/.claude/skills/us-market-bubble-detector/references/quick_reference_en.md +342 -0
  227. package/.claude/skills/us-market-bubble-detector/scripts/bubble_scorer.py +309 -0
  228. package/.claude/skills/us-stock-analysis/SKILL.md +294 -0
  229. package/.claude/skills/us-stock-analysis/references/financial-metrics.md +172 -0
  230. package/.claude/skills/us-stock-analysis/references/fundamental-analysis.md +129 -0
  231. package/.claude/skills/us-stock-analysis/references/report-template.md +207 -0
  232. package/.claude/skills/us-stock-analysis/references/technical-analysis.md +93 -0
  233. package/.claude/skills/value-dividend-screener/SKILL.md +562 -0
  234. package/.claude/skills/value-dividend-screener/references/fmp_api_guide.md +348 -0
  235. package/.claude/skills/value-dividend-screener/references/screening_methodology.md +315 -0
  236. package/.claude/skills/value-dividend-screener/scripts/screen_dividend_stocks.py +1138 -0
  237. package/.claude/skills/vcp-screener/SKILL.md +79 -0
  238. package/.claude/skills/vcp-screener/references/fmp_api_endpoints.md +45 -0
  239. package/.claude/skills/vcp-screener/references/scoring_system.md +154 -0
  240. package/.claude/skills/vcp-screener/references/vcp_methodology.md +124 -0
  241. package/.claude/skills/vcp-screener/scripts/calculators/__init__.py +1 -0
  242. package/.claude/skills/vcp-screener/scripts/calculators/pivot_proximity_calculator.py +139 -0
  243. package/.claude/skills/vcp-screener/scripts/calculators/relative_strength_calculator.py +161 -0
  244. package/.claude/skills/vcp-screener/scripts/calculators/trend_template_calculator.py +228 -0
  245. package/.claude/skills/vcp-screener/scripts/calculators/vcp_pattern_calculator.py +322 -0
  246. package/.claude/skills/vcp-screener/scripts/calculators/volume_pattern_calculator.py +121 -0
  247. package/.claude/skills/vcp-screener/scripts/fmp_client.py +162 -0
  248. package/.claude/skills/vcp-screener/scripts/report_generator.py +317 -0
  249. package/.claude/skills/vcp-screener/scripts/scorer.py +155 -0
  250. package/.claude/skills/vcp-screener/scripts/screen_vcp.py +536 -0
  251. package/.claude/skills/vcp-screener/scripts/tests/__init__.py +0 -0
  252. package/.claude/skills/vcp-screener/scripts/tests/conftest.py +9 -0
  253. package/.claude/skills/vcp-screener/scripts/tests/test_vcp_screener.py +834 -0
  254. package/.claude/skills/weekly-trade-strategy/.claude/agents/druckenmiller-strategy-planner.md +300 -0
  255. package/.claude/skills/weekly-trade-strategy/.claude/agents/market-news-analyzer.md +239 -0
  256. package/.claude/skills/weekly-trade-strategy/.claude/agents/technical-market-analyst.md +187 -0
  257. package/.claude/skills/weekly-trade-strategy/.claude/agents/us-market-analyst.md +218 -0
  258. package/.claude/skills/weekly-trade-strategy/.claude/agents/weekly-trade-blog-writer.md +318 -0
  259. package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/SKILL.md +662 -0
  260. package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/assets/SP500_Breadth_Index_200MA_8MA.jpeg +0 -0
  261. package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/assets/US_Stock_Market_Uptrend_Ratio.jpeg +0 -0
  262. package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/assets/breadth_analysis_template.md +558 -0
  263. package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/references/breadth_chart_methodology.md +590 -0
  264. package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/SKILL.md +721 -0
  265. package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/assets/earnings_report_template.md +102 -0
  266. package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/earnings_calendar_2025-11-02.md +447 -0
  267. package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/references/fmp_api_guide.md +590 -0
  268. package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/scripts/fetch_earnings_fmp.py +443 -0
  269. package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/scripts/generate_report.py +366 -0
  270. package/.claude/skills/weekly-trade-strategy/.claude/skills/economic-calendar-fetcher/SKILL.md +365 -0
  271. package/.claude/skills/weekly-trade-strategy/.claude/skills/economic-calendar-fetcher/references/fmp_api_documentation.md +345 -0
  272. package/.claude/skills/weekly-trade-strategy/.claude/skills/economic-calendar-fetcher/scripts/get_economic_calendar.py +267 -0
  273. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/SKILL.md +139 -0
  274. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/references/analysis_patterns.md +124 -0
  275. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/references/indicators.md +99 -0
  276. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/scripts/market_utils.py +127 -0
  277. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/SKILL.md +714 -0
  278. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/corporate_news_impact.md +446 -0
  279. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/geopolitical_commodity_correlations.md +499 -0
  280. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/market_event_patterns.md +393 -0
  281. package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/trusted_news_sources.md +510 -0
  282. package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/SKILL.md +206 -0
  283. package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/assets/industory_performance_1.jpeg +0 -0
  284. package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/assets/industory_performance_2.jpeg +0 -0
  285. package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/assets/sector_performance.jpeg +0 -0
  286. package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/references/sector_rotation.md +170 -0
  287. package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/SKILL.md +84 -0
  288. package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/references/case-studies.md +148 -0
  289. package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/references/investment-philosophy.md +80 -0
  290. package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/references/market-analysis-guide.md +146 -0
  291. package/.claude/skills/weekly-trade-strategy/.claude/skills/technical-analyst/SKILL.md +238 -0
  292. package/.claude/skills/weekly-trade-strategy/.claude/skills/technical-analyst/assets/analysis_template.md +183 -0
  293. package/.claude/skills/weekly-trade-strategy/.claude/skills/technical-analyst/references/technical_analysis_framework.md +282 -0
  294. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/CHANGELOG.md +118 -0
  295. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/SKILL.md +545 -0
  296. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/bubble_framework.md +335 -0
  297. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/historical_cases.md +327 -0
  298. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/implementation_guide.md +473 -0
  299. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/quick_reference.md +354 -0
  300. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/quick_reference_en.md +342 -0
  301. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/scripts/bubble_scorer.py +309 -0
  302. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/SKILL.md +294 -0
  303. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/financial-metrics.md +172 -0
  304. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/fundamental-analysis.md +129 -0
  305. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/report-template.md +207 -0
  306. package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/technical-analysis.md +93 -0
  307. package/.claude/skills/weekly-trade-strategy/CLAUDE.md +454 -0
  308. package/.claude/skills/weekly-trade-strategy/README.md +287 -0
  309. package/.claude/skills/weekly-trade-strategy/blogs/.gitkeep +0 -0
  310. package/.claude/skills/weekly-trade-strategy/charts/.gitkeep +0 -0
  311. package/.claude/skills/weekly-trade-strategy/earnings_data.json +10054 -0
  312. package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/SKILL.md +662 -0
  313. package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/assets/SP500_Breadth_Index_200MA_8MA.jpeg +0 -0
  314. package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/assets/US_Stock_Market_Uptrend_Ratio.jpeg +0 -0
  315. package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/assets/breadth_analysis_template.md +558 -0
  316. package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/references/breadth_chart_methodology.md +590 -0
  317. package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/SKILL.md +721 -0
  318. package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/assets/earnings_report_template.md +102 -0
  319. package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/earnings_calendar_2025-11-02.md +447 -0
  320. package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/references/fmp_api_guide.md +590 -0
  321. package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/scripts/fetch_earnings_fmp.py +443 -0
  322. package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/scripts/generate_report.py +366 -0
  323. package/.claude/skills/weekly-trade-strategy/skills/economic-calendar-fetcher/SKILL.md +365 -0
  324. package/.claude/skills/weekly-trade-strategy/skills/economic-calendar-fetcher/references/fmp_api_documentation.md +345 -0
  325. package/.claude/skills/weekly-trade-strategy/skills/economic-calendar-fetcher/scripts/get_economic_calendar.py +267 -0
  326. package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/SKILL.md +139 -0
  327. package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/references/analysis_patterns.md +124 -0
  328. package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/references/indicators.md +99 -0
  329. package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/scripts/market_utils.py +127 -0
  330. package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/SKILL.md +714 -0
  331. package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/corporate_news_impact.md +446 -0
  332. package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/geopolitical_commodity_correlations.md +499 -0
  333. package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/market_event_patterns.md +393 -0
  334. package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/trusted_news_sources.md +510 -0
  335. package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/SKILL.md +206 -0
  336. package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/assets/industory_performance_1.jpeg +0 -0
  337. package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/assets/industory_performance_2.jpeg +0 -0
  338. package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/assets/sector_performance.jpeg +0 -0
  339. package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/references/sector_rotation.md +170 -0
  340. package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/SKILL.md +84 -0
  341. package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/references/case-studies.md +148 -0
  342. package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/references/investment-philosophy.md +80 -0
  343. package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/references/market-analysis-guide.md +146 -0
  344. package/.claude/skills/weekly-trade-strategy/skills/technical-analyst/SKILL.md +238 -0
  345. package/.claude/skills/weekly-trade-strategy/skills/technical-analyst/assets/analysis_template.md +183 -0
  346. package/.claude/skills/weekly-trade-strategy/skills/technical-analyst/references/technical_analysis_framework.md +282 -0
  347. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/CHANGELOG.md +118 -0
  348. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/SKILL.md +545 -0
  349. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/bubble_framework.md +335 -0
  350. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/historical_cases.md +327 -0
  351. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/implementation_guide.md +473 -0
  352. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/quick_reference.md +354 -0
  353. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/quick_reference_en.md +342 -0
  354. package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/scripts/bubble_scorer.py +309 -0
  355. package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/SKILL.md +294 -0
  356. package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/financial-metrics.md +172 -0
  357. package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/fundamental-analysis.md +129 -0
  358. package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/report-template.md +207 -0
  359. package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/technical-analysis.md +93 -0
  360. package/.mcp.json +3 -0
  361. package/cli.mjs +16 -16
  362. package/package.json +4 -2
@@ -0,0 +1,810 @@
1
+ """Unit tests for ETFScanner FMP API migration.
2
+
3
+ Tests FMP backend, symbol normalization, caching, batching,
4
+ per-symbol retry, symbol-level fallback, and backend stats.
5
+ """
6
+
7
+ from unittest.mock import patch, MagicMock
8
+ import pandas as pd
9
+ import numpy as np
10
+
11
+ from etf_scanner import ETFScanner
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # TestCalculateRSI
16
+ # ---------------------------------------------------------------------------
17
+ class TestCalculateRSI:
18
+ """Tests for the static _calculate_rsi method."""
19
+
20
+ def test_known_sequence_rsi(self):
21
+ """RSI of a known oscillating sequence returns a value in (0, 100)."""
22
+ # 20 data points with alternating gains and losses
23
+ prices = pd.Series([
24
+ 44.0, 44.34, 44.09, 44.15, 43.61,
25
+ 44.33, 44.83, 45.10, 45.42, 45.84,
26
+ 46.08, 45.89, 46.03, 45.61, 46.28,
27
+ 46.28, 46.00, 46.03, 46.41, 46.22,
28
+ ])
29
+ rsi = ETFScanner._calculate_rsi(prices, period=14)
30
+ assert rsi is not None
31
+ assert 0 < rsi < 100
32
+
33
+ def test_insufficient_data_returns_none(self):
34
+ """Fewer than period+1 data points returns None."""
35
+ prices = pd.Series([10.0, 11.0, 12.0])
36
+ assert ETFScanner._calculate_rsi(prices, period=14) is None
37
+
38
+ def test_all_gains_returns_100(self):
39
+ """Monotonically increasing prices produce RSI = 100."""
40
+ prices = pd.Series([float(i) for i in range(1, 20)])
41
+ rsi = ETFScanner._calculate_rsi(prices, period=14)
42
+ assert rsi == 100.0
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # TestCalculate52wDistances
47
+ # ---------------------------------------------------------------------------
48
+ class TestCalculate52wDistances:
49
+ """Tests for the static _calculate_52w_distances method."""
50
+
51
+ def test_at_52w_high_returns_zero(self):
52
+ """When current price equals 52w high, dist_from_52w_high is 0."""
53
+ close = pd.Series([90.0, 95.0, 100.0])
54
+ high = pd.Series([92.0, 97.0, 100.0])
55
+ low = pd.Series([88.0, 93.0, 98.0])
56
+ result = ETFScanner._calculate_52w_distances(close, high, low)
57
+ assert result["dist_from_52w_high"] == 0.0
58
+
59
+ def test_at_52w_low_returns_zero(self):
60
+ """When current price equals 52w low, dist_from_52w_low is 0."""
61
+ close = pd.Series([100.0, 95.0, 88.0])
62
+ high = pd.Series([102.0, 97.0, 90.0])
63
+ low = pd.Series([98.0, 93.0, 88.0])
64
+ result = ETFScanner._calculate_52w_distances(close, high, low)
65
+ assert result["dist_from_52w_low"] == 0.0
66
+
67
+ def test_midpoint_values(self):
68
+ """Midpoint values produce non-zero distances for both."""
69
+ close = pd.Series([50.0, 100.0, 75.0])
70
+ high = pd.Series([52.0, 102.0, 77.0])
71
+ low = pd.Series([48.0, 98.0, 73.0])
72
+ result = ETFScanner._calculate_52w_distances(close, high, low)
73
+ assert result["dist_from_52w_high"] is not None
74
+ assert result["dist_from_52w_low"] is not None
75
+ assert result["dist_from_52w_high"] > 0
76
+ assert result["dist_from_52w_low"] > 0
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # TestNormalizeSymbol
81
+ # ---------------------------------------------------------------------------
82
+ class TestNormalizeSymbol:
83
+ """Tests for symbol normalization (dash to dot)."""
84
+
85
+ def test_dash_to_dot(self):
86
+ assert ETFScanner._normalize_symbol_for_fmp("BRK-B") == "BRK.B"
87
+
88
+ def test_normal_symbol_unchanged(self):
89
+ assert ETFScanner._normalize_symbol_for_fmp("AAPL") == "AAPL"
90
+
91
+ def test_already_dot_unchanged(self):
92
+ assert ETFScanner._normalize_symbol_for_fmp("BRK.B") == "BRK.B"
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # TestFMPEndpointFallback
97
+ # ---------------------------------------------------------------------------
98
+ class TestFMPEndpointFallback:
99
+ """Tests for _fmp_request stable -> v3 fallback."""
100
+
101
+ def _make_scanner(self):
102
+ return ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
103
+
104
+ @patch("etf_scanner._requests_lib")
105
+ def test_stable_success_uses_stable_url_format(self, mock_requests):
106
+ """Stable endpoint uses ?symbol= query param format."""
107
+ scanner = self._make_scanner()
108
+ mock_resp = MagicMock()
109
+ mock_resp.status_code = 200
110
+ mock_resp.json.return_value = [{"symbol": "AAPL", "pe": 30}]
111
+ mock_requests.get.return_value = mock_resp
112
+
113
+ result = scanner._fmp_request("quote", "AAPL,MSFT")
114
+ assert result is not None
115
+
116
+ # Verify stable URL was called (base URL without symbols in path)
117
+ called_url = mock_requests.get.call_args[0][0]
118
+ assert "stable/quote" in called_url
119
+ assert "AAPL,MSFT" not in called_url # symbols in params, not path
120
+ called_params = mock_requests.get.call_args[1]["params"]
121
+ assert called_params["symbol"] == "AAPL,MSFT"
122
+
123
+ @patch("etf_scanner._requests_lib")
124
+ def test_stable_fails_falls_back_to_v3_path_format(self, mock_requests):
125
+ """When stable fails, v3 endpoint uses /SYMBOLS path format."""
126
+ scanner = self._make_scanner()
127
+
128
+ # First call (stable) fails
129
+ fail_resp = MagicMock()
130
+ fail_resp.status_code = 500
131
+ # Second call (v3) succeeds
132
+ ok_resp = MagicMock()
133
+ ok_resp.status_code = 200
134
+ ok_resp.json.return_value = [{"symbol": "AAPL"}]
135
+ mock_requests.get.side_effect = [fail_resp, ok_resp]
136
+
137
+ result = scanner._fmp_request("quote", "AAPL,MSFT")
138
+ assert result is not None
139
+
140
+ # Verify v3 call uses path-based symbols
141
+ v3_call = mock_requests.get.call_args_list[1]
142
+ called_url = v3_call[0][0]
143
+ assert "/api/v3/quote/AAPL,MSFT" in called_url
144
+ # Symbols should NOT be in params for v3
145
+ assert "symbol" not in v3_call[1]["params"]
146
+
147
+ @patch("etf_scanner._requests_lib")
148
+ def test_both_fail_returns_none(self, mock_requests):
149
+ """When both stable and v3 fail, returns None."""
150
+ scanner = self._make_scanner()
151
+ fail_resp = MagicMock()
152
+ fail_resp.status_code = 500
153
+ mock_requests.get.return_value = fail_resp
154
+
155
+ result = scanner._fmp_request("quote", "AAPL")
156
+ assert result is None
157
+ assert scanner._stats["fmp_failures"] == 2
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # TestFMPQuoteFetch
162
+ # ---------------------------------------------------------------------------
163
+ class TestFMPQuoteFetch:
164
+ """Tests for _fetch_fmp_quotes."""
165
+
166
+ def _make_scanner(self):
167
+ return ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
168
+
169
+ @patch("etf_scanner._requests_lib")
170
+ def test_batch_returns_mapped_dict(self, mock_requests):
171
+ """Quote fetch returns {symbol: quote_dict} mapping."""
172
+ scanner = self._make_scanner()
173
+ mock_resp = MagicMock()
174
+ mock_resp.status_code = 200
175
+ mock_resp.json.return_value = [
176
+ {"symbol": "AAPL", "pe": 30, "price": 150},
177
+ {"symbol": "MSFT", "pe": 35, "price": 400},
178
+ ]
179
+ mock_requests.get.return_value = mock_resp
180
+
181
+ result = scanner._fetch_fmp_quotes(["AAPL", "MSFT"])
182
+ assert "AAPL" in result
183
+ assert "MSFT" in result
184
+ assert result["AAPL"]["pe"] == 30
185
+
186
+ @patch("etf_scanner._requests_lib")
187
+ def test_splits_large_batch_by_quote_batch_size(self, mock_requests):
188
+ """Symbols exceeding FMP_QUOTE_BATCH_SIZE are split into batches."""
189
+ scanner = self._make_scanner()
190
+ scanner.FMP_QUOTE_BATCH_SIZE = 3 # small batch for testing
191
+
192
+ symbols = ["A", "B", "C", "D", "E"]
193
+ # Return non-empty data so stable succeeds (no v3 fallback)
194
+ mock_resp = MagicMock()
195
+ mock_resp.status_code = 200
196
+ mock_resp.json.return_value = [{"symbol": "X"}]
197
+ mock_requests.get.return_value = mock_resp
198
+
199
+ scanner._fetch_fmp_quotes(symbols)
200
+ # 5 symbols / batch_size 3 = 2 batches, stable succeeds = 2 API calls
201
+ assert mock_requests.get.call_count == 2
202
+
203
+ @patch("etf_scanner._requests_lib")
204
+ def test_cache_uses_normalized_key(self, mock_requests):
205
+ """BRK-B is cached as BRK.B (normalized)."""
206
+ scanner = self._make_scanner()
207
+ mock_resp = MagicMock()
208
+ mock_resp.status_code = 200
209
+ mock_resp.json.return_value = [
210
+ {"symbol": "BRK.B", "pe": 10, "price": 400},
211
+ ]
212
+ mock_requests.get.return_value = mock_resp
213
+
214
+ result = scanner._fetch_fmp_quotes(["BRK-B"])
215
+ # Cached under normalized key
216
+ assert "BRK.B" in scanner._fmp_quote_cache
217
+ # But result maps back to original symbol
218
+ assert "BRK-B" in result
219
+
220
+ @patch("etf_scanner._requests_lib")
221
+ def test_cache_hit_prevents_duplicate_call(self, mock_requests):
222
+ """Second call for same symbol uses cache, no API call."""
223
+ scanner = self._make_scanner()
224
+ mock_resp = MagicMock()
225
+ mock_resp.status_code = 200
226
+ mock_resp.json.return_value = [
227
+ {"symbol": "AAPL", "pe": 30},
228
+ ]
229
+ mock_requests.get.return_value = mock_resp
230
+
231
+ scanner._fetch_fmp_quotes(["AAPL"])
232
+ call_count_1 = mock_requests.get.call_count
233
+
234
+ scanner._fetch_fmp_quotes(["AAPL"])
235
+ call_count_2 = mock_requests.get.call_count
236
+
237
+ # No additional API call for cached symbol
238
+ assert call_count_2 == call_count_1
239
+
240
+ @patch("etf_scanner._requests_lib")
241
+ def test_api_error_returns_empty(self, mock_requests):
242
+ """API error returns empty dict."""
243
+ scanner = self._make_scanner()
244
+ mock_requests.get.side_effect = Exception("Connection error")
245
+
246
+ result = scanner._fetch_fmp_quotes(["AAPL"])
247
+ assert result == {}
248
+
249
+ @patch("etf_scanner._requests_lib")
250
+ def test_original_symbol_retry_normalizes_cache_key(self, mock_requests):
251
+ """When retry returns BRK-B, it is cached under normalized BRK.B."""
252
+ scanner = self._make_scanner()
253
+
254
+ # Batch call with normalized BRK.B: stable fails, v3 fails
255
+ fail_resp = MagicMock()
256
+ fail_resp.status_code = 500
257
+ # Retry with original BRK-B: stable returns data with BRK-B symbol
258
+ retry_resp = MagicMock()
259
+ retry_resp.status_code = 200
260
+ retry_resp.json.return_value = [{"symbol": "BRK-B", "pe": 10}]
261
+ mock_requests.get.side_effect = [fail_resp, fail_resp, retry_resp]
262
+
263
+ result = scanner._fetch_fmp_quotes(["BRK-B"])
264
+ # Cache key is normalized to BRK.B despite API returning BRK-B
265
+ assert "BRK.B" in scanner._fmp_quote_cache
266
+ # Result maps back to original symbol
267
+ assert "BRK-B" in result
268
+ assert result["BRK-B"]["pe"] == 10
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # TestFMPHistoricalFetch
273
+ # ---------------------------------------------------------------------------
274
+ class TestFMPHistoricalFetch:
275
+ """Tests for _fetch_fmp_historical."""
276
+
277
+ def _make_scanner(self):
278
+ return ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
279
+
280
+ @patch("etf_scanner._requests_lib")
281
+ def test_multi_symbol_parses_historicalStockList(self, mock_requests):
282
+ """Multi-symbol response uses historicalStockList format."""
283
+ scanner = self._make_scanner()
284
+ mock_resp = MagicMock()
285
+ mock_resp.status_code = 200
286
+ mock_resp.json.return_value = {
287
+ "historicalStockList": [
288
+ {"symbol": "AAPL", "historical": [
289
+ {"date": "2026-02-14", "close": 150},
290
+ {"date": "2026-02-13", "close": 148},
291
+ ]},
292
+ {"symbol": "MSFT", "historical": [
293
+ {"date": "2026-02-14", "close": 400},
294
+ ]},
295
+ ]
296
+ }
297
+ mock_requests.get.return_value = mock_resp
298
+
299
+ result = scanner._fetch_fmp_historical(["AAPL", "MSFT"], timeseries=20)
300
+ assert "AAPL" in result
301
+ assert "MSFT" in result
302
+ assert len(result["AAPL"]) == 2
303
+
304
+ @patch("etf_scanner._requests_lib")
305
+ def test_single_symbol_parses_historical(self, mock_requests):
306
+ """Single-symbol response uses {symbol, historical} format."""
307
+ scanner = self._make_scanner()
308
+ mock_resp = MagicMock()
309
+ mock_resp.status_code = 200
310
+ mock_resp.json.return_value = {
311
+ "symbol": "AAPL",
312
+ "historical": [
313
+ {"date": "2026-02-14", "close": 150},
314
+ ]
315
+ }
316
+ mock_requests.get.return_value = mock_resp
317
+
318
+ result = scanner._fetch_fmp_historical(["AAPL"], timeseries=20)
319
+ assert "AAPL" in result
320
+
321
+ @patch("etf_scanner._requests_lib")
322
+ def test_batches_in_groups_of_5(self, mock_requests):
323
+ """Historical fetch batches symbols in groups of FMP_HIST_BATCH_SIZE."""
324
+ scanner = self._make_scanner()
325
+ scanner.FMP_HIST_BATCH_SIZE = 2 # small batch for testing
326
+
327
+ # Each batch returns empty but valid response
328
+ mock_resp = MagicMock()
329
+ mock_resp.status_code = 200
330
+ mock_resp.json.return_value = {"historicalStockList": []}
331
+ mock_requests.get.return_value = mock_resp
332
+
333
+ scanner._fetch_fmp_historical(["A", "B", "C", "D", "E"], timeseries=20)
334
+ # 5 symbols / batch_size 2 = 3 batch calls
335
+ # No per-symbol retry since all symbols missing -> 5 more calls
336
+ # But with empty data, all symbols are missing -> retry each
337
+ assert mock_requests.get.call_count == 3 + 5
338
+
339
+ @patch("etf_scanner._requests_lib")
340
+ def test_batch_incomplete_retries_missing_per_symbol(self, mock_requests):
341
+ """Missing symbols from batch get retried individually."""
342
+ scanner = self._make_scanner()
343
+
344
+ # First batch call returns only AAPL (MSFT missing)
345
+ batch_resp = MagicMock()
346
+ batch_resp.status_code = 200
347
+ batch_resp.json.return_value = {
348
+ "historicalStockList": [
349
+ {"symbol": "AAPL", "historical": [{"close": 150}]},
350
+ ]
351
+ }
352
+ # Per-symbol retry for MSFT succeeds
353
+ retry_resp = MagicMock()
354
+ retry_resp.status_code = 200
355
+ retry_resp.json.return_value = {
356
+ "symbol": "MSFT",
357
+ "historical": [{"close": 400}],
358
+ }
359
+ mock_requests.get.side_effect = [batch_resp, retry_resp]
360
+
361
+ result = scanner._fetch_fmp_historical(["AAPL", "MSFT"], timeseries=20)
362
+ assert "AAPL" in result
363
+ assert "MSFT" in result
364
+
365
+ @patch("etf_scanner._requests_lib")
366
+ def test_api_error_returns_empty(self, mock_requests):
367
+ """Total failure returns empty dict."""
368
+ scanner = self._make_scanner()
369
+ mock_requests.get.side_effect = Exception("Timeout")
370
+
371
+ result = scanner._fetch_fmp_historical(["AAPL"], timeseries=20)
372
+ assert result == {}
373
+
374
+ @patch("etf_scanner._requests_lib")
375
+ def test_original_symbol_retry_normalizes_result_key(self, mock_requests):
376
+ """When retry returns BRK-B, result key is normalized to BRK.B."""
377
+ scanner = self._make_scanner()
378
+
379
+ # Batch with normalized BRK.B: stable+v3 both fail
380
+ fail_resp = MagicMock()
381
+ fail_resp.status_code = 500
382
+ # Per-symbol retry with normalized BRK.B: fails
383
+ # Per-symbol retry with original BRK-B: returns data
384
+ retry_resp = MagicMock()
385
+ retry_resp.status_code = 200
386
+ retry_resp.json.return_value = {
387
+ "symbol": "BRK-B",
388
+ "historical": [{"close": 400}],
389
+ }
390
+ mock_requests.get.side_effect = [
391
+ fail_resp, fail_resp, # batch stable+v3
392
+ fail_resp, fail_resp, # per-symbol BRK.B stable+v3
393
+ retry_resp, # per-symbol BRK-B stable succeeds
394
+ ]
395
+
396
+ result = scanner._fetch_fmp_historical(["BRK-B"], timeseries=20)
397
+ # Result maps to original symbol despite API returning BRK-B
398
+ assert "BRK-B" in result
399
+ assert result["BRK-B"][0]["close"] == 400
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # TestBatchStockMetricsFMP
404
+ # ---------------------------------------------------------------------------
405
+ class TestBatchStockMetricsFMP:
406
+ """Tests for FMP-based batch_stock_metrics internal path."""
407
+
408
+ def _make_scanner(self):
409
+ return ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
410
+
411
+ @patch("etf_scanner._requests_lib")
412
+ def test_pe_from_quote(self, mock_requests):
413
+ """PE ratio is extracted from FMP quote data."""
414
+ scanner = self._make_scanner()
415
+
416
+ # Quote response
417
+ quote_resp = MagicMock()
418
+ quote_resp.status_code = 200
419
+ quote_resp.json.return_value = [
420
+ {"symbol": "AAPL", "pe": 30.5, "price": 150,
421
+ "yearHigh": 180, "yearLow": 120},
422
+ ]
423
+ # Historical response for RSI
424
+ hist_resp = MagicMock()
425
+ hist_resp.status_code = 200
426
+ hist_resp.json.return_value = {
427
+ "historicalStockList": [
428
+ {"symbol": "AAPL", "historical": [
429
+ {"close": 150 - i * 0.5} for i in range(20)
430
+ ]},
431
+ ]
432
+ }
433
+ mock_requests.get.side_effect = [quote_resp, hist_resp]
434
+
435
+ results = scanner._batch_stock_metrics_fmp(["AAPL"])
436
+ assert len(results) == 1
437
+ assert results[0]["pe_ratio"] == 30.5
438
+
439
+ @patch("etf_scanner._requests_lib")
440
+ def test_52w_distances_from_quote(self, mock_requests):
441
+ """52-week distances come from yearHigh/yearLow in quote."""
442
+ scanner = self._make_scanner()
443
+
444
+ quote_resp = MagicMock()
445
+ quote_resp.status_code = 200
446
+ quote_resp.json.return_value = [
447
+ {"symbol": "AAPL", "pe": 30, "price": 150,
448
+ "yearHigh": 200, "yearLow": 100},
449
+ ]
450
+ hist_resp = MagicMock()
451
+ hist_resp.status_code = 200
452
+ hist_resp.json.return_value = {"historicalStockList": []}
453
+ mock_requests.get.side_effect = [quote_resp, hist_resp]
454
+
455
+ results = scanner._batch_stock_metrics_fmp(["AAPL"])
456
+ assert results[0]["dist_from_52w_high"] == round((200 - 150) / 200, 4)
457
+ assert results[0]["dist_from_52w_low"] == round((150 - 100) / 150, 4)
458
+
459
+ @patch("etf_scanner._requests_lib")
460
+ def test_rsi_from_historical_close(self, mock_requests):
461
+ """RSI is calculated from historical close prices."""
462
+ scanner = self._make_scanner()
463
+
464
+ quote_resp = MagicMock()
465
+ quote_resp.status_code = 200
466
+ quote_resp.json.return_value = [
467
+ {"symbol": "AAPL", "pe": 30, "price": 150,
468
+ "yearHigh": 180, "yearLow": 120},
469
+ ]
470
+ # FMP returns newest-first; code reverses to oldest-first for RSI.
471
+ # Decreasing close here -> reversed = increasing -> RSI=100
472
+ hist_resp = MagicMock()
473
+ hist_resp.status_code = 200
474
+ hist_resp.json.return_value = {
475
+ "historicalStockList": [
476
+ {"symbol": "AAPL", "historical": [
477
+ {"close": float(150 - i)} for i in range(20)
478
+ ]},
479
+ ]
480
+ }
481
+ mock_requests.get.side_effect = [quote_resp, hist_resp]
482
+
483
+ results = scanner._batch_stock_metrics_fmp(["AAPL"])
484
+ # After reversal: monotonic increase -> RSI = 100
485
+ assert results[0]["rsi_14"] is not None
486
+ assert results[0]["rsi_14"] == 100.0
487
+
488
+ @patch("etf_scanner._requests_lib")
489
+ def test_missing_fields_return_none(self, mock_requests):
490
+ """Missing quote fields produce None for those metrics."""
491
+ scanner = self._make_scanner()
492
+
493
+ quote_resp = MagicMock()
494
+ quote_resp.status_code = 200
495
+ quote_resp.json.return_value = [
496
+ {"symbol": "AAPL"}, # No pe, price, yearHigh, yearLow
497
+ ]
498
+ hist_resp = MagicMock()
499
+ hist_resp.status_code = 200
500
+ hist_resp.json.return_value = {"historicalStockList": []}
501
+ mock_requests.get.side_effect = [quote_resp, hist_resp]
502
+
503
+ results = scanner._batch_stock_metrics_fmp(["AAPL"])
504
+ assert results[0]["pe_ratio"] is None
505
+ assert results[0]["dist_from_52w_high"] is None
506
+ assert results[0]["rsi_14"] is None
507
+
508
+ def test_empty_symbols_returns_empty(self):
509
+ """Empty input returns empty list."""
510
+ scanner = self._make_scanner()
511
+ results = scanner.batch_stock_metrics([])
512
+ assert results == []
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # TestETFVolumeRatioFMP
517
+ # ---------------------------------------------------------------------------
518
+ class TestETFVolumeRatioFMP:
519
+ """Tests for FMP-based ETF volume ratio calculation."""
520
+
521
+ def _make_scanner(self):
522
+ return ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
523
+
524
+ @patch("etf_scanner._requests_lib")
525
+ def test_20d_60d_from_historical(self, mock_requests):
526
+ """Volume ratio is calculated from 20d/60d averages."""
527
+ scanner = self._make_scanner()
528
+
529
+ # 60 days of volume data
530
+ hist_data = [
531
+ {"date": f"2026-02-{14-i:02d}" if i < 14 else f"2026-01-{31-(i-14):02d}",
532
+ "volume": 1_000_000 + i * 10_000}
533
+ for i in range(60)
534
+ ]
535
+ mock_resp = MagicMock()
536
+ mock_resp.status_code = 200
537
+ mock_resp.json.return_value = {
538
+ "historicalStockList": [
539
+ {"symbol": "XLK", "historical": hist_data},
540
+ ]
541
+ }
542
+ mock_requests.get.return_value = mock_resp
543
+
544
+ result = scanner._batch_etf_volume_ratios_fmp(["XLK"])
545
+ assert "XLK" in result
546
+ assert result["XLK"]["vol_20d"] is not None
547
+ assert result["XLK"]["vol_60d"] is not None
548
+ assert result["XLK"]["vol_ratio"] is not None
549
+
550
+ @patch("etf_scanner._requests_lib")
551
+ def test_insufficient_data_returns_none(self, mock_requests):
552
+ """Fewer than 20 data points returns None values."""
553
+ scanner = self._make_scanner()
554
+
555
+ mock_resp = MagicMock()
556
+ mock_resp.status_code = 200
557
+ mock_resp.json.return_value = {
558
+ "historicalStockList": [
559
+ {"symbol": "XLK", "historical": [
560
+ {"date": "2026-02-14", "volume": 1000000},
561
+ ]},
562
+ ]
563
+ }
564
+ mock_requests.get.return_value = mock_resp
565
+
566
+ result = scanner._batch_etf_volume_ratios_fmp(["XLK"])
567
+ assert result["XLK"]["vol_20d"] is None
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # TestBatchETFVolumeRatios
572
+ # ---------------------------------------------------------------------------
573
+ class TestBatchETFVolumeRatios:
574
+ """Tests for the public batch_etf_volume_ratios method."""
575
+
576
+ @patch("etf_scanner._requests_lib")
577
+ def test_batch_returns_all_etfs(self, mock_requests):
578
+ """Batch fetches volume ratios for multiple ETFs."""
579
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
580
+
581
+ hist_data = [{"volume": 1_000_000} for _ in range(60)]
582
+ mock_resp = MagicMock()
583
+ mock_resp.status_code = 200
584
+ mock_resp.json.return_value = {
585
+ "historicalStockList": [
586
+ {"symbol": "XLK", "historical": hist_data},
587
+ {"symbol": "SMH", "historical": hist_data},
588
+ ]
589
+ }
590
+ mock_requests.get.return_value = mock_resp
591
+
592
+ result = scanner.batch_etf_volume_ratios(["XLK", "SMH"])
593
+ assert "XLK" in result
594
+ assert "SMH" in result
595
+
596
+
597
+ # ---------------------------------------------------------------------------
598
+ # TestSymbolLevelFallback
599
+ # ---------------------------------------------------------------------------
600
+ class TestSymbolLevelFallback:
601
+ """Tests for symbol-level FMP -> yfinance fallback."""
602
+
603
+ @patch("etf_scanner._requests_lib")
604
+ @patch("etf_scanner.yf")
605
+ def test_partial_fmp_success_fills_missing_from_yfinance(
606
+ self, mock_yf, mock_requests
607
+ ):
608
+ """Partial FMP success -> missing symbols fall back to yfinance."""
609
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
610
+
611
+ # FMP: only AAPL succeeds
612
+ quote_resp = MagicMock()
613
+ quote_resp.status_code = 200
614
+ quote_resp.json.return_value = [
615
+ {"symbol": "AAPL", "pe": 30, "price": 150,
616
+ "yearHigh": 180, "yearLow": 120},
617
+ ]
618
+ hist_resp = MagicMock()
619
+ hist_resp.status_code = 200
620
+ # Historical with enough data for RSI (newest-first from FMP)
621
+ hist_resp.json.return_value = {
622
+ "historicalStockList": [
623
+ {"symbol": "AAPL", "historical": [
624
+ {"close": float(150 - i)} for i in range(20)
625
+ ]},
626
+ ]
627
+ }
628
+ mock_requests.get.side_effect = [
629
+ quote_resp, hist_resp,
630
+ # Per-symbol retry for MSFT (fails)
631
+ MagicMock(status_code=500), MagicMock(status_code=500),
632
+ ]
633
+
634
+ # yfinance fallback: download for MSFT
635
+ mock_df = pd.DataFrame({
636
+ "Close": np.linspace(300, 400, 20),
637
+ "High": np.linspace(305, 405, 20),
638
+ "Low": np.linspace(295, 395, 20),
639
+ "Volume": [1_000_000] * 20,
640
+ })
641
+ mock_yf.download.return_value = mock_df
642
+ mock_ticker = MagicMock()
643
+ mock_ticker.info = {"trailingPE": 35.0}
644
+ mock_yf.Ticker.return_value = mock_ticker
645
+
646
+ results = scanner.batch_stock_metrics(["AAPL", "MSFT"])
647
+ assert len(results) == 2
648
+ # AAPL from FMP
649
+ aapl = [r for r in results if r["symbol"] == "AAPL"][0]
650
+ assert aapl["pe_ratio"] == 30
651
+ # MSFT from yfinance fallback
652
+ msft = [r for r in results if r["symbol"] == "MSFT"][0]
653
+ assert msft["pe_ratio"] == 35.0
654
+ # Stats show fallback occurred
655
+ assert scanner._stats["yf_fallbacks"] >= 1
656
+
657
+ @patch("etf_scanner._requests_lib")
658
+ def test_all_fmp_success_no_yfinance_calls(self, mock_requests):
659
+ """When FMP succeeds for all symbols, yfinance is not called."""
660
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
661
+
662
+ quote_resp = MagicMock()
663
+ quote_resp.status_code = 200
664
+ quote_resp.json.return_value = [
665
+ {"symbol": "AAPL", "pe": 30, "price": 150,
666
+ "yearHigh": 180, "yearLow": 120},
667
+ ]
668
+ hist_resp = MagicMock()
669
+ hist_resp.status_code = 200
670
+ hist_resp.json.return_value = {
671
+ "historicalStockList": [
672
+ {"symbol": "AAPL", "historical": [
673
+ {"close": float(150 - i)} for i in range(20)
674
+ ]},
675
+ ]
676
+ }
677
+ mock_requests.get.side_effect = [quote_resp, hist_resp]
678
+
679
+ with patch("etf_scanner.yf") as mock_yf:
680
+ scanner.batch_stock_metrics(["AAPL"])
681
+ mock_yf.download.assert_not_called()
682
+
683
+ assert scanner._stats["yf_calls"] == 0
684
+
685
+ @patch("etf_scanner.HAS_REQUESTS", False)
686
+ @patch("etf_scanner.yf")
687
+ def test_all_fmp_fail_falls_back_entirely(self, mock_yf):
688
+ """Without requests library, falls back entirely to yfinance."""
689
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
690
+
691
+ mock_df = pd.DataFrame({
692
+ "Close": np.linspace(100, 150, 20),
693
+ "High": np.linspace(105, 155, 20),
694
+ "Low": np.linspace(95, 145, 20),
695
+ "Volume": [1_000_000] * 20,
696
+ })
697
+ mock_yf.download.return_value = mock_df
698
+ mock_ticker = MagicMock()
699
+ mock_ticker.info = {"trailingPE": 25.0}
700
+ mock_yf.Ticker.return_value = mock_ticker
701
+
702
+ results = scanner.batch_stock_metrics(["AAPL"])
703
+ assert len(results) == 1
704
+ assert scanner._stats["yf_calls"] == 1
705
+
706
+
707
+ # ---------------------------------------------------------------------------
708
+ # TestBackendStats
709
+ # ---------------------------------------------------------------------------
710
+ class TestBackendStats:
711
+ """Tests for backend_stats() tracking."""
712
+
713
+ def test_initial_stats_all_zero(self):
714
+ scanner = ETFScanner(fmp_api_key="test_key")
715
+ stats = scanner.backend_stats()
716
+ assert stats["fmp_calls"] == 0
717
+ assert stats["fmp_failures"] == 0
718
+ assert stats["yf_calls"] == 0
719
+ assert stats["yf_fallbacks"] == 0
720
+
721
+ @patch("etf_scanner._requests_lib")
722
+ def test_stats_after_fmp_success(self, mock_requests):
723
+ """FMP calls are counted after successful requests."""
724
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
725
+
726
+ mock_resp = MagicMock()
727
+ mock_resp.status_code = 200
728
+ mock_resp.json.return_value = [
729
+ {"symbol": "AAPL", "pe": 30, "price": 150,
730
+ "yearHigh": 180, "yearLow": 120},
731
+ ]
732
+ mock_requests.get.return_value = mock_resp
733
+
734
+ scanner._fmp_request("quote", "AAPL")
735
+ stats = scanner.backend_stats()
736
+ assert stats["fmp_calls"] == 1
737
+ assert stats["fmp_failures"] == 0
738
+
739
+ @patch("etf_scanner._requests_lib")
740
+ @patch("etf_scanner.yf")
741
+ def test_stats_after_yfinance_fallback(self, mock_yf, mock_requests):
742
+ """yf_calls and yf_fallbacks are counted after fallback."""
743
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
744
+
745
+ # FMP fails for everything
746
+ fail_resp = MagicMock()
747
+ fail_resp.status_code = 500
748
+ mock_requests.get.return_value = fail_resp
749
+
750
+ # yfinance works
751
+ mock_df = pd.DataFrame({
752
+ "Close": np.linspace(100, 150, 20),
753
+ "High": np.linspace(105, 155, 20),
754
+ "Low": np.linspace(95, 145, 20),
755
+ "Volume": [1_000_000] * 20,
756
+ })
757
+ mock_yf.download.return_value = mock_df
758
+ mock_ticker = MagicMock()
759
+ mock_ticker.info = {"trailingPE": 25.0}
760
+ mock_yf.Ticker.return_value = mock_ticker
761
+
762
+ scanner.batch_stock_metrics(["AAPL"])
763
+ stats = scanner.backend_stats()
764
+ assert stats["yf_calls"] == 1
765
+ assert stats["yf_fallbacks"] == 1 # FMP attempted but failed entirely
766
+ assert stats["fmp_failures"] > 0
767
+
768
+ @patch("etf_scanner._requests_lib")
769
+ @patch("etf_scanner.yf")
770
+ def test_yf_calls_and_yf_fallbacks_increment(self, mock_yf, mock_requests):
771
+ """yf_fallbacks increments when partial FMP data triggers fallback."""
772
+ scanner = ETFScanner(fmp_api_key="test_key", rate_limit_sec=0)
773
+
774
+ # FMP: AAPL succeeds, MSFT missing
775
+ quote_resp = MagicMock()
776
+ quote_resp.status_code = 200
777
+ quote_resp.json.return_value = [
778
+ {"symbol": "AAPL", "pe": 30, "price": 150,
779
+ "yearHigh": 180, "yearLow": 120},
780
+ ]
781
+ hist_resp = MagicMock()
782
+ hist_resp.status_code = 200
783
+ hist_resp.json.return_value = {
784
+ "historicalStockList": [
785
+ {"symbol": "AAPL", "historical": [
786
+ {"close": float(150 - i)} for i in range(20)
787
+ ]},
788
+ ]
789
+ }
790
+ mock_requests.get.side_effect = [
791
+ quote_resp, hist_resp,
792
+ # Per-symbol retry for MSFT fails
793
+ MagicMock(status_code=500), MagicMock(status_code=500),
794
+ ]
795
+
796
+ mock_df = pd.DataFrame({
797
+ "Close": np.linspace(300, 400, 20),
798
+ "High": np.linspace(305, 405, 20),
799
+ "Low": np.linspace(295, 395, 20),
800
+ "Volume": [1_000_000] * 20,
801
+ })
802
+ mock_yf.download.return_value = mock_df
803
+ mock_ticker = MagicMock()
804
+ mock_ticker.info = {"trailingPE": 35.0}
805
+ mock_yf.Ticker.return_value = mock_ticker
806
+
807
+ scanner.batch_stock_metrics(["AAPL", "MSFT"])
808
+ stats = scanner.backend_stats()
809
+ assert stats["yf_fallbacks"] == 1
810
+ assert stats["yf_calls"] == 1