fix: surface empty OHLCV fetch as a warning, not success
Fetching a symbol the provider doesn't cover (e.g. RHM/Rheinmetall — Alpaca serves US listings only) returned 0 bars but reported "complete · Successfully ingested 0 records", which the UI showed as green success. fetch_and_ingest now returns a distinct `no_data` status when the provider returns nothing AND the ticker has no history (vs. "already up to date" when bars exist). The fetch endpoint maps it to a `warning` source status, and the fetch toast renders it as ⚠ with the provider message instead of success. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user