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
+ ⚠ 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 ? (