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