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,898 @@
1
+ """Tests for representative_stock_selector module."""
2
+
3
+ import time
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ import pytest
7
+
8
+ from representative_stock_selector import (
9
+ RepresentativeStockSelector,
10
+ _SourceState,
11
+ _parse_market_cap,
12
+ _parse_change,
13
+ _parse_volume,
14
+ _MAX_CONSECUTIVE_FAILURES,
15
+ )
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Parse helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ class TestParseMarketCap:
23
+
24
+ def test_trillions(self):
25
+ assert _parse_market_cap("2.8T") == 2_800_000_000_000
26
+
27
+ def test_billions(self):
28
+ assert _parse_market_cap("150B") == 150_000_000_000
29
+
30
+ def test_millions(self):
31
+ assert _parse_market_cap("500M") == 500_000_000
32
+
33
+ def test_none_returns_zero(self):
34
+ assert _parse_market_cap(None) == 0
35
+
36
+ def test_dash_returns_zero(self):
37
+ assert _parse_market_cap("-") == 0
38
+
39
+ def test_numeric_passthrough(self):
40
+ assert _parse_market_cap(1_000_000) == 1_000_000
41
+
42
+ def test_float_passthrough(self):
43
+ assert _parse_market_cap(1.5e9) == 1_500_000_000
44
+
45
+
46
+ class TestParseChange:
47
+
48
+ def test_percent_string(self):
49
+ assert _parse_change("12.50%") == pytest.approx(12.50)
50
+
51
+ def test_negative_percent(self):
52
+ assert _parse_change("-3.20%") == pytest.approx(-3.20)
53
+
54
+ def test_float_fraction(self):
55
+ # finvizfinance returns 0.125 meaning 12.5%
56
+ assert _parse_change(0.125) == pytest.approx(12.5)
57
+
58
+ def test_negative_float_fraction(self):
59
+ assert _parse_change(-0.032) == pytest.approx(-3.2)
60
+
61
+ def test_already_float_large(self):
62
+ # abs > 1 means already in percent form
63
+ assert _parse_change(12.5) == pytest.approx(12.5)
64
+
65
+ def test_negative_already_float_large(self):
66
+ assert _parse_change(-5.3) == pytest.approx(-5.3)
67
+
68
+ def test_dash_returns_none(self):
69
+ assert _parse_change("-") is None
70
+
71
+ def test_none_returns_none(self):
72
+ assert _parse_change(None) is None
73
+
74
+
75
+ class TestParseVolume:
76
+
77
+ def test_comma_string(self):
78
+ assert _parse_volume("1,234,567") == 1234567
79
+
80
+ def test_int_passthrough(self):
81
+ assert _parse_volume(1234567) == 1234567
82
+
83
+ def test_float_passthrough(self):
84
+ assert _parse_volume(1234567.0) == 1234567
85
+
86
+ def test_m_suffix(self):
87
+ assert _parse_volume("1.2M") == 1200000
88
+
89
+ def test_dash_returns_none(self):
90
+ assert _parse_volume("-") is None
91
+
92
+ def test_none_returns_none(self):
93
+ assert _parse_volume(None) is None
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Composite score
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class TestCompositeScore:
101
+
102
+ def _make_selector(self):
103
+ return RepresentativeStockSelector()
104
+
105
+ def test_ranks_by_composite_not_market_cap_alone(self):
106
+ """A stock with large market_cap but low change/volume is not #1."""
107
+ sel = self._make_selector()
108
+ stocks = [
109
+ {"symbol": "BIG", "source": "finviz_public",
110
+ "market_cap": 2_000_000_000_000, "change": 1.0, "volume": 100_000,
111
+ "matched_industries": ["X"], "reasons": []},
112
+ {"symbol": "SMALL", "source": "finviz_public",
113
+ "market_cap": 10_000_000_000, "change": 20.0, "volume": 5_000_000,
114
+ "matched_industries": ["X"], "reasons": []},
115
+ ]
116
+ scored = sel._compute_composite_score(stocks, is_bearish=False)
117
+ # SMALL ranks #1: cap_rank=2 (0.4*0) but change_rank=1 (0.3*1) + vol_rank=1 (0.3*1)
118
+ # BIG ranks #2: cap_rank=1 (0.4*1) but change_rank=2 (0.3*0) + vol_rank=2 (0.3*0)
119
+ assert scored[0]["symbol"] == "SMALL"
120
+ assert scored[1]["symbol"] == "BIG"
121
+ assert scored[0]["composite_score"] > scored[1]["composite_score"]
122
+
123
+ def test_weights_are_applied(self):
124
+ """composite = 0.4 * cap_rank + 0.3 * change_rank + 0.3 * vol_rank"""
125
+ sel = self._make_selector()
126
+ stocks = [
127
+ {"symbol": "A", "source": "finviz_public",
128
+ "market_cap": 100_000_000_000, "change": 10.0, "volume": 1_000_000,
129
+ "matched_industries": [], "reasons": []},
130
+ ]
131
+ scored = sel._compute_composite_score(stocks, is_bearish=False)
132
+ # Single stock => rank 1/1 => score = 0.4*1 + 0.3*1 + 0.3*1 = 1.0
133
+ assert scored[0]["composite_score"] == pytest.approx(1.0)
134
+
135
+ def test_bearish_uses_abs_change(self):
136
+ """is_bearish=True => abs(change) ranks descending."""
137
+ sel = self._make_selector()
138
+ stocks = [
139
+ {"symbol": "DROP", "source": "finviz_public",
140
+ "market_cap": 50_000_000_000, "change": -15.0, "volume": 1_000_000,
141
+ "matched_industries": [], "reasons": []},
142
+ {"symbol": "FLAT", "source": "finviz_public",
143
+ "market_cap": 50_000_000_000, "change": -1.0, "volume": 1_000_000,
144
+ "matched_industries": [], "reasons": []},
145
+ ]
146
+ scored = sel._compute_composite_score(stocks, is_bearish=True)
147
+ # DROP has larger abs(change), should score higher
148
+ drop = next(s for s in scored if s["symbol"] == "DROP")
149
+ flat = next(s for s in scored if s["symbol"] == "FLAT")
150
+ assert drop["composite_score"] > flat["composite_score"]
151
+
152
+ def test_missing_fields_renormalize(self):
153
+ """change/volume=None => re-normalize with available metrics only."""
154
+ sel = self._make_selector()
155
+ stocks = [
156
+ {"symbol": "A", "source": "etf_holdings",
157
+ "market_cap": 100_000_000_000, "change": None, "volume": None,
158
+ "matched_industries": [], "reasons": []},
159
+ {"symbol": "B", "source": "etf_holdings",
160
+ "market_cap": 50_000_000_000, "change": None, "volume": None,
161
+ "matched_industries": [], "reasons": []},
162
+ ]
163
+ scored = sel._compute_composite_score(stocks, is_bearish=False)
164
+ # Should not crash; A has bigger cap so should rank higher
165
+ assert scored[0]["symbol"] == "A"
166
+ assert scored[0]["composite_score"] > scored[1]["composite_score"]
167
+
168
+ def test_empty_input(self):
169
+ sel = self._make_selector()
170
+ assert sel._compute_composite_score([], is_bearish=False) == []
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Merge and rank
175
+ # ---------------------------------------------------------------------------
176
+
177
+ class TestMergeAndRank:
178
+
179
+ def _make_selector(self):
180
+ return RepresentativeStockSelector()
181
+
182
+ def test_deduplicates_by_symbol(self):
183
+ sel = self._make_selector()
184
+ candidates = [
185
+ {"symbol": "NVDA", "source": "finviz_public",
186
+ "market_cap": 100, "matched_industries": ["Semi"],
187
+ "reasons": ["reason1"], "composite_score": 0.9},
188
+ {"symbol": "NVDA", "source": "finviz_public",
189
+ "market_cap": 100, "matched_industries": ["Hardware"],
190
+ "reasons": ["reason2"], "composite_score": 0.8},
191
+ ]
192
+ result = sel._merge_and_rank(candidates, max_stocks=10)
193
+ assert len(result) == 1
194
+ assert result[0]["symbol"] == "NVDA"
195
+
196
+ def test_merges_matched_industries_on_duplicate(self):
197
+ sel = self._make_selector()
198
+ candidates = [
199
+ {"symbol": "NVDA", "source": "finviz_public",
200
+ "market_cap": 100, "matched_industries": ["Semi"],
201
+ "reasons": [], "composite_score": 0.9},
202
+ {"symbol": "NVDA", "source": "finviz_public",
203
+ "market_cap": 100, "matched_industries": ["Hardware"],
204
+ "reasons": [], "composite_score": 0.8},
205
+ ]
206
+ result = sel._merge_and_rank(candidates, max_stocks=10)
207
+ assert "Semi" in result[0]["matched_industries"]
208
+ assert "Hardware" in result[0]["matched_industries"]
209
+
210
+ def test_accumulates_reasons_on_duplicate(self):
211
+ sel = self._make_selector()
212
+ candidates = [
213
+ {"symbol": "X", "source": "finviz_public",
214
+ "market_cap": 0, "matched_industries": [],
215
+ "reasons": ["r1"], "composite_score": 0.5},
216
+ {"symbol": "X", "source": "finviz_public",
217
+ "market_cap": 0, "matched_industries": [],
218
+ "reasons": ["r2"], "composite_score": 0.4},
219
+ ]
220
+ result = sel._merge_and_rank(candidates, max_stocks=10)
221
+ assert "r1" in result[0]["reasons"]
222
+ assert "r2" in result[0]["reasons"]
223
+
224
+ def test_sorts_by_composite_score_descending(self):
225
+ sel = self._make_selector()
226
+ candidates = [
227
+ {"symbol": "A", "source": "s", "market_cap": 0,
228
+ "matched_industries": [], "reasons": [], "composite_score": 0.3},
229
+ {"symbol": "B", "source": "s", "market_cap": 0,
230
+ "matched_industries": [], "reasons": [], "composite_score": 0.9},
231
+ {"symbol": "C", "source": "s", "market_cap": 0,
232
+ "matched_industries": [], "reasons": [], "composite_score": 0.6},
233
+ ]
234
+ result = sel._merge_and_rank(candidates, max_stocks=10)
235
+ assert [r["symbol"] for r in result] == ["B", "C", "A"]
236
+
237
+ def test_respects_max_stocks(self):
238
+ sel = self._make_selector()
239
+ candidates = [
240
+ {"symbol": f"S{i}", "source": "s", "market_cap": 0,
241
+ "matched_industries": [], "reasons": [], "composite_score": i * 0.1}
242
+ for i in range(20)
243
+ ]
244
+ result = sel._merge_and_rank(candidates, max_stocks=5)
245
+ assert len(result) == 5
246
+
247
+ def test_empty_input(self):
248
+ sel = self._make_selector()
249
+ assert sel._merge_and_rank([], max_stocks=10) == []
250
+
251
+ def test_duplicate_uses_max_composite_score(self):
252
+ sel = self._make_selector()
253
+ candidates = [
254
+ {"symbol": "X", "source": "s", "market_cap": 0,
255
+ "matched_industries": [], "reasons": [], "composite_score": 0.3},
256
+ {"symbol": "X", "source": "s", "market_cap": 0,
257
+ "matched_industries": [], "reasons": [], "composite_score": 0.9},
258
+ ]
259
+ result = sel._merge_and_rank(candidates, max_stocks=10)
260
+ assert result[0]["composite_score"] == 0.9
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # select_stocks fallback chain
265
+ # ---------------------------------------------------------------------------
266
+
267
+ def _mock_finviz_public_stocks(industry, limit, is_bearish):
268
+ """Return fake stocks for FINVIZ public."""
269
+ return [
270
+ {"symbol": f"{industry[:3].upper()}{i}", "source": "finviz_public",
271
+ "market_cap": (10 - i) * 1_000_000_000, "change": 5.0 + i,
272
+ "volume": 1_000_000, "matched_industries": [industry],
273
+ "reasons": [f"Top in {industry}"]}
274
+ for i in range(min(limit, 8))
275
+ ]
276
+
277
+
278
+ def _mock_finviz_elite_stocks(industry, limit, is_bearish):
279
+ """Return fake stocks for FINVIZ elite."""
280
+ return [
281
+ {"symbol": f"E{industry[:2].upper()}{i}", "source": "finviz_elite",
282
+ "market_cap": (10 - i) * 2_000_000_000, "change": 8.0 + i,
283
+ "volume": 2_000_000, "matched_industries": [industry],
284
+ "reasons": [f"Elite top in {industry}"]}
285
+ for i in range(min(limit, 8))
286
+ ]
287
+
288
+
289
+ def _mock_etf_holdings(etf, limit):
290
+ """Return fake ETF holdings."""
291
+ return [
292
+ {"symbol": f"ETF{etf}{i}", "source": "etf_holdings",
293
+ "market_cap": (5 - i) * 500_000_000, "change": None,
294
+ "volume": None, "matched_industries": [],
295
+ "reasons": [f"Held by {etf}"]}
296
+ for i in range(min(limit, 5))
297
+ ]
298
+
299
+
300
+ class TestSelectStocks:
301
+
302
+ def test_finviz_elite_priority(self):
303
+ """finviz_mode=elite + key => Elite is used."""
304
+ sel = RepresentativeStockSelector(
305
+ finviz_elite_key="test_key",
306
+ finviz_mode="elite",
307
+ )
308
+ with patch.object(sel, '_fetch_finviz_elite', side_effect=_mock_finviz_elite_stocks), \
309
+ patch.object(sel, '_fetch_finviz_public', side_effect=_mock_finviz_public_stocks), \
310
+ patch.object(sel, '_rate_limit'):
311
+ theme = {
312
+ "direction": "bullish",
313
+ "matching_industries": [{"name": "Gold"}],
314
+ "proxy_etfs": [],
315
+ "static_stocks": [],
316
+ }
317
+ result = sel.select_stocks(theme, max_stocks=5)
318
+ assert len(result) > 0
319
+ assert all(d["source"] == "finviz_elite" for d in result)
320
+
321
+ def test_finviz_mode_public_ignores_elite_key(self):
322
+ """finviz_mode=public + key => Public is used, not Elite."""
323
+ sel = RepresentativeStockSelector(
324
+ finviz_elite_key="test_key",
325
+ finviz_mode="public",
326
+ )
327
+ with patch.object(sel, '_fetch_finviz_elite', side_effect=_mock_finviz_elite_stocks) as elite_mock, \
328
+ patch.object(sel, '_fetch_finviz_public', side_effect=_mock_finviz_public_stocks), \
329
+ patch.object(sel, '_rate_limit'):
330
+ theme = {
331
+ "direction": "bullish",
332
+ "matching_industries": [{"name": "Gold"}],
333
+ "proxy_etfs": [],
334
+ "static_stocks": [],
335
+ }
336
+ result = sel.select_stocks(theme, max_stocks=5)
337
+ elite_mock.assert_not_called()
338
+ assert all(d["source"] == "finviz_public" for d in result)
339
+
340
+ def test_finviz_public_fallback(self):
341
+ """No elite key => public screener."""
342
+ sel = RepresentativeStockSelector()
343
+ with patch.object(sel, '_fetch_finviz_public', side_effect=_mock_finviz_public_stocks), \
344
+ patch.object(sel, '_rate_limit'):
345
+ theme = {
346
+ "direction": "bullish",
347
+ "matching_industries": [{"name": "Gold"}],
348
+ "proxy_etfs": [],
349
+ "static_stocks": [],
350
+ }
351
+ result = sel.select_stocks(theme, max_stocks=5)
352
+ assert len(result) > 0
353
+ assert all(d["source"] == "finviz_public" for d in result)
354
+
355
+ def test_etf_holdings_supplement(self):
356
+ """FINVIZ returns few stocks => ETF holdings supplement."""
357
+ sel = RepresentativeStockSelector(fmp_api_key="test_key")
358
+
359
+ def empty_finviz(industry, limit, is_bearish):
360
+ return []
361
+
362
+ with patch.object(sel, '_fetch_finviz_public', side_effect=empty_finviz), \
363
+ patch.object(sel, '_fetch_etf_holdings', side_effect=_mock_etf_holdings), \
364
+ patch.object(sel, '_rate_limit'):
365
+ theme = {
366
+ "direction": "bullish",
367
+ "matching_industries": [{"name": "Gold"}],
368
+ "proxy_etfs": ["GDX", "GLD"],
369
+ "static_stocks": [],
370
+ }
371
+ result = sel.select_stocks(theme, max_stocks=5)
372
+ assert len(result) > 0
373
+ assert any(d["source"] == "etf_holdings" for d in result)
374
+
375
+ def test_static_final_fallback(self):
376
+ """All sources fail => static_stocks."""
377
+ sel = RepresentativeStockSelector()
378
+
379
+ def fail_finviz(industry, limit, is_bearish):
380
+ return []
381
+
382
+ with patch.object(sel, '_fetch_finviz_public', side_effect=fail_finviz), \
383
+ patch.object(sel, '_rate_limit'):
384
+ theme = {
385
+ "direction": "bullish",
386
+ "matching_industries": [{"name": "Gold"}],
387
+ "proxy_etfs": [],
388
+ "static_stocks": ["NEM", "GOLD", "AEM"],
389
+ }
390
+ result = sel.select_stocks(theme, max_stocks=5)
391
+ assert len(result) == 3
392
+ assert all(d["source"] == "static" for d in result)
393
+ assert [d["symbol"] for d in result] == ["NEM", "GOLD", "AEM"]
394
+
395
+ def test_vertical_theme_gets_stocks(self):
396
+ """Vertical theme (static_stocks=[]) gets stocks from FINVIZ."""
397
+ sel = RepresentativeStockSelector()
398
+ with patch.object(sel, '_fetch_finviz_public', side_effect=_mock_finviz_public_stocks), \
399
+ patch.object(sel, '_rate_limit'):
400
+ theme = {
401
+ "direction": "bullish",
402
+ "matching_industries": [
403
+ {"name": "Gold"},
404
+ {"name": "Silver"},
405
+ {"name": "Copper"},
406
+ ],
407
+ "proxy_etfs": [],
408
+ "static_stocks": [],
409
+ }
410
+ result = sel.select_stocks(theme, max_stocks=10)
411
+ assert len(result) > 0
412
+
413
+ def test_bearish_theme_uses_month_down_filter(self):
414
+ """Bearish theme passes is_bearish=True to fetch methods."""
415
+ sel = RepresentativeStockSelector()
416
+ calls = []
417
+
418
+ def track_finviz(industry, limit, is_bearish):
419
+ calls.append(is_bearish)
420
+ return _mock_finviz_public_stocks(industry, limit, is_bearish)
421
+
422
+ with patch.object(sel, '_fetch_finviz_public', side_effect=track_finviz), \
423
+ patch.object(sel, '_rate_limit'):
424
+ theme = {
425
+ "direction": "bearish",
426
+ "matching_industries": [{"name": "Retail"}],
427
+ "proxy_etfs": [],
428
+ "static_stocks": [],
429
+ }
430
+ sel.select_stocks(theme, max_stocks=5)
431
+ assert all(c is True for c in calls)
432
+
433
+ def test_bearish_composite_ranks_by_abs_change(self):
434
+ """Bearish theme: abs(change) ranks stocks by drop magnitude."""
435
+ sel = RepresentativeStockSelector()
436
+
437
+ def bearish_stocks(industry, limit, is_bearish):
438
+ return [
439
+ {"symbol": "BIG_DROP", "source": "finviz_public",
440
+ "market_cap": 10_000_000_000, "change": -20.0, "volume": 1_000_000,
441
+ "matched_industries": [industry], "reasons": []},
442
+ {"symbol": "SMALL_DROP", "source": "finviz_public",
443
+ "market_cap": 10_000_000_000, "change": -2.0, "volume": 1_000_000,
444
+ "matched_industries": [industry], "reasons": []},
445
+ ]
446
+
447
+ with patch.object(sel, '_fetch_finviz_public', side_effect=bearish_stocks), \
448
+ patch.object(sel, '_rate_limit'):
449
+ theme = {
450
+ "direction": "bearish",
451
+ "matching_industries": [{"name": "Retail"}],
452
+ "proxy_etfs": [],
453
+ "static_stocks": [],
454
+ }
455
+ result = sel.select_stocks(theme, max_stocks=5)
456
+ assert result[0]["symbol"] == "BIG_DROP"
457
+
458
+ def test_max_per_industry_quota(self):
459
+ """Each industry contributes at most max_per_industry in 1st pass.
460
+
461
+ With max_per_industry=2 and max_stocks=4 (== 2 industries * 2),
462
+ no 2nd pass occurs, so exactly 4 stocks are returned with each
463
+ industry providing exactly 2.
464
+ """
465
+ sel = RepresentativeStockSelector(max_per_industry=2)
466
+
467
+ def distinct_stocks(industry, limit, is_bearish):
468
+ """Return stocks with industry-unique prefixes."""
469
+ prefix = industry[:3].upper()
470
+ return [
471
+ {"symbol": f"{prefix}{i}", "source": "finviz_public",
472
+ "market_cap": (10 - i) * 1_000_000_000, "change": 5.0,
473
+ "volume": 1_000_000, "matched_industries": [industry],
474
+ "reasons": []}
475
+ for i in range(8)
476
+ ]
477
+
478
+ with patch.object(sel, '_fetch_finviz_public', side_effect=distinct_stocks), \
479
+ patch.object(sel, '_rate_limit'):
480
+ theme = {
481
+ "direction": "bullish",
482
+ "matching_industries": [
483
+ {"name": "Gold"},
484
+ {"name": "Silver"},
485
+ ],
486
+ "proxy_etfs": [],
487
+ "static_stocks": [],
488
+ }
489
+ result = sel.select_stocks(theme, max_stocks=4)
490
+ assert len(result) == 4
491
+ # Verify each industry contributes exactly 2 (no more)
492
+ gold_count = sum(
493
+ 1 for r in result if "Gold" in r.get("matched_industries", [])
494
+ )
495
+ silver_count = sum(
496
+ 1 for r in result if "Silver" in r.get("matched_industries", [])
497
+ )
498
+ assert gold_count == 2
499
+ assert silver_count == 2
500
+
501
+ def test_single_industry_theme_2nd_pass_fills(self):
502
+ """Single industry theme: 2nd pass fills up to max_stocks."""
503
+ sel = RepresentativeStockSelector(max_per_industry=4)
504
+
505
+ def many_stocks(industry, limit, is_bearish):
506
+ return [
507
+ {"symbol": f"S{i}", "source": "finviz_public",
508
+ "market_cap": (20 - i) * 1_000_000_000, "change": 5.0,
509
+ "volume": 1_000_000, "matched_industries": [industry],
510
+ "reasons": []}
511
+ for i in range(min(limit, 15))
512
+ ]
513
+
514
+ with patch.object(sel, '_fetch_finviz_public', side_effect=many_stocks), \
515
+ patch.object(sel, '_rate_limit'):
516
+ theme = {
517
+ "direction": "bullish",
518
+ "matching_industries": [{"name": "Gold"}],
519
+ "proxy_etfs": [],
520
+ "static_stocks": [],
521
+ }
522
+ result = sel.select_stocks(theme, max_stocks=10)
523
+ assert len(result) == 10
524
+
525
+ def test_fetch_limit_at_least_max_stocks(self):
526
+ """fetch_limit = max(max_stocks, max_per_industry*2)."""
527
+ sel = RepresentativeStockSelector(max_per_industry=4)
528
+ fetch_limits = []
529
+
530
+ def track_limit(industry, limit, is_bearish):
531
+ fetch_limits.append(limit)
532
+ return []
533
+
534
+ with patch.object(sel, '_fetch_finviz_public', side_effect=track_limit), \
535
+ patch.object(sel, '_rate_limit'):
536
+ theme = {
537
+ "direction": "bullish",
538
+ "matching_industries": [{"name": "Gold"}],
539
+ "proxy_etfs": [],
540
+ "static_stocks": ["A"],
541
+ }
542
+ sel.select_stocks(theme, max_stocks=10)
543
+ assert all(fl >= 10 for fl in fetch_limits)
544
+
545
+ def test_cache_prevents_duplicate_queries(self):
546
+ """Same (industry, direction) only queried once."""
547
+ sel = RepresentativeStockSelector()
548
+ call_count = 0
549
+
550
+ def count_calls(industry, limit, is_bearish):
551
+ nonlocal call_count
552
+ call_count += 1
553
+ return _mock_finviz_public_stocks(industry, limit, is_bearish)
554
+
555
+ with patch.object(sel, '_fetch_finviz_public', side_effect=count_calls), \
556
+ patch.object(sel, '_rate_limit'):
557
+ theme1 = {
558
+ "direction": "bullish",
559
+ "matching_industries": [{"name": "Gold"}],
560
+ "proxy_etfs": [],
561
+ "static_stocks": [],
562
+ }
563
+ theme2 = {
564
+ "direction": "bullish",
565
+ "matching_industries": [{"name": "Gold"}],
566
+ "proxy_etfs": [],
567
+ "static_stocks": [],
568
+ }
569
+ sel.select_stocks(theme1, max_stocks=5)
570
+ sel.select_stocks(theme2, max_stocks=5)
571
+ assert call_count == 1 # 2nd call uses cache
572
+
573
+ def test_selector_none_uses_static(self):
574
+ """When selector is None, static_stocks are used directly."""
575
+ # This tests the _get_representative_stocks wrapper in theme_detector
576
+ # but we verify the static fallback path in select_stocks
577
+ sel = RepresentativeStockSelector()
578
+
579
+ def fail_finviz(industry, limit, is_bearish):
580
+ return []
581
+
582
+ with patch.object(sel, '_fetch_finviz_public', side_effect=fail_finviz), \
583
+ patch.object(sel, '_rate_limit'):
584
+ theme = {
585
+ "direction": "bullish",
586
+ "matching_industries": [],
587
+ "proxy_etfs": [],
588
+ "static_stocks": ["A", "B", "C"],
589
+ }
590
+ result = sel.select_stocks(theme, max_stocks=5)
591
+ assert [d["symbol"] for d in result] == ["A", "B", "C"]
592
+
593
+
594
+ # ---------------------------------------------------------------------------
595
+ # Circuit breaker
596
+ # ---------------------------------------------------------------------------
597
+
598
+ class TestCircuitBreaker:
599
+
600
+ def test_consecutive_failures_disables_elite_only(self):
601
+ """Elite 3 consecutive failures => Elite disabled, Public still active."""
602
+ sel = RepresentativeStockSelector(
603
+ finviz_elite_key="key", finviz_mode="elite",
604
+ )
605
+ for _ in range(_MAX_CONSECUTIVE_FAILURES):
606
+ sel._record_failure("elite")
607
+ assert sel._source_states["elite"].disabled is True
608
+ assert sel._source_states["public"].disabled is False
609
+
610
+ def test_mixed_source_failures_independent(self):
611
+ """Elite fail -> Public success -> FMP fail: independent counters."""
612
+ sel = RepresentativeStockSelector(
613
+ finviz_elite_key="key", fmp_api_key="key", finviz_mode="elite",
614
+ )
615
+ sel._record_failure("elite")
616
+ sel._record_success("public")
617
+ sel._record_failure("fmp")
618
+
619
+ assert sel._source_states["elite"].consecutive_failures == 1
620
+ assert sel._source_states["public"].consecutive_failures == 0
621
+ assert sel._source_states["fmp"].consecutive_failures == 1
622
+
623
+ def test_success_resets_own_source_count(self):
624
+ """Success resets only that source's consecutive counter."""
625
+ sel = RepresentativeStockSelector(
626
+ finviz_elite_key="key", finviz_mode="elite",
627
+ )
628
+ sel._record_failure("elite")
629
+ sel._record_failure("elite")
630
+ assert sel._source_states["elite"].consecutive_failures == 2
631
+ sel._record_success("elite")
632
+ assert sel._source_states["elite"].consecutive_failures == 0
633
+ # total_failures stays
634
+ assert sel._source_states["elite"].total_failures == 2
635
+
636
+ def test_status_degraded_when_one_active_source_disabled(self):
637
+ """One active source disabled => status='degraded'."""
638
+ sel = RepresentativeStockSelector(
639
+ finviz_elite_key="key", fmp_api_key="key", finviz_mode="elite",
640
+ )
641
+ for _ in range(_MAX_CONSECUTIVE_FAILURES):
642
+ sel._record_failure("elite")
643
+ assert sel.status == "degraded"
644
+
645
+ def test_status_circuit_broken_when_all_active_disabled(self):
646
+ """All active sources disabled => status='circuit_broken'."""
647
+ sel = RepresentativeStockSelector(
648
+ finviz_elite_key="key", fmp_api_key="key", finviz_mode="elite",
649
+ )
650
+ for source in ["elite", "public", "fmp"]:
651
+ for _ in range(_MAX_CONSECUTIVE_FAILURES):
652
+ sel._record_failure(source)
653
+ assert sel.status == "circuit_broken"
654
+
655
+ def test_status_active_ignores_elite_when_mode_public(self):
656
+ """finviz_mode=public => elite not in active sources."""
657
+ sel = RepresentativeStockSelector(
658
+ finviz_elite_key="key", finviz_mode="public",
659
+ )
660
+ for _ in range(_MAX_CONSECUTIVE_FAILURES):
661
+ sel._record_failure("elite")
662
+ # elite is disabled but not in active sources
663
+ assert sel.status == "active"
664
+
665
+ def test_status_active_ignores_fmp_when_no_key(self):
666
+ """fmp_api_key=None => fmp not in active sources."""
667
+ sel = RepresentativeStockSelector()
668
+ for _ in range(_MAX_CONSECUTIVE_FAILURES):
669
+ sel._record_failure("fmp")
670
+ assert sel.status == "active"
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # FINVIZ Public fetch
675
+ # ---------------------------------------------------------------------------
676
+
677
+ class TestFetchFinvizPublic:
678
+
679
+ def test_returns_stock_dicts_with_schema(self):
680
+ """Each element has required keys."""
681
+ sel = RepresentativeStockSelector()
682
+ pd = pytest.importorskip("pandas")
683
+ mock_df = pd.DataFrame({
684
+ "Ticker": ["NVDA", "AMD"],
685
+ "Company": ["NVIDIA", "AMD Inc"],
686
+ "Sector": ["Technology", "Technology"],
687
+ "Industry": ["Semiconductors", "Semiconductors"],
688
+ "Country": ["USA", "USA"],
689
+ "Market Cap": [2_800_000_000_000, 200_000_000_000],
690
+ "P/E": [60.0, 40.0],
691
+ "Price": [800.0, 150.0],
692
+ "Change": [0.05, 0.03],
693
+ "Volume": [50_000_000, 30_000_000],
694
+ })
695
+ with patch("representative_stock_selector.Overview") as MockOverview:
696
+ mock_instance = MockOverview.return_value
697
+ mock_instance.screener_view.return_value = mock_df
698
+ with patch.object(sel, '_rate_limit'):
699
+ result = sel._fetch_finviz_public("Semiconductors", limit=10, is_bearish=False)
700
+ assert len(result) == 2
701
+ for stock in result:
702
+ assert "symbol" in stock
703
+ assert "source" in stock
704
+ assert stock["source"] == "finviz_public"
705
+ assert "market_cap" in stock
706
+ assert "matched_industries" in stock
707
+
708
+ def test_uses_correct_filter_option_names(self):
709
+ """filter_dict uses exact finvizfinance option names."""
710
+ sel = RepresentativeStockSelector(min_cap="small")
711
+ pd = pytest.importorskip("pandas")
712
+ mock_df = pd.DataFrame({
713
+ "Ticker": ["X"],
714
+ "Market Cap": [1_000_000_000],
715
+ "Change": [0.01],
716
+ "Volume": [100_000],
717
+ })
718
+ with patch("representative_stock_selector.Overview") as MockOverview:
719
+ mock_instance = MockOverview.return_value
720
+ mock_instance.screener_view.return_value = mock_df
721
+ with patch.object(sel, '_rate_limit'):
722
+ sel._fetch_finviz_public("Gold", limit=10, is_bearish=False)
723
+ # Verify set_filter was called with correct filter names
724
+ call_args = mock_instance.set_filter.call_args
725
+ filters = call_args[1].get("filters_dict", {}) if call_args[1] else call_args[0][0] if call_args[0] else {}
726
+ assert filters.get("Market Cap.") == "+Small (over $300mln)"
727
+ assert filters.get("Average Volume") == "Over 100K"
728
+ assert filters.get("Price") == "Over $10"
729
+ assert filters.get("Performance 2") == "Month Up"
730
+
731
+ def test_bearish_uses_month_down_filter(self):
732
+ """Bearish => Performance 2: Month Down."""
733
+ sel = RepresentativeStockSelector()
734
+ pd = pytest.importorskip("pandas")
735
+ mock_df = pd.DataFrame({
736
+ "Ticker": ["X"],
737
+ "Market Cap": [1_000_000_000],
738
+ "Change": [-0.05],
739
+ "Volume": [100_000],
740
+ })
741
+ with patch("representative_stock_selector.Overview") as MockOverview:
742
+ mock_instance = MockOverview.return_value
743
+ mock_instance.screener_view.return_value = mock_df
744
+ with patch.object(sel, '_rate_limit'):
745
+ sel._fetch_finviz_public("Retail", limit=10, is_bearish=True)
746
+ call_args = mock_instance.set_filter.call_args
747
+ filters = call_args[1].get("filters_dict", {}) if call_args[1] else call_args[0][0] if call_args[0] else {}
748
+ assert filters.get("Performance 2") == "Month Down"
749
+
750
+ def test_rate_limiting(self):
751
+ """Consecutive calls have rate_limit_sec delay."""
752
+ sel = RepresentativeStockSelector(rate_limit_sec=0.1)
753
+ pd = pytest.importorskip("pandas")
754
+ mock_df = pd.DataFrame({
755
+ "Ticker": ["X"],
756
+ "Market Cap": [1_000_000_000],
757
+ "Change": [0.01],
758
+ "Volume": [100_000],
759
+ })
760
+ with patch("representative_stock_selector.Overview") as MockOverview:
761
+ mock_instance = MockOverview.return_value
762
+ mock_instance.screener_view.return_value = mock_df
763
+ start = time.time()
764
+ sel._fetch_finviz_public("Gold", limit=5, is_bearish=False)
765
+ sel._fetch_finviz_public("Silver", limit=5, is_bearish=False)
766
+ elapsed = time.time() - start
767
+ assert elapsed >= 0.1 # At least one rate limit pause
768
+
769
+ def test_failure_returns_empty_and_records(self):
770
+ """Exception => empty list + failure recorded."""
771
+ sel = RepresentativeStockSelector()
772
+ with patch("representative_stock_selector.Overview") as MockOverview:
773
+ mock_instance = MockOverview.return_value
774
+ mock_instance.screener_view.side_effect = Exception("network error")
775
+ with patch.object(sel, '_rate_limit'):
776
+ result = sel._fetch_finviz_public("Gold", limit=10, is_bearish=False)
777
+ assert result == []
778
+ assert sel._source_states["public"].consecutive_failures == 1
779
+
780
+
781
+ # ---------------------------------------------------------------------------
782
+ # FINVIZ Elite fetch
783
+ # ---------------------------------------------------------------------------
784
+
785
+ class TestFetchFinvizElite:
786
+
787
+ def test_csv_parsing(self):
788
+ """CSV response is correctly parsed."""
789
+ sel = RepresentativeStockSelector(
790
+ finviz_elite_key="test_key", finviz_mode="elite",
791
+ )
792
+ csv_content = (
793
+ 'No.,Ticker,Company,Sector,Industry,Country,Market Cap,P/E,Price,Change,Volume\n'
794
+ '1,NEM,Newmont,Basic Materials,Gold,USA,50.5B,20.5,45.30,5.20%,"1,234,567"\n'
795
+ '2,GOLD,Barrick Gold,Basic Materials,Gold,Canada,30.2B,15.3,18.50,3.10%,"2,345,678"\n'
796
+ )
797
+ mock_response = MagicMock()
798
+ mock_response.status_code = 200
799
+ mock_response.text = csv_content
800
+ with patch("representative_stock_selector.requests.get", return_value=mock_response), \
801
+ patch.object(sel, '_rate_limit'):
802
+ result = sel._fetch_finviz_elite("Gold", limit=10, is_bearish=False)
803
+ assert len(result) == 2
804
+ assert result[0]["symbol"] == "NEM"
805
+ assert result[0]["source"] == "finviz_elite"
806
+
807
+ @pytest.mark.parametrize("min_cap,expected_code", [
808
+ ("micro", "cap_microover"),
809
+ ("small", "cap_smallover"),
810
+ ("mid", "cap_midover"),
811
+ ])
812
+ def test_filter_string_format(self, min_cap, expected_code):
813
+ """Filter string contains correct cap code."""
814
+ sel = RepresentativeStockSelector(
815
+ finviz_elite_key="test_key", finviz_mode="elite",
816
+ min_cap=min_cap,
817
+ )
818
+ mock_response = MagicMock()
819
+ mock_response.status_code = 200
820
+ mock_response.text = "No.,Ticker,Company\n"
821
+ with patch("representative_stock_selector.requests.get", return_value=mock_response) as mock_get, \
822
+ patch.object(sel, '_rate_limit'):
823
+ sel._fetch_finviz_elite("Gold", limit=10, is_bearish=False)
824
+ url = mock_get.call_args[0][0]
825
+ assert expected_code in url
826
+
827
+ def test_bearish_filter_uses_4wdown(self):
828
+ """is_bearish=True => ta_perf2_4wdown in URL."""
829
+ sel = RepresentativeStockSelector(
830
+ finviz_elite_key="test_key", finviz_mode="elite",
831
+ )
832
+ mock_response = MagicMock()
833
+ mock_response.status_code = 200
834
+ mock_response.text = "No.,Ticker,Company\n"
835
+ with patch("representative_stock_selector.requests.get", return_value=mock_response) as mock_get, \
836
+ patch.object(sel, '_rate_limit'):
837
+ sel._fetch_finviz_elite("Gold", limit=10, is_bearish=True)
838
+ url = mock_get.call_args[0][0]
839
+ assert "ta_perf2_4wdown" in url
840
+
841
+ def test_auth_failure_returns_empty(self):
842
+ """401/403 => empty list."""
843
+ sel = RepresentativeStockSelector(
844
+ finviz_elite_key="bad_key", finviz_mode="elite",
845
+ )
846
+ mock_response = MagicMock()
847
+ mock_response.status_code = 403
848
+ mock_response.text = "Forbidden"
849
+ with patch("representative_stock_selector.requests.get", return_value=mock_response), \
850
+ patch.object(sel, '_rate_limit'):
851
+ result = sel._fetch_finviz_elite("Gold", limit=10, is_bearish=False)
852
+ assert result == []
853
+ assert sel._source_states["elite"].consecutive_failures == 1
854
+
855
+
856
+ # ---------------------------------------------------------------------------
857
+ # Properties
858
+ # ---------------------------------------------------------------------------
859
+
860
+ class TestProperties:
861
+
862
+ def test_query_count(self):
863
+ sel = RepresentativeStockSelector()
864
+ sel._source_states["public"].total_queries = 5
865
+ sel._source_states["fmp"].total_queries = 2
866
+ assert sel.query_count == 7
867
+
868
+ def test_failure_count(self):
869
+ sel = RepresentativeStockSelector()
870
+ sel._source_states["public"].total_failures = 3
871
+ sel._source_states["elite"].total_failures = 1
872
+ assert sel.failure_count == 4
873
+
874
+ def test_active_sources_public_mode(self):
875
+ sel = RepresentativeStockSelector(finviz_mode="public")
876
+ assert sel._active_sources == ["public"]
877
+
878
+ def test_active_sources_public_mode_with_fmp(self):
879
+ sel = RepresentativeStockSelector(finviz_mode="public", fmp_api_key="key")
880
+ assert sel._active_sources == ["public", "fmp"]
881
+
882
+ def test_active_sources_elite_mode(self):
883
+ sel = RepresentativeStockSelector(
884
+ finviz_elite_key="key", finviz_mode="elite", fmp_api_key="key",
885
+ )
886
+ assert sel._active_sources == ["elite", "public", "fmp"]
887
+
888
+ def test_active_sources_elite_mode_no_key(self):
889
+ """elite mode but no key => elite not in active sources."""
890
+ sel = RepresentativeStockSelector(finviz_mode="elite")
891
+ assert "elite" not in sel._active_sources
892
+
893
+ def test_source_states_property(self):
894
+ sel = RepresentativeStockSelector()
895
+ states = sel.source_states
896
+ assert "elite" in states
897
+ assert "public" in states
898
+ assert "fmp" in states