2840 lines
119 KiB
Python
2840 lines
119 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
|
|
|
|
|
|
def _optional_float(value: Any) -> float | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
class ReviewDatabase:
|
|
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 count_users(self) -> int:
|
|
with self.connect() as connection:
|
|
row = connection.execute("SELECT COUNT(*) AS total FROM users").fetchone()
|
|
return int(row["total"] if row else 0)
|
|
|
|
def first_user_id(self) -> int:
|
|
with self.connect() as connection:
|
|
row = connection.execute("SELECT MIN(id) AS id FROM users").fetchone()
|
|
return int(row["id"] or 0) if row else 0
|
|
|
|
def create_user(
|
|
self,
|
|
username: str,
|
|
password_salt: str,
|
|
password_hash: str,
|
|
) -> dict[str, Any]:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
try:
|
|
with self.connect() as connection:
|
|
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO users
|
|
(username, password_salt, password_hash, role, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(username, password_salt, password_hash, role, now, now),
|
|
)
|
|
user_id = int(cursor.lastrowid)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise ValueError("该账号名已被使用。") from exc
|
|
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
|
|
|
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id, username, password_salt, password_hash, role, llm_mode,
|
|
membership_status, membership_plan, membership_starts_at,
|
|
membership_expires_at, created_at
|
|
FROM users WHERE username = ? COLLATE NOCASE
|
|
""",
|
|
(username,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def user_password(self, user_id: int) -> dict[str, str] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT password_salt, password_hash FROM users WHERE id = ?",
|
|
(user_id,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def update_user_password(self, user_id: int, password_salt: str, password_hash: str) -> bool:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"UPDATE users SET password_salt = ?, password_hash = ?, updated_at = ? WHERE id = ?",
|
|
(password_salt, password_hash, now, user_id),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def delete_user(self, user_id: int) -> bool:
|
|
with self.connect() as connection:
|
|
cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
return cursor.rowcount > 0
|
|
|
|
def create_session(
|
|
self,
|
|
session_hash: str,
|
|
user_id: int,
|
|
csrf_token: str,
|
|
expires_at: str,
|
|
) -> None:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute("DELETE FROM user_sessions WHERE expires_at <= ?", (now,))
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO user_sessions
|
|
(token_hash, user_id, csrf_token, expires_at, created_at, last_seen_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(session_hash, user_id, csrf_token, expires_at, now, now),
|
|
)
|
|
|
|
def session_user(self, session_hash: str) -> dict[str, Any] | None:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
|
|
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
|
|
u.created_at, s.csrf_token, s.expires_at
|
|
FROM user_sessions AS s
|
|
JOIN users AS u ON u.id = s.user_id
|
|
WHERE s.token_hash = ? AND s.expires_at > ?
|
|
""",
|
|
(session_hash, now),
|
|
).fetchone()
|
|
if row:
|
|
connection.execute(
|
|
"UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?",
|
|
(now, session_hash),
|
|
)
|
|
return dict(row) if row else None
|
|
|
|
def delete_session(self, session_hash: str) -> bool:
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"DELETE FROM user_sessions WHERE token_hash = ?",
|
|
(session_hash,),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def get_user_credentials(self, user_id: int) -> str:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT encrypted_payload FROM user_credentials WHERE user_id = ?",
|
|
(user_id,),
|
|
).fetchone()
|
|
return str(row["encrypted_payload"]) if row else ""
|
|
|
|
def save_user_credentials(self, user_id: int, encrypted_payload: str) -> None:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO user_credentials (user_id, encrypted_payload, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
encrypted_payload = excluded.encrypted_payload,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(user_id, encrypted_payload, now),
|
|
)
|
|
|
|
def list_user_credentials(self) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT user_id, encrypted_payload FROM user_credentials ORDER BY user_id"
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_system_setting(self, key: str) -> str:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT encrypted_payload FROM system_settings WHERE setting_key = ?",
|
|
(key,),
|
|
).fetchone()
|
|
return str(row["encrypted_payload"]) if row else ""
|
|
|
|
def save_system_setting(self, key: str, encrypted_payload: str) -> None:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO system_settings (setting_key, encrypted_payload, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(setting_key) DO UPDATE SET
|
|
encrypted_payload = excluded.encrypted_payload,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(key, encrypted_payload, now),
|
|
)
|
|
|
|
def user_access(self, user_id: int) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
|
membership_starts_at, membership_expires_at, created_at
|
|
FROM users WHERE id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def list_users(self) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
|
membership_starts_at, membership_expires_at, created_at
|
|
FROM users ORDER BY id
|
|
"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def update_user_llm_mode(self, user_id: int, mode: str) -> None:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"UPDATE users SET llm_mode = ?, updated_at = ? WHERE id = ?",
|
|
(mode, now, user_id),
|
|
)
|
|
|
|
def update_membership(
|
|
self,
|
|
user_id: int,
|
|
status: str,
|
|
plan: str,
|
|
starts_at: str | None,
|
|
expires_at: str | None,
|
|
) -> bool:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"""
|
|
UPDATE users
|
|
SET membership_status = ?, membership_plan = ?,
|
|
membership_starts_at = ?, membership_expires_at = ?, updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(status, plan, starts_at, expires_at, now, user_id),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
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 get_user_birth_profile(self, user_id: int) -> str:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT encrypted_payload FROM user_birth_profiles WHERE user_id = ?",
|
|
(user_id,),
|
|
).fetchone()
|
|
return str(row["encrypted_payload"]) if row else ""
|
|
|
|
def save_user_birth_profile(self, user_id: int, encrypted_payload: str) -> None:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO user_birth_profiles (user_id, encrypted_payload, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
encrypted_payload = excluded.encrypted_payload,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(user_id, encrypted_payload, now),
|
|
)
|
|
|
|
def delete_user_birth_profile(self, user_id: int) -> bool:
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"DELETE FROM user_birth_profiles WHERE user_id = ?",
|
|
(user_id,),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT payload FROM dashboard_snapshots WHERE trade_date = ?",
|
|
(trade_date,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
try:
|
|
return json.loads(row["payload"])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def get_latest_real_snapshot(
|
|
self, trade_date: str, strictly_before: bool = False
|
|
) -> dict[str, Any] | None:
|
|
operator = "<" if strictly_before else "<="
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT payload FROM dashboard_snapshots
|
|
WHERE trade_date {operator} ? AND source != 'demo'
|
|
ORDER BY trade_date DESC LIMIT 1
|
|
""",
|
|
(trade_date,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
try:
|
|
return json.loads(row["payload"])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def save_snapshot(self, trade_date: str, source: str, payload: dict[str, Any]) -> None:
|
|
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
record_count = sum(
|
|
len(payload.get(key) or [])
|
|
for key in ("limits", "broken", "down_limits", "yesterday_limits")
|
|
)
|
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO dashboard_snapshots
|
|
(trade_date, source, payload, record_count, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(trade_date) DO UPDATE SET
|
|
source = excluded.source,
|
|
payload = excluded.payload,
|
|
record_count = excluded.record_count,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(trade_date, source, content, record_count, updated_at),
|
|
)
|
|
|
|
def get_data_snapshot(self, kind: str, cache_key: str) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT payload FROM data_snapshots WHERE kind = ? AND cache_key = ?",
|
|
(kind, cache_key),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
try:
|
|
return json.loads(row["payload"])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def get_latest_data_snapshot(
|
|
self,
|
|
kind: str,
|
|
cache_key_prefix: str,
|
|
maximum_cache_key: str,
|
|
exclude_source: str = "",
|
|
) -> dict[str, Any] | None:
|
|
source_clause = " AND source != ?" if exclude_source else ""
|
|
parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key]
|
|
if exclude_source:
|
|
parameters.append(exclude_source)
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT payload FROM data_snapshots
|
|
WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause}
|
|
ORDER BY cache_key DESC LIMIT 1
|
|
""",
|
|
parameters,
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
try:
|
|
return json.loads(row["payload"])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def save_data_snapshot(
|
|
self, kind: str, cache_key: str, source: str, payload: dict[str, Any]
|
|
) -> None:
|
|
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO data_snapshots (kind, cache_key, source, payload, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(kind, cache_key) DO UPDATE SET
|
|
source = excluded.source,
|
|
payload = excluded.payload,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(kind, cache_key, source, content, updated_at),
|
|
)
|
|
|
|
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 save_reason_override(self, trade_date: str, code: str, reason: str) -> None:
|
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO reason_overrides (trade_date, code, reason, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(trade_date, code) DO UPDATE SET
|
|
reason = excluded.reason,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(trade_date, code, reason, now),
|
|
)
|
|
|
|
def reason_overrides(self, trade_date: str) -> dict[str, str]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT code, reason FROM reason_overrides WHERE trade_date = ?",
|
|
(trade_date,),
|
|
).fetchall()
|
|
return {row["code"]: row["reason"] for row in rows}
|
|
|
|
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 search_stock_master(self, query: str, limit: int = 12) -> list[dict[str, Any]]:
|
|
text = str(query or "").strip()
|
|
if not text:
|
|
return []
|
|
escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT ts_code, code, name, industry, market, list_date
|
|
FROM stock_master
|
|
WHERE code = ? OR name = ? OR name LIKE ? ESCAPE '\\'
|
|
ORDER BY
|
|
CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 ELSE 2 END,
|
|
list_date DESC,
|
|
code
|
|
LIMIT ?
|
|
""",
|
|
(text, text, f"%{escaped}%", text, text, max(1, min(30, int(limit)))),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
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 list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT trade_date, payload FROM dashboard_snapshots
|
|
WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ?
|
|
""",
|
|
(end_date, limit),
|
|
).fetchall()
|
|
result: list[dict[str, Any]] = []
|
|
for row in reversed(rows):
|
|
try:
|
|
payload = json.loads(row["payload"])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
payload["_snapshot_date"] = row["trade_date"]
|
|
result.append(payload)
|
|
return result
|
|
|
|
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
|
|
|
|
def start_sync(self, trade_date: str, source: str) -> int:
|
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO sync_runs (trade_date, source, status, started_at)
|
|
VALUES (?, ?, 'running', ?)
|
|
""",
|
|
(trade_date, source, started_at),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
def finish_sync(
|
|
self,
|
|
sync_id: int,
|
|
status: str,
|
|
record_count: int = 0,
|
|
message: str = "",
|
|
source: str | None = None,
|
|
) -> None:
|
|
finished_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE sync_runs
|
|
SET status = ?, finished_at = ?, record_count = ?, message = ?,
|
|
source = COALESCE(?, source)
|
|
WHERE id = ?
|
|
""",
|
|
(status, finished_at, record_count, message[:1000], source, sync_id),
|
|
)
|
|
|
|
def status(self) -> dict[str, Any]:
|
|
with self.connect() as connection:
|
|
last_sync = connection.execute(
|
|
"""
|
|
SELECT id, trade_date, source, status, started_at, finished_at,
|
|
record_count, message
|
|
FROM sync_runs ORDER BY id DESC LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
snapshot_stats = connection.execute(
|
|
"""
|
|
SELECT COUNT(*) AS dates, COALESCE(SUM(record_count), 0) AS records,
|
|
MAX(updated_at) AS updated_at
|
|
FROM dashboard_snapshots
|
|
"""
|
|
).fetchone()
|
|
watchlist_count = connection.execute("SELECT COUNT(*) FROM watchlist").fetchone()[0]
|
|
note_count = connection.execute("SELECT COUNT(*) FROM review_notes").fetchone()[0]
|
|
|
|
return {
|
|
"database": str(self.path.name),
|
|
"snapshot_dates": int(snapshot_stats["dates"]),
|
|
"snapshot_records": int(snapshot_stats["records"]),
|
|
"updated_at": snapshot_stats["updated_at"],
|
|
"last_sync": dict(last_sync) if last_sync else None,
|
|
"watchlist_count": int(watchlist_count),
|
|
"note_count": int(note_count),
|
|
}
|