add earnings-date guard — warn when a report falls in the target horizon
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 25s

Finnhub's earnings calendar now supplies next_earnings_date through the
fundamentals chain; persisted on fundamental_data (migration 006) and exposed in
the fundamentals API. The recommendation panel warns when earnings fall within
the ~30-day target horizon (a report can gap price through stop/target) and
otherwise shows the next date. Informational only.

Deploy: run alembic upgrade (new fundamental_data.next_earnings_date column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:44:08 +02:00
parent c4f2673799
commit f0b92a9718
13 changed files with 136 additions and 6 deletions
@@ -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")
+3 -2
View File
@@ -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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -17,6 +17,7 @@ class FundamentalData(Base):
revenue_growth: Mapped[float | None] = mapped_column(Float, nullable=True) revenue_growth: Mapped[float | None] = mapped_column(Float, nullable=True)
earnings_surprise: 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) 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( fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False DateTime(timezone=True), nullable=False
) )
+43 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging import logging
import os import os
from datetime import datetime, timezone from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -59,6 +59,7 @@ class FinnhubFundamentalProvider:
unavailable: dict[str, str] = {} unavailable: dict[str, str] = {}
api_symbol = _to_api_symbol(ticker) api_symbol = _to_api_symbol(ticker)
today = date.today()
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client: async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
profile_resp = await client.get( profile_resp = await client.get(
f"{self._base_url}/stock/profile2", f"{self._base_url}/stock/profile2",
@@ -72,11 +73,21 @@ class FinnhubFundamentalProvider:
f"{self._base_url}/stock/earnings", f"{self._base_url}/stock/earnings",
params={"symbol": api_symbol, "limit": 1, "token": self._api_key}, 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 ( for resp, endpoint in (
(profile_resp, "profile2"), (profile_resp, "profile2"),
(metric_resp, "stock/metric"), (metric_resp, "stock/metric"),
(earnings_resp, "stock/earnings"), (earnings_resp, "stock/earnings"),
(calendar_resp, "calendar/earnings"),
): ):
if resp.status_code == 429: if resp.status_code == 429:
raise RateLimitError(f"Finnhub rate limit hit for {ticker} ({endpoint})") 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 {} first = earnings_payload[0] if isinstance(earnings_payload[0], dict) else {}
earnings_surprise = _safe_float(first.get("surprisePercent")) earnings_surprise = _safe_float(first.get("surprisePercent"))
next_earnings_date = self._next_earnings(calendar_resp)
if pe_ratio is None: if pe_ratio is None:
unavailable["pe_ratio"] = "not available from provider payload" unavailable["pe_ratio"] = "not available from provider payload"
if revenue_growth is None: if revenue_growth is None:
@@ -115,9 +128,32 @@ class FinnhubFundamentalProvider:
earnings_surprise=earnings_surprise, earnings_surprise=earnings_surprise,
market_cap=market_cap, market_cap=market_cap,
fetched_at=datetime.now(timezone.utc), fetched_at=datetime.now(timezone.utc),
next_earnings_date=next_earnings_date,
unavailable_fields=unavailable, 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: class AlphaVantageFundamentalProvider:
"""Fundamentals provider backed by Alpha Vantage free endpoints.""" """Fundamentals provider backed by Alpha Vantage free endpoints."""
@@ -235,9 +271,10 @@ class ChainedFundamentalProvider:
field_source: dict[str, str] = {} field_source: dict[str, str] = {}
errors: list[str] = [] errors: list[str] = []
rate_limited = False rate_limited = False
next_earnings_date = None
for provider_name, provider in self._providers: 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 break
try: try:
data = await provider.fetch_fundamentals(ticker) data = await provider.fetch_fundamentals(ticker)
@@ -249,6 +286,9 @@ class ChainedFundamentalProvider:
errors.append(f"{provider_name}: {type(exc).__name__}: {exc}") errors.append(f"{provider_name}: {type(exc).__name__}: {exc}")
continue 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: for field in _FUNDAMENTAL_FIELDS:
if merged[field] is None: if merged[field] is None:
value = getattr(data, field) value = getattr(data, field)
@@ -287,6 +327,7 @@ class ChainedFundamentalProvider:
earnings_surprise=merged["earnings_surprise"], earnings_surprise=merged["earnings_surprise"],
market_cap=merged["market_cap"], market_cap=merged["market_cap"],
fetched_at=datetime.now(timezone.utc), fetched_at=datetime.now(timezone.utc),
next_earnings_date=next_earnings_date,
unavailable_fields=unavailable, unavailable_fields=unavailable,
) )
+1
View File
@@ -53,6 +53,7 @@ class FundamentalData:
earnings_surprise: float | None earnings_surprise: float | None
market_cap: float | None market_cap: float | None
fetched_at: datetime fetched_at: datetime
next_earnings_date: date | None = None
unavailable_fields: dict[str, str] = field(default_factory=dict) unavailable_fields: dict[str, str] = field(default_factory=dict)
+1
View File
@@ -42,6 +42,7 @@ async def read_fundamentals(
revenue_growth=record.revenue_growth, revenue_growth=record.revenue_growth,
earnings_surprise=record.earnings_surprise, earnings_surprise=record.earnings_surprise,
market_cap=record.market_cap, market_cap=record.market_cap,
next_earnings_date=record.next_earnings_date,
fetched_at=record.fetched_at, fetched_at=record.fetched_at,
unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json), unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json),
) )
+1
View File
@@ -171,6 +171,7 @@ async def fetch_symbol(
revenue_growth=fdata.revenue_growth, revenue_growth=fdata.revenue_growth,
earnings_surprise=fdata.earnings_surprise, earnings_surprise=fdata.earnings_surprise,
market_cap=fdata.market_cap, market_cap=fdata.market_cap,
next_earnings_date=fdata.next_earnings_date,
unavailable_fields=fdata.unavailable_fields, unavailable_fields=fdata.unavailable_fields,
) )
sources_out["fundamentals"] = {"status": "ok", "message": None} sources_out["fundamentals"] = {"status": "ok", "message": None}
+1
View File
@@ -599,6 +599,7 @@ async def collect_fundamentals() -> None:
revenue_growth=data.revenue_growth, revenue_growth=data.revenue_growth,
earnings_surprise=data.earnings_surprise, earnings_surprise=data.earnings_surprise,
market_cap=data.market_cap, market_cap=data.market_cap,
next_earnings_date=data.next_earnings_date,
unavailable_fields=data.unavailable_fields, unavailable_fields=data.unavailable_fields,
) )
+2 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import date, datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -15,5 +15,6 @@ class FundamentalResponse(BaseModel):
revenue_growth: float | None = None revenue_growth: float | None = None
earnings_surprise: float | None = None earnings_surprise: float | None = None
market_cap: float | None = None market_cap: float | None = None
next_earnings_date: date | None = None
fetched_at: datetime | None = None fetched_at: datetime | None = None
unavailable_fields: dict[str, str] = {} unavailable_fields: dict[str, str] = {}
+3
View File
@@ -38,6 +38,7 @@ async def store_fundamental(
revenue_growth: float | None = None, revenue_growth: float | None = None,
earnings_surprise: float | None = None, earnings_surprise: float | None = None,
market_cap: float | None = None, market_cap: float | None = None,
next_earnings_date=None,
unavailable_fields: dict[str, str] | None = None, unavailable_fields: dict[str, str] | None = None,
) -> FundamentalData: ) -> FundamentalData:
"""Store or update fundamental data for a ticker. """Store or update fundamental data for a ticker.
@@ -61,6 +62,7 @@ async def store_fundamental(
existing.revenue_growth = revenue_growth existing.revenue_growth = revenue_growth
existing.earnings_surprise = earnings_surprise existing.earnings_surprise = earnings_surprise
existing.market_cap = market_cap existing.market_cap = market_cap
existing.next_earnings_date = next_earnings_date
existing.fetched_at = now existing.fetched_at = now
existing.unavailable_fields_json = unavailable_fields_json existing.unavailable_fields_json = unavailable_fields_json
record = existing record = existing
@@ -71,6 +73,7 @@ async def store_fundamental(
revenue_growth=revenue_growth, revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise, earnings_surprise=earnings_surprise,
market_cap=market_cap, market_cap=market_cap,
next_earnings_date=next_earnings_date,
fetched_at=now, fetched_at=now,
unavailable_fields_json=unavailable_fields_json, unavailable_fields_json=unavailable_fields_json,
) )
@@ -12,8 +12,19 @@ interface RecommendationPanelProps {
longSetup?: TradeSetup; longSetup?: TradeSetup;
shortSetup?: TradeSetup; shortSetup?: TradeSetup;
currentPrice?: number; 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 * 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 * 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 { settings: risk, update: updateRisk } = useRiskSettings();
const regime = useMarketRegime().data; const regime = useMarketRegime().data;
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action']; const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
const preferredDirection = recommendationActionDirection(action); const preferredDirection = recommendationActionDirection(action);
@@ -261,6 +273,17 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<RiskSettingsBar risk={risk} update={updateRisk} /> <RiskSettingsBar risk={risk} update={updateRisk} />
{earningsDays != null && earningsDays >= 0 && (
earningsDays <= EARNINGS_HORIZON_DAYS ? (
<p className="rounded border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
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.
</p>
) : (
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
)
)}
{preferredDirection !== 'neutral' && preferredSetup ? ( {preferredDirection !== 'neutral' && preferredSetup ? (
<div className="space-y-3"> <div className="space-y-3">
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} /> <SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
+1
View File
@@ -285,6 +285,7 @@ export interface FundamentalResponse {
revenue_growth: number | null; revenue_growth: number | null;
earnings_surprise: number | null; earnings_surprise: number | null;
market_cap: number | null; market_cap: number | null;
next_earnings_date: string | null;
fetched_at: string | null; fetched_at: string | null;
unavailable_fields: Record<string, string>; unavailable_fields: Record<string, string>;
} }
+1
View File
@@ -262,6 +262,7 @@ export default function TickerDetailPage() {
longSetup={longSetup} longSetup={longSetup}
shortSetup={shortSetup} shortSetup={shortSetup}
currentPrice={priceInfo?.price} currentPrice={priceInfo?.price}
nextEarningsDate={fundamentals.data?.next_earnings_date}
/> />
{/* Chart — always visible */} {/* Chart — always visible */}
@@ -153,3 +153,29 @@ async def test_rate_limited_but_complete_does_not_raise():
result = await provider.fetch_fundamentals("AAPL") result = await provider.fetch_fundamentals("AAPL")
assert result.pe_ratio == 20.0 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)