"""Incremental OHLC accumulation. Kraken REST /public/OHLC returns at most the 720 most recent bars per (pair, interval) — older data cannot be retrieved from the exchange, so history is *accumulated* by polling: each poll stores closed bars, and the still-forming last bar is stored with closed=false (and overwritten on the next poll). Coverage therefore grows from first deployment onward (720 × interval backwards from the first run). Gaps are detected and recorded. """ from __future__ import annotations import time import asyncpg from ktrade.kraken.models import PairMeta from ktrade.kraken.rest import KrakenREST from ktrade.logging import get_logger log = get_logger("ingest.backfill") async def upsert_candles(conn: asyncpg.Connection, symbol: str, interval: int, rows: list[tuple], source: str) -> int: if not rows: return 0 await conn.executemany( """INSERT INTO candles (symbol, interval_min, ts, open, high, low, close, vwap, volume, count, closed, source, ingested_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now()) ON CONFLICT (symbol, interval_min, ts) DO UPDATE SET open=EXCLUDED.open, high=EXCLUDED.high, low=EXCLUDED.low, close=EXCLUDED.close, vwap=EXCLUDED.vwap, volume=EXCLUDED.volume, count=EXCLUDED.count, closed=EXCLUDED.closed OR candles.closed, source=EXCLUDED.source, ingested_at=now() WHERE candles.closed = false OR EXCLUDED.closed = true""", [(symbol, interval, *r, source) for r in rows], ) return len(rows) async def backfill_pair(rest: KrakenREST, pool: asyncpg.Pool, pair: PairMeta, interval: int) -> int: key = f"ohlc:{pair.wsname}:{interval}" state = await pool.fetchval("SELECT value FROM ingestion_state WHERE key=$1", key) since = int(state["last"]) if state and state.get("last") else None candles, last = await rest.ohlc(pair.pair_id, interval, since) if not candles: return 0 now = int(time.time()) rows = [] for c in candles: closed = c.closed_at(interval) <= now rows.append((c.ts, c.open, c.high, c.low, c.close, c.vwap, c.volume, c.count, closed)) async with pool.acquire() as conn, conn.transaction(): n = await upsert_candles(conn, pair.wsname, interval, rows, "rest") await conn.execute( "INSERT INTO ingestion_state (key, value, updated_at) VALUES ($1, $2, now()) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()", key, {"last": last, "polled_at": now, "rows": n, "first_ts": min(r[0] for r in rows)}, ) return n async def detect_gaps(pool: asyncpg.Pool, symbol: str, interval: int) -> int: """Record missing bars between consecutive stored bars. Kraken omits bars with zero trades on illiquid pairs, so a 'gap' may be genuine inactivity — they are reported, not silently filled.""" step = interval * 60 rows = await pool.fetch( """WITH s AS (SELECT ts, lag(ts) OVER (ORDER BY ts) AS prev FROM candles WHERE symbol=$1 AND interval_min=$2 AND closed) SELECT prev, ts FROM s WHERE prev IS NOT NULL AND ts - prev > $3""", symbol, interval, step, ) n = 0 async with pool.acquire() as conn: for r in rows: missing = (r["ts"] - r["prev"]) // step - 1 await conn.execute( "INSERT INTO data_gaps (symbol, interval_min, gap_start, gap_end, missing_bars) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (symbol, interval_min, gap_start) DO UPDATE SET gap_end=EXCLUDED.gap_end, missing_bars=EXCLUDED.missing_bars", symbol, interval, r["prev"] + step, r["ts"] - step, int(missing), ) n += 1 return n async def backfill_all(rest: KrakenREST, pool: asyncpg.Pool, pairs: dict[str, PairMeta], intervals: list[int]) -> dict[str, int]: out: dict[str, int] = {} for sym, p in pairs.items(): for iv in intervals: try: out[f"{sym}:{iv}"] = await backfill_pair(rest, pool, p, iv) await detect_gaps(pool, sym, iv) except Exception as e: # noqa: BLE001 log.warning("ingest.backfill.error", symbol=sym, interval=iv, error=str(e)) return out