Honor custom S/R tolerance as a transient detect, refresh levels after OHLCV mutations without failing committed price writes, report per-ticker S/R rebuild failures from admin cleanup, and warn in the admin UI when refresh is partial.
151 lines
4.8 KiB
Python
151 lines
4.8 KiB
Python
"""Price Store service: upsert and query OHLCV records."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date, datetime
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import insert_for_session
|
|
from app.exceptions import NotFoundError, ValidationError
|
|
from app.models.ohlcv import OHLCVRecord
|
|
from app.models.ticker import Ticker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
|
"""Look up a ticker by symbol. Raises NotFoundError if missing."""
|
|
normalised = symbol.strip().upper()
|
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
|
ticker = result.scalar_one_or_none()
|
|
if ticker is None:
|
|
raise NotFoundError(f"Ticker not found: {normalised}")
|
|
return ticker
|
|
|
|
|
|
def _validate_ohlcv(
|
|
high: float, low: float, open_: float, close: float, volume: int, record_date: date
|
|
) -> None:
|
|
"""Business-rule validation for an OHLCV record."""
|
|
if high < low:
|
|
raise ValidationError("Validation error: high must be >= low")
|
|
if any(p < 0 for p in (open_, high, low, close)):
|
|
raise ValidationError("Validation error: prices must be >= 0")
|
|
if volume < 0:
|
|
raise ValidationError("Validation error: volume must be >= 0")
|
|
if record_date > date.today():
|
|
raise ValidationError("Validation error: date must not be in the future")
|
|
|
|
|
|
async def upsert_ohlcv(
|
|
db: AsyncSession,
|
|
symbol: str,
|
|
record_date: date,
|
|
open_: float,
|
|
high: float,
|
|
low: float,
|
|
close: float,
|
|
volume: int,
|
|
*,
|
|
refresh_sr: bool = True,
|
|
) -> OHLCVRecord:
|
|
"""Insert or update an OHLCV record for (ticker, date).
|
|
|
|
Validates business rules, resolves ticker, then uses
|
|
ON CONFLICT DO UPDATE on the (ticker_id, date) unique constraint.
|
|
|
|
``refresh_sr`` (default True) recalculates persisted Structural S/R after
|
|
the write so chart levels stay current. Batch ingestion passes
|
|
``refresh_sr=False`` and refreshes once at the end of the ticker batch.
|
|
|
|
The OHLCV commit is authoritative: if S/R rebuild fails after a successful
|
|
price write, the error is logged, the session is rolled back to clear
|
|
poison, and the upsert still returns the persisted bar (caller can retry
|
|
S/R via the scanner/ingestion pipeline).
|
|
"""
|
|
_validate_ohlcv(high, low, open_, close, volume, record_date)
|
|
ticker = await _get_ticker(db, symbol)
|
|
|
|
stmt = insert_for_session(db, OHLCVRecord).values(
|
|
ticker_id=ticker.id,
|
|
date=record_date,
|
|
open=open_,
|
|
high=high,
|
|
low=low,
|
|
close=close,
|
|
volume=volume,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=["ticker_id", "date"],
|
|
set_={
|
|
"open": stmt.excluded.open,
|
|
"high": stmt.excluded.high,
|
|
"low": stmt.excluded.low,
|
|
"close": stmt.excluded.close,
|
|
"volume": stmt.excluded.volume,
|
|
"created_at": stmt.excluded.created_at,
|
|
},
|
|
)
|
|
stmt = stmt.returning(OHLCVRecord)
|
|
result = await db.execute(stmt)
|
|
await db.commit()
|
|
|
|
record = result.scalar_one()
|
|
|
|
from app.cache import indicator_cache
|
|
|
|
indicator_cache.invalidate_ticker(ticker.symbol)
|
|
|
|
if refresh_sr:
|
|
await _refresh_structural_sr_best_effort(db, ticker.symbol)
|
|
|
|
return record
|
|
|
|
|
|
async def _refresh_structural_sr_best_effort(db: AsyncSession, symbol: str) -> bool:
|
|
"""Rebuild Structural S/R; never fail a successful OHLCV write.
|
|
|
|
Returns True on success. On failure rolls the session back so a later
|
|
operation on the same session is not poisoned by the failed unit of work.
|
|
"""
|
|
from app.services.sr_service import recalculate_sr_levels
|
|
|
|
try:
|
|
await recalculate_sr_levels(db, symbol)
|
|
return True
|
|
except Exception:
|
|
logger.exception("Structural S/R refresh failed for %s after OHLCV write", symbol)
|
|
try:
|
|
await db.rollback()
|
|
except Exception:
|
|
logger.exception("Session rollback after S/R failure also failed for %s", symbol)
|
|
return False
|
|
|
|
|
|
async def query_ohlcv(
|
|
db: AsyncSession,
|
|
symbol: str,
|
|
start_date: date | None = None,
|
|
end_date: date | None = None,
|
|
) -> list[OHLCVRecord]:
|
|
"""Query OHLCV records for a ticker, optionally filtered by date range.
|
|
|
|
Returns records sorted by date ascending.
|
|
Raises NotFoundError if the ticker does not exist.
|
|
"""
|
|
ticker = await _get_ticker(db, symbol)
|
|
|
|
stmt = select(OHLCVRecord).where(OHLCVRecord.ticker_id == ticker.id)
|
|
if start_date is not None:
|
|
stmt = stmt.where(OHLCVRecord.date >= start_date)
|
|
if end_date is not None:
|
|
stmt = stmt.where(OHLCVRecord.date <= end_date)
|
|
stmt = stmt.order_by(OHLCVRecord.date.asc())
|
|
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|