add earnings-date guard — warn when a report falls in the target horizon
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:
@@ -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")
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,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] = {}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user