diff --git a/app/routers/ingestion.py b/app/routers/ingestion.py index 18c42e0..1a94512 100644 --- a/app/routers/ingestion.py +++ b/app/routers/ingestion.py @@ -114,8 +114,9 @@ async def fetch_symbol( result = await ingestion_service.fetch_and_ingest( db, provider, symbol_upper, start_date, end_date ) + status_map = {"complete": "ok", "partial": "ok", "no_data": "warning"} sources_out["ohlcv"] = { - "status": "ok" if result.status in ("complete", "partial") else "error", + "status": status_map.get(result.status, "error"), "records": result.records_ingested, "message": result.message, } diff --git a/app/services/ingestion_service.py b/app/services/ingestion_service.py index 826a6db..94bd4b2 100644 --- a/app/services/ingestion_service.py +++ b/app/services/ingestion_service.py @@ -30,7 +30,7 @@ class IngestionResult: symbol: str records_ingested: int last_date: date | None - status: str # "complete" | "partial" | "error" + status: str # "complete" | "partial" | "error" | "no_data" message: str | None = None @@ -143,6 +143,31 @@ async def fetch_and_ingest( message=str(exc), ) + # 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. + if not records: + existing = await _get_ohlcv_bar_count(db, ticker.id) + if existing == 0: + return IngestionResult( + symbol=ticker.symbol, + records_ingested=0, + last_date=None, + status="no_data", + message=( + "No data returned by the provider — it may not cover this symbol " + "(Alpaca serves US-listed securities only)." + ), + ) + return IngestionResult( + symbol=ticker.symbol, + records_ingested=0, + last_date=None, + status="complete", + message="Already up to date — no new bars.", + ) + # Sort records by date to ensure ordered ingestion records.sort(key=lambda r: r.date) diff --git a/frontend/src/api/ingestion.ts b/frontend/src/api/ingestion.ts index 6fc6410..02fbb1c 100644 --- a/frontend/src/api/ingestion.ts +++ b/frontend/src/api/ingestion.ts @@ -1,7 +1,7 @@ import apiClient from './client'; export interface IngestionSourceResult { - status: 'ok' | 'error' | 'skipped'; + status: 'ok' | 'error' | 'skipped' | 'warning'; message?: string | null; records?: number; classification?: string; diff --git a/frontend/src/lib/ingestionStatus.ts b/frontend/src/lib/ingestionStatus.ts index 821b913..60dc6e5 100644 --- a/frontend/src/lib/ingestionStatus.ts +++ b/frontend/src/lib/ingestionStatus.ts @@ -25,6 +25,9 @@ export function summarizeIngestionResult( if (info.status === 'ok') { return `${label} ✓`; } + if (info.status === 'warning') { + return `${label} ⚠${info.message ? `: ${info.message}` : ': no data'}`; + } if (info.status === 'skipped') { return `${label}: skipped${info.message ? ` (${info.message})` : ''}`; } @@ -32,8 +35,10 @@ export function summarizeIngestionResult( }); const hasError = entries.some(([, source]) => source.status === 'error'); + const hasWarning = entries.some(([, source]) => source.status === 'warning'); const hasSkip = entries.some(([, source]) => source.status === 'skipped'); - const toastType: IngestionToastType = hasError ? 'error' : hasSkip ? 'info' : 'success'; + // A warning (e.g. 0 bars returned) must not read as success. + const toastType: IngestionToastType = hasError ? 'error' : hasWarning || hasSkip ? 'info' : 'success'; return { toastType, diff --git a/tests/unit/test_ingestion_service.py b/tests/unit/test_ingestion_service.py new file mode 100644 index 0000000..caec764 --- /dev/null +++ b/tests/unit/test_ingestion_service.py @@ -0,0 +1,62 @@ +"""Tests for the ingestion service — honest reporting of empty provider fetches.""" + +from __future__ import annotations + +from datetime import date, timedelta + +import pytest + +from app.models.ticker import Ticker +from app.providers.protocol import OHLCVData +from app.services import ingestion_service as svc +from tests.conftest import MockMarketDataProvider, _test_session_factory # type: ignore + + +@pytest.fixture +async def session(): + async with _test_session_factory() as s: + yield s + + +async def _add_ticker(session, symbol: str) -> None: + session.add(Ticker(symbol=symbol)) + await session.commit() + + +def _bars(symbol: str, n: int) -> list[OHLCVData]: + today = date.today() + return [ + OHLCVData(ticker=symbol, date=today - timedelta(days=i), + open=100.0, high=101.0, low=99.0, close=100.0, volume=1000) + for i in range(n) + ] + + +async def test_empty_fetch_on_new_ticker_reports_no_data(session): + # A non-US symbol (e.g. RHM/Rheinmetall) Alpaca doesn't cover → empty bars. + # This must NOT report success; it surfaces as no_data. + await _add_ticker(session, "RHM") + result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "RHM") + + assert result.status == "no_data" + assert result.records_ingested == 0 + assert "provider" in (result.message or "").lower() + + +async def test_happy_path_ingests_bars(session): + await _add_ticker(session, "AAA") + result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=_bars("AAA", 3)), "AAA") + + assert result.status == "complete" + assert result.records_ingested == 3 + + +async def test_empty_fetch_with_existing_history_is_up_to_date(session): + # Covered ticker, just no new bars in the window → complete, not no_data. + await _add_ticker(session, "BBB") + await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=_bars("BBB", 2)), "BBB") + + result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "BBB") + + assert result.status == "complete" + assert result.records_ingested == 0