Files
xiaobai-review/database.py
T

1227 lines
51 KiB
Python

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.auction.repository import AuctionRepositoryMixin
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
from backend.features.market.repository import MarketRepositoryMixin
from backend.features.mentor.repository import MentorRepositoryMixin
from backend.features.pools.repository import PoolRepositoryMixin
from backend.features.popularity.repository import PopularityRepositoryMixin
from backend.features.screener.repository import ScreenerRepositoryMixin
from backend.features.system.repository import SystemSettingsRepositoryMixin
from backend.llm.repository import LLMAuditRepositoryMixin
class ReviewDatabase(
AccountRepositoryMixin,
AuctionRepositoryMixin,
DragonTigerRepositoryMixin,
MarketRepositoryMixin,
MentorRepositoryMixin,
PoolRepositoryMixin,
PopularityRepositoryMixin,
ScreenerRepositoryMixin,
SystemSettingsRepositoryMixin,
LLMAuditRepositoryMixin,
):
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 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_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 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_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