from __future__ import annotations import json import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory from backend.features.accounts.repository import AccountRepositoryMixin from backend.features.market.repository import MarketRepositoryMixin from backend.features.pools.repository import PoolRepositoryMixin from backend.features.system.repository import SystemSettingsRepositoryMixin def _optional_float(value: Any) -> float | None: if value in (None, ""): return None try: return float(value) except (TypeError, ValueError): return None class ReviewDatabase( AccountRepositoryMixin, MarketRepositoryMixin, PoolRepositoryMixin, SystemSettingsRepositoryMixin, ): def __init__(self, path: Path) -> None: self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) self.connection_factory = SQLiteConnectionFactory(self.path) self._initialize() def connect(self) -> sqlite3.Connection: return self.connection_factory.connect() def _initialize(self) -> None: with self.connect() as connection: connection.executescript( """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_salt TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS user_sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, csrf_token TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, expires_at); CREATE TABLE IF NOT EXISTS user_credentials ( user_id INTEGER PRIMARY KEY, encrypted_payload TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS user_birth_profiles ( user_id INTEGER PRIMARY KEY, encrypted_payload TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS system_settings ( setting_key TEXT PRIMARY KEY, encrypted_payload TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS llm_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, feature TEXT NOT NULL, source TEXT NOT NULL, model TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, latency_ms INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_llm_usage_user_time ON llm_usage(user_id, created_at DESC); CREATE TABLE IF NOT EXISTS dashboard_snapshots ( trade_date TEXT PRIMARY KEY, source TEXT NOT NULL, payload TEXT NOT NULL, record_count INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sync_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, trade_date TEXT NOT NULL, source TEXT NOT NULL, status TEXT NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, record_count INTEGER NOT NULL DEFAULT 0, message TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_sync_runs_trade_date ON sync_runs(trade_date, id DESC); CREATE TABLE IF NOT EXISTS data_snapshots ( kind TEXT NOT NULL, cache_key TEXT NOT NULL, source TEXT NOT NULL, payload TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (kind, cache_key) ); CREATE TABLE IF NOT EXISTS watchlist ( user_id INTEGER NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, sector TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT 'red', remark TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (user_id, code), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS review_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, code TEXT NOT NULL DEFAULT '', stock_name TEXT NOT NULL DEFAULT '', trade_date TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', plan TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_review_notes_code_date ON review_notes(code, trade_date DESC, id DESC); CREATE TABLE IF NOT EXISTS reason_overrides ( trade_date TEXT NOT NULL, code TEXT NOT NULL, reason TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (trade_date, code) ); CREATE TABLE IF NOT EXISTS seat_aliases ( seat_name TEXT PRIMARY KEY, alias TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sector_phase_overrides ( name TEXT PRIMARY KEY, element TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS stock_master ( ts_code TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, industry TEXT NOT NULL DEFAULT '', market TEXT NOT NULL DEFAULT '', list_date TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_stock_master_code ON stock_master(code); CREATE TABLE IF NOT EXISTS daily_bars ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, open REAL NOT NULL DEFAULT 0, high REAL NOT NULL DEFAULT 0, low REAL NOT NULL DEFAULT 0, close REAL NOT NULL DEFAULT 0, pct_chg REAL NOT NULL DEFAULT 0, vol REAL NOT NULL DEFAULT 0, amount REAL NOT NULL DEFAULT 0, PRIMARY KEY (trade_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_daily_bars_code_date ON daily_bars(ts_code, trade_date DESC); CREATE TABLE IF NOT EXISTS benchmark_bars ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, close REAL NOT NULL DEFAULT 0, pct_chg REAL NOT NULL DEFAULT 0, PRIMARY KEY (trade_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_benchmark_bars_code_date ON benchmark_bars(ts_code, trade_date DESC); CREATE TABLE IF NOT EXISTS daily_indicators ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, turnover_rate REAL NOT NULL DEFAULT 0, volume_ratio REAL NOT NULL DEFAULT 0, total_mv REAL NOT NULL DEFAULT 0, circ_mv REAL NOT NULL DEFAULT 0, pe_ttm REAL, pb REAL, ps_ttm REAL, dv_ttm REAL, PRIMARY KEY (trade_date, ts_code) ); CREATE TABLE IF NOT EXISTS fundamental_indicators ( end_date TEXT NOT NULL, ann_date TEXT NOT NULL DEFAULT '', ts_code TEXT NOT NULL, roe REAL, roa REAL, roic REAL, grossprofit_margin REAL, netprofit_yoy REAL, or_yoy REAL, ocf_to_opincome REAL, PRIMARY KEY (end_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_fundamental_indicators_code_date ON fundamental_indicators(ts_code, ann_date DESC, end_date DESC); CREATE TABLE IF NOT EXISTS moneyflow_daily ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, net_mf_amount REAL NOT NULL DEFAULT 0, large_net_amount REAL NOT NULL DEFAULT 0, medium_net_amount REAL NOT NULL DEFAULT 0, small_net_amount REAL NOT NULL DEFAULT 0, PRIMARY KEY (trade_date, ts_code) ); CREATE TABLE IF NOT EXISTS auction_factors ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, pre_close REAL NOT NULL DEFAULT 0, change REAL NOT NULL DEFAULT 0, vol REAL NOT NULL DEFAULT 0, amount REAL NOT NULL DEFAULT 0, turnover_rate REAL NOT NULL DEFAULT 0, volume_ratio REAL NOT NULL DEFAULT 0, PRIMARY KEY (trade_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_auction_factors_code_date ON auction_factors(ts_code, trade_date DESC); CREATE TABLE IF NOT EXISTS earnings_events ( end_date TEXT NOT NULL, ann_date TEXT NOT NULL, ts_code TEXT NOT NULL, forecast_profit REAL, actual_profit REAL, surprise_pct REAL, revenue_yoy REAL, netprofit_yoy REAL, source TEXT NOT NULL DEFAULT '', PRIMARY KEY (end_date, ann_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_earnings_events_code_announcement ON earnings_events(ts_code, ann_date DESC, end_date DESC); CREATE TABLE IF NOT EXISTS popularity_factors ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, ths_rank INTEGER, dc_rank INTEGER, combined_score REAL NOT NULL DEFAULT 0, rank_change INTEGER, dual_source INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (trade_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_popularity_factors_code_date ON popularity_factors(ts_code, trade_date DESC); CREATE TABLE IF NOT EXISTS lhb_institution_daily ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, net_buy_amount REAL NOT NULL DEFAULT 0, buy_amount REAL NOT NULL DEFAULT 0, sell_amount REAL NOT NULL DEFAULT 0, seat_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (trade_date, ts_code) ); CREATE INDEX IF NOT EXISTS idx_lhb_institution_code_date ON lhb_institution_daily(ts_code, trade_date DESC); CREATE TABLE IF NOT EXISTS screener_strategies ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', regimes TEXT NOT NULL, formula TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS screener_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, trade_date TEXT NOT NULL, regime TEXT NOT NULL, mode TEXT NOT NULL DEFAULT 'smart', strategy_name TEXT NOT NULL, formula TEXT NOT NULL, result TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS mentor_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, mentor_id TEXT NOT NULL, trade_date TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, meta TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_mentor_messages_conversation ON mentor_messages(user_id, mentor_id, trade_date, id DESC); CREATE TABLE IF NOT EXISTS mentor_preferences ( user_id INTEGER NOT NULL, mentor_id TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY (user_id, mentor_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_mentor_preferences_user_order ON mentor_preferences(user_id, pinned DESC, sort_order, mentor_id); CREATE TABLE IF NOT EXISTS wencai_saved_queries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, query TEXT NOT NULL, search_type TEXT NOT NULL DEFAULT 'stock', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, query, search_type), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_wencai_saved_queries_user ON wencai_saved_queries(user_id, updated_at DESC, id DESC); CREATE TABLE IF NOT EXISTS strategy_tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, run_id INTEGER NOT NULL, selection_date TEXT NOT NULL, strategy_name TEXT NOT NULL, ts_code TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, sector TEXT NOT NULL DEFAULT '', entry_price REAL NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, run_id, ts_code), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_strategy_tracks_user_run ON strategy_tracks(user_id, run_id DESC, id); CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', available_date TEXT NOT NULL, code TEXT NOT NULL DEFAULT '', dedupe_key TEXT NOT NULL, is_read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, read_at TEXT, UNIQUE(user_id, dedupe_key), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_alerts_user_due ON alerts(user_id, available_date, is_read, id DESC); CREATE TABLE IF NOT EXISTS trade_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, trade_date TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, action TEXT NOT NULL, price REAL NOT NULL, quantity INTEGER NOT NULL DEFAULT 0, position_pct REAL NOT NULL DEFAULT 0, pnl_amount REAL, pnl_pct REAL, thesis TEXT NOT NULL DEFAULT '', execution TEXT NOT NULL DEFAULT '', emotion TEXT NOT NULL DEFAULT 'calm', tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_trade_entries_user_date ON trade_entries(user_id, trade_date DESC, id DESC); CREATE TABLE IF NOT EXISTS assistant_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, context_date TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_assistant_messages_user ON assistant_messages(user_id, id DESC); CREATE TABLE IF NOT EXISTS heaven_readings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, mode TEXT NOT NULL, context_date TEXT NOT NULL, subject TEXT NOT NULL, subject_detail TEXT NOT NULL DEFAULT '', answer TEXT NOT NULL, context_snapshot TEXT NOT NULL DEFAULT '{}', dedupe_key TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(user_id, dedupe_key), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_heaven_readings_user_mode ON heaven_readings(user_id, mode, context_date DESC, id DESC); """ ) user_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(users)") } migrations = { "role": "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'", "llm_mode": "ALTER TABLE users ADD COLUMN llm_mode TEXT NOT NULL DEFAULT 'auto'", "membership_status": "ALTER TABLE users ADD COLUMN membership_status TEXT NOT NULL DEFAULT 'inactive'", "membership_plan": "ALTER TABLE users ADD COLUMN membership_plan TEXT NOT NULL DEFAULT ''", "membership_starts_at": "ALTER TABLE users ADD COLUMN membership_starts_at TEXT", "membership_expires_at": "ALTER TABLE users ADD COLUMN membership_expires_at TEXT", } for column, statement in migrations.items(): if column not in user_columns: connection.execute(statement) indicator_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(daily_indicators)") } indicator_migrations = { "pe_ttm": "ALTER TABLE daily_indicators ADD COLUMN pe_ttm REAL", "pb": "ALTER TABLE daily_indicators ADD COLUMN pb REAL", "ps_ttm": "ALTER TABLE daily_indicators ADD COLUMN ps_ttm REAL", "dv_ttm": "ALTER TABLE daily_indicators ADD COLUMN dv_ttm REAL", } for column, statement in indicator_migrations.items(): if column not in indicator_columns: connection.execute(statement) connection.execute( """ UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users) AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin') """ ) watchlist_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(watchlist)") } if "user_id" not in watchlist_columns: connection.execute("ALTER TABLE watchlist RENAME TO watchlist_legacy") connection.execute( """ CREATE TABLE watchlist ( user_id INTEGER NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, sector TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT 'red', remark TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (user_id, code), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """ ) first_user = connection.execute("SELECT MIN(id) AS id FROM users").fetchone() if first_user and first_user["id"]: connection.execute( """ INSERT INTO watchlist (user_id, code, name, sector, color, created_at, updated_at) SELECT ?, code, name, sector, color, created_at, updated_at FROM watchlist_legacy """, (int(first_user["id"]),), ) connection.execute("DROP TABLE watchlist_legacy") watchlist_columns.add("remark") if "remark" not in watchlist_columns: connection.execute( "ALTER TABLE watchlist ADD COLUMN remark TEXT NOT NULL DEFAULT ''" ) note_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(review_notes)") } if "user_id" not in note_columns: connection.execute("ALTER TABLE review_notes ADD COLUMN user_id INTEGER") if "summary" not in note_columns: connection.execute( "ALTER TABLE review_notes ADD COLUMN summary TEXT NOT NULL DEFAULT ''" ) first_user = connection.execute("SELECT MIN(id) AS id FROM users").fetchone() if first_user and first_user["id"]: connection.execute( "UPDATE review_notes SET user_id = ? WHERE user_id IS NULL", (int(first_user["id"]),), ) connection.execute( """ CREATE INDEX IF NOT EXISTS idx_review_notes_user_date ON review_notes(user_id, trade_date DESC, id DESC) """ ) strategy_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_strategies)") } if "user_id" not in strategy_columns: connection.execute("ALTER TABLE screener_strategies ADD COLUMN user_id INTEGER") run_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)") } legacy_run_ownership = "user_id" not in run_columns if "user_id" not in run_columns: connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER") if "mode" not in run_columns: connection.execute( "ALTER TABLE screener_runs ADD COLUMN mode TEXT NOT NULL DEFAULT 'smart'" ) legacy_runs = connection.execute( "SELECT id, strategy_name, formula FROM screener_runs" ).fetchall() for run in legacy_runs: try: formula = json.loads(run["formula"]) except (TypeError, json.JSONDecodeError): formula = {} meta = formula.get("meta") if isinstance(formula, dict) else {} library = str((meta or {}).get("library") or "") category = str((meta or {}).get("category") or "") if library == "curated": mode = "curated" elif library == "quant" or ( library == "custom" and category == "量化公式" ) or str(run["strategy_name"] or "") == "自定义量化公式": mode = "quant" else: mode = "smart" connection.execute( "UPDATE screener_runs SET mode = ? WHERE id = ?", (mode, int(run["id"])), ) connection.execute( "UPDATE screener_runs SET user_id = NULL WHERE user_id = 0" ) if first_user and first_user["id"]: first_user_id = int(first_user["id"]) connection.execute( "UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL", (first_user_id,), ) if legacy_run_ownership: connection.execute( "UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL", (first_user_id,), ) else: connection.execute( """ UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL AND mode = 'quant' """, (first_user_id,), ) connection.execute( """ CREATE INDEX IF NOT EXISTS idx_screener_strategies_user ON screener_strategies(user_id, builtin, updated_at DESC) """ ) connection.execute( """ CREATE INDEX IF NOT EXISTS idx_screener_runs_user_date ON screener_runs(user_id, trade_date DESC, id DESC) """ ) connection.execute( """ CREATE INDEX IF NOT EXISTS idx_screener_runs_user_mode_date ON screener_runs(user_id, mode, trade_date DESC, id DESC) """ ) MigrationRunner().apply(connection, MIGRATIONS) def record_llm_usage( self, user_id: int, feature: str, source: str, model: str, status: str, latency_ms: int = 0, *, role: str = "", prompt_version: str = "", error_code: str = "", input_tokens: int = 0, output_tokens: int = 0, ) -> None: now = datetime.now(timezone.utc).isoformat(timespec="seconds") with self.connect() as connection: connection.execute( """ INSERT INTO llm_usage (user_id, feature, source, model, status, latency_ms, created_at, role, prompt_version, error_code, input_tokens, output_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user_id, feature, source, model, status, int(latency_ms), now, role, prompt_version, error_code, int(input_tokens), int(output_tokens), ), ) def count_llm_usage_since(self, user_id: int, source: str, since: str) -> int: with self.connect() as connection: row = connection.execute( """ SELECT COUNT(*) AS total FROM llm_usage WHERE user_id = ? AND source = ? AND created_at >= ? """, (user_id, source, since), ).fetchone() return int(row["total"] if row else 0) def list_watchlist(self, user_id: int) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( """ SELECT code, name, sector, color, remark, created_at, updated_at FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code """, (int(user_id),), ).fetchall() return [dict(row) for row in rows] def save_watchlist( self, user_id: int, code: str, name: str, sector: str, color: str, remark: str | None = None, ) -> None: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: existing = connection.execute( "SELECT remark FROM watchlist WHERE user_id = ? AND code = ?", (int(user_id), code), ).fetchone() saved_remark = ( str(existing["remark"] or "") if remark is None and existing else str(remark or "") ) connection.execute( """ INSERT INTO watchlist (user_id, code, name, sector, color, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, code) DO UPDATE SET name = excluded.name, sector = excluded.sector, color = excluded.color, remark = excluded.remark, updated_at = excluded.updated_at """, (int(user_id), code, name, sector, color, saved_remark, now, now), ) def watchlist_price_history( self, codes: list[str], end_date: str, limit_per_code: int = 6 ) -> dict[str, list[dict[str, Any]]]: result: dict[str, list[dict[str, Any]]] = {} if not codes: return result with self.connect() as connection: for code in codes: rows = connection.execute( """ SELECT trade_date, ts_code, close, pct_chg FROM daily_bars WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ? ORDER BY trade_date DESC LIMIT ? """, (str(code), end_date, int(limit_per_code)), ).fetchall() result[str(code)] = [dict(row) for row in reversed(rows)] return result def delete_watchlist(self, user_id: int, code: str) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM watchlist WHERE user_id = ? AND code = ?", (int(user_id), code), ) return cursor.rowcount > 0 def list_notes( self, user_id: int, code: str = "", trade_date: str = "", scope: str = "all", ) -> list[dict[str, Any]]: clauses: list[str] = ["user_id = ?"] parameters: list[Any] = [int(user_id)] if scope == "daily": clauses.append("code = ''") elif scope == "stock": clauses.append("code <> ''") if code: clauses.append("code = ?") parameters.append(code) if trade_date: clauses.append("trade_date = ?") parameters.append(trade_date) where = f"WHERE {' AND '.join(clauses)}" if clauses else "" with self.connect() as connection: rows = connection.execute( f""" SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at FROM review_notes {where} ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200 """, parameters, ).fetchall() return [dict(row) for row in rows] def save_note( self, user_id: int, code: str, stock_name: str, trade_date: str, content: str, plan: str, note_id: int | None = None, summary: str = "", ) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: if note_id: cursor = connection.execute( """ UPDATE review_notes SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ? WHERE id = ? AND user_id = ? """, (code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)), ) if cursor.rowcount == 0: raise ValueError("复盘笔记不存在。") return note_id cursor = connection.execute( """ INSERT INTO review_notes (user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (int(user_id), code, stock_name, trade_date, summary, content, plan, now, now), ) return int(cursor.lastrowid) def delete_note(self, user_id: int, note_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM review_notes WHERE id = ? AND user_id = ?", (note_id, int(user_id)), ) return cursor.rowcount > 0 def list_seat_aliases(self) -> dict[str, str]: with self.connect() as connection: rows = connection.execute("SELECT seat_name, alias FROM seat_aliases").fetchall() return {row["seat_name"]: row["alias"] for row in rows} def save_seat_alias(self, seat_name: str, alias: str) -> None: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: connection.execute( """ INSERT INTO seat_aliases (seat_name, alias, updated_at) VALUES (?, ?, ?) ON CONFLICT(seat_name) DO UPDATE SET alias = excluded.alias, updated_at = excluded.updated_at """, (seat_name, alias, now), ) def list_sector_phase_overrides(self) -> dict[str, str]: with self.connect() as connection: rows = connection.execute( "SELECT name, element FROM sector_phase_overrides ORDER BY updated_at DESC, name" ).fetchall() return {row["name"]: row["element"] for row in rows} def save_sector_phase_override(self, name: str, element: str) -> None: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: connection.execute( """ INSERT INTO sector_phase_overrides (name, element, updated_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET element = excluded.element, updated_at = excluded.updated_at """, (name, element, now), ) def delete_sector_phase_override(self, name: str) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM sector_phase_overrides WHERE name = ?", (name,), ) return cursor.rowcount > 0 def upsert_stock_master(self, rows: list[dict[str, Any]]) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") values = [ ( row.get("ts_code", ""), str(row.get("ts_code", "")).split(".")[0], row.get("name") or "--", row.get("industry") or "", row.get("market") or "", str(row.get("list_date") or ""), now, ) for row in rows if row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO stock_master (ts_code, code, name, industry, market, list_date, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(ts_code) DO UPDATE SET code=excluded.code, name=excluded.name, industry=excluded.industry, market=excluded.market, list_date=excluded.list_date, updated_at=excluded.updated_at """, values, ) return len(values) def list_stock_master(self) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( "SELECT ts_code, code, name, industry, market, list_date FROM stock_master" ).fetchall() return [dict(row) for row in rows] def upsert_daily_bars(self, rows: list[dict[str, Any]]) -> int: values = [ ( str(row.get("trade_date") or ""), row.get("ts_code", ""), float(row.get("open") or 0), float(row.get("high") or 0), float(row.get("low") or 0), float(row.get("close") or 0), float(row.get("pct_chg") or 0), float(row.get("vol") or 0), float(row.get("amount") or 0), ) for row in rows if row.get("trade_date") and row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO daily_bars (trade_date, ts_code, open, high, low, close, pct_chg, vol, amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET open=excluded.open, high=excluded.high, low=excluded.low, close=excluded.close, pct_chg=excluded.pct_chg, vol=excluded.vol, amount=excluded.amount """, values, ) return len(values) def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int: values = [ ( str(row.get("trade_date") or ""), str(row.get("ts_code") or ""), float(row.get("close") or 0), float(row.get("pct_chg") or 0), ) for row in rows if row.get("trade_date") and row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg) VALUES (?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET close=excluded.close, pct_chg=excluded.pct_chg """, values, ) return len(values) def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int: values = [ ( str(row.get("trade_date") or ""), row.get("ts_code", ""), float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0), float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0), _optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")), _optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")), ) for row in rows if row.get("trade_date") and row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO daily_indicators (trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio, total_mv=excluded.total_mv, circ_mv=excluded.circ_mv, pe_ttm=excluded.pe_ttm, pb=excluded.pb, ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm """, values, ) return len(values) def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int: values = [ ( str(row.get("end_date") or ""), str(row.get("ann_date") or ""), str(row.get("ts_code") or ""), _optional_float(row.get("roe")), _optional_float(row.get("roa")), _optional_float(row.get("roic")), _optional_float(row.get("grossprofit_margin")), _optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")), _optional_float(row.get("ocf_to_opincome")), ) for row in rows if row.get("end_date") and row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO fundamental_indicators (end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin, netprofit_yoy, or_yoy, ocf_to_opincome) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(end_date, ts_code) DO UPDATE SET ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa, roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin, netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy, ocf_to_opincome=excluded.ocf_to_opincome """, values, ) return len(values) def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int: values = [] for row in rows: if not row.get("trade_date") or not row.get("ts_code"): continue large_net = ( float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0) - float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0) ) medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0) small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0) values.append(( str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0), large_net, medium_net, small_net, )) with self.connect() as connection: connection.executemany( """ INSERT INTO moneyflow_daily (trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount, medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount """, values, ) return len(values) def upsert_auction_factors(self, rows: list[dict[str, Any]]) -> int: values = [] for row in rows: trade_date = str(row.get("trade_date") or "") ts_code = str(row.get("ts_code") or "") price = float(row.get("price") or 0) pre_close = float(row.get("pre_close") or 0) if not trade_date or not ts_code or price <= 0 or pre_close <= 0: continue values.append( ( trade_date, ts_code, price, pre_close, (price / pre_close - 1) * 100, float(row.get("vol") or 0), float(row.get("amount") or 0), float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0), ) ) with self.connect() as connection: connection.executemany( """ INSERT INTO auction_factors (trade_date, ts_code, price, pre_close, change, vol, amount, turnover_rate, volume_ratio) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET price=excluded.price, pre_close=excluded.pre_close, change=excluded.change, vol=excluded.vol, amount=excluded.amount, turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio """, values, ) return len(values) def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int: values = [ ( str(row.get("end_date") or ""), str(row.get("ann_date") or ""), str(row.get("ts_code") or ""), _optional_float(row.get("forecast_profit")), _optional_float(row.get("actual_profit")), _optional_float(row.get("surprise_pct")), _optional_float(row.get("revenue_yoy")), _optional_float(row.get("netprofit_yoy")), str(row.get("source") or ""), ) for row in rows if row.get("end_date") and row.get("ann_date") and row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO earnings_events (end_date, ann_date, ts_code, forecast_profit, actual_profit, surprise_pct, revenue_yoy, netprofit_yoy, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET forecast_profit=excluded.forecast_profit, actual_profit=excluded.actual_profit, surprise_pct=excluded.surprise_pct, revenue_yoy=excluded.revenue_yoy, netprofit_yoy=excluded.netprofit_yoy, source=excluded.source """, values, ) return len(values) def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int: values = [ ( str(row.get("trade_date") or ""), str(row.get("ts_code") or ""), int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None, int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None, float(row.get("combined_score") or 0), int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None, int(bool(row.get("dual_source"))), ) for row in rows if row.get("trade_date") and row.get("ts_code") ] with self.connect() as connection: connection.executemany( """ INSERT INTO popularity_factors (trade_date, ts_code, ths_rank, dc_rank, combined_score, rank_change, dual_source) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET ths_rank=excluded.ths_rank, dc_rank=excluded.dc_rank, combined_score=excluded.combined_score, rank_change=excluded.rank_change, dual_source=excluded.dual_source """, values, ) return len(values) def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int: grouped: dict[tuple[str, str], dict[str, float | int]] = {} for row in rows: trade_date = str(row.get("trade_date") or "") ts_code = str(row.get("ts_code") or "") seat_name = str(row.get("exalter") or row.get("seat_name") or "") if not trade_date or not ts_code or "机构专用" not in seat_name: continue group = grouped.setdefault( (trade_date, ts_code), {"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0}, ) group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0) group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0) group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0) group["seats"] = int(group["seats"]) + 1 values = [ (trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"]) for (trade_date, ts_code), item in grouped.items() ] with self.connect() as connection: connection.executemany( """ INSERT INTO lhb_institution_daily (trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(trade_date, ts_code) DO UPDATE SET net_buy_amount=excluded.net_buy_amount, buy_amount=excluded.buy_amount, sell_amount=excluded.sell_amount, seat_count=excluded.seat_count """, values, ) return len(values) def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]: where = "WHERE trade_date <= ?" if end_date else "" parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) with self.connect() as connection: rows = connection.execute( f"SELECT DISTINCT trade_date FROM auction_factors {where} " "ORDER BY trade_date DESC LIMIT ?", parameters, ).fetchall() return [row["trade_date"] for row in reversed(rows)] def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]: where = "WHERE trade_date <= ?" if end_date else "" parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) with self.connect() as connection: rows = connection.execute( f"SELECT DISTINCT trade_date FROM daily_indicators {where} " "ORDER BY trade_date DESC LIMIT ?", parameters, ).fetchall() return [row["trade_date"] for row in reversed(rows)] def fundamental_periods(self) -> list[str]: with self.connect() as connection: rows = connection.execute( "SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date" ).fetchall() return [str(row["end_date"]) for row in rows] def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( "SELECT * FROM auction_factors WHERE trade_date = ? ORDER BY ts_code", (trade_date,), ).fetchall() return [dict(row) for row in rows] def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( "SELECT * FROM daily_bars WHERE trade_date = ? ORDER BY ts_code", (trade_date,), ).fetchall() return [dict(row) for row in rows] def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]: where = "WHERE trade_date <= ?" if end_date else "" parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) with self.connect() as connection: rows = connection.execute( f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?", parameters, ).fetchall() return [row["trade_date"] for row in reversed(rows)] def factor_health_summary(self, end_date: str) -> dict[str, Any]: dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101" with self.connect() as connection: market = connection.execute( "SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)", (end_date,), ).fetchone()[0] auction = connection.execute( "SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)", (end_date,), ).fetchone()[0] benchmark_rows = connection.execute( "SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?", (end_date,), ).fetchone()[0] indicator_date = connection.execute( "SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?", (end_date,), ).fetchone()[0] if indicator_date: valuation_rows, valuation_available = connection.execute( """ SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0) FROM daily_indicators WHERE trade_date = ? """, (indicator_date,), ).fetchone() else: valuation_rows, valuation_available = 0, 0 dividend_years = connection.execute( """ SELECT COUNT(DISTINCT substr(trade_date, 1, 4)) FROM daily_indicators WHERE trade_date <= ? AND trade_date >= ? """, (end_date, dividend_start), ).fetchone()[0] fundamental_rows = connection.execute( """ SELECT COUNT(*) FROM fundamental_indicators fi INNER JOIN ( SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key FROM fundamental_indicators WHERE ann_date = '' OR ann_date <= ? GROUP BY ts_code ) latest ON latest.ts_code = fi.ts_code AND latest.latest_key = (fi.ann_date || ':' || fi.end_date) """, (end_date,), ).fetchone()[0] moneyflow_dates = connection.execute( """ SELECT COUNT(DISTINCT trade_date) FROM moneyflow_daily WHERE trade_date IN ( SELECT DISTINCT trade_date FROM daily_bars WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 5 ) """, (end_date,), ).fetchone()[0] earnings_rows = connection.execute( """ SELECT COUNT(*) FROM earnings_events WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '') """, (end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"), ).fetchone()[0] popularity_rows = connection.execute( "SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?", (end_date,), ).fetchone()[0] institution_rows = connection.execute( "SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?", (end_date,), ).fetchone()[0] return { "market": bool(market), "auction": bool(auction), "benchmark": int(benchmark_rows or 0) >= 60, "benchmark_rows": int(benchmark_rows or 0), "valuation": bool(valuation_available), "fundamental": int(fundamental_rows or 0) >= 100, "dividend_history": int(dividend_years or 0) >= 4, "valuation_rows": int(valuation_rows or 0), "fundamental_rows": int(fundamental_rows or 0), "dividend_years": int(dividend_years or 0), "moneyflow_history": int(moneyflow_dates or 0) >= 5, "moneyflow_dates": int(moneyflow_dates or 0), "earnings_events": int(earnings_rows or 0) > 0, "earnings_event_rows": int(earnings_rows or 0), "popularity": int(popularity_rows or 0) > 0, "popularity_rows": int(popularity_rows or 0), "institutions": int(institution_rows or 0) > 0, "institution_rows": int(institution_rows or 0), } def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]: dates = self.factor_dates(end_date, limit_dates) if not dates: return { "dates": [], "bars": [], "master": [], "indicators": [], "indicator_history": [], "indicator_series": [], "fundamentals": [], "moneyflow": [], "moneyflow_history": [], "auction": [], "benchmarks": [], "fundamental_history": [], "earnings_events": [], "popularity": [], "institutions": [], } placeholders = ",".join("?" for _ in dates) with self.connect() as connection: bars = connection.execute( f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code", dates, ).fetchall() master = connection.execute("SELECT * FROM stock_master").fetchall() indicators = connection.execute( """ SELECT * FROM daily_indicators WHERE trade_date = ( SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ? ) """, (end_date,), ).fetchall() indicator_history = connection.execute( """ SELECT di.* FROM daily_indicators di INNER JOIN ( SELECT ts_code, substr(trade_date, 1, 4) AS year_key, MAX(trade_date) AS max_date FROM daily_indicators WHERE trade_date <= ? AND trade_date >= ? GROUP BY ts_code, substr(trade_date, 1, 4) ) latest ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date ORDER BY di.trade_date, di.ts_code """, (end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"), ).fetchall() indicator_series = connection.execute( f""" SELECT trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm FROM daily_indicators WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code """, dates, ).fetchall() fundamentals = connection.execute( """ SELECT fi.* FROM fundamental_indicators fi INNER JOIN ( SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key FROM fundamental_indicators WHERE ann_date = '' OR ann_date <= ? GROUP BY ts_code ) latest ON latest.ts_code = fi.ts_code AND latest.latest_key = (fi.ann_date || ':' || fi.end_date) """, (end_date,), ).fetchall() fundamental_history = connection.execute( """ SELECT * FROM fundamental_indicators WHERE ann_date = '' OR ann_date <= ? ORDER BY ann_date, end_date, ts_code """, (end_date,), ).fetchall() moneyflow = connection.execute( """ SELECT * FROM moneyflow_daily WHERE trade_date = ( SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ? ) """, (end_date,), ).fetchall() flow_dates = dates[-min(5, len(dates)):] flow_placeholders = ",".join("?" for _ in flow_dates) moneyflow_history = connection.execute( f""" SELECT * FROM moneyflow_daily WHERE trade_date IN ({flow_placeholders}) ORDER BY trade_date, ts_code """, flow_dates, ).fetchall() auction = connection.execute( """ SELECT * FROM auction_factors WHERE trade_date = ( SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ? ) """, (end_date,), ).fetchall() benchmarks = connection.execute( f""" SELECT * FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders}) ORDER BY trade_date """, dates, ).fetchall() earnings_events = connection.execute( """ SELECT * FROM earnings_events WHERE ann_date <= ? ORDER BY ann_date, end_date, ts_code """, (end_date,), ).fetchall() popularity = connection.execute( "SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code", (end_date,), ).fetchall() institutions = connection.execute( "SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code", (end_date,), ).fetchall() return { "dates": dates, "bars": [dict(row) for row in bars], "master": [dict(row) for row in master], "indicators": [dict(row) for row in indicators], "indicator_history": [dict(row) for row in indicator_history], "indicator_series": [dict(row) for row in indicator_series], "fundamentals": [dict(row) for row in fundamentals], "fundamental_history": [dict(row) for row in fundamental_history], "moneyflow": [dict(row) for row in moneyflow], "moneyflow_history": [dict(row) for row in moneyflow_history], "auction": [dict(row) for row in auction], "benchmarks": [dict(row) for row in benchmarks], "earnings_events": [dict(row) for row in earnings_events], "popularity": [dict(row) for row in popularity], "institutions": [dict(row) for row in institutions], } def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]: try: from sentiment_engine import build_sentiment_history except ModuleNotFoundError: from .sentiment_engine import build_sentiment_history series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260)) return [ { "trade_date": row["trade_date"], "sentiment_score": row["score"], "seal_rate": row["seal_rate"], "limit_up_count": row["limit_up_count"], "limit_down_count": row["limit_down_count"], "broken_count": row["broken_count"], "up_count": row["up_count"], "down_count": row["down_count"], "amount_billion": row["amount_billion"], } for row in series[-limit:] ] def save_screener_strategy( self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any], builtin: bool = False, strategy_id: int | None = None, ) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") regimes_json = json.dumps(regimes, ensure_ascii=False) formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":")) with self.connect() as connection: if strategy_id: if builtin: cursor = connection.execute( """ UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1 """, (name, description, regimes_json, formula_json, now, strategy_id), ) else: cursor = connection.execute( """ UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, updated_at=? WHERE id=? AND builtin=0 AND user_id=? """, (name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)), ) if cursor.rowcount == 0: raise ValueError("选股策略不存在。") return strategy_id cursor = connection.execute( """ INSERT INTO screener_strategies (user_id, name, description, regimes, formula, builtin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now), ) return int(cursor.lastrowid) def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]: with self.connect() as connection: if user_id is None: rows = connection.execute( "SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id" ).fetchall() else: rows = connection.execute( """ SELECT * FROM screener_strategies WHERE builtin = 1 OR user_id = ? ORDER BY builtin DESC, updated_at DESC, id """, (int(user_id),), ).fetchall() result = [] for row in rows: item = dict(row) item["regimes"] = json.loads(item["regimes"]) item["formula"] = json.loads(item["formula"]) item["builtin"] = bool(item["builtin"]) result.append(item) return result def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool: with self.connect() as connection: row = connection.execute( "SELECT builtin, user_id FROM screener_strategies WHERE id = ?", (strategy_id,), ).fetchone() if not row: raise ValueError("选股策略不存在。") if bool(row["builtin"]): raise ValueError("内置策略不能删除。") if int(row["user_id"] or 0) != int(user_id): raise ValueError("无权删除其他账号的策略。") cursor = connection.execute( "DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?", (strategy_id, int(user_id)), ) return cursor.rowcount > 0 def save_screener_run( self, user_id: int, trade_date: str, regime: str, strategy_name: str, formula: dict[str, Any], result: dict[str, Any], mode: str = "smart", ) -> int: normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart" now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: cursor = connection.execute( """ INSERT INTO screener_runs (user_id, trade_date, regime, mode, strategy_name, formula, result, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (None if int(user_id) == 0 else int(user_id), trade_date, regime, normalized_mode, strategy_name, json.dumps(formula, ensure_ascii=False, separators=(",", ":")), json.dumps(result, ensure_ascii=False, separators=(",", ":")), now), ) return int(cursor.lastrowid) @staticmethod def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None: try: result = json.loads(row["result"]) except json.JSONDecodeError: return None result.setdefault("meta", {}).update( { "run_id": int(row["id"]), "trade_date": str(row["trade_date"] or ""), "regime": str(row["regime"] or ""), "mode": str(row["mode"] or "smart"), "strategy_name": str(row["strategy_name"] or ""), "created_at": row["created_at"], } ) return result def latest_screener_run( self, user_id: int, trade_date: str, mode: str = "", ) -> dict[str, Any] | None: owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) parameters += (trade_date,) mode_clause = "" if mode in {"smart", "curated", "quant"}: mode_clause = " AND mode = ?" parameters += (mode,) with self.connect() as connection: row = connection.execute( f""" SELECT id, trade_date, regime, mode, strategy_name, result, created_at FROM screener_runs WHERE {owner_clause} AND trade_date <= ?{mode_clause} ORDER BY id DESC LIMIT 1 """, parameters, ).fetchone() return self._screener_run_payload(row) if row else None def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]: owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) parameters += (trade_date,) with self.connect() as connection: rows = connection.execute( f""" SELECT runs.id, runs.trade_date, runs.regime, runs.mode, runs.strategy_name, runs.result, runs.created_at FROM screener_runs runs INNER JOIN ( SELECT mode, MAX(id) AS id FROM screener_runs WHERE {owner_clause} AND trade_date <= ? GROUP BY mode ) latest ON latest.id = runs.id """, parameters, ).fetchall() results: dict[str, dict[str, Any]] = {} for row in rows: mode = str(row["mode"] or "smart") payload = self._screener_run_payload(row) if mode in {"smart", "curated", "quant"} and payload: results[mode] = payload return results def latest_screener_context_runs( self, user_id: int, trade_date: str, limit: int = 60, ) -> list[dict[str, Any]]: safe_limit = max(1, min(120, int(limit))) owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) parameters += (trade_date, safe_limit) with self.connect() as connection: rows = connection.execute( f""" WITH ranked AS ( SELECT id, trade_date, regime, mode, strategy_name, result, created_at, ROW_NUMBER() OVER ( PARTITION BY mode, CASE WHEN mode = 'smart' THEN regime ELSE '' END, CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END ORDER BY id DESC ) AS context_rank FROM screener_runs WHERE {owner_clause} AND trade_date <= ? ) SELECT id, trade_date, regime, mode, strategy_name, result, created_at FROM ranked WHERE context_rank = 1 ORDER BY id DESC LIMIT ? """, parameters, ).fetchall() return [ payload for row in rows if (payload := self._screener_run_payload(row)) is not None ] def screener_runs_for_date( self, user_id: int, trade_date: str, limit: int = 80, ) -> list[dict[str, Any]]: safe_limit = max(1, min(160, int(limit))) owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) parameters += (trade_date, safe_limit) with self.connect() as connection: rows = connection.execute( f""" SELECT id, trade_date, regime, mode, strategy_name, result, created_at FROM screener_runs WHERE {owner_clause} AND trade_date = ? ORDER BY id DESC LIMIT ? """, parameters, ).fetchall() result = [] seen: set[tuple[str, str, str]] = set() for row in rows: key = ( str(row["mode"] or "smart"), str(row["regime"] or ""), str(row["strategy_name"] or ""), ) if key in seen: continue seen.add(key) payload = self._screener_run_payload(row) if payload is not None: result.append(payload) return result def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None: owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" parameters: tuple[Any, ...] = (int(run_id),) if int(user_id) != 0: parameters += (int(user_id),) with self.connect() as connection: row = connection.execute( f""" SELECT id, trade_date, regime, mode, strategy_name, result, created_at FROM screener_runs WHERE id = ? AND {owner_clause} """, parameters, ).fetchone() if not row: return None result = self._screener_run_payload(row) if result is None: return None result.setdefault("meta", {}).update( { "run_id": int(row["id"]), "trade_date": row["trade_date"], "mode": str(row["mode"] or "smart"), "created_at": row["created_at"], } ) result["strategy_name"] = row["strategy_name"] result["regime"] = row["regime"] return result def save_mentor_exchange( self, user_id: int, mentor_id: str, trade_date: str, question: str, answer: str, meta: str = "", ) -> None: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: connection.executemany( """ INSERT INTO mentor_messages (user_id, mentor_id, trade_date, role, content, meta, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) """, [ (int(user_id), mentor_id, trade_date, "user", question, "", now), (int(user_id), mentor_id, trade_date, "assistant", answer, meta, now), ], ) connection.execute( """ DELETE FROM mentor_messages WHERE user_id = ? AND id NOT IN ( SELECT id FROM mentor_messages WHERE user_id = ? ORDER BY id DESC LIMIT 500 ) """, (int(user_id), int(user_id)), ) def list_mentor_messages( self, user_id: int, mentor_id: str, trade_date: str, limit: int = 100 ) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( """ SELECT role, content, meta, created_at FROM mentor_messages WHERE user_id = ? AND mentor_id = ? AND trade_date = ? ORDER BY id DESC LIMIT ? """, (int(user_id), mentor_id, trade_date, max(1, min(500, int(limit)))), ).fetchall() return [dict(row) for row in reversed(rows)] def delete_mentor_messages(self, user_id: int, mentor_id: str, trade_date: str) -> int: with self.connect() as connection: cursor = connection.execute( "DELETE FROM mentor_messages WHERE user_id = ? AND mentor_id = ? AND trade_date = ?", (int(user_id), mentor_id, trade_date), ) return int(cursor.rowcount) def list_mentor_preferences(self, user_id: int) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( """ SELECT mentor_id, pinned, sort_order FROM mentor_preferences WHERE user_id = ? ORDER BY sort_order, mentor_id """, (int(user_id),), ).fetchall() return [ { "mentor_id": str(row["mentor_id"]), "pinned": bool(row["pinned"]), "sort_order": int(row["sort_order"]), } for row in rows ] def save_mentor_preferences( self, user_id: int, ordered_ids: list[str], pinned_ids: set[str] ) -> None: now = datetime.now().astimezone().isoformat(timespec="seconds") values = [ (int(user_id), mentor_id, int(mentor_id in pinned_ids), index, now) for index, mentor_id in enumerate(ordered_ids) ] with self.connect() as connection: connection.execute( "DELETE FROM mentor_preferences WHERE user_id = ?", (int(user_id),), ) connection.executemany( """ INSERT INTO mentor_preferences (user_id, mentor_id, pinned, sort_order, updated_at) VALUES (?, ?, ?, ?, ?) """, values, ) def list_wencai_saved_queries( self, user_id: int, limit: int = 30 ) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( """ SELECT id, title, query, search_type, created_at, updated_at FROM wencai_saved_queries WHERE user_id = ? ORDER BY updated_at DESC, id DESC LIMIT ? """, (int(user_id), max(1, min(100, int(limit)))), ).fetchall() return [dict(row) for row in rows] def save_wencai_query( self, user_id: int, title: str, query: str, search_type: str = "stock" ) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: connection.execute( """ INSERT INTO wencai_saved_queries (user_id, title, query, search_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, query, search_type) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at """, (int(user_id), title, query, search_type, now, now), ) row = connection.execute( """ SELECT id FROM wencai_saved_queries WHERE user_id = ? AND query = ? AND search_type = ? """, (int(user_id), query, search_type), ).fetchone() if not row: raise ValueError("问财条件保存失败。") return int(row["id"]) def delete_wencai_saved_query(self, user_id: int, query_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM wencai_saved_queries WHERE id = ? AND user_id = ?", (int(query_id), int(user_id)), ) return cursor.rowcount > 0 def save_strategy_tracks( self, user_id: int, run_id: int, selection_date: str, strategy_name: str, candidates: list[dict[str, Any]], ) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") values = [] for item in candidates: ts_code = str(item.get("ts_code") or "").strip() code = str(item.get("code") or ts_code.split(".")[0]).strip() entry_price = float(item.get("price") or 0) if not ts_code or not code or entry_price <= 0: continue values.append( ( int(user_id), int(run_id), selection_date, strategy_name, ts_code, code, str(item.get("name") or "--"), str(item.get("sector") or "其他"), entry_price, now, now, ) ) with self.connect() as connection: connection.executemany( """ INSERT INTO strategy_tracks (user_id, run_id, selection_date, strategy_name, ts_code, code, name, sector, entry_price, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET name=excluded.name, sector=excluded.sector, entry_price=excluded.entry_price, updated_at=excluded.updated_at """, values, ) return len(values) def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]: limit_batches = max(1, min(50, int(limit_batches))) with self.connect() as connection: rows = connection.execute( """ SELECT * FROM strategy_tracks WHERE user_id = ? AND run_id IN ( SELECT run_id FROM strategy_tracks WHERE user_id = ? GROUP BY run_id ORDER BY run_id DESC LIMIT ? ) ORDER BY run_id DESC, id """, (int(user_id), int(user_id), limit_batches), ).fetchall() return [dict(row) for row in rows] def delete_strategy_track(self, user_id: int, track_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?", (int(track_id), int(user_id)), ) return cursor.rowcount > 0 def load_tracking_bars( self, targets: list[tuple[str, str]], limit: int = 5 ) -> dict[tuple[str, str], list[dict[str, Any]]]: unique_targets = set(targets) if not unique_targets: return {} codes = sorted({ts_code for ts_code, _ in unique_targets}) earliest_date = min(selection_date for _, selection_date in unique_targets) placeholders = ",".join("?" for _ in codes) with self.connect() as connection: rows = connection.execute( f""" SELECT ts_code, trade_date, open, high, low, close FROM daily_bars WHERE ts_code IN ({placeholders}) AND trade_date > ? ORDER BY ts_code, trade_date """, [*codes, earliest_date], ).fetchall() by_code: dict[str, list[dict[str, Any]]] = {} for row in rows: item = dict(row) by_code.setdefault(str(item["ts_code"]), []).append(item) row_limit = max(1, min(20, int(limit))) return { (ts_code, selection_date): [ row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date ][:row_limit] for ts_code, selection_date in unique_targets } def save_alert( self, user_id: int, kind: str, title: str, content: str, available_date: str, code: str, dedupe_key: str, ) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: connection.execute( """ INSERT INTO alerts (user_id, kind, title, content, available_date, code, dedupe_key, is_read, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(user_id, dedupe_key) DO UPDATE SET title=excluded.title, content=excluded.content, available_date=excluded.available_date, updated_at=excluded.updated_at """, ( int(user_id), kind, title, content, available_date, code, dedupe_key, now, now, ), ) row = connection.execute( "SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?", (int(user_id), dedupe_key), ).fetchone() return int(row["id"]) def list_alerts( self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100 ) -> list[dict[str, Any]]: with self.connect() as connection: if unread_only: rows = connection.execute( """ SELECT id, kind, title, content, available_date, code, is_read, created_at, updated_at, read_at FROM alerts WHERE user_id = ? AND available_date <= ? AND is_read = 0 ORDER BY available_date DESC, id DESC LIMIT ? """, (int(user_id), as_of, max(1, min(300, int(limit)))), ).fetchall() else: rows = connection.execute( """ SELECT id, kind, title, content, available_date, code, is_read, created_at, updated_at, read_at FROM alerts WHERE user_id = ? ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END, is_read, available_date, id DESC LIMIT ? """, (int(user_id), as_of, max(1, min(300, int(limit)))), ).fetchall() return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows] def count_unread_alerts(self, user_id: int, as_of: str) -> int: with self.connect() as connection: row = connection.execute( """ SELECT COUNT(*) AS total FROM alerts WHERE user_id = ? AND available_date <= ? AND is_read = 0 """, (int(user_id), as_of), ).fetchone() return int(row["total"] if row else 0) def mark_alert_read(self, user_id: int, alert_id: int) -> bool: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: cursor = connection.execute( """ UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? WHERE id = ? AND user_id = ? """, (now, now, int(alert_id), int(user_id)), ) return cursor.rowcount > 0 def mark_all_alerts_read(self, user_id: int, as_of: str) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: cursor = connection.execute( """ UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? WHERE user_id = ? AND available_date <= ? AND is_read = 0 """, (now, now, int(user_id), as_of), ) return int(cursor.rowcount) def delete_alert(self, user_id: int, alert_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM alerts WHERE id = ? AND user_id = ?", (int(alert_id), int(user_id)), ) return cursor.rowcount > 0 def save_trade_entry( self, user_id: int, trade_date: str, code: str, name: str, action: str, price: float, quantity: int, position_pct: float, pnl_amount: float | None, pnl_pct: float | None, thesis: str, execution: str, emotion: str, tags: list[str], trade_id: int | None = None, ) -> int: now = datetime.now().astimezone().isoformat(timespec="seconds") tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":")) with self.connect() as connection: if trade_id: cursor = connection.execute( """ UPDATE trade_entries SET trade_date=?, code=?, name=?, action=?, price=?, quantity=?, position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?, emotion=?, tags=?, updated_at=? WHERE id=? AND user_id=? """, ( trade_date, code, name, action, price, quantity, position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now, int(trade_id), int(user_id), ), ) if cursor.rowcount == 0: raise ValueError("交易记录不存在或无权修改。") return int(trade_id) cursor = connection.execute( """ INSERT INTO trade_entries (user_id, trade_date, code, name, action, price, quantity, position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( int(user_id), trade_date, code, name, action, price, quantity, position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now, now, ), ) return int(cursor.lastrowid) def list_trade_entries( self, user_id: int, start_date: str = "", end_date: str = "", code: str = "", limit: int = 300, ) -> list[dict[str, Any]]: clauses = ["user_id = ?"] parameters: list[Any] = [int(user_id)] if start_date: clauses.append("trade_date >= ?") parameters.append(start_date) if end_date: clauses.append("trade_date <= ?") parameters.append(end_date) if code: clauses.append("code = ?") parameters.append(code) parameters.append(max(1, min(1000, int(limit)))) with self.connect() as connection: rows = connection.execute( f""" SELECT * FROM trade_entries WHERE {' AND '.join(clauses)} ORDER BY trade_date DESC, id DESC LIMIT ? """, parameters, ).fetchall() return [dict(row) for row in rows] def delete_trade_entry(self, user_id: int, trade_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM trade_entries WHERE id = ? AND user_id = ?", (int(trade_id), int(user_id)), ) return cursor.rowcount > 0 def save_assistant_exchange( self, user_id: int, question: str, answer: str, context_date: str ) -> None: now = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: connection.executemany( """ INSERT INTO assistant_messages (user_id, role, content, context_date, created_at) VALUES (?, ?, ?, ?, ?) """, [ (int(user_id), "user", question, context_date, now), (int(user_id), "assistant", answer, context_date, now), ], ) connection.execute( """ DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN ( SELECT id FROM assistant_messages WHERE user_id = ? ORDER BY id DESC LIMIT 200 ) """, (int(user_id), int(user_id)), ) def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( """ SELECT role, content, context_date, created_at FROM assistant_messages WHERE user_id = ? ORDER BY id DESC LIMIT ? """, (int(user_id), max(1, min(200, int(limit)))), ).fetchall() return [dict(row) for row in reversed(rows)] def delete_assistant_messages(self, user_id: int) -> int: with self.connect() as connection: cursor = connection.execute( "DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),) ) return int(cursor.rowcount) @staticmethod def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: if not row: return None return { "id": int(row["id"]), "mode": str(row["mode"]), "context_date": str(row["context_date"]), "subject": str(row["subject"]), "subject_detail": str(row["subject_detail"]), "answer": str(row["answer"]), "created_at": str(row["created_at"]), } def save_heaven_reading( self, user_id: int, mode: str, context_date: str, subject: str, subject_detail: str, answer: str, context_snapshot: dict[str, Any], dedupe_key: str, ) -> dict[str, Any]: now = datetime.now().astimezone().isoformat(timespec="seconds") snapshot_json = json.dumps( context_snapshot, ensure_ascii=False, separators=(",", ":") ) with self.connect() as connection: connection.execute( """ INSERT INTO heaven_readings (user_id, mode, context_date, subject, subject_detail, answer, context_snapshot, dedupe_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, dedupe_key) DO NOTHING """, ( int(user_id), mode, context_date, subject, subject_detail, answer, snapshot_json, dedupe_key, now, ), ) row = connection.execute( """ SELECT id, mode, context_date, subject, subject_detail, answer, created_at FROM heaven_readings WHERE user_id = ? AND dedupe_key = ? """, (int(user_id), dedupe_key), ).fetchone() connection.execute( """ DELETE FROM heaven_readings WHERE user_id = ? AND mode = ? AND id NOT IN ( SELECT id FROM heaven_readings WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100 ) """, (int(user_id), mode, int(user_id), mode), ) result = self._heaven_reading_dict(row) if not result: raise ValueError("解读记录保存失败。") return result def list_heaven_readings( self, user_id: int, mode: str, context_date: str = "", limit: int = 100, ) -> list[dict[str, Any]]: clauses = ["user_id = ?", "mode = ?"] parameters: list[Any] = [int(user_id), mode] if context_date: clauses.append("context_date = ?") parameters.append(context_date) parameters.append(max(1, min(100, int(limit)))) with self.connect() as connection: rows = connection.execute( f""" SELECT id, mode, context_date, subject, subject_detail, answer, created_at FROM heaven_readings WHERE {' AND '.join(clauses)} ORDER BY context_date DESC, id DESC LIMIT ? """, parameters, ).fetchall() return [self._heaven_reading_dict(row) for row in rows if row] def latest_heaven_reading( self, user_id: int, mode: str, context_date: str = "" ) -> dict[str, Any] | None: items = self.list_heaven_readings(user_id, mode, context_date, 1) return items[0] if items else None def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( "DELETE FROM heaven_readings WHERE id = ? AND user_id = ?", (int(reading_id), int(user_id)), ) return cursor.rowcount > 0