diff --git a/alembic/versions/006_add_next_earnings_date.py b/alembic/versions/006_add_next_earnings_date.py new file mode 100644 index 0000000..c5cfe85 --- /dev/null +++ b/alembic/versions/006_add_next_earnings_date.py @@ -0,0 +1,29 @@ +"""add next_earnings_date to fundamental_data + +Revision ID: 006 +Revises: 005 +Create Date: 2026-06-15 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "006" +down_revision: Union[str, None] = "005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "fundamental_data", + sa.Column("next_earnings_date", sa.Date(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("fundamental_data", "next_earnings_date") diff --git a/app/models/fundamental.py b/app/models/fundamental.py index 4a479cf..d2af7bf 100644 --- a/app/models/fundamental.py +++ b/app/models/fundamental.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import DateTime, Float, ForeignKey, Text +from sqlalchemy import Date, DateTime, Float, ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -17,6 +17,7 @@ class FundamentalData(Base): revenue_growth: Mapped[float | None] = mapped_column(Float, nullable=True) earnings_surprise: Mapped[float | None] = mapped_column(Float, nullable=True) market_cap: Mapped[float | None] = mapped_column(Float, nullable=True) + next_earnings_date: Mapped[date | None] = mapped_column(Date, nullable=True) fetched_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) diff --git a/app/providers/fundamentals_chain.py b/app/providers/fundamentals_chain.py index 4631ee6..d2f4896 100644 --- a/app/providers/fundamentals_chain.py +++ b/app/providers/fundamentals_chain.py @@ -10,7 +10,7 @@ from __future__ import annotations import logging import os -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone from pathlib import Path import httpx @@ -59,6 +59,7 @@ class FinnhubFundamentalProvider: unavailable: dict[str, str] = {} api_symbol = _to_api_symbol(ticker) + today = date.today() async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client: profile_resp = await client.get( f"{self._base_url}/stock/profile2", @@ -72,11 +73,21 @@ class FinnhubFundamentalProvider: f"{self._base_url}/stock/earnings", params={"symbol": api_symbol, "limit": 1, "token": self._api_key}, ) + calendar_resp = await client.get( + f"{self._base_url}/calendar/earnings", + params={ + "symbol": api_symbol, + "from": today.isoformat(), + "to": (today + timedelta(days=120)).isoformat(), + "token": self._api_key, + }, + ) for resp, endpoint in ( (profile_resp, "profile2"), (metric_resp, "stock/metric"), (earnings_resp, "stock/earnings"), + (calendar_resp, "calendar/earnings"), ): if resp.status_code == 429: raise RateLimitError(f"Finnhub rate limit hit for {ticker} ({endpoint})") @@ -99,6 +110,8 @@ class FinnhubFundamentalProvider: first = earnings_payload[0] if isinstance(earnings_payload[0], dict) else {} earnings_surprise = _safe_float(first.get("surprisePercent")) + next_earnings_date = self._next_earnings(calendar_resp) + if pe_ratio is None: unavailable["pe_ratio"] = "not available from provider payload" if revenue_growth is None: @@ -115,9 +128,32 @@ class FinnhubFundamentalProvider: earnings_surprise=earnings_surprise, market_cap=market_cap, fetched_at=datetime.now(timezone.utc), + next_earnings_date=next_earnings_date, unavailable_fields=unavailable, ) + @staticmethod + def _next_earnings(resp: httpx.Response) -> date | None: + """Earliest upcoming earnings date from Finnhub's calendar payload.""" + try: + payload = resp.json() if resp.text else {} + except ValueError: + return None + entries = payload.get("earningsCalendar", []) if isinstance(payload, dict) else [] + dates: list[date] = [] + today = date.today() + for entry in entries if isinstance(entries, list) else []: + raw = entry.get("date") if isinstance(entry, dict) else None + if not raw: + continue + try: + parsed = date.fromisoformat(raw) + except ValueError: + continue + if parsed >= today: + dates.append(parsed) + return min(dates) if dates else None + class AlphaVantageFundamentalProvider: """Fundamentals provider backed by Alpha Vantage free endpoints.""" @@ -235,9 +271,10 @@ class ChainedFundamentalProvider: field_source: dict[str, str] = {} errors: list[str] = [] rate_limited = False + next_earnings_date = None for provider_name, provider in self._providers: - if all(merged[f] is not None for f in _FUNDAMENTAL_FIELDS): + if all(merged[f] is not None for f in _FUNDAMENTAL_FIELDS) and next_earnings_date: break try: data = await provider.fetch_fundamentals(ticker) @@ -249,6 +286,9 @@ class ChainedFundamentalProvider: errors.append(f"{provider_name}: {type(exc).__name__}: {exc}") continue + if next_earnings_date is None and data.next_earnings_date is not None: + next_earnings_date = data.next_earnings_date + for field in _FUNDAMENTAL_FIELDS: if merged[field] is None: value = getattr(data, field) @@ -287,6 +327,7 @@ class ChainedFundamentalProvider: earnings_surprise=merged["earnings_surprise"], market_cap=merged["market_cap"], fetched_at=datetime.now(timezone.utc), + next_earnings_date=next_earnings_date, unavailable_fields=unavailable, ) diff --git a/app/providers/protocol.py b/app/providers/protocol.py index 089e975..8911011 100644 --- a/app/providers/protocol.py +++ b/app/providers/protocol.py @@ -53,6 +53,7 @@ class FundamentalData: earnings_surprise: float | None market_cap: float | None fetched_at: datetime + next_earnings_date: date | None = None unavailable_fields: dict[str, str] = field(default_factory=dict) diff --git a/app/routers/fundamentals.py b/app/routers/fundamentals.py index 9189b4f..ffa61ef 100644 --- a/app/routers/fundamentals.py +++ b/app/routers/fundamentals.py @@ -42,6 +42,7 @@ async def read_fundamentals( revenue_growth=record.revenue_growth, earnings_surprise=record.earnings_surprise, market_cap=record.market_cap, + next_earnings_date=record.next_earnings_date, fetched_at=record.fetched_at, unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json), ) diff --git a/app/routers/ingestion.py b/app/routers/ingestion.py index 22b1d66..18c42e0 100644 --- a/app/routers/ingestion.py +++ b/app/routers/ingestion.py @@ -171,6 +171,7 @@ async def fetch_symbol( revenue_growth=fdata.revenue_growth, earnings_surprise=fdata.earnings_surprise, market_cap=fdata.market_cap, + next_earnings_date=fdata.next_earnings_date, unavailable_fields=fdata.unavailable_fields, ) sources_out["fundamentals"] = {"status": "ok", "message": None} diff --git a/app/scheduler.py b/app/scheduler.py index 3a87695..7901b0d 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -599,6 +599,7 @@ async def collect_fundamentals() -> None: revenue_growth=data.revenue_growth, earnings_surprise=data.earnings_surprise, market_cap=data.market_cap, + next_earnings_date=data.next_earnings_date, unavailable_fields=data.unavailable_fields, ) diff --git a/app/schemas/fundamental.py b/app/schemas/fundamental.py index cc07b0e..dd337e8 100644 --- a/app/schemas/fundamental.py +++ b/app/schemas/fundamental.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from pydantic import BaseModel @@ -15,5 +15,6 @@ class FundamentalResponse(BaseModel): revenue_growth: float | None = None earnings_surprise: float | None = None market_cap: float | None = None + next_earnings_date: date | None = None fetched_at: datetime | None = None unavailable_fields: dict[str, str] = {} diff --git a/app/services/fundamental_service.py b/app/services/fundamental_service.py index 2fde272..edcf6a2 100644 --- a/app/services/fundamental_service.py +++ b/app/services/fundamental_service.py @@ -38,6 +38,7 @@ async def store_fundamental( revenue_growth: float | None = None, earnings_surprise: float | None = None, market_cap: float | None = None, + next_earnings_date=None, unavailable_fields: dict[str, str] | None = None, ) -> FundamentalData: """Store or update fundamental data for a ticker. @@ -61,6 +62,7 @@ async def store_fundamental( existing.revenue_growth = revenue_growth existing.earnings_surprise = earnings_surprise existing.market_cap = market_cap + existing.next_earnings_date = next_earnings_date existing.fetched_at = now existing.unavailable_fields_json = unavailable_fields_json record = existing @@ -71,6 +73,7 @@ async def store_fundamental( revenue_growth=revenue_growth, earnings_surprise=earnings_surprise, market_cap=market_cap, + next_earnings_date=next_earnings_date, fetched_at=now, unavailable_fields_json=unavailable_fields_json, ) diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index c778d05..30a7180 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -12,8 +12,19 @@ interface RecommendationPanelProps { longSetup?: TradeSetup; shortSetup?: TradeSetup; currentPrice?: number; + nextEarningsDate?: string | null; } +/** Whole days from today until an ISO date (negative if past). */ +function daysUntil(iso: string): number | null { + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return null; + return Math.ceil((t - Date.now()) / 86_400_000); +} + +/** Earnings within the ~30-day target horizon can gap price through stop/target. */ +const EARNINGS_HORIZON_DAYS = 30; + /** * How far current price has drifted from the setup's entry. A setup whose * entry is far from the live price (price already ran toward target, or fell @@ -215,10 +226,11 @@ function RiskSettingsBar({ risk, update }: { risk: RiskSettings; update: (p: Par ); } -export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice }: RecommendationPanelProps) { +export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate }: RecommendationPanelProps) { const { settings: risk, update: updateRisk } = useRiskSettings(); const regime = useMarketRegime().data; const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; + const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null; const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action']; const preferredDirection = recommendationActionDirection(action); @@ -261,6 +273,17 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric + {earningsDays != null && earningsDays >= 0 && ( + earningsDays <= EARNINGS_HORIZON_DAYS ? ( +

+ ⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day + target horizon. A report can gap price through your stop or target; consider waiting or sizing down. +

+ ) : ( +

Next earnings: {nextEarningsDate} ({earningsDays} days).

+ ) + )} + {preferredDirection !== 'neutral' && preferredSetup ? (
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 465df43..cfbd866 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -285,6 +285,7 @@ export interface FundamentalResponse { revenue_growth: number | null; earnings_surprise: number | null; market_cap: number | null; + next_earnings_date: string | null; fetched_at: string | null; unavailable_fields: Record; } diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 6a35806..51c55f7 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -262,6 +262,7 @@ export default function TickerDetailPage() { longSetup={longSetup} shortSetup={shortSetup} currentPrice={priceInfo?.price} + nextEarningsDate={fundamentals.data?.next_earnings_date} /> {/* Chart — always visible */} diff --git a/tests/unit/test_fundamentals_chain_provider.py b/tests/unit/test_fundamentals_chain_provider.py index 09d4282..6bda245 100644 --- a/tests/unit/test_fundamentals_chain_provider.py +++ b/tests/unit/test_fundamentals_chain_provider.py @@ -153,3 +153,29 @@ async def test_rate_limited_but_complete_does_not_raise(): result = await provider.fetch_fundamentals("AAPL") assert result.pe_ratio == 20.0 + + +@pytest.mark.asyncio +async def test_chain_merges_next_earnings_date(): + """Earnings date is taken from the first provider that supplies it.""" + from datetime import date as _date + + primary = FundamentalData( + ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None, + market_cap=100.0, fetched_at=datetime.now(timezone.utc), + ) + + class _EarningsProvider: + async def fetch_fundamentals(self, ticker: str) -> FundamentalData: + return FundamentalData( + ticker=ticker, pe_ratio=10.0, revenue_growth=5.0, earnings_surprise=1.0, + market_cap=None, fetched_at=datetime.now(timezone.utc), + next_earnings_date=_date(2026, 7, 1), + ) + + provider = ChainedFundamentalProvider([ + ("fmp", _DataProvider(primary)), + ("finnhub", _EarningsProvider()), + ]) + result = await provider.fetch_fundamentals("AAPL") + assert result.next_earnings_date == _date(2026, 7, 1)