from __future__ import annotations import json import re import sqlite3 from datetime import datetime from typing import Any from backend.data.contracts import MarketEntity class MarketRepository: def replace_calendar( self, connection: sqlite3.Connection, rows: tuple[dict[str, Any], ...], source: str, observed_at: str, ) -> None: connection.executemany( """ INSERT INTO trading_days (trade_date, is_open, previous_open_date, source, observed_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(trade_date) DO UPDATE SET is_open = excluded.is_open, previous_open_date = excluded.previous_open_date, source = excluded.source, observed_at = excluded.observed_at """, [ ( _display(str(row.get("cal_date") or "")), 1 if int(row.get("is_open") or 0) == 1 else 0, _display(str(row.get("pretrade_date") or "")) or None, source, observed_at, ) for row in rows if _display(str(row.get("cal_date") or "")) ], ) def replace_stocks( self, connection: sqlite3.Connection, rows: tuple[dict[str, Any], ...], source: str, observed_at: str, ) -> None: connection.executemany( """ INSERT INTO market_entities ( entity_type, identifier, code, name, search_key, sector, active, source, observed_at ) VALUES ('stock', ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(entity_type, identifier) DO UPDATE SET code = excluded.code, name = excluded.name, search_key = excluded.search_key, sector = excluded.sector, active = excluded.active, source = excluded.source, observed_at = excluded.observed_at """, [ ( str(row.get("ts_code") or "").upper(), str(row.get("symbol") or ""), str(row.get("name") or "").strip(), _search_key(row), str(row.get("industry") or "").strip() or None, 0 if row.get("list_status") == "D" else 1, source, observed_at, ) for row in rows if row.get("ts_code") and row.get("symbol") and row.get("name") ], ) def replace_themes( self, connection: sqlite3.Connection, rows: list[dict[str, Any]], source: str, observed_at: str, ) -> None: connection.executemany( """ INSERT INTO market_entities ( entity_type, identifier, code, name, search_key, sector, active, source, observed_at ) VALUES ('theme', ?, ?, ?, ?, NULL, 1, ?, ?) ON CONFLICT(entity_type, identifier) DO UPDATE SET code = excluded.code, name = excluded.name, search_key = excluded.search_key, active = 1, source = excluded.source, observed_at = excluded.observed_at """, [ ( str(row.get("code") or "").upper(), str(row.get("code") or "").split(".")[0], str(row.get("name") or "").strip(), _normalize( f"{row.get('code') or ''} {row.get('name') or ''}" ), source, observed_at, ) for row in rows if row.get("code") and row.get("name") ], ) def search( self, connection: sqlite3.Connection, query: str, limit: int = 32 ) -> tuple[MarketEntity, ...]: normalized = _normalize(query) if not normalized: return () rows = connection.execute( """ SELECT entity_type, identifier, code, name, sector FROM market_entities WHERE active = 1 AND search_key LIKE ? ORDER BY CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 WHEN code LIKE ? THEN 2 ELSE 3 END, entity_type, name LIMIT ? """, (f"%{normalized}%", normalized, query.strip(), f"{normalized}%", limit), ).fetchall() return tuple(MarketEntity(**dict(row)) for row in rows) def entity( self, connection: sqlite3.Connection, entity_type: str, identifier: str ) -> MarketEntity | None: row = connection.execute( """ SELECT entity_type, identifier, code, name, sector FROM market_entities WHERE entity_type = ? AND identifier = ? AND active = 1 """, (entity_type, identifier), ).fetchone() return MarketEntity(**dict(row)) if row else None def open_dates( self, connection: sqlite3.Connection, through: str, limit: int = 12 ) -> tuple[str, ...]: return tuple( str(row["trade_date"]) for row in connection.execute( """ SELECT trade_date FROM trading_days WHERE is_open = 1 AND trade_date <= ? ORDER BY trade_date DESC LIMIT ? """, (through, limit), ) ) def open_dates_between( self, connection: sqlite3.Connection, start_date: str, end_date: str ) -> tuple[str, ...]: return tuple( str(row["trade_date"]) for row in connection.execute( """ SELECT trade_date FROM trading_days WHERE is_open = 1 AND trade_date BETWEEN ? AND ? ORDER BY trade_date """, (start_date, end_date), ).fetchall() ) def active_stock_count(self, connection: sqlite3.Connection) -> int: row = connection.execute( """ SELECT COUNT(*) AS count FROM market_entities WHERE entity_type = 'stock' AND active = 1 """ ).fetchone() return int(row["count"] if row else 0) def stock_directory(self, connection: sqlite3.Connection) -> tuple[sqlite3.Row, ...]: return tuple( connection.execute( """ SELECT identifier, code, name, sector FROM market_entities WHERE entity_type = 'stock' AND active = 1 """ ).fetchall() ) def save_summary( self, connection: sqlite3.Connection, *, trade_date: str, observed_at: str, state: str, source: str, coverage: float, payload: dict[str, Any], ) -> None: connection.execute( """ INSERT INTO market_summaries (trade_date, observed_at, state, source, coverage, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date) DO UPDATE SET observed_at = excluded.observed_at, state = excluded.state, source = excluded.source, coverage = excluded.coverage, payload_json = excluded.payload_json, created_at = excluded.created_at """, ( trade_date, observed_at, state, source, coverage, json.dumps(payload, ensure_ascii=False, separators=(",", ":")), datetime.now().astimezone().isoformat(timespec="seconds"), ), ) def summaries( self, connection: sqlite3.Connection, through: str, limit: int = 260 ) -> tuple[sqlite3.Row, ...]: rows = connection.execute( """ SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ? """, (through, limit), ).fetchall() return tuple(reversed(rows)) def latest_summary(self, connection: sqlite3.Connection, through: str) -> sqlite3.Row | None: return connection.execute( "SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1", (through,), ).fetchone() def save_event_revision( self, connection: sqlite3.Connection, *, trade_date: str, identifier: str, event_type: str, reason: str, first_time: str, last_time: str, open_times: int | None, source: str, priority: int, created_by: int | None, created_at: str, ) -> int: cursor = connection.execute( """ INSERT INTO market_event_revisions ( trade_date, identifier, event_type, reason, first_time, last_time, open_times, source, priority, created_by, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( trade_date, identifier, event_type, reason, first_time, last_time, open_times, source, priority, created_by, created_at, ), ) return int(cursor.lastrowid) def event_revisions( self, connection: sqlite3.Connection, trade_date: str ) -> tuple[sqlite3.Row, ...]: return tuple( connection.execute( """ SELECT * FROM ( SELECT revisions.*, ROW_NUMBER() OVER ( PARTITION BY identifier, event_type ORDER BY priority DESC, id DESC ) AS rank FROM market_event_revisions AS revisions WHERE trade_date = ? ) WHERE rank = 1 """, (trade_date,), ).fetchall() ) def event_revision_history( self, connection: sqlite3.Connection, trade_date: str, identifier: str ) -> tuple[sqlite3.Row, ...]: return tuple( connection.execute( """ SELECT revisions.*, users.username AS created_by_name FROM market_event_revisions AS revisions LEFT JOIN users ON users.id = revisions.created_by WHERE revisions.trade_date = ? AND revisions.identifier = ? ORDER BY revisions.id DESC """, (trade_date, identifier), ).fetchall() ) def sector_members( self, connection: sqlite3.Connection, trade_date: str, sector_name: str ) -> sqlite3.Row | None: return connection.execute( """ SELECT * FROM sector_member_snapshots WHERE trade_date = ? AND sector_name = ? """, (trade_date, sector_name), ).fetchone() def save_sector_members( self, connection: sqlite3.Connection, *, trade_date: str, sector_name: str, sector_code: str, observed_at: str, source: str, coverage: float, payload: dict[str, Any], ) -> None: connection.execute( """ INSERT INTO sector_member_snapshots ( trade_date, sector_name, sector_code, observed_at, source, coverage, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, sector_name) DO UPDATE SET sector_code = excluded.sector_code, observed_at = excluded.observed_at, source = excluded.source, coverage = excluded.coverage, payload_json = excluded.payload_json """, ( trade_date, sector_name, sector_code, observed_at, source, coverage, json.dumps(payload, ensure_ascii=False, separators=(",", ":")), ), ) def insight_snapshot( self, connection: sqlite3.Connection, kind: str, trade_date: str, entity_key: str = "", ) -> sqlite3.Row | None: return connection.execute( """ SELECT * FROM market_insight_snapshots WHERE kind = ? AND trade_date = ? AND entity_key = ? """, (kind, trade_date, entity_key), ).fetchone() def latest_insight_snapshot( self, connection: sqlite3.Connection, kind: str, through: str, entity_key: str = "", ) -> sqlite3.Row | None: return connection.execute( """ SELECT * FROM market_insight_snapshots WHERE kind = ? AND trade_date <= ? AND entity_key = ? ORDER BY trade_date DESC LIMIT 1 """, (kind, through, entity_key), ).fetchone() def insight_snapshots( self, connection: sqlite3.Connection, kind: str, through: str, limit: int ) -> tuple[sqlite3.Row, ...]: rows = connection.execute( """ SELECT * FROM market_insight_snapshots WHERE kind = ? AND trade_date <= ? AND entity_key = '' ORDER BY trade_date DESC LIMIT ? """, (kind, through, limit), ).fetchall() return tuple(reversed(rows)) def save_insight_snapshot( self, connection: sqlite3.Connection, *, kind: str, trade_date: str, entity_key: str, observed_at: str, state: str, source: str, coverage: float, payload: dict[str, Any], ) -> None: connection.execute( """ INSERT INTO market_insight_snapshots ( kind, trade_date, entity_key, observed_at, state, source, coverage, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(kind, trade_date, entity_key) DO UPDATE SET observed_at = excluded.observed_at, state = excluded.state, source = excluded.source, coverage = excluded.coverage, payload_json = excluded.payload_json """, ( kind, trade_date, entity_key, observed_at, state, source, coverage, json.dumps(payload, ensure_ascii=False, separators=(",", ":")), ), ) def seat_aliases(self, connection: sqlite3.Connection) -> dict[str, str]: return { str(row["seat_name"]): str(row["alias_name"]) for row in connection.execute( "SELECT seat_name, alias_name FROM seat_aliases ORDER BY seat_name" ).fetchall() } def save_seat_alias( self, connection: sqlite3.Connection, seat_name: str, alias_name: str, updated_at: str, updated_by: int, ) -> None: connection.execute( """ INSERT INTO seat_aliases (seat_name, alias_name, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(seat_name) DO UPDATE SET alias_name = excluded.alias_name, updated_at = excluded.updated_at, updated_by = excluded.updated_by """, (seat_name, alias_name, updated_at, updated_by), ) def save_chart( self, connection: sqlite3.Connection, *, entity_type: str, identifier: str, interval: str, trade_date: str, observed_at: str, source: str, usage: str, adjustment: str, coverage: float, payload: dict[str, Any], ) -> None: connection.execute( """ INSERT INTO chart_series (entity_type, identifier, interval, trade_date, observed_at, source, usage, adjustment, coverage, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(entity_type, identifier, interval, trade_date) DO UPDATE SET observed_at = excluded.observed_at, source = excluded.source, usage = excluded.usage, adjustment = excluded.adjustment, coverage = excluded.coverage, payload_json = excluded.payload_json, created_at = excluded.created_at """, ( entity_type, identifier, interval, trade_date, observed_at, source, usage, adjustment, coverage, json.dumps(payload, ensure_ascii=False, separators=(",", ":")), datetime.now().astimezone().isoformat(timespec="seconds"), ), ) def chart( self, connection: sqlite3.Connection, entity_type: str, identifier: str, interval: str ) -> sqlite3.Row | None: return connection.execute( """ SELECT * FROM chart_series WHERE entity_type = ? AND identifier = ? AND interval = ? ORDER BY trade_date DESC LIMIT 1 """, (entity_type, identifier, interval), ).fetchone() def _display(value: str) -> str: compact = value.replace("-", "") if len(compact) != 8 or not compact.isdigit(): return "" return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" def _normalize(value: str) -> str: return re.sub(r"\s+", "", value).casefold() def _search_key(row: dict[str, Any]) -> str: return " ".join( filter( None, ( _normalize(str(row.get("symbol") or "")), _normalize(str(row.get("ts_code") or "")), _normalize(str(row.get("name") or "")), _normalize(str(row.get("industry") or "")), ), ) )