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.
- package/.claude/skills/README.md +80 -0
- package/.claude/skills/backtest-expert/SKILL.md +206 -0
- package/.claude/skills/backtest-expert/references/failed_tests.md +236 -0
- package/.claude/skills/backtest-expert/references/methodology.md +227 -0
- package/.claude/skills/breadth-chart-analyst/SKILL.md +583 -0
- package/.claude/skills/breadth-chart-analyst/assets/SP500_Breadth_Index_200MA_8MA.jpeg +0 -0
- package/.claude/skills/breadth-chart-analyst/assets/US_Stock_Market_Uptrend_Ratio.jpeg +0 -0
- package/.claude/skills/breadth-chart-analyst/assets/breadth_analysis_template.md +558 -0
- package/.claude/skills/breadth-chart-analyst/references/breadth_chart_methodology.md +590 -0
- package/.claude/skills/canslim-screener/SKILL.md +599 -0
- package/.claude/skills/canslim-screener/references/canslim_methodology.md +606 -0
- package/.claude/skills/canslim-screener/references/fmp_api_endpoints.md +707 -0
- package/.claude/skills/canslim-screener/references/interpretation_guide.md +516 -0
- package/.claude/skills/canslim-screener/references/scoring_system.md +597 -0
- package/.claude/skills/canslim-screener/scripts/calculators/earnings_calculator.py +343 -0
- package/.claude/skills/canslim-screener/scripts/calculators/growth_calculator.py +334 -0
- package/.claude/skills/canslim-screener/scripts/calculators/institutional_calculator.py +347 -0
- package/.claude/skills/canslim-screener/scripts/calculators/leadership_calculator.py +380 -0
- package/.claude/skills/canslim-screener/scripts/calculators/market_calculator.py +244 -0
- package/.claude/skills/canslim-screener/scripts/calculators/new_highs_calculator.py +194 -0
- package/.claude/skills/canslim-screener/scripts/calculators/supply_demand_calculator.py +221 -0
- package/.claude/skills/canslim-screener/scripts/finviz_stock_client.py +227 -0
- package/.claude/skills/canslim-screener/scripts/fmp_client.py +393 -0
- package/.claude/skills/canslim-screener/scripts/report_generator.py +405 -0
- package/.claude/skills/canslim-screener/scripts/scorer.py +625 -0
- package/.claude/skills/canslim-screener/scripts/screen_canslim.py +361 -0
- package/.claude/skills/canslim-screener/scripts/test_institutional_endpoint.py +109 -0
- package/.claude/skills/chart/SKILL.md +20 -0
- package/.claude/skills/dividend-growth-pullback-screener/SKILL.md +322 -0
- package/.claude/skills/dividend-growth-pullback-screener/references/dividend_growth_compounding.md +400 -0
- package/.claude/skills/dividend-growth-pullback-screener/references/fmp_api_guide.md +642 -0
- package/.claude/skills/dividend-growth-pullback-screener/references/rsi_oversold_strategy.md +333 -0
- package/.claude/skills/dividend-growth-pullback-screener/scripts/screen_dividend_growth_rsi.py +1155 -0
- package/.claude/skills/earnings-calendar/SKILL.md +721 -0
- package/.claude/skills/earnings-calendar/assets/earnings_report_template.md +102 -0
- package/.claude/skills/earnings-calendar/references/fmp_api_guide.md +590 -0
- package/.claude/skills/earnings-calendar/scripts/fetch_earnings_fmp.py +443 -0
- package/.claude/skills/earnings-calendar/scripts/generate_report.py +366 -0
- package/.claude/skills/economic-calendar-fetcher/SKILL.md +365 -0
- package/.claude/skills/economic-calendar-fetcher/references/fmp_api_documentation.md +345 -0
- package/.claude/skills/economic-calendar-fetcher/scripts/get_economic_calendar.py +267 -0
- package/.claude/skills/ftd-detector/SKILL.md +147 -0
- package/.claude/skills/ftd-detector/references/ftd_methodology.md +188 -0
- package/.claude/skills/ftd-detector/references/post_ftd_guide.md +185 -0
- package/.claude/skills/ftd-detector/scripts/fmp_client.py +158 -0
- package/.claude/skills/ftd-detector/scripts/ftd_detector.py +280 -0
- package/.claude/skills/ftd-detector/scripts/post_ftd_monitor.py +404 -0
- package/.claude/skills/ftd-detector/scripts/rally_tracker.py +508 -0
- package/.claude/skills/ftd-detector/scripts/report_generator.py +341 -0
- package/.claude/skills/ftd-detector/scripts/tests/conftest.py +9 -0
- package/.claude/skills/ftd-detector/scripts/tests/helpers.py +107 -0
- package/.claude/skills/ftd-detector/scripts/tests/test_post_ftd_monitor.py +311 -0
- package/.claude/skills/ftd-detector/scripts/tests/test_rally_tracker.py +302 -0
- package/.claude/skills/institutional-flow-tracker/README.md +362 -0
- package/.claude/skills/institutional-flow-tracker/SKILL.md +357 -0
- package/.claude/skills/institutional-flow-tracker/references/13f_filings_guide.md +383 -0
- package/.claude/skills/institutional-flow-tracker/references/institutional_investor_types.md +580 -0
- package/.claude/skills/institutional-flow-tracker/references/interpretation_framework.md +573 -0
- package/.claude/skills/institutional-flow-tracker/scripts/analyze_single_stock.py +457 -0
- package/.claude/skills/institutional-flow-tracker/scripts/track_institution_portfolio.py +108 -0
- package/.claude/skills/institutional-flow-tracker/scripts/track_institutional_flow.py +450 -0
- package/.claude/skills/macro-regime-detector/SKILL.md +86 -0
- package/.claude/skills/macro-regime-detector/references/historical_regimes.md +124 -0
- package/.claude/skills/macro-regime-detector/references/indicator_interpretation_guide.md +144 -0
- package/.claude/skills/macro-regime-detector/references/regime_detection_methodology.md +138 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/__init__.py +1 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/concentration_calculator.py +165 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/credit_conditions_calculator.py +124 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/equity_bond_calculator.py +198 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/sector_rotation_calculator.py +123 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/size_factor_calculator.py +131 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/utils.py +347 -0
- package/.claude/skills/macro-regime-detector/scripts/calculators/yield_curve_calculator.py +279 -0
- package/.claude/skills/macro-regime-detector/scripts/fmp_client.py +134 -0
- package/.claude/skills/macro-regime-detector/scripts/macro_regime_detector.py +278 -0
- package/.claude/skills/macro-regime-detector/scripts/report_generator.py +327 -0
- package/.claude/skills/macro-regime-detector/scripts/scorer.py +574 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/conftest.py +9 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_concentration.py +78 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_credit_conditions.py +59 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_equity_bond.py +74 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_helpers.py +90 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_scorer.py +439 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_sector_rotation.py +78 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_size_factor.py +59 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_utils.py +126 -0
- package/.claude/skills/macro-regime-detector/scripts/tests/test_yield_curve.py +64 -0
- package/.claude/skills/market-breadth-analyzer/SKILL.md +121 -0
- package/.claude/skills/market-breadth-analyzer/references/breadth_analysis_methodology.md +168 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/__init__.py +1 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/bearish_signal_calculator.py +150 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/cycle_calculator.py +168 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/divergence_calculator.py +119 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/historical_context_calculator.py +120 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/ma_crossover_calculator.py +115 -0
- package/.claude/skills/market-breadth-analyzer/scripts/calculators/trend_level_calculator.py +103 -0
- package/.claude/skills/market-breadth-analyzer/scripts/csv_client.py +225 -0
- package/.claude/skills/market-breadth-analyzer/scripts/market_breadth_analyzer.py +307 -0
- package/.claude/skills/market-breadth-analyzer/scripts/report_generator.py +330 -0
- package/.claude/skills/market-breadth-analyzer/scripts/scorer.py +271 -0
- package/.claude/skills/market-environment-analysis/SKILL.md +139 -0
- package/.claude/skills/market-environment-analysis/references/analysis_patterns.md +124 -0
- package/.claude/skills/market-environment-analysis/references/indicators.md +99 -0
- package/.claude/skills/market-environment-analysis/scripts/market_utils.py +127 -0
- package/.claude/skills/market-news-analyst/SKILL.md +714 -0
- package/.claude/skills/market-news-analyst/references/corporate_news_impact.md +446 -0
- package/.claude/skills/market-news-analyst/references/geopolitical_commodity_correlations.md +499 -0
- package/.claude/skills/market-news-analyst/references/market_event_patterns.md +393 -0
- package/.claude/skills/market-news-analyst/references/trusted_news_sources.md +510 -0
- package/.claude/skills/market-top-detector/SKILL.md +159 -0
- package/.claude/skills/market-top-detector/references/distribution_day_guide.md +100 -0
- package/.claude/skills/market-top-detector/references/historical_tops.md +142 -0
- package/.claude/skills/market-top-detector/references/market_top_methodology.md +167 -0
- package/.claude/skills/market-top-detector/scripts/calculators/__init__.py +17 -0
- package/.claude/skills/market-top-detector/scripts/calculators/breadth_calculator.py +116 -0
- package/.claude/skills/market-top-detector/scripts/calculators/defensive_rotation_calculator.py +127 -0
- package/.claude/skills/market-top-detector/scripts/calculators/distribution_day_calculator.py +161 -0
- package/.claude/skills/market-top-detector/scripts/calculators/index_technical_calculator.py +254 -0
- package/.claude/skills/market-top-detector/scripts/calculators/leading_stock_calculator.py +198 -0
- package/.claude/skills/market-top-detector/scripts/calculators/sentiment_calculator.py +213 -0
- package/.claude/skills/market-top-detector/scripts/fmp_client.py +158 -0
- package/.claude/skills/market-top-detector/scripts/market_top_detector.py +349 -0
- package/.claude/skills/market-top-detector/scripts/report_generator.py +314 -0
- package/.claude/skills/market-top-detector/scripts/scorer.py +473 -0
- package/.claude/skills/market-top-detector/scripts/tests/conftest.py +9 -0
- package/.claude/skills/market-top-detector/scripts/tests/helpers.py +49 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_breadth.py +62 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_defensive_rotation.py +56 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_distribution_day.py +92 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_index_technical.py +73 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_leading_stock.py +57 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_scorer.py +180 -0
- package/.claude/skills/market-top-detector/scripts/tests/test_sentiment.py +64 -0
- package/.claude/skills/options-strategy-advisor/README.md +469 -0
- package/.claude/skills/options-strategy-advisor/SKILL.md +959 -0
- package/.claude/skills/options-strategy-advisor/scripts/black_scholes.py +495 -0
- package/.claude/skills/pair-trade-screener/README.md +389 -0
- package/.claude/skills/pair-trade-screener/SKILL.md +622 -0
- package/.claude/skills/pair-trade-screener/references/cointegration_guide.md +745 -0
- package/.claude/skills/pair-trade-screener/references/methodology.md +853 -0
- package/.claude/skills/pair-trade-screener/scripts/analyze_spread.py +394 -0
- package/.claude/skills/pair-trade-screener/scripts/find_pairs.py +535 -0
- package/.claude/skills/portfolio-manager/README.md +394 -0
- package/.claude/skills/portfolio-manager/SKILL.md +750 -0
- package/.claude/skills/portfolio-manager/references/alpaca-mcp-setup.md +367 -0
- package/.claude/skills/portfolio-manager/references/asset-allocation.md +502 -0
- package/.claude/skills/portfolio-manager/references/diversification-principles.md +553 -0
- package/.claude/skills/portfolio-manager/references/portfolio-risk-metrics.md +603 -0
- package/.claude/skills/portfolio-manager/references/position-evaluation.md +477 -0
- package/.claude/skills/portfolio-manager/references/rebalancing-strategies.md +715 -0
- package/.claude/skills/portfolio-manager/references/risk-profile-questionnaire.md +608 -0
- package/.claude/skills/portfolio-manager/references/target-allocations.md +558 -0
- package/.claude/skills/portfolio-manager/scripts/test_alpaca_connection.py +286 -0
- package/.claude/skills/scenario-analyzer/SKILL.md +317 -0
- package/.claude/skills/scenario-analyzer/references/headline_event_patterns.md +264 -0
- package/.claude/skills/scenario-analyzer/references/scenario_playbooks.md +320 -0
- package/.claude/skills/scenario-analyzer/references/sector_sensitivity_matrix.md +217 -0
- package/.claude/skills/sector-analyst/SKILL.md +206 -0
- package/.claude/skills/sector-analyst/assets/industory_performance_1.jpeg +0 -0
- package/.claude/skills/sector-analyst/assets/industory_performance_2.jpeg +0 -0
- package/.claude/skills/sector-analyst/assets/sector_performance.jpeg +0 -0
- package/.claude/skills/sector-analyst/references/sector_rotation.md +170 -0
- package/.claude/skills/stanley-druckenmiller-investment/SKILL.md +84 -0
- package/.claude/skills/stanley-druckenmiller-investment/references/case-studies.md +148 -0
- package/.claude/skills/stanley-druckenmiller-investment/references/investment-philosophy.md +80 -0
- package/.claude/skills/stanley-druckenmiller-investment/references/market-analysis-guide.md +146 -0
- package/.claude/skills/stock/NOTION_SETUP.md +33 -0
- package/.claude/skills/stock/SKILL.md +38 -0
- package/.claude/skills/technical-analyst/SKILL.md +238 -0
- package/.claude/skills/technical-analyst/assets/analysis_template.md +183 -0
- package/.claude/skills/technical-analyst/references/technical_analysis_framework.md +282 -0
- package/.claude/skills/theme-detector/SKILL.md +320 -0
- package/.claude/skills/theme-detector/assets/report_template.md +155 -0
- package/.claude/skills/theme-detector/references/cross_sector_themes.md +252 -0
- package/.claude/skills/theme-detector/references/finviz_industry_codes.md +403 -0
- package/.claude/skills/theme-detector/references/thematic_etf_catalog.md +333 -0
- package/.claude/skills/theme-detector/references/theme_detection_methodology.md +430 -0
- package/.claude/skills/theme-detector/scripts/calculators/__init__.py +1 -0
- package/.claude/skills/theme-detector/scripts/calculators/heat_calculator.py +123 -0
- package/.claude/skills/theme-detector/scripts/calculators/industry_ranker.py +98 -0
- package/.claude/skills/theme-detector/scripts/calculators/lifecycle_calculator.py +172 -0
- package/.claude/skills/theme-detector/scripts/calculators/theme_classifier.py +195 -0
- package/.claude/skills/theme-detector/scripts/calculators/theme_discoverer.py +280 -0
- package/.claude/skills/theme-detector/scripts/config_loader.py +142 -0
- package/.claude/skills/theme-detector/scripts/default_theme_config.py +254 -0
- package/.claude/skills/theme-detector/scripts/etf_scanner.py +609 -0
- package/.claude/skills/theme-detector/scripts/finviz_performance_client.py +131 -0
- package/.claude/skills/theme-detector/scripts/report_generator.py +490 -0
- package/.claude/skills/theme-detector/scripts/representative_stock_selector.py +673 -0
- package/.claude/skills/theme-detector/scripts/scorer.py +87 -0
- package/.claude/skills/theme-detector/scripts/tests/README.md +21 -0
- package/.claude/skills/theme-detector/scripts/tests/conftest.py +9 -0
- package/.claude/skills/theme-detector/scripts/tests/test_config_loader.py +239 -0
- package/.claude/skills/theme-detector/scripts/tests/test_etf_scanner.py +810 -0
- package/.claude/skills/theme-detector/scripts/tests/test_heat_calculator.py +245 -0
- package/.claude/skills/theme-detector/scripts/tests/test_industry_ranker.py +256 -0
- package/.claude/skills/theme-detector/scripts/tests/test_lifecycle_calculator.py +301 -0
- package/.claude/skills/theme-detector/scripts/tests/test_report_generator.py +624 -0
- package/.claude/skills/theme-detector/scripts/tests/test_representative_stock_selector.py +898 -0
- package/.claude/skills/theme-detector/scripts/tests/test_scorer.py +185 -0
- package/.claude/skills/theme-detector/scripts/tests/test_theme_classifier.py +534 -0
- package/.claude/skills/theme-detector/scripts/tests/test_theme_detector_e2e.py +467 -0
- package/.claude/skills/theme-detector/scripts/tests/test_theme_discoverer.py +458 -0
- package/.claude/skills/theme-detector/scripts/tests/test_uptrend_client.py +76 -0
- package/.claude/skills/theme-detector/scripts/theme_detector.py +815 -0
- package/.claude/skills/theme-detector/scripts/themes.yaml +168 -0
- package/.claude/skills/theme-detector/scripts/uptrend_client.py +241 -0
- package/.claude/skills/uptrend-analyzer/SKILL.md +108 -0
- package/.claude/skills/uptrend-analyzer/references/uptrend_methodology.md +215 -0
- package/.claude/skills/uptrend-analyzer/scripts/calculators/__init__.py +1 -0
- package/.claude/skills/uptrend-analyzer/scripts/calculators/historical_context_calculator.py +122 -0
- package/.claude/skills/uptrend-analyzer/scripts/calculators/market_breadth_calculator.py +145 -0
- package/.claude/skills/uptrend-analyzer/scripts/calculators/momentum_calculator.py +183 -0
- package/.claude/skills/uptrend-analyzer/scripts/calculators/sector_participation_calculator.py +204 -0
- package/.claude/skills/uptrend-analyzer/scripts/calculators/sector_rotation_calculator.py +218 -0
- package/.claude/skills/uptrend-analyzer/scripts/data_fetcher.py +236 -0
- package/.claude/skills/uptrend-analyzer/scripts/report_generator.py +329 -0
- package/.claude/skills/uptrend-analyzer/scripts/scorer.py +276 -0
- package/.claude/skills/uptrend-analyzer/scripts/uptrend_analyzer.py +219 -0
- package/.claude/skills/us-market-bubble-detector/CHANGELOG.md +118 -0
- package/.claude/skills/us-market-bubble-detector/SKILL.md +545 -0
- package/.claude/skills/us-market-bubble-detector/references/bubble_framework.md +335 -0
- package/.claude/skills/us-market-bubble-detector/references/historical_cases.md +327 -0
- package/.claude/skills/us-market-bubble-detector/references/implementation_guide.md +473 -0
- package/.claude/skills/us-market-bubble-detector/references/quick_reference.md +354 -0
- package/.claude/skills/us-market-bubble-detector/references/quick_reference_en.md +342 -0
- package/.claude/skills/us-market-bubble-detector/scripts/bubble_scorer.py +309 -0
- package/.claude/skills/us-stock-analysis/SKILL.md +294 -0
- package/.claude/skills/us-stock-analysis/references/financial-metrics.md +172 -0
- package/.claude/skills/us-stock-analysis/references/fundamental-analysis.md +129 -0
- package/.claude/skills/us-stock-analysis/references/report-template.md +207 -0
- package/.claude/skills/us-stock-analysis/references/technical-analysis.md +93 -0
- package/.claude/skills/value-dividend-screener/SKILL.md +562 -0
- package/.claude/skills/value-dividend-screener/references/fmp_api_guide.md +348 -0
- package/.claude/skills/value-dividend-screener/references/screening_methodology.md +315 -0
- package/.claude/skills/value-dividend-screener/scripts/screen_dividend_stocks.py +1138 -0
- package/.claude/skills/vcp-screener/SKILL.md +79 -0
- package/.claude/skills/vcp-screener/references/fmp_api_endpoints.md +45 -0
- package/.claude/skills/vcp-screener/references/scoring_system.md +154 -0
- package/.claude/skills/vcp-screener/references/vcp_methodology.md +124 -0
- package/.claude/skills/vcp-screener/scripts/calculators/__init__.py +1 -0
- package/.claude/skills/vcp-screener/scripts/calculators/pivot_proximity_calculator.py +139 -0
- package/.claude/skills/vcp-screener/scripts/calculators/relative_strength_calculator.py +161 -0
- package/.claude/skills/vcp-screener/scripts/calculators/trend_template_calculator.py +228 -0
- package/.claude/skills/vcp-screener/scripts/calculators/vcp_pattern_calculator.py +322 -0
- package/.claude/skills/vcp-screener/scripts/calculators/volume_pattern_calculator.py +121 -0
- package/.claude/skills/vcp-screener/scripts/fmp_client.py +162 -0
- package/.claude/skills/vcp-screener/scripts/report_generator.py +317 -0
- package/.claude/skills/vcp-screener/scripts/scorer.py +155 -0
- package/.claude/skills/vcp-screener/scripts/screen_vcp.py +536 -0
- package/.claude/skills/vcp-screener/scripts/tests/__init__.py +0 -0
- package/.claude/skills/vcp-screener/scripts/tests/conftest.py +9 -0
- package/.claude/skills/vcp-screener/scripts/tests/test_vcp_screener.py +834 -0
- package/.claude/skills/weekly-trade-strategy/.claude/agents/druckenmiller-strategy-planner.md +300 -0
- package/.claude/skills/weekly-trade-strategy/.claude/agents/market-news-analyzer.md +239 -0
- package/.claude/skills/weekly-trade-strategy/.claude/agents/technical-market-analyst.md +187 -0
- package/.claude/skills/weekly-trade-strategy/.claude/agents/us-market-analyst.md +218 -0
- package/.claude/skills/weekly-trade-strategy/.claude/agents/weekly-trade-blog-writer.md +318 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/SKILL.md +662 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/assets/SP500_Breadth_Index_200MA_8MA.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/assets/US_Stock_Market_Uptrend_Ratio.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/assets/breadth_analysis_template.md +558 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/breadth-chart-analyst/references/breadth_chart_methodology.md +590 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/SKILL.md +721 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/assets/earnings_report_template.md +102 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/earnings_calendar_2025-11-02.md +447 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/references/fmp_api_guide.md +590 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/scripts/fetch_earnings_fmp.py +443 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/earnings-calendar/scripts/generate_report.py +366 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/economic-calendar-fetcher/SKILL.md +365 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/economic-calendar-fetcher/references/fmp_api_documentation.md +345 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/economic-calendar-fetcher/scripts/get_economic_calendar.py +267 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/SKILL.md +139 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/references/analysis_patterns.md +124 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/references/indicators.md +99 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-environment-analysis/scripts/market_utils.py +127 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/SKILL.md +714 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/corporate_news_impact.md +446 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/geopolitical_commodity_correlations.md +499 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/market_event_patterns.md +393 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/market-news-analyst/references/trusted_news_sources.md +510 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/SKILL.md +206 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/assets/industory_performance_1.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/assets/industory_performance_2.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/assets/sector_performance.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/sector-analyst/references/sector_rotation.md +170 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/SKILL.md +84 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/references/case-studies.md +148 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/references/investment-philosophy.md +80 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/stanley-druckenmiller-investment/references/market-analysis-guide.md +146 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/technical-analyst/SKILL.md +238 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/technical-analyst/assets/analysis_template.md +183 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/technical-analyst/references/technical_analysis_framework.md +282 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/CHANGELOG.md +118 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/SKILL.md +545 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/bubble_framework.md +335 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/historical_cases.md +327 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/implementation_guide.md +473 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/quick_reference.md +354 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/references/quick_reference_en.md +342 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-market-bubble-detector/scripts/bubble_scorer.py +309 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/SKILL.md +294 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/financial-metrics.md +172 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/fundamental-analysis.md +129 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/report-template.md +207 -0
- package/.claude/skills/weekly-trade-strategy/.claude/skills/us-stock-analysis/references/technical-analysis.md +93 -0
- package/.claude/skills/weekly-trade-strategy/CLAUDE.md +454 -0
- package/.claude/skills/weekly-trade-strategy/README.md +287 -0
- package/.claude/skills/weekly-trade-strategy/blogs/.gitkeep +0 -0
- package/.claude/skills/weekly-trade-strategy/charts/.gitkeep +0 -0
- package/.claude/skills/weekly-trade-strategy/earnings_data.json +10054 -0
- package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/SKILL.md +662 -0
- package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/assets/SP500_Breadth_Index_200MA_8MA.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/assets/US_Stock_Market_Uptrend_Ratio.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/assets/breadth_analysis_template.md +558 -0
- package/.claude/skills/weekly-trade-strategy/skills/breadth-chart-analyst/references/breadth_chart_methodology.md +590 -0
- package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/SKILL.md +721 -0
- package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/assets/earnings_report_template.md +102 -0
- package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/earnings_calendar_2025-11-02.md +447 -0
- package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/references/fmp_api_guide.md +590 -0
- package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/scripts/fetch_earnings_fmp.py +443 -0
- package/.claude/skills/weekly-trade-strategy/skills/earnings-calendar/scripts/generate_report.py +366 -0
- package/.claude/skills/weekly-trade-strategy/skills/economic-calendar-fetcher/SKILL.md +365 -0
- package/.claude/skills/weekly-trade-strategy/skills/economic-calendar-fetcher/references/fmp_api_documentation.md +345 -0
- package/.claude/skills/weekly-trade-strategy/skills/economic-calendar-fetcher/scripts/get_economic_calendar.py +267 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/SKILL.md +139 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/references/analysis_patterns.md +124 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/references/indicators.md +99 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-environment-analysis/scripts/market_utils.py +127 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/SKILL.md +714 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/corporate_news_impact.md +446 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/geopolitical_commodity_correlations.md +499 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/market_event_patterns.md +393 -0
- package/.claude/skills/weekly-trade-strategy/skills/market-news-analyst/references/trusted_news_sources.md +510 -0
- package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/SKILL.md +206 -0
- package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/assets/industory_performance_1.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/assets/industory_performance_2.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/assets/sector_performance.jpeg +0 -0
- package/.claude/skills/weekly-trade-strategy/skills/sector-analyst/references/sector_rotation.md +170 -0
- package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/SKILL.md +84 -0
- package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/references/case-studies.md +148 -0
- package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/references/investment-philosophy.md +80 -0
- package/.claude/skills/weekly-trade-strategy/skills/stanley-druckenmiller-investment/references/market-analysis-guide.md +146 -0
- package/.claude/skills/weekly-trade-strategy/skills/technical-analyst/SKILL.md +238 -0
- package/.claude/skills/weekly-trade-strategy/skills/technical-analyst/assets/analysis_template.md +183 -0
- package/.claude/skills/weekly-trade-strategy/skills/technical-analyst/references/technical_analysis_framework.md +282 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/CHANGELOG.md +118 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/SKILL.md +545 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/bubble_framework.md +335 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/historical_cases.md +327 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/implementation_guide.md +473 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/quick_reference.md +354 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/references/quick_reference_en.md +342 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-market-bubble-detector/scripts/bubble_scorer.py +309 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/SKILL.md +294 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/financial-metrics.md +172 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/fundamental-analysis.md +129 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/report-template.md +207 -0
- package/.claude/skills/weekly-trade-strategy/skills/us-stock-analysis/references/technical-analysis.md +93 -0
- package/.mcp.json +3 -0
- package/cli.mjs +16 -16
- 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
|