phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""Market context feature computation for aggregation windows.
|
||||
|
||||
Fetches recent market snapshots from PostgreSQL and computes context
|
||||
features (price change, volume trend, volatility) that enrich trend
|
||||
summaries and modulate signal weighting.
|
||||
|
||||
Requirements: 6.1, 6.2
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.shared.schemas import MarketContext, TrendWindow
|
||||
|
||||
# Map TrendWindow values to lookback durations in days.
|
||||
WINDOW_LOOKBACK_DAYS: dict[str, int] = {
|
||||
TrendWindow.INTRADAY.value: 1,
|
||||
TrendWindow.ONE_DAY.value: 2,
|
||||
TrendWindow.SEVEN_DAY.value: 8,
|
||||
TrendWindow.THIRTY_DAY.value: 35,
|
||||
TrendWindow.NINETY_DAY.value: 95,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_market_context(
|
||||
pool: asyncpg.Pool,
|
||||
ticker: str,
|
||||
window: str,
|
||||
reference_time: datetime | None = None,
|
||||
) -> MarketContext:
|
||||
"""Build a MarketContext for *ticker* over the given trend *window*.
|
||||
|
||||
Queries the ``market_snapshots`` table for recent bars and computes:
|
||||
- price_change_pct: (last_close - first_close) / first_close
|
||||
- avg_volume: mean volume across bars
|
||||
- volume_change_pct: second-half avg volume vs first-half avg volume
|
||||
- volatility: std-dev of close prices
|
||||
- latest_close / latest_bar_at
|
||||
|
||||
Returns a MarketContext with ``bars_available == 0`` when no data exists.
|
||||
"""
|
||||
if reference_time is None:
|
||||
reference_time = datetime.now(timezone.utc)
|
||||
|
||||
lookback_days = WINDOW_LOOKBACK_DAYS.get(window, 8)
|
||||
start = reference_time - timedelta(days=lookback_days)
|
||||
|
||||
rows = await pool.fetch(
|
||||
"""
|
||||
SELECT data, captured_at
|
||||
FROM market_snapshots
|
||||
WHERE ticker = $1
|
||||
AND captured_at >= $2
|
||||
AND captured_at <= $3
|
||||
ORDER BY captured_at ASC
|
||||
""",
|
||||
ticker,
|
||||
start,
|
||||
reference_time,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return MarketContext(ticker=ticker)
|
||||
|
||||
bars = _extract_bars(rows)
|
||||
if not bars:
|
||||
return MarketContext(ticker=ticker)
|
||||
|
||||
return _compute_context(ticker, bars)
|
||||
|
||||
|
||||
def _extract_bars(rows: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Extract OHLCV bar dicts from market_snapshot rows.
|
||||
|
||||
The ``data`` column is JSONB. Polygon prev-day bars store fields like
|
||||
``o``, ``h``, ``l``, ``c``, ``v``, ``t``. We normalise to a common
|
||||
dict with ``close``, ``volume``, ``captured_at``.
|
||||
"""
|
||||
bars: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
data = row["data"]
|
||||
if isinstance(data, str):
|
||||
import json
|
||||
data = json.loads(data)
|
||||
|
||||
# Polygon-style single bar or list of bars
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items:
|
||||
close = item.get("c") or item.get("close")
|
||||
volume = item.get("v") or item.get("volume")
|
||||
if close is not None:
|
||||
bars.append({
|
||||
"close": float(close),
|
||||
"volume": float(volume) if volume is not None else 0.0,
|
||||
"captured_at": row["captured_at"],
|
||||
})
|
||||
return bars
|
||||
|
||||
|
||||
def _compute_context(ticker: str, bars: list[dict[str, Any]]) -> MarketContext:
|
||||
"""Derive market context features from a sorted list of bar dicts."""
|
||||
closes = [b["close"] for b in bars]
|
||||
volumes = [b["volume"] for b in bars]
|
||||
|
||||
first_close = closes[0]
|
||||
last_close = closes[-1]
|
||||
|
||||
price_change_pct = (
|
||||
((last_close - first_close) / first_close * 100.0)
|
||||
if first_close != 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
avg_volume = sum(volumes) / len(volumes) if volumes else 0.0
|
||||
|
||||
# Volume trend: compare second half to first half
|
||||
mid = len(volumes) // 2
|
||||
if mid > 0:
|
||||
first_half_avg = sum(volumes[:mid]) / mid
|
||||
second_half_avg = sum(volumes[mid:]) / len(volumes[mid:])
|
||||
volume_change_pct = (
|
||||
((second_half_avg - first_half_avg) / first_half_avg * 100.0)
|
||||
if first_half_avg > 0
|
||||
else 0.0
|
||||
)
|
||||
else:
|
||||
volume_change_pct = 0.0
|
||||
|
||||
# Volatility: std dev of closes
|
||||
if len(closes) > 1:
|
||||
mean_close = sum(closes) / len(closes)
|
||||
variance = sum((c - mean_close) ** 2 for c in closes) / len(closes)
|
||||
volatility = math.sqrt(variance)
|
||||
else:
|
||||
volatility = 0.0
|
||||
|
||||
return MarketContext(
|
||||
ticker=ticker,
|
||||
price_change_pct=round(price_change_pct, 4),
|
||||
avg_volume=round(avg_volume, 2),
|
||||
volume_change_pct=round(volume_change_pct, 4),
|
||||
volatility=round(volatility, 6),
|
||||
latest_close=last_close,
|
||||
latest_bar_at=bars[-1]["captured_at"],
|
||||
bars_available=len(bars),
|
||||
)
|
||||
Reference in New Issue
Block a user