Add system alerts log with nav badge and Admin Alerts tab.
Deploy / lint (push) Successful in 8s
Deploy / test (push) Failing after 1m4s
Deploy / deploy (push) Has been skipped

Persist job and ingestion warnings/errors for 7 days, surface a dismissible top-nav badge, treat stale OHLCV as a warning (e.g. ticker renames), and show market bar age on the ticker freshness chip.
This commit is contained in:
2026-07-14 13:38:04 +02:00
parent ed82d0a665
commit f59c0c3484
16 changed files with 825 additions and 7 deletions
+31 -3
View File
@@ -59,6 +59,19 @@ async def _get_ohlcv_bar_count(db: AsyncSession, ticker_id: int) -> int:
return int(result.scalar() or 0)
async def _get_latest_ohlcv_date(db: AsyncSession, ticker_id: int) -> date | None:
result = await db.execute(
select(func.max(OHLCVRecord.date)).where(OHLCVRecord.ticker_id == ticker_id)
)
return result.scalar_one_or_none()
# If the provider returns no bars but our last stored session is older than this,
# treat the run as stale (not "success / up to date"). Common causes: ticker
# rename, delisting, or multi-day halt — SATS→ECHO is the canonical example.
_STALE_OHLCV_GAP_DAYS = 5
async def _update_progress(
db: AsyncSession, ticker_id: int, last_date: date
) -> None:
@@ -145,8 +158,9 @@ async def fetch_and_ingest(
# Provider returned nothing. With no history at all this almost always means
# the provider doesn't cover this symbol (Alpaca = US listings only) — surface
# that instead of a misleading "success". With existing bars it just means
# there were no new bars in the requested window.
# that instead of a misleading "success". With recent history, an empty window
# usually means weekends/holidays. With a multi-day gap, the symbol is likely
# halted, delisted, or *renamed* (e.g. SATS → ECHO) and we must not claim success.
if not records:
existing = await _get_ohlcv_bar_count(db, ticker.id)
if existing == 0:
@@ -160,10 +174,24 @@ async def fetch_and_ingest(
"(Alpaca serves US-listed securities only)."
),
)
latest = await _get_latest_ohlcv_date(db, ticker.id)
gap_days = (end_date - latest).days if latest is not None else None
if gap_days is not None and gap_days > _STALE_OHLCV_GAP_DAYS:
return IngestionResult(
symbol=ticker.symbol,
records_ingested=0,
last_date=latest,
status="stale",
message=(
f"No new bars since {latest.isoformat()} ({gap_days}d gap). "
"The symbol may be halted, delisted, or renamed under a new ticker — "
"check the listing and add/fetch the current symbol if it changed."
),
)
return IngestionResult(
symbol=ticker.symbol,
records_ingested=0,
last_date=None,
last_date=latest,
status="complete",
message="Already up to date — no new bars.",
)
+191
View File
@@ -0,0 +1,191 @@
"""Persist and query operational system events (warnings / errors)."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_event import SystemEvent
logger = logging.getLogger(__name__)
DEFAULT_LOOKBACK_DAYS = 7
DEFAULT_DEDUP_HOURS = 24
SEVERITIES = frozenset({"warning", "error"})
async def log_event(
db: AsyncSession,
*,
severity: str,
source: str,
code: str,
message: str,
symbol: str | None = None,
dedup_key: str | None = None,
dedup_hours: int = DEFAULT_DEDUP_HOURS,
) -> SystemEvent | None:
"""Insert a system event, optionally de-duplicating recent identical keys.
Returns the new row, or None when a recent dedup_key already exists.
Commits the session.
"""
severity = (severity or "").strip().lower()
if severity not in SEVERITIES:
severity = "warning"
source = (source or "system")[:64]
code = (code or "unknown")[:64]
message = (message or "").strip() or code
symbol = symbol.strip().upper()[:20] if symbol else None
dedup_key = dedup_key[:200] if dedup_key else None
if dedup_key:
cutoff = datetime.now(timezone.utc) - timedelta(hours=max(1, dedup_hours))
existing = await db.execute(
select(SystemEvent.id)
.where(
SystemEvent.dedup_key == dedup_key,
SystemEvent.created_at >= cutoff,
)
.limit(1)
)
if existing.scalar_one_or_none() is not None:
return None
row = SystemEvent(
severity=severity,
source=source,
code=code,
message=message[:4000],
symbol=symbol,
dedup_key=dedup_key,
created_at=datetime.now(timezone.utc),
)
db.add(row)
await db.commit()
await db.refresh(row)
return row
async def log_event_standalone(
*,
severity: str,
source: str,
code: str,
message: str,
symbol: str | None = None,
dedup_key: str | None = None,
) -> None:
"""Open a short-lived session and log an event (for scheduler / fire-and-forget)."""
try:
from app.database import async_session_factory
async with async_session_factory() as db:
await log_event(
db,
severity=severity,
source=source,
code=code,
message=message,
symbol=symbol,
dedup_key=dedup_key,
)
except Exception:
logger.exception("Failed to persist system event %s/%s", source, code)
async def list_events(
db: AsyncSession,
*,
days: int = DEFAULT_LOOKBACK_DAYS,
severity: str | None = None,
unacknowledged_only: bool = False,
limit: int = 200,
) -> list[SystemEvent]:
days = max(1, min(int(days), 30))
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
stmt = (
select(SystemEvent)
.where(SystemEvent.created_at >= cutoff)
.order_by(SystemEvent.created_at.desc())
.limit(max(1, min(limit, 500)))
)
if severity in SEVERITIES:
stmt = stmt.where(SystemEvent.severity == severity)
if unacknowledged_only:
stmt = stmt.where(SystemEvent.acknowledged_at.is_(None))
result = await db.execute(stmt)
return list(result.scalars().all())
async def summary(
db: AsyncSession,
*,
days: int = DEFAULT_LOOKBACK_DAYS,
) -> dict:
"""Counts for badge + admin header."""
days = max(1, min(int(days), 30))
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
await db.execute(
select(SystemEvent.severity, SystemEvent.acknowledged_at).where(
SystemEvent.created_at >= cutoff
)
)
).all()
total = len(rows)
unacked = 0
errors = 0
warnings = 0
for severity, acknowledged_at in rows:
if acknowledged_at is not None:
continue
unacked += 1
if severity == "error":
errors += 1
elif severity == "warning":
warnings += 1
return {
"days": days,
"total": total,
"unacknowledged": unacked,
"unacknowledged_errors": errors,
"unacknowledged_warnings": warnings,
}
async def acknowledge_all(
db: AsyncSession,
*,
days: int = DEFAULT_LOOKBACK_DAYS,
) -> int:
"""Mark unacknowledged events in the lookback window as dismissed. Returns count."""
days = max(1, min(int(days), 30))
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
now = datetime.now(timezone.utc)
result = await db.execute(
update(SystemEvent)
.where(
SystemEvent.created_at >= cutoff,
SystemEvent.acknowledged_at.is_(None),
)
.values(acknowledged_at=now)
)
await db.commit()
return int(result.rowcount or 0)
def event_to_dict(row: SystemEvent) -> dict:
return {
"id": row.id,
"severity": row.severity,
"source": row.source,
"code": row.code,
"message": row.message,
"symbol": row.symbol,
"created_at": row.created_at.isoformat() if row.created_at else None,
"acknowledged_at": row.acknowledged_at.isoformat() if row.acknowledged_at else None,
}