# Extended Strategies + Fundamental Data — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add 6 new trading strategies (Value, MACD Crossover, Bollinger Breakout, VWAP, Liquidity, MA Stack) with a fundamental data pipeline (3 providers with rotation + DB caching) and 7 new technical indicators. **Architecture:** New `shared/fundamentals/` module provides fundamental data via a rotating provider abstraction (Alpha Vantage → FMP → Yahoo Finance). Technical indicators (MACD, Bollinger, VWAP, ATR, EMA-9, EMA-21, SMA-200) are computed in `MarketDataManager` from existing OHLCV bars. Strategies implement the existing `BaseStrategy.evaluate()` interface and plug into the `WeightedEnsemble`. **Tech Stack:** Python 3.12, Pydantic v2, SQLAlchemy 2.0 async, httpx (REST calls), yfinance, pytest **Design doc:** `docs/plans/2026-02-23-strategies-fundamentals-design.md` --- ## Task 1: Add new technical indicator fields to MarketSnapshot **Files:** - Modify: `shared/schemas/trading.py:127-140` - Test: `tests/test_schemas.py` (existing — new fields are optional, won't break) **Step 1: Add indicator fields to MarketSnapshot** In `shared/schemas/trading.py`, add after line 137 (`rsi: float | None = None`): ```python # Technical indicators — computed by MarketDataManager ema_9: float | None = None ema_21: float | None = None sma_200: float | None = None macd: float | None = None macd_signal: float | None = None macd_histogram: float | None = None bollinger_upper: float | None = None bollinger_mid: float | None = None bollinger_lower: float | None = None vwap: float | None = None atr: float | None = None ``` **Step 2: Add FundamentalsSnapshot schema** Add after `SentimentContext` in the same file: ```python class FundamentalsSnapshot(BaseModel): """Fundamental financial data for a single ticker — cached daily.""" ticker: str eps_ttm: float | None = None pe_ratio: float | None = None peg_ratio: float | None = None revenue_growth_yoy: float | None = None profit_margin: float | None = None debt_to_equity: float | None = None market_cap: float | None = None fetched_at: datetime model_config = {"from_attributes": True} ``` Also add a `fundamentals` field to `MarketSnapshot`: ```python fundamentals: FundamentalsSnapshot | None = None ``` **Step 3: Run existing tests to confirm nothing breaks** Run: `python -m pytest tests/test_schemas.py -v` Expected: All 49 tests PASS (new fields are optional with defaults) **Step 4: Commit** ```bash git add shared/schemas/trading.py git commit -m "feat: add technical indicator and fundamentals fields to MarketSnapshot" ``` --- ## Task 2: Implement technical indicator computations in MarketDataManager **Files:** - Modify: `services/signal_generator/market_data.py` - Test: `tests/test_indicators.py` (new) **Step 1: Write failing tests for EMA computation** Create `tests/test_indicators.py`: ```python """Tests for technical indicator computations in MarketDataManager.""" import pytest from datetime import datetime, timezone from services.signal_generator.market_data import MarketDataManager from shared.schemas.trading import OHLCVBar def _bar(close: float, volume: float = 1000.0, high: float | None = None, low: float | None = None, open_: float | None = None) -> OHLCVBar: """Helper to create an OHLCVBar with sensible defaults.""" return OHLCVBar( timestamp=datetime.now(timezone.utc), open=open_ if open_ is not None else close, high=high if high is not None else close + 1, low=low if low is not None else close - 1, close=close, volume=volume, ) def _add_bars(mgr: MarketDataManager, ticker: str, closes: list[float], volume: float = 1000.0) -> None: """Add multiple bars with given close prices.""" for c in closes: mgr.add_bar(ticker, _bar(c, volume=volume)) class TestEMA: """Tests for exponential moving average computation.""" def test_ema_returns_none_insufficient_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 5) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.ema_9 is None # Need at least 9 bars def test_ema_9_with_exact_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 9) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.ema_9 is not None assert snap.ema_9 == pytest.approx(100.0, abs=0.01) def test_ema_responds_to_recent_prices(self) -> None: mgr = MarketDataManager(max_bars=300) # 20 bars at 100, then 1 bar at 110 _add_bars(mgr, "AAPL", [100.0] * 20 + [110.0]) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.ema_9 is not None # EMA-9 should be > 100 because the most recent price is 110 assert snap.ema_9 > 100.0 assert snap.ema_9 < 110.0 def test_ema_21_returns_none_insufficient_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 15) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.ema_21 is None def test_ema_21_computed_with_enough_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 25) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.ema_21 is not None assert snap.ema_21 == pytest.approx(100.0, abs=0.01) class TestSMA200: """Tests for SMA-200 computation.""" def test_sma_200_returns_none_insufficient_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 100) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.sma_200 is None def test_sma_200_computed_with_enough_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 200) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.sma_200 is not None assert snap.sma_200 == pytest.approx(100.0, abs=0.01) class TestMACD: """Tests for MACD computation.""" def test_macd_returns_none_insufficient_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 20) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.macd is None def test_macd_computed_with_enough_data(self) -> None: mgr = MarketDataManager(max_bars=300) # Need 26 bars for EMA-26, plus 9 more for the signal line = 35 minimum _add_bars(mgr, "AAPL", [100.0] * 40) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.macd is not None assert snap.macd_signal is not None assert snap.macd_histogram is not None # With constant prices, MACD should be ~0 assert snap.macd == pytest.approx(0.0, abs=0.1) def test_macd_positive_in_uptrend(self) -> None: mgr = MarketDataManager(max_bars=300) # Rising prices: EMA-12 > EMA-26 → positive MACD prices = [100.0 + i * 0.5 for i in range(50)] _add_bars(mgr, "AAPL", prices) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.macd is not None assert snap.macd > 0 class TestBollingerBands: """Tests for Bollinger Bands computation.""" def test_bollinger_returns_none_insufficient_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 10) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.bollinger_upper is None def test_bollinger_computed_with_enough_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 25) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.bollinger_upper is not None assert snap.bollinger_mid is not None assert snap.bollinger_lower is not None # With constant prices, bands should be tight around the price assert snap.bollinger_mid == pytest.approx(100.0, abs=0.01) assert snap.bollinger_upper >= snap.bollinger_mid assert snap.bollinger_lower <= snap.bollinger_mid def test_bollinger_width_increases_with_volatility(self) -> None: mgr_stable = MarketDataManager(max_bars=300) _add_bars(mgr_stable, "AAPL", [100.0] * 25) snap_stable = mgr_stable.get_snapshot("AAPL") mgr_volatile = MarketDataManager(max_bars=300) # Alternating prices = high volatility _add_bars(mgr_volatile, "AAPL", [90.0 + (i % 2) * 20 for i in range(25)]) snap_volatile = mgr_volatile.get_snapshot("AAPL") assert snap_stable is not None and snap_volatile is not None width_stable = snap_stable.bollinger_upper - snap_stable.bollinger_lower width_volatile = snap_volatile.bollinger_upper - snap_volatile.bollinger_lower assert width_volatile > width_stable class TestVWAP: """Tests for VWAP computation.""" def test_vwap_computed(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 5, volume=1000.0) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.vwap is not None # With constant prices and volume, VWAP = typical price # typical = (high + low + close) / 3 = (101 + 99 + 100) / 3 = 100.0 assert snap.vwap == pytest.approx(100.0, abs=0.01) def test_vwap_weighted_by_volume(self) -> None: mgr = MarketDataManager(max_bars=300) # Bar 1: close=100, volume=1000 mgr.add_bar("AAPL", _bar(100.0, volume=1000.0)) # Bar 2: close=200, volume=3000 (should pull VWAP toward 200) mgr.add_bar("AAPL", _bar(200.0, volume=3000.0)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.vwap is not None assert snap.vwap > 150.0 # Weighted toward 200 class TestATR: """Tests for ATR computation.""" def test_atr_returns_none_insufficient_data(self) -> None: mgr = MarketDataManager(max_bars=300) _add_bars(mgr, "AAPL", [100.0] * 5) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.atr is None def test_atr_computed_with_enough_data(self) -> None: mgr = MarketDataManager(max_bars=300) # 15 bars needed (14-period ATR + 1 for prev close) _add_bars(mgr, "AAPL", [100.0] * 15) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.atr is not None assert snap.atr >= 0 def test_atr_increases_with_volatility(self) -> None: mgr_stable = MarketDataManager(max_bars=300) for i in range(20): mgr_stable.add_bar("AAPL", OHLCVBar( timestamp=datetime.now(timezone.utc), open=100.0, high=101.0, low=99.0, close=100.0, volume=1000.0, )) snap_stable = mgr_stable.get_snapshot("AAPL") mgr_volatile = MarketDataManager(max_bars=300) for i in range(20): mgr_volatile.add_bar("AAPL", OHLCVBar( timestamp=datetime.now(timezone.utc), open=100.0, high=115.0, low=85.0, close=100.0, volume=1000.0, )) snap_volatile = mgr_volatile.get_snapshot("AAPL") assert snap_stable is not None and snap_volatile is not None assert snap_volatile.atr > snap_stable.atr ``` **Step 2: Run tests to verify they fail** Run: `python -m pytest tests/test_indicators.py -v` Expected: FAIL — `ema_9`, `macd`, etc. don't exist on `MarketSnapshot` yet (Task 1 must be done first) and computations aren't implemented. **Step 3: Implement indicator computations in MarketDataManager** Modify `services/signal_generator/market_data.py`: 1. Add `_compute_ema` static method: ```python @staticmethod def _compute_ema(closes: list[float], period: int) -> float | None: """Compute exponential moving average over *closes* with given *period*.""" if len(closes) < period: return None multiplier = 2.0 / (period + 1) ema = sum(closes[:period]) / period # Seed with SMA for price in closes[period:]: ema = (price - ema) * multiplier + ema return round(ema, 6) ``` 2. Add `_compute_macd` static method: ```python @staticmethod def _compute_macd(closes: list[float]) -> tuple[float, float, float] | None: """Compute MACD (12,26,9). Returns (macd_line, signal_line, histogram) or None.""" if len(closes) < 35: # 26 for slow EMA + 9 for signal return None mul_12 = 2.0 / 13 mul_26 = 2.0 / 27 mul_9 = 2.0 / 10 ema_12 = sum(closes[:12]) / 12 ema_26 = sum(closes[:26]) / 26 macd_values: list[float] = [] for i in range(len(closes)): if i < 12: continue if i == 12: ema_12 = sum(closes[:12]) / 12 else: ema_12 = (closes[i] - ema_12) * mul_12 + ema_12 if i < 26: continue if i == 26: ema_26 = sum(closes[:26]) / 26 # Recompute ema_12 from scratch for accuracy ema_12 = sum(closes[:12]) / 12 for j in range(12, i + 1): ema_12 = (closes[j] - ema_12) * mul_12 + ema_12 else: ema_26 = (closes[i] - ema_26) * mul_26 + ema_26 macd_values.append(ema_12 - ema_26) if len(macd_values) < 9: return None signal = sum(macd_values[:9]) / 9 for val in macd_values[9:]: signal = (val - signal) * mul_9 + signal macd_line = macd_values[-1] histogram = macd_line - signal return round(macd_line, 6), round(signal, 6), round(histogram, 6) ``` 3. Add `_compute_bollinger` static method: ```python @staticmethod def _compute_bollinger(closes: list[float], period: int = 20, num_std: float = 2.0) -> tuple[float, float, float] | None: """Compute Bollinger Bands. Returns (upper, mid, lower) or None.""" if len(closes) < period: return None window = closes[-period:] mid = sum(window) / period variance = sum((x - mid) ** 2 for x in window) / period std = variance ** 0.5 return round(mid + num_std * std, 6), round(mid, 6), round(mid - num_std * std, 6) ``` 4. Add `_compute_vwap` static method: ```python @staticmethod def _compute_vwap(bars: list[OHLCVBar]) -> float | None: """Compute VWAP from bars. Returns None if no bars.""" if not bars: return None cum_tp_vol = 0.0 cum_vol = 0.0 for bar in bars: typical_price = (bar.high + bar.low + bar.close) / 3.0 cum_tp_vol += typical_price * bar.volume cum_vol += bar.volume if cum_vol == 0: return None return round(cum_tp_vol / cum_vol, 6) ``` 5. Add `_compute_atr` static method: ```python @staticmethod def _compute_atr(bars: list[OHLCVBar], period: int = 14) -> float | None: """Compute Average True Range over *period*. Needs period+1 bars.""" if len(bars) < period + 1: return None relevant = bars[-(period + 1):] true_ranges: list[float] = [] for i in range(1, len(relevant)): high_low = relevant[i].high - relevant[i].low high_prev_close = abs(relevant[i].high - relevant[i - 1].close) low_prev_close = abs(relevant[i].low - relevant[i - 1].close) true_ranges.append(max(high_low, high_prev_close, low_prev_close)) return round(sum(true_ranges) / len(true_ranges), 6) ``` 6. Update `get_snapshot()` to compute and include all new indicators: ```python def get_snapshot(self, ticker: str) -> MarketSnapshot | None: bars = self._bars.get(ticker) if not bars: return None latest = bars[-1] closes = [b.close for b in bars] bar_list = list(bars) # MACD macd_result = self._compute_macd(closes) macd_val = macd_signal = macd_hist = None if macd_result: macd_val, macd_signal, macd_hist = macd_result # Bollinger boll_result = self._compute_bollinger(closes) boll_upper = boll_mid = boll_lower = None if boll_result: boll_upper, boll_mid, boll_lower = boll_result return MarketSnapshot( ticker=ticker, current_price=latest.close, open=latest.open, high=latest.high, low=latest.low, close=latest.close, volume=latest.volume, sma_20=self._compute_sma(closes, 20), sma_50=self._compute_sma(closes, 50), sma_200=self._compute_sma(closes, 200), rsi=self._compute_rsi(closes, _RSI_PERIOD), ema_9=self._compute_ema(closes, 9), ema_21=self._compute_ema(closes, 21), macd=macd_val, macd_signal=macd_signal, macd_histogram=macd_hist, bollinger_upper=boll_upper, bollinger_mid=boll_mid, bollinger_lower=boll_lower, vwap=self._compute_vwap(bar_list), atr=self._compute_atr(bar_list), bars=[b.model_dump(mode="json") for b in bars], ) ``` **Step 4: Run tests to verify they pass** Run: `python -m pytest tests/test_indicators.py -v` Expected: All PASS **Step 5: Run existing tests to confirm no regressions** Run: `python -m pytest tests/ -v -m "not integration" --timeout=30` Expected: All 246+ tests PASS **Step 6: Commit** ```bash git add services/signal_generator/market_data.py tests/test_indicators.py git commit -m "feat: add MACD, Bollinger, VWAP, ATR, EMA, SMA-200 indicator computations" ``` --- ## Task 3: Implement fundamental data providers **Files:** - Create: `shared/fundamentals/__init__.py` - Create: `shared/fundamentals/base.py` - Create: `shared/fundamentals/alpha_vantage.py` - Create: `shared/fundamentals/fmp.py` - Create: `shared/fundamentals/yahoo.py` - Create: `shared/fundamentals/rotating.py` - Test: `tests/test_fundamentals.py` (new) **Step 1: Write failing tests** Create `tests/test_fundamentals.py`: ```python """Tests for fundamental data providers.""" import pytest from datetime import datetime, timezone from unittest.mock import AsyncMock, patch, MagicMock from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot from shared.fundamentals.alpha_vantage import AlphaVantageProvider from shared.fundamentals.fmp import FMPProvider from shared.fundamentals.yahoo import YahooFinanceProvider from shared.fundamentals.rotating import RotatingProvider class TestFundamentalsSnapshot: """Tests for the FundamentalsSnapshot model.""" def test_snapshot_all_fields(self) -> None: snap = FundamentalsSnapshot( ticker="AAPL", eps_ttm=6.5, pe_ratio=28.0, peg_ratio=1.5, revenue_growth_yoy=0.08, profit_margin=0.25, debt_to_equity=1.8, market_cap=3_000_000_000_000.0, fetched_at=datetime.now(timezone.utc), ) assert snap.ticker == "AAPL" assert snap.eps_ttm == 6.5 def test_snapshot_optional_fields(self) -> None: snap = FundamentalsSnapshot( ticker="AAPL", fetched_at=datetime.now(timezone.utc), ) assert snap.eps_ttm is None assert snap.pe_ratio is None class TestAlphaVantageProvider: """Tests for Alpha Vantage provider.""" @pytest.mark.asyncio async def test_fetch_parses_response(self) -> None: mock_response = AsyncMock() mock_response.status_code = 200 mock_response.json.return_value = { "Symbol": "AAPL", "EPS": "6.50", "PERatio": "28.00", "PEGRatio": "1.50", "QuarterlyRevenueGrowthYOY": "0.08", "ProfitMargin": "0.25", "DebtToEquity": "180", # Alpha Vantage returns as percentage "MarketCapitalization": "3000000000000", } provider = AlphaVantageProvider(api_key="test-key") with patch.object(provider, "_client") as mock_client: mock_client.get = AsyncMock(return_value=mock_response) result = await provider.fetch("AAPL") assert result is not None assert result.ticker == "AAPL" assert result.eps_ttm == 6.5 assert result.pe_ratio == 28.0 @pytest.mark.asyncio async def test_fetch_handles_rate_limit(self) -> None: mock_response = AsyncMock() mock_response.status_code = 200 mock_response.json.return_value = { "Note": "Thank you for using Alpha Vantage! Our standard API rate limit is 25 requests per day." } provider = AlphaVantageProvider(api_key="test-key") with patch.object(provider, "_client") as mock_client: mock_client.get = AsyncMock(return_value=mock_response) result = await provider.fetch("AAPL") assert result is None class TestFMPProvider: """Tests for Financial Modeling Prep provider.""" @pytest.mark.asyncio async def test_fetch_parses_response(self) -> None: mock_response = AsyncMock() mock_response.status_code = 200 mock_response.json.return_value = [ { "symbol": "AAPL", "eps": 6.5, "pe": 28.0, "pegRatio": 1.5, "revenueGrowth": 0.08, "netProfitMargin": 0.25, "debtToEquity": 1.8, "marketCap": 3_000_000_000_000, } ] provider = FMPProvider(api_key="test-key") with patch.object(provider, "_client") as mock_client: mock_client.get = AsyncMock(return_value=mock_response) result = await provider.fetch("AAPL") assert result is not None assert result.ticker == "AAPL" assert result.peg_ratio == 1.5 @pytest.mark.asyncio async def test_fetch_handles_empty_response(self) -> None: mock_response = AsyncMock() mock_response.status_code = 200 mock_response.json.return_value = [] provider = FMPProvider(api_key="test-key") with patch.object(provider, "_client") as mock_client: mock_client.get = AsyncMock(return_value=mock_response) result = await provider.fetch("INVALID") assert result is None class TestYahooFinanceProvider: """Tests for Yahoo Finance provider.""" @pytest.mark.asyncio async def test_fetch_parses_ticker_info(self) -> None: mock_ticker = MagicMock() mock_ticker.info = { "trailingEps": 6.5, "trailingPE": 28.0, "pegRatio": 1.5, "revenueGrowth": 0.08, "profitMargins": 0.25, "debtToEquity": 180.0, "marketCap": 3_000_000_000_000, } provider = YahooFinanceProvider() with patch("shared.fundamentals.yahoo.yfinance.Ticker", return_value=mock_ticker): result = await provider.fetch("AAPL") assert result is not None assert result.ticker == "AAPL" assert result.eps_ttm == 6.5 assert result.debt_to_equity == pytest.approx(1.8, abs=0.01) class TestRotatingProvider: """Tests for the rotating provider with failover.""" @pytest.mark.asyncio async def test_uses_first_provider_on_success(self) -> None: snap = FundamentalsSnapshot(ticker="AAPL", fetched_at=datetime.now(timezone.utc), eps_ttm=6.5) p1 = AsyncMock(spec=FundamentalsProvider) p1.fetch = AsyncMock(return_value=snap) p2 = AsyncMock(spec=FundamentalsProvider) rotating = RotatingProvider([p1, p2]) result = await rotating.fetch("AAPL") assert result is not None assert result.eps_ttm == 6.5 p1.fetch.assert_awaited_once_with("AAPL") p2.fetch.assert_not_awaited() @pytest.mark.asyncio async def test_falls_back_on_failure(self) -> None: snap = FundamentalsSnapshot(ticker="AAPL", fetched_at=datetime.now(timezone.utc), eps_ttm=7.0) p1 = AsyncMock(spec=FundamentalsProvider) p1.fetch = AsyncMock(return_value=None) # Rate limited p2 = AsyncMock(spec=FundamentalsProvider) p2.fetch = AsyncMock(return_value=snap) rotating = RotatingProvider([p1, p2]) result = await rotating.fetch("AAPL") assert result is not None assert result.eps_ttm == 7.0 @pytest.mark.asyncio async def test_returns_none_when_all_fail(self) -> None: p1 = AsyncMock(spec=FundamentalsProvider) p1.fetch = AsyncMock(return_value=None) p2 = AsyncMock(spec=FundamentalsProvider) p2.fetch = AsyncMock(return_value=None) rotating = RotatingProvider([p1, p2]) result = await rotating.fetch("AAPL") assert result is None @pytest.mark.asyncio async def test_handles_exception_and_falls_back(self) -> None: snap = FundamentalsSnapshot(ticker="AAPL", fetched_at=datetime.now(timezone.utc)) p1 = AsyncMock(spec=FundamentalsProvider) p1.fetch = AsyncMock(side_effect=Exception("API error")) p2 = AsyncMock(spec=FundamentalsProvider) p2.fetch = AsyncMock(return_value=snap) rotating = RotatingProvider([p1, p2]) result = await rotating.fetch("AAPL") assert result is not None ``` **Step 2: Run tests to verify they fail** Run: `python -m pytest tests/test_fundamentals.py -v` Expected: FAIL — modules don't exist yet **Step 3: Create `shared/fundamentals/__init__.py`** ```python """Fundamental data providers for stock financial metrics.""" from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot from shared.fundamentals.rotating import RotatingProvider __all__ = ["FundamentalsProvider", "FundamentalsSnapshot", "RotatingProvider"] ``` **Step 4: Create `shared/fundamentals/base.py`** ```python """Abstract base class for fundamental data providers.""" from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime from pydantic import BaseModel class FundamentalsSnapshot(BaseModel): """Fundamental financial data for a single ticker.""" ticker: str eps_ttm: float | None = None pe_ratio: float | None = None peg_ratio: float | None = None revenue_growth_yoy: float | None = None profit_margin: float | None = None debt_to_equity: float | None = None market_cap: float | None = None fetched_at: datetime model_config = {"from_attributes": True} class FundamentalsProvider(ABC): """Abstract base class for fundamental data providers.""" @abstractmethod async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: """Fetch fundamental data for *ticker*. Returns None on failure/rate limit.""" ... ``` **Step 5: Create `shared/fundamentals/alpha_vantage.py`** ```python """Alpha Vantage fundamental data provider.""" from __future__ import annotations import logging from datetime import datetime, timezone import httpx from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot logger = logging.getLogger(__name__) _BASE_URL = "https://www.alphavantage.co/query" class AlphaVantageProvider(FundamentalsProvider): """Fetch fundamentals from the Alpha Vantage OVERVIEW endpoint.""" def __init__(self, api_key: str) -> None: self._api_key = api_key self._client = httpx.AsyncClient(timeout=30.0) async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: try: resp = await self._client.get( _BASE_URL, params={"function": "OVERVIEW", "symbol": ticker, "apikey": self._api_key}, ) data = resp.json() # Rate limit detection if "Note" in data or "Information" in data: logger.warning("Alpha Vantage rate limit hit for %s", ticker) return None if "Symbol" not in data: logger.warning("Alpha Vantage returned no data for %s", ticker) return None return FundamentalsSnapshot( ticker=ticker, eps_ttm=_safe_float(data.get("EPS")), pe_ratio=_safe_float(data.get("PERatio")), peg_ratio=_safe_float(data.get("PEGRatio")), revenue_growth_yoy=_safe_float(data.get("QuarterlyRevenueGrowthYOY")), profit_margin=_safe_float(data.get("ProfitMargin")), debt_to_equity=_safe_float_div100(data.get("DebtToEquity")), market_cap=_safe_float(data.get("MarketCapitalization")), fetched_at=datetime.now(timezone.utc), ) except Exception: logger.exception("Alpha Vantage fetch failed for %s", ticker) return None def _safe_float(val: str | None) -> float | None: if val is None or val in ("None", "-", ""): return None try: return float(val) except (ValueError, TypeError): return None def _safe_float_div100(val: str | None) -> float | None: """Alpha Vantage returns debt-to-equity as a percentage (e.g. 180 = 1.8).""" f = _safe_float(val) return f / 100.0 if f is not None else None ``` **Step 6: Create `shared/fundamentals/fmp.py`** ```python """Financial Modeling Prep (FMP) fundamental data provider.""" from __future__ import annotations import logging from datetime import datetime, timezone import httpx from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot logger = logging.getLogger(__name__) _BASE_URL = "https://financialmodelingprep.com/api/v3" class FMPProvider(FundamentalsProvider): """Fetch fundamentals from the FMP key-metrics endpoint.""" def __init__(self, api_key: str) -> None: self._api_key = api_key self._client = httpx.AsyncClient(timeout=30.0) async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: try: resp = await self._client.get( f"{_BASE_URL}/key-metrics-ttm/{ticker}", params={"apikey": self._api_key}, ) data = resp.json() if not data or not isinstance(data, list) or len(data) == 0: logger.warning("FMP returned no data for %s", ticker) return None item = data[0] return FundamentalsSnapshot( ticker=ticker, eps_ttm=item.get("eps") or item.get("netIncomePerShareTTM"), pe_ratio=item.get("pe") or item.get("peRatioTTM"), peg_ratio=item.get("pegRatio") or item.get("pegRatioTTM"), revenue_growth_yoy=item.get("revenueGrowth"), profit_margin=item.get("netProfitMargin") or item.get("netProfitMarginTTM"), debt_to_equity=item.get("debtToEquity") or item.get("debtToEquityTTM"), market_cap=item.get("marketCap") or item.get("marketCapTTM"), fetched_at=datetime.now(timezone.utc), ) except Exception: logger.exception("FMP fetch failed for %s", ticker) return None ``` **Step 7: Create `shared/fundamentals/yahoo.py`** ```python """Yahoo Finance fundamental data provider (via yfinance library).""" from __future__ import annotations import asyncio import logging from datetime import datetime, timezone from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot logger = logging.getLogger(__name__) class YahooFinanceProvider(FundamentalsProvider): """Fetch fundamentals via yfinance (runs in thread pool to avoid blocking).""" async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: try: import yfinance loop = asyncio.get_running_loop() info = await loop.run_in_executor(None, _get_ticker_info, ticker) if not info: logger.warning("yfinance returned no info for %s", ticker) return None d2e = info.get("debtToEquity") # yfinance returns debt-to-equity as percentage (e.g. 180.0 = 1.8) if d2e is not None: d2e = d2e / 100.0 return FundamentalsSnapshot( ticker=ticker, eps_ttm=info.get("trailingEps"), pe_ratio=info.get("trailingPE"), peg_ratio=info.get("pegRatio"), revenue_growth_yoy=info.get("revenueGrowth"), profit_margin=info.get("profitMargins"), debt_to_equity=d2e, market_cap=info.get("marketCap"), fetched_at=datetime.now(timezone.utc), ) except Exception: logger.exception("yfinance fetch failed for %s", ticker) return None def _get_ticker_info(ticker: str) -> dict: """Synchronous helper — called via run_in_executor.""" import yfinance t = yfinance.Ticker(ticker) return t.info ``` **Step 8: Create `shared/fundamentals/rotating.py`** ```python """Rotating provider that tries multiple providers with failover.""" from __future__ import annotations import logging from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot logger = logging.getLogger(__name__) class RotatingProvider(FundamentalsProvider): """Try each provider in order; return the first successful result.""" def __init__(self, providers: list[FundamentalsProvider]) -> None: self._providers = providers async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: for provider in self._providers: try: result = await provider.fetch(ticker) if result is not None: logger.debug( "Fetched fundamentals for %s via %s", ticker, type(provider).__name__, ) return result logger.debug( "%s returned None for %s, trying next", type(provider).__name__, ticker, ) except Exception: logger.exception( "%s failed for %s, trying next", type(provider).__name__, ticker, ) logger.warning("All providers failed for %s", ticker) return None ``` **Step 9: Run tests** Run: `python -m pytest tests/test_fundamentals.py -v` Expected: All PASS **Step 10: Commit** ```bash git add shared/fundamentals/ tests/test_fundamentals.py git commit -m "feat: add fundamental data providers (Alpha Vantage, FMP, Yahoo Finance) with rotation" ``` --- ## Task 4: Add fundamentals DB table and caching **Files:** - Create: `shared/models/fundamentals.py` - Modify: `shared/models/__init__.py` - Create: `alembic/versions/xxx_add_fundamentals_table.py` (via alembic) - Create: `shared/fundamentals/cache.py` - Test: `tests/test_fundamentals.py` (add cache tests) **Step 1: Create `shared/models/fundamentals.py`** ```python """Fundamentals database model for caching fundamental data.""" import uuid from sqlalchemy import Float, String, DateTime from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from shared.models.base import Base, TimestampMixin class Fundamentals(TimestampMixin, Base): __tablename__ = "fundamentals" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) ticker: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) eps_ttm: Mapped[float | None] = mapped_column(Float, nullable=True) pe_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) peg_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) revenue_growth_yoy: Mapped[float | None] = mapped_column(Float, nullable=True) profit_margin: Mapped[float | None] = mapped_column(Float, nullable=True) debt_to_equity: Mapped[float | None] = mapped_column(Float, nullable=True) market_cap: Mapped[float | None] = mapped_column(Float, nullable=True) fetched_at: Mapped[str] = mapped_column(DateTime(timezone=True), nullable=False) ``` **Step 2: Update `shared/models/__init__.py`** Add `from shared.models.fundamentals import Fundamentals` and add `"Fundamentals"` to `__all__`. **Step 3: Generate Alembic migration** Run: `python -m alembic revision --autogenerate -m "add fundamentals table"` Then verify the generated migration looks correct. **Step 4: Create `shared/fundamentals/cache.py`** ```python """DB-backed cache for fundamental data.""" from __future__ import annotations import logging from datetime import datetime, timezone, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot from shared.models.fundamentals import Fundamentals logger = logging.getLogger(__name__) class CachedFundamentalsProvider: """Wraps a FundamentalsProvider with DB-backed caching. On fetch: checks DB first. If data is fresh (within TTL), returns cached. Otherwise fetches from the underlying provider and updates the DB. """ def __init__( self, provider: FundamentalsProvider, session_factory: async_sessionmaker, cache_ttl_hours: int = 24, ) -> None: self._provider = provider self._session_factory = session_factory self._cache_ttl = timedelta(hours=cache_ttl_hours) async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: # Try cache first cached = await self._load_from_db(ticker) if cached is not None: age = datetime.now(timezone.utc) - cached.fetched_at.replace(tzinfo=timezone.utc) if age < self._cache_ttl: logger.debug("Cache hit for %s (age=%s)", ticker, age) return cached logger.debug("Cache stale for %s (age=%s), refreshing", ticker, age) # Fetch from provider result = await self._provider.fetch(ticker) if result is not None: await self._save_to_db(result) return result async def _load_from_db(self, ticker: str) -> FundamentalsSnapshot | None: try: async with self._session_factory() as session: stmt = select(Fundamentals).where(Fundamentals.ticker == ticker) row = (await session.execute(stmt)).scalar_one_or_none() if row is None: return None return FundamentalsSnapshot( ticker=row.ticker, eps_ttm=row.eps_ttm, pe_ratio=row.pe_ratio, peg_ratio=row.peg_ratio, revenue_growth_yoy=row.revenue_growth_yoy, profit_margin=row.profit_margin, debt_to_equity=row.debt_to_equity, market_cap=row.market_cap, fetched_at=row.fetched_at, ) except Exception: logger.exception("Failed to load fundamentals from DB for %s", ticker) return None async def _save_to_db(self, snapshot: FundamentalsSnapshot) -> None: try: async with self._session_factory() as session: stmt = select(Fundamentals).where(Fundamentals.ticker == snapshot.ticker) existing = (await session.execute(stmt)).scalar_one_or_none() if existing: existing.eps_ttm = snapshot.eps_ttm existing.pe_ratio = snapshot.pe_ratio existing.peg_ratio = snapshot.peg_ratio existing.revenue_growth_yoy = snapshot.revenue_growth_yoy existing.profit_margin = snapshot.profit_margin existing.debt_to_equity = snapshot.debt_to_equity existing.market_cap = snapshot.market_cap existing.fetched_at = snapshot.fetched_at else: row = Fundamentals( ticker=snapshot.ticker, eps_ttm=snapshot.eps_ttm, pe_ratio=snapshot.pe_ratio, peg_ratio=snapshot.peg_ratio, revenue_growth_yoy=snapshot.revenue_growth_yoy, profit_margin=snapshot.profit_margin, debt_to_equity=snapshot.debt_to_equity, market_cap=snapshot.market_cap, fetched_at=snapshot.fetched_at, ) session.add(row) await session.commit() logger.debug("Saved fundamentals for %s to DB", snapshot.ticker) except Exception: logger.exception("Failed to save fundamentals for %s to DB", snapshot.ticker) ``` **Step 5: Run tests** Run: `python -m pytest tests/test_fundamentals.py tests/test_models.py -v` Expected: PASS **Step 6: Commit** ```bash git add shared/models/fundamentals.py shared/models/__init__.py shared/fundamentals/cache.py alembic/ git commit -m "feat: add fundamentals DB model and cached provider" ``` --- ## Task 5: Implement the 6 new strategies **Files:** - Create: `shared/strategies/value.py` - Create: `shared/strategies/macd_crossover.py` - Create: `shared/strategies/bollinger_breakout.py` - Create: `shared/strategies/vwap.py` - Create: `shared/strategies/liquidity.py` - Create: `shared/strategies/ma_stack.py` - Modify: `shared/strategies/__init__.py` - Test: `tests/test_new_strategies.py` (new) **Step 1: Write failing tests** Create `tests/test_new_strategies.py`: ```python """Tests for the 6 new trading strategies.""" import pytest from datetime import datetime, timezone from shared.fundamentals.base import FundamentalsSnapshot from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection from shared.strategies import BaseStrategy from shared.strategies.value import ValueStrategy from shared.strategies.macd_crossover import MACDCrossoverStrategy from shared.strategies.bollinger_breakout import BollingerBreakoutStrategy from shared.strategies.vwap import VWAPStrategy from shared.strategies.liquidity import LiquidityStrategy from shared.strategies.ma_stack import MAStackStrategy # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _market( ticker: str = "AAPL", price: float = 150.0, volume: float = 1_000_000, sma_20: float | None = None, sma_50: float | None = None, sma_200: float | None = None, rsi: float | None = None, ema_9: float | None = None, ema_21: float | None = None, macd: float | None = None, macd_signal: float | None = None, macd_histogram: float | None = None, bollinger_upper: float | None = None, bollinger_mid: float | None = None, bollinger_lower: float | None = None, vwap: float | None = None, atr: float | None = None, fundamentals: FundamentalsSnapshot | None = None, bars: list | None = None, ) -> MarketSnapshot: return MarketSnapshot( ticker=ticker, current_price=price, open=price - 1, high=price + 2, low=price - 2, close=price, volume=volume, sma_20=sma_20, sma_50=sma_50, sma_200=sma_200, rsi=rsi, ema_9=ema_9, ema_21=ema_21, macd=macd, macd_signal=macd_signal, macd_histogram=macd_histogram, bollinger_upper=bollinger_upper, bollinger_mid=bollinger_mid, bollinger_lower=bollinger_lower, vwap=vwap, atr=atr, fundamentals=fundamentals, bars=bars or [], ) def _fundamentals( ticker: str = "AAPL", eps_ttm: float | None = 6.5, pe_ratio: float | None = 28.0, peg_ratio: float | None = 1.5, revenue_growth_yoy: float | None = 0.08, profit_margin: float | None = 0.25, debt_to_equity: float | None = 1.8, ) -> FundamentalsSnapshot: return FundamentalsSnapshot( ticker=ticker, eps_ttm=eps_ttm, pe_ratio=pe_ratio, peg_ratio=peg_ratio, revenue_growth_yoy=revenue_growth_yoy, profit_margin=profit_margin, debt_to_equity=debt_to_equity, fetched_at=datetime.now(timezone.utc), ) # =================================================================== # Value strategy # =================================================================== class TestValueStrategy: @pytest.fixture() def strategy(self) -> ValueStrategy: return ValueStrategy() @pytest.mark.asyncio async def test_long_signal_undervalued(self, strategy: ValueStrategy) -> None: f = _fundamentals(peg_ratio=0.8, pe_ratio=15.0, eps_ttm=5.0, revenue_growth_yoy=0.1) market = _market(fundamentals=f) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG @pytest.mark.asyncio async def test_short_signal_overvalued(self, strategy: ValueStrategy) -> None: f = _fundamentals(peg_ratio=4.0, pe_ratio=60.0, eps_ttm=-1.0) market = _market(fundamentals=f) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.SHORT @pytest.mark.asyncio async def test_no_signal_without_fundamentals(self, strategy: ValueStrategy) -> None: market = _market(fundamentals=None) signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_no_signal_neutral_fundamentals(self, strategy: ValueStrategy) -> None: f = _fundamentals(peg_ratio=1.5, pe_ratio=20.0, eps_ttm=3.0) market = _market(fundamentals=f) signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_strength_bounded(self, strategy: ValueStrategy) -> None: f = _fundamentals(peg_ratio=0.1, pe_ratio=5.0, eps_ttm=10.0, revenue_growth_yoy=0.5) market = _market(fundamentals=f) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert 0 < signal.strength <= 1.0 # =================================================================== # MACD Crossover strategy # =================================================================== class TestMACDCrossoverStrategy: @pytest.fixture() def strategy(self) -> MACDCrossoverStrategy: return MACDCrossoverStrategy() @pytest.mark.asyncio async def test_long_signal_bullish_crossover(self, strategy: MACDCrossoverStrategy) -> None: # Simulate: previous MACD was below signal, now above # First call sets the state market_prev = _market(macd=-0.5, macd_signal=0.1, macd_histogram=-0.6, atr=2.0) await strategy.evaluate("AAPL", market_prev) # Second call: MACD crossed above signal market = _market(macd=0.5, macd_signal=0.1, macd_histogram=0.4, atr=2.0) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG @pytest.mark.asyncio async def test_short_signal_bearish_crossover(self, strategy: MACDCrossoverStrategy) -> None: # Previous: MACD above signal market_prev = _market(macd=0.5, macd_signal=0.1, macd_histogram=0.4, atr=2.0) await strategy.evaluate("AAPL", market_prev) # Now: MACD crossed below signal market = _market(macd=-0.5, macd_signal=0.1, macd_histogram=-0.6, atr=2.0) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.SHORT @pytest.mark.asyncio async def test_no_signal_without_macd(self, strategy: MACDCrossoverStrategy) -> None: market = _market(macd=None, macd_signal=None) signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_no_signal_without_crossover(self, strategy: MACDCrossoverStrategy) -> None: # Both calls have MACD above signal — no crossover market1 = _market(macd=0.5, macd_signal=0.1, macd_histogram=0.4, atr=2.0) await strategy.evaluate("AAPL", market1) market2 = _market(macd=0.6, macd_signal=0.1, macd_histogram=0.5, atr=2.0) signal = await strategy.evaluate("AAPL", market2) assert signal is None # =================================================================== # Bollinger Breakout strategy # =================================================================== class TestBollingerBreakoutStrategy: @pytest.fixture() def strategy(self) -> BollingerBreakoutStrategy: return BollingerBreakoutStrategy() @pytest.mark.asyncio async def test_long_signal_above_upper_band(self, strategy: BollingerBreakoutStrategy) -> None: market = _market( price=155.0, bollinger_upper=152.0, bollinger_mid=150.0, bollinger_lower=148.0, volume=2_000_000, sma_20=150.0, ) # Need bars to compute average volume bars = [{"volume": 1_000_000}] * 20 market.bars = bars signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG @pytest.mark.asyncio async def test_long_signal_below_lower_band(self, strategy: BollingerBreakoutStrategy) -> None: market = _market( price=145.0, bollinger_upper=152.0, bollinger_mid=150.0, bollinger_lower=148.0, ) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG @pytest.mark.asyncio async def test_no_signal_inside_bands(self, strategy: BollingerBreakoutStrategy) -> None: market = _market( price=150.0, bollinger_upper=155.0, bollinger_mid=150.0, bollinger_lower=145.0, ) signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_no_signal_without_bollinger(self, strategy: BollingerBreakoutStrategy) -> None: market = _market() signal = await strategy.evaluate("AAPL", market) assert signal is None # =================================================================== # VWAP strategy # =================================================================== class TestVWAPStrategy: @pytest.fixture() def strategy(self) -> VWAPStrategy: return VWAPStrategy() @pytest.mark.asyncio async def test_long_signal_above_vwap(self, strategy: VWAPStrategy) -> None: # First call: below VWAP market_prev = _market(price=148.0, vwap=150.0, volume=1_000_000) await strategy.evaluate("AAPL", market_prev) # Second call: crossed above VWAP with volume market = _market(price=152.0, vwap=150.0, volume=1_500_000) market.bars = [{"volume": 1_000_000}] * 20 signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG @pytest.mark.asyncio async def test_short_signal_below_vwap(self, strategy: VWAPStrategy) -> None: market_prev = _market(price=152.0, vwap=150.0, volume=1_000_000) await strategy.evaluate("AAPL", market_prev) market = _market(price=148.0, vwap=150.0, volume=1_500_000) market.bars = [{"volume": 1_000_000}] * 20 signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.SHORT @pytest.mark.asyncio async def test_no_signal_without_vwap(self, strategy: VWAPStrategy) -> None: market = _market(vwap=None) signal = await strategy.evaluate("AAPL", market) assert signal is None # =================================================================== # Liquidity strategy # =================================================================== class TestLiquidityStrategy: @pytest.fixture() def strategy(self) -> LiquidityStrategy: return LiquidityStrategy() @pytest.mark.asyncio async def test_long_signal_high_volume_up(self, strategy: LiquidityStrategy) -> None: bars = [{"close": 100.0, "volume": 1_000_000}] * 20 market = _market(price=105.0, volume=2_500_000, bars=bars) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG @pytest.mark.asyncio async def test_short_signal_high_volume_down(self, strategy: LiquidityStrategy) -> None: bars = [{"close": 100.0, "volume": 1_000_000}] * 20 market = _market(price=95.0, volume=2_500_000, bars=bars) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.SHORT @pytest.mark.asyncio async def test_no_signal_thin_volume(self, strategy: LiquidityStrategy) -> None: bars = [{"close": 100.0, "volume": 1_000_000}] * 20 market = _market(price=105.0, volume=400_000, bars=bars) signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_no_signal_no_bars(self, strategy: LiquidityStrategy) -> None: market = _market(price=105.0, volume=2_000_000, bars=[]) signal = await strategy.evaluate("AAPL", market) assert signal is None # =================================================================== # MA Stack strategy # =================================================================== class TestMAStackStrategy: @pytest.fixture() def strategy(self) -> MAStackStrategy: return MAStackStrategy() @pytest.mark.asyncio async def test_full_bull_alignment(self, strategy: MAStackStrategy) -> None: market = _market( price=160.0, ema_9=155.0, ema_21=150.0, sma_50=145.0, sma_200=140.0, ) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.LONG assert signal.strength > 0.5 @pytest.mark.asyncio async def test_full_bear_alignment(self, strategy: MAStackStrategy) -> None: market = _market( price=130.0, ema_9=135.0, ema_21=140.0, sma_50=145.0, sma_200=150.0, ) signal = await strategy.evaluate("AAPL", market) assert signal is not None assert signal.direction == SignalDirection.SHORT assert signal.strength > 0.5 @pytest.mark.asyncio async def test_no_signal_tangled_mas(self, strategy: MAStackStrategy) -> None: market = _market( price=150.0, ema_9=151.0, ema_21=149.0, sma_50=152.0, sma_200=148.0, ) signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_no_signal_missing_mas(self, strategy: MAStackStrategy) -> None: market = _market(ema_9=155.0) # Missing others signal = await strategy.evaluate("AAPL", market) assert signal is None @pytest.mark.asyncio async def test_partial_bull(self, strategy: MAStackStrategy) -> None: # Price > EMA-9 > EMA-21, but below SMA-200 (partial bull) market = _market( price=155.0, ema_9=152.0, ema_21=148.0, sma_50=145.0, sma_200=160.0, ) signal = await strategy.evaluate("AAPL", market) # Should still produce LONG but with lower strength if signal is not None: assert signal.direction == SignalDirection.LONG assert signal.strength < 0.8 # =================================================================== # Cross-strategy tests # =================================================================== class TestNewStrategyCrossChecks: def test_all_new_strategies_inherit_base(self) -> None: for cls in (ValueStrategy, MACDCrossoverStrategy, BollingerBreakoutStrategy, VWAPStrategy, LiquidityStrategy, MAStackStrategy): assert issubclass(cls, BaseStrategy) def test_all_strategy_names_unique(self) -> None: strategies = [ ValueStrategy(), MACDCrossoverStrategy(), BollingerBreakoutStrategy(), VWAPStrategy(), LiquidityStrategy(), MAStackStrategy(), ] names = [s.name for s in strategies] assert len(names) == len(set(names)) def test_strategy_names_non_empty(self) -> None: for cls in (ValueStrategy, MACDCrossoverStrategy, BollingerBreakoutStrategy, VWAPStrategy, LiquidityStrategy, MAStackStrategy): assert len(cls().name) > 0 ``` **Step 2: Run tests to verify they fail** Run: `python -m pytest tests/test_new_strategies.py -v` Expected: FAIL — strategy modules don't exist **Step 3: Implement all 6 strategies** Create each strategy file following the `BaseStrategy.evaluate()` interface. Each strategy: - Has a `name: str` class attribute - Implements `async def evaluate(self, ticker, market, sentiment) -> TradeSignal | None` - Returns `None` when required data is missing **`shared/strategies/value.py`:** ```python """Value investing strategy — trade on fundamental data.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class ValueStrategy(BaseStrategy): """Generate signals from fundamental metrics (EPS, P/E, PEG, etc.).""" name: str = "value" async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: f = market.fundamentals if f is None: return None # Need at least PEG and P/E to form an opinion if f.peg_ratio is None or f.pe_ratio is None: return None score = 0.0 # Positive = undervalued, negative = overvalued # PEG ratio: < 1.0 is undervalued, > 3.0 is overvalued if f.peg_ratio < 1.0: score += (1.0 - f.peg_ratio) # max 1.0 elif f.peg_ratio > 3.0: score -= min((f.peg_ratio - 3.0) / 3.0, 1.0) # P/E ratio: < 15 is cheap, > 40 is expensive if f.pe_ratio < 15: score += (15 - f.pe_ratio) / 15 elif f.pe_ratio > 40: score -= min((f.pe_ratio - 40) / 40, 1.0) # EPS: positive is good, negative is bad if f.eps_ttm is not None: if f.eps_ttm > 0: score += 0.2 elif f.eps_ttm < 0: score -= 0.3 # Revenue growth: positive growth is bullish if f.revenue_growth_yoy is not None: if f.revenue_growth_yoy > 0.1: score += 0.2 elif f.revenue_growth_yoy < -0.1: score -= 0.2 # Profit margin: healthy margin is bullish if f.profit_margin is not None: if f.profit_margin > 0.15: score += 0.1 elif f.profit_margin < 0: score -= 0.2 # Debt-to-equity: low is healthy if f.debt_to_equity is not None: if f.debt_to_equity > 3.0: score -= 0.2 elif f.debt_to_equity < 0.5: score += 0.1 # Determine direction and strength if score > 0.3: direction = SignalDirection.LONG elif score < -0.3: direction = SignalDirection.SHORT else: return None strength = max(0.0, min(1.0, abs(score) / 2.0)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) ``` **`shared/strategies/macd_crossover.py`:** ```python """MACD crossover strategy — trade on MACD/signal line crossovers.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class MACDCrossoverStrategy(BaseStrategy): """Detect MACD/signal line crossovers for trade signals.""" name: str = "macd_crossover" def __init__(self) -> None: self._prev_macd: dict[str, float] = {} self._prev_signal: dict[str, float] = {} async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: if market.macd is None or market.macd_signal is None: return None macd = market.macd sig = market.macd_signal prev_macd = self._prev_macd.get(ticker) prev_signal = self._prev_signal.get(ticker) # Store current state for next evaluation self._prev_macd[ticker] = macd self._prev_signal[ticker] = sig # Need previous state to detect a crossover if prev_macd is None or prev_signal is None: return None prev_diff = prev_macd - prev_signal curr_diff = macd - sig # Detect crossover: sign change if prev_diff <= 0 and curr_diff > 0: direction = SignalDirection.LONG elif prev_diff >= 0 and curr_diff < 0: direction = SignalDirection.SHORT else: return None # Strength: magnitude of histogram normalized by ATR if available histogram = abs(market.macd_histogram) if market.macd_histogram is not None else abs(curr_diff) if market.atr and market.atr > 0: raw_strength = histogram / market.atr else: raw_strength = min(histogram / 2.0, 1.0) strength = max(0.0, min(1.0, raw_strength)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) ``` **`shared/strategies/bollinger_breakout.py`:** ```python """Bollinger Bands breakout strategy.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class BollingerBreakoutStrategy(BaseStrategy): """Generate signals from Bollinger Band breakouts.""" name: str = "bollinger_breakout" async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: if market.bollinger_upper is None or market.bollinger_lower is None or market.bollinger_mid is None: return None price = market.current_price upper = market.bollinger_upper lower = market.bollinger_lower band_width = upper - lower if band_width <= 0: return None direction: SignalDirection | None = None if price > upper: # Breakout above upper band — check for volume confirmation avg_vol = _avg_volume(market.bars) if avg_vol and market.volume > avg_vol * 1.5: direction = SignalDirection.LONG else: # Above band but no volume = potential failed breakout, skip return None elif price < lower: # Below lower band — mean reversion bounce expected direction = SignalDirection.LONG else: return None # Strength: how far price is from the band, relative to band width if direction == SignalDirection.LONG and price > upper: raw_strength = (price - upper) / band_width elif price < lower: raw_strength = (lower - price) / band_width else: raw_strength = 0.3 strength = max(0.0, min(1.0, raw_strength)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) def _avg_volume(bars: list[dict]) -> float | None: """Compute average volume from bar dicts.""" if not bars: return None volumes = [b.get("volume", 0) for b in bars if "volume" in b] if not volumes: return None return sum(volumes) / len(volumes) ``` **`shared/strategies/vwap.py`:** ```python """VWAP strategy — trade on price crossing VWAP.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class VWAPStrategy(BaseStrategy): """Generate signals when price crosses VWAP with volume confirmation.""" name: str = "vwap" def __init__(self) -> None: self._prev_price: dict[str, float] = {} self._prev_vwap: dict[str, float] = {} async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: if market.vwap is None: return None price = market.current_price vwap = market.vwap prev_price = self._prev_price.get(ticker) prev_vwap = self._prev_vwap.get(ticker) self._prev_price[ticker] = price self._prev_vwap[ticker] = vwap if prev_price is None or prev_vwap is None: return None prev_diff = prev_price - prev_vwap curr_diff = price - vwap # Detect crossover if prev_diff <= 0 and curr_diff > 0: direction = SignalDirection.LONG elif prev_diff >= 0 and curr_diff < 0: direction = SignalDirection.SHORT else: return None # Strength: distance from VWAP as % of price, amplified by relative volume distance_pct = abs(curr_diff) / price if price > 0 else 0 vol_ratio = 1.0 avg_vol = _avg_volume(market.bars) if avg_vol and avg_vol > 0: vol_ratio = min(market.volume / avg_vol, 3.0) / 3.0 raw_strength = distance_pct * 20 * vol_ratio # Scale up since % distance is small strength = max(0.0, min(1.0, raw_strength)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) def _avg_volume(bars: list[dict]) -> float | None: if not bars: return None volumes = [b.get("volume", 0) for b in bars if "volume" in b] return sum(volumes) / len(volumes) if volumes else None ``` **`shared/strategies/liquidity.py`:** ```python """Liquidity strategy — trade on volume patterns.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class LiquidityStrategy(BaseStrategy): """Analyze volume-based liquidity to confirm directional moves.""" name: str = "liquidity" async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: if not market.bars or len(market.bars) < 5: return None # Compute average volume from bars volumes = [b.get("volume", 0) for b in market.bars if "volume" in b] if not volumes: return None avg_vol = sum(volumes) / len(volumes) if avg_vol <= 0: return None relative_volume = market.volume / avg_vol # Thin liquidity — unreliable, skip if relative_volume < 1.0: return None # Determine price direction from bars closes = [b.get("close", 0) for b in market.bars if "close" in b] if len(closes) < 2: return None # Compare current price to recent average close recent_avg = sum(closes[-5:]) / min(len(closes), 5) price_change = (market.current_price - recent_avg) / recent_avg if recent_avg > 0 else 0 if relative_volume >= 2.0 and price_change > 0.001: direction = SignalDirection.LONG elif relative_volume >= 2.0 and price_change < -0.001: direction = SignalDirection.SHORT elif price_change > 0.001 and relative_volume < 0.7: # Price rising on declining volume — weak rally direction = SignalDirection.SHORT else: return None # Strength based on relative volume magnitude raw_strength = min(relative_volume / 4.0, 1.0) strength = max(0.0, min(1.0, raw_strength)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) ``` **`shared/strategies/ma_stack.py`:** ```python """MA Stack strategy — read the full 5-MA stack for trend alignment.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class MAStackStrategy(BaseStrategy): """Assess trend strength from the alignment of 5 moving averages.""" name: str = "ma_stack" def __init__(self) -> None: self._prev_sma50: dict[str, float] = {} self._prev_sma200: dict[str, float] = {} async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: # Need all MAs if any(v is None for v in [market.ema_9, market.ema_21, market.sma_50, market.sma_200]): return None price = market.current_price ema_9 = market.ema_9 ema_21 = market.ema_21 sma_50 = market.sma_50 sma_200 = market.sma_200 # Count bull alignment: each pair in order scores a point values = [price, ema_9, ema_21, sma_50, sma_200] bull_score = sum(1 for i in range(len(values) - 1) if values[i] > values[i + 1]) bear_score = sum(1 for i in range(len(values) - 1) if values[i] < values[i + 1]) # Golden/death cross detection prev_50 = self._prev_sma50.get(ticker) prev_200 = self._prev_sma200.get(ticker) self._prev_sma50[ticker] = sma_50 self._prev_sma200[ticker] = sma_200 cross_bonus = 0.0 if prev_50 is not None and prev_200 is not None: if prev_50 <= prev_200 and sma_50 > sma_200: cross_bonus = 0.2 # Golden cross elif prev_50 >= prev_200 and sma_50 < sma_200: cross_bonus = -0.2 # Death cross if bull_score >= 3: direction = SignalDirection.LONG raw_strength = bull_score / 4.0 + max(cross_bonus, 0) elif bear_score >= 3: direction = SignalDirection.SHORT raw_strength = bear_score / 4.0 + max(-cross_bonus, 0) else: # Tangled — no clear trend return None strength = max(0.0, min(1.0, raw_strength)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) ``` **Step 4: Update `shared/strategies/__init__.py`** Add imports for all 6 new strategies and update `__all__`. **Step 5: Run tests** Run: `python -m pytest tests/test_new_strategies.py tests/test_strategies.py -v` Expected: All PASS **Step 6: Commit** ```bash git add shared/strategies/ tests/test_new_strategies.py git commit -m "feat: add 6 new strategies (value, MACD, Bollinger, VWAP, liquidity, MA stack)" ``` --- ## Task 6: Wire everything into the signal generator **Files:** - Modify: `services/signal_generator/main.py` - Modify: `services/signal_generator/config.py` - Modify: `.env` - Modify: `scripts/seed_strategies.py` - Modify: `pyproject.toml` **Step 1: Update signal generator config** In `services/signal_generator/config.py`, add: ```python alpha_vantage_api_key: str = "" fmp_api_key: str = "" fundamentals_cache_ttl_hours: int = 24 ``` **Step 2: Update `.env`** Add: ``` TRADING_ALPHA_VANTAGE_API_KEY=M0I3TWB6VKU0UF51 TRADING_FMP_API_KEY=34zqbQFeRxYvPtzp3Y5QLKPVPztkZyfK TRADING_FUNDAMENTALS_CACHE_TTL_HOURS=24 TRADING_HISTORICAL_BARS=250 ``` **Step 3: Update `services/signal_generator/main.py`** 1. Import the 6 new strategies. 2. Update `_DEFAULT_WEIGHTS` to include all 9 strategies (equal weight ~0.111). 3. Add fundamentals initialization: - Create `RotatingProvider` with all three providers - Wrap with `CachedFundamentalsProvider` - Fetch fundamentals for watchlist tickers on startup - Store in a `dict[str, FundamentalsSnapshot]` 4. In `_consume_scored_articles`, inject fundamentals into `MarketSnapshot` before calling ensemble: ```python if fundamentals_cache: snapshot.fundamentals = fundamentals_cache.get(ticker) ``` 5. Add a daily refresh background task for fundamentals. **Step 4: Update `scripts/seed_strategies.py`** Add 6 new strategies to `DEFAULT_STRATEGIES` with descriptions and equal weights (recalculate to ~0.111). **Step 5: Update `pyproject.toml`** Add `yfinance` to the `trading` optional dependency group: ```toml trading = ["alpaca-py>=0.21", "pytz>=2024.1", "yfinance>=0.2"] ``` Add `httpx` to the `trading` group if not already available (it's in `news` group but signal-generator uses the `trading` group): ```toml trading = ["alpaca-py>=0.21", "pytz>=2024.1", "yfinance>=0.2", "httpx>=0.27"] ``` **Step 6: Run all tests** Run: `python -m pytest tests/ -v -m "not integration" --timeout=30` Expected: All tests PASS (should be ~270+ tests now) **Step 7: Commit** ```bash git add services/signal_generator/ scripts/seed_strategies.py .env pyproject.toml git commit -m "feat: wire 6 new strategies and fundamentals into signal generator" ``` --- ## Task 7: Update MarketDataManager max_bars default **Files:** - Modify: `services/signal_generator/market_data.py:16` **Step 1: Update default max bars** Change `_DEFAULT_MAX_BARS = 100` to `_DEFAULT_MAX_BARS = 250`. **Step 2: Run tests** Run: `python -m pytest tests/test_indicators.py tests/ -v -m "not integration" --timeout=30` Expected: All PASS **Step 3: Commit** ```bash git add services/signal_generator/market_data.py git commit -m "feat: increase default max bars to 250 for SMA-200 support" ``` --- ## Task 8: Run migration and seed new strategies **Step 1: Apply migration (if docker is running)** Run: `docker compose exec api-gateway python -m alembic upgrade head` **Step 2: Seed new strategies** Run: `docker compose exec api-gateway python -m scripts.seed_strategies` **Step 3: Rebuild and restart services** Run: `docker compose build signal-generator && docker compose up -d signal-generator` **Step 4: Verify** Check logs: `docker compose logs signal-generator --tail 50` Expected: All 9 strategies loaded, fundamentals fetched for watchlist tickers.