diff --git a/.gitignore b/.gitignore index bc7db9e6..908ee638 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ gateway/temp/ index.html static/mofin_health.json + +deploy/profile-scripts/mofin_db.py +deploy/profile-scripts/mo_data.py +scripts/mofin_db.py +scripts/mo_data.py diff --git a/deploy/profile-scripts/mo_data.py b/deploy/profile-scripts/mo_data.py deleted file mode 100644 index cb168a13..00000000 --- a/deploy/profile-scripts/mo_data.py +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env python3 -""" -mo_data.py — MoFin 统一数据层(纯 DB) - -所有数据从 SQLite 读取。不做 JSON fallback。 -JSON 文件已弃用,仅保留为历史备份。 - -用法: - from mo_data import read_portfolio, read_decisions, read_watchlist - - pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构 - dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构 - wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构 -""" - -import sqlite3, json, sys -from datetime import datetime -from pathlib import Path - -DB_PATH = '/home/hmo/MoFin/data/mofin.db' -SCRIPT_DIR = Path('/home/hmo/MoFin/scripts') - - -def _get_db(): - db = sqlite3.connect(DB_PATH) - db.row_factory = sqlite3.Row - return db - - -# ── portfolio ───────────────────────────────────────────────────── - -def read_portfolio(): - """返回 portfolio.json 等价 dict。纯 DB。""" - db = _get_db() - rows = db.execute( - "SELECT code, name, shares, cost, price, market_value, " - "change_pct, currency, position_pct " - "FROM holdings WHERE is_active=1" - ).fetchall() - holdings = [] - for r in rows: - h = dict(r) - h['_currency'] = h.get('currency', 'CNY') - holdings.append(h) - - sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() - summary = dict(sum_row) if sum_row else {} - - db.close() - - return { - "holdings": holdings, - "total_assets": summary.get("total_assets", 0), - "total_mv": summary.get("total_mv", 0), - "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), - "cash": summary.get("cash", 0), - "frozen_cash": summary.get("frozen_cash", 0), - "position_pct": summary.get("position_pct", 0), - "currency": summary.get("currency", "CNY"), - "updated_at": summary.get("updated_at", ""), - } - - -# ── decisions ───────────────────────────────────────────────────── - -def _parse_json(val, default): - if val: - try: return json.loads(val) - except: pass - return default - - -def read_decisions(): - """返回 decisions.json 等价 dict。纯 DB。""" - db = _get_db() - rows = db.execute( - "SELECT code, name, version, price, cost, shares, " - "stop_loss, take_profit, entry_low, entry_high, " - "currency, strategy_type, action, timing_signal, " - "rr_ratio, tech_snapshot, stock_category, sector_context, " - "status, trigger_json, changelog_json, source, reason, " - "created_at, updated_at, " - "avg_price, decision_timestamp, note, quality_check, " - "quality_checked_at, quality_issues_json, position_advice, " - "signal_factors_json, time_horizon, decision_type, tag " - "FROM holding_strategies WHERE status IN ('active','updated') " - "ORDER BY code" - ).fetchall() - - decisions = [] - for r in rows: - d = dict(r) - d['trigger'] = _parse_json(r['trigger_json'], {}) - d['changelog'] = _parse_json(r['changelog_json'], []) - d['quality_issues'] = _parse_json(r['quality_issues_json'], {}) - d['signal_factors'] = _parse_json(r['signal_factors_json'], []) - d['timestamp'] = r['decision_timestamp'] or r['created_at'] or '' - d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略' - decisions.append(d) - - db.close() - - return { - "decisions": decisions, - "total": len(decisions), - "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), - } - - -# ── watchlist ───────────────────────────────────────────────────── - -def read_watchlist(): - """返回 watchlist 等价 dict。纯 DB。 - 从 holding_strategies(自选策略)读取,watchlist_stocks 已废弃。""" - db = _get_db() - # 主数据源:holding_strategies 自选策略 - rows = db.execute( - "SELECT code, name, price, entry_low, entry_high, " - "stop_loss, currency, updated_at " - "FROM holding_strategies WHERE status='active' AND decision_type='自选策略'" - ).fetchall() - - stocks = [] - seen = set() - for r in rows: - code = str(r["code"]) - if code in seen: - continue - seen.add(code) - stocks.append({ - "code": code, - "name": r["name"] or "", - "price": r["price"] or 0, - "entry_low": r["entry_low"] or 0, - "entry_high": r["entry_high"] or 0, - "stop_loss": r["stop_loss"] or 0, - "currency": r["currency"] or "CNY", - "added_at": r["updated_at"] or "", - "analysis": {}, - }) - - return {"stocks": stocks, "total": len(stocks)} - - db.close() - - return { - "stocks": stocks, - "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), - } - - -# ── 便捷别名 ─────────────────────────────────────────────────────── - -def read_portfolio_json(): - return read_portfolio() - -def read_decisions_json(): - return read_decisions() - -def read_watchlist_json(): - return read_watchlist() - - -# ── 统一价格获取(唯一入口,禁止各脚本自拉API)── - -def get_price(code, max_age_minutes=5, use_stale_fallback=True): - """获取单只股票最新价格。 - - 优先级: live_prices(DB) → stock_quote(API兜底) - - live_prices 有且不超过 max_age_minutes → 直接返回 - - 没有或过期 → 调 stock_quote 拉,写回 live_prices - - 都失败 → 返回 (None, None) - - 返回 (price, change_pct),两值都是 float 或 None。 - """ - from mofin_db import get_price_from_db - from datetime import datetime, timedelta - - # 1. 先读 DB - try: - db_price, db_chg = get_price_from_db(code) - if db_price is not None and db_price > 0: - # 检查时效性 - conn = __import__('sqlite3').connect(str(DB_PATH)) - row = conn.execute( - "SELECT updated_at FROM live_prices WHERE code=?", - (str(code).strip(),) - ).fetchone() - conn.close() - if row and row[0]: - try: - updated = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") - age = (datetime.now() - updated).total_seconds() / 60 - if age <= max_age_minutes: - return (db_price, db_chg) - except: - pass - else: - return (db_price, db_chg) - except Exception: - pass - - # 2. DB 没有或过期 → 调 stock_quote - if not use_stale_fallback: - return (None, None) - - try: - import subprocess, json - r = subprocess.run( - [sys.executable, str(SCRIPT_DIR / "stock_quote.py"), str(code)], - capture_output=True, text=True, timeout=15 - ) - if r.returncode == 0: - data = json.loads(r.stdout.strip()) - price = float(data.get("price", 0)) - chg = float(data.get("change_pct", 0)) - if price > 0: - # 写回 live_prices - try: - conn = __import__('sqlite3').connect(str(DB_PATH)) - conn.execute(""" - INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) - VALUES (?, ?, ?, datetime('now','localtime')) - """, (str(code).strip(), price, chg)) - conn.commit() - conn.close() - except: - pass - return (price, chg) - except Exception: - pass - - return (None, None) - - -def get_prices_batch(codes, max_age_minutes=5): - """批量获取价格,返回 {code: (price, change_pct)}""" - from mofin_db import get_prices_batch_from_db - - result = {} - need_api = [] - - # 1. 批量读 DB - try: - db_prices = get_prices_batch_from_db(codes) - for code in codes: - cs = str(code).strip() - if cs in db_prices: - p, c = db_prices[cs] - if p and p > 0: - result[cs] = (p, c) - continue - need_api.append(cs) - except: - need_api = [str(c).strip() for c in codes] - - # 2. 缺失的调 API - if need_api: - try: - import subprocess, json - r = subprocess.run( - [sys.executable, str(SCRIPT_DIR / "stock_quote.py")] + need_api, - capture_output=True, text=True, timeout=30 - ) - if r.returncode == 0: - for line in r.stdout.strip().split("\n"): - if not line: - continue - try: - data = json.loads(line) - code = str(data.get("code", "")).strip() - price = float(data.get("price", 0)) - chg = float(data.get("change_pct", 0)) - if code and price > 0: - result[code] = (price, chg) - except: - pass - except: - pass - - return result - - -# ── cash_log 写入 ────────────────────────────────────────────────── - -def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, - source, note, verified=0): - """记录现金变更到 cash_log 表。""" - change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0 - db = sqlite3.connect(DB_PATH) - try: - cur = db.execute( - """INSERT INTO cash_log - (timestamp, cash_before, cash_after, frozen_before, frozen_after, - change_amount, source, note, verified) - VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""", - (cash_before, cash_after, frozen_before, frozen_after, - change_amount, source, note, verified) - ) - db.commit() - return cur.lastrowid - finally: - db.close() - - -# ── 自检 ─────────────────────────────────────────────────────────── - -if __name__ == "__main__": - pf = read_portfolio() - print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}") - - dec = read_decisions() - print(f"decisions: {len(dec.get('decisions',[]))} entries") - - wl = read_watchlist() - print(f"watchlist: {len(wl.get('stocks',[]))} stocks") diff --git a/deploy/profile-scripts/mofin_db.py b/deploy/profile-scripts/mofin_db.py deleted file mode 100644 index 98ff7177..00000000 --- a/deploy/profile-scripts/mofin_db.py +++ /dev/null @@ -1,1384 +0,0 @@ -#!/usr/bin/env python3 -"""mofin_db.py — MoFin 统一数据库访问层 - -所有脚本通过此模块访问 mofin.db,避免重复建表/连接逻辑。 - -用法: - from mofin_db import get_conn, write_market_snapshot, write_klines, ... - -设计原则: - - 幂等建表(CREATE TABLE IF NOT EXISTS) - - WAL 模式 + 外键约束 - - 所有写操作返回 (success: bool, detail: str) - - JSON 写入由调用方负责,本模块只写 SQLite -""" - -import sqlite3 -import json -import time -import functools -from datetime import datetime -from pathlib import Path -from typing import Optional, Callable - -DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 -DB_PATH = DATA_DIR / "mofin.db" - -# ═══════════════════════════════════════════════════════════ -# 连接管理 -# ═══════════════════════════════════════════════════════════ - -def get_conn() -> sqlite3.Connection: - """获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁,autocommit模式)""" - DATA_DIR.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA foreign_keys=ON") - conn.execute("PRAGMA busy_timeout=30000") - conn.execute("PRAGMA synchronous=NORMAL") - # 每次连接时清理WAL:防止被kill的进程留下残留事务导致后续全部卡死 - try: - conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") - except Exception: - pass - return conn - - -def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (), - max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor: - """执行SQL并自动重试(捕获 database is locked)""" - last_err = None - for attempt in range(max_retries + 1): - try: - return conn.execute(sql, params) - except sqlite3.OperationalError as e: - if "database is locked" not in str(e) and "cannot commit" not in str(e): - raise # 非锁错误直接抛 - last_err = e - if attempt < max_retries: - delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s - time.sleep(delay) - else: - raise sqlite3.OperationalError( - f"DB锁重试{max_retries}次仍失败: {e}" - ) - # unreachable -- both paths in loop either return or raise - if last_err: - raise last_err # type: ignore[misc] - - -def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3, - base_delay: float = 1.0) -> None: - """提交事务并自动重试""" - last_err = None - for attempt in range(max_retries + 1): - try: - conn.commit() - return - except sqlite3.OperationalError as e: - if "database is locked" not in str(e) and "cannot commit" not in str(e): - raise - last_err = e - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - time.sleep(delay) - else: - raise sqlite3.OperationalError( - f"DB提交重试{max_retries}次仍失败: {e}" - ) - raise last_err - - -def retry_db_write(func: Callable) -> Callable: - """装饰器:为 DB 写函数自动添加重试""" - @functools.wraps(func) - def wrapper(*args, **kwargs): - max_retries = 3 - base_delay = 1.0 - last_err = None - for attempt in range(max_retries + 1): - try: - return func(*args, **kwargs) - except sqlite3.OperationalError as e: - if "database is locked" not in str(e) and "cannot commit" not in str(e): - raise - last_err = e - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - time.sleep(delay) - else: - raise sqlite3.OperationalError( - f"DB写重试{max_retries}次仍失败({func.__name__}): {e}" - ) - raise last_err - return wrapper - - -# ═══════════════════════════════════════════════════════════ -# 建表(幂等) -# ═══════════════════════════════════════════════════════════ - -def init_all_tables(conn: sqlite3.Connection): - """创建全部表(幂等,已存在则跳过)""" - conn.executescript(""" - -- 市场快照 - CREATE TABLE IF NOT EXISTS market_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'ths', - up_ratio REAL, - mood TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_snapshots_time ON market_snapshots(timestamp); - - -- 板块快照 - CREATE TABLE IF NOT EXISTS sector_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snapshot_id INTEGER NOT NULL REFERENCES market_snapshots(id), - name TEXT NOT NULL, - change_pct REAL, - up_count INTEGER, - down_count INTEGER, - net_inflow REAL, - lead_stock TEXT, - lead_stock_change REAL, - volume REAL, - turnover REAL - ); - CREATE INDEX IF NOT EXISTS idx_sector_name ON sector_snapshots(name); - CREATE INDEX IF NOT EXISTS idx_sector_snapshot ON sector_snapshots(snapshot_id); - CREATE INDEX IF NOT EXISTS idx_sector_name_time ON sector_snapshots(name, snapshot_id); - - -- 个股 - CREATE TABLE IF NOT EXISTS stocks ( - code TEXT PRIMARY KEY, - name TEXT NOT NULL, - exchange TEXT DEFAULT 'SH', - type TEXT DEFAULT 'A', - updated_at TEXT - ); - - -- K线(日/周/月) - CREATE TABLE IF NOT EXISTS stock_daily ( - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT NOT NULL, - open REAL, close REAL, high REAL, low REAL, - volume REAL, amount REAL, - PRIMARY KEY (code, date) - ); - CREATE TABLE IF NOT EXISTS stock_weekly ( - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT NOT NULL, - open REAL, close REAL, high REAL, low REAL, - volume REAL, - PRIMARY KEY (code, date) - ); - CREATE TABLE IF NOT EXISTS stock_monthly ( - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT NOT NULL, - open REAL, close REAL, high REAL, low REAL, - volume REAL, - PRIMARY KEY (code, date) - ); - - -- 基本面 - CREATE TABLE IF NOT EXISTS stock_fundamentals ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - pe REAL, pb REAL, eps REAL, - mcap_total REAL, mcap_flow REAL, - updated_at TEXT - ); - - -- 板块成分映射 - CREATE TABLE IF NOT EXISTS stock_sectors ( - code TEXT NOT NULL REFERENCES stocks(code), - sector_name TEXT NOT NULL, - source TEXT DEFAULT 'ths', - updated_at TEXT DEFAULT (datetime('now','localtime')), - PRIMARY KEY (code, sector_name) - ); - CREATE INDEX IF NOT EXISTS idx_stock_sector ON stock_sectors(sector_name); - - -- 持仓 - CREATE TABLE IF NOT EXISTS holdings ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - name TEXT NOT NULL, - shares INTEGER NOT NULL, - cost REAL, - price REAL, -- 当前价格 (CNY) - market_value REAL, -- 市值 = shares * price - change_pct REAL, -- 涨跌幅 - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - position_pct REAL, - added_at TEXT, - is_active INTEGER DEFAULT 1, - closed_at TEXT, - close_pnl REAL - ); - - -- 持仓策略(对应 decisions.json decisions[]) - CREATE TABLE IF NOT EXISTS holding_strategies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES holdings(code), - name TEXT, - version INTEGER DEFAULT 1, - price REAL, - cost REAL, - shares INTEGER DEFAULT 0, - stop_loss REAL, - take_profit REAL, - entry_low REAL, - entry_high REAL, - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - strategy_type TEXT DEFAULT 'holding', - action TEXT, - timing_signal TEXT, - rr_ratio REAL, - tech_snapshot TEXT, - stock_category TEXT, - sector_context TEXT, - status TEXT DEFAULT 'active', - trigger_json TEXT, - changelog_json TEXT, - source TEXT, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')), - updated_at TEXT, - superseded_at TEXT, - -- 以下为 decisions.json→DB 迁移新增列 - avg_price REAL, - decision_timestamp TEXT, - note TEXT, - quality_check TEXT, - quality_checked_at TEXT, - quality_issues_json TEXT, - position_advice TEXT, - signal_factors_json TEXT, - time_horizon TEXT, - decision_type TEXT - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); - CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); - - -- 策略历史快照(每次覆写前自动记录) - CREATE TABLE IF NOT EXISTS strategy_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL, - name TEXT, - decision_type TEXT, - strategy_type TEXT, - full_analysis TEXT, - action TEXT, - timing_signal TEXT, - entry_low REAL, - entry_high REAL, - stop_loss REAL, - take_profit REAL, - position_advice TEXT, - rr_ratio REAL, - version INTEGER, - source_trigger TEXT, - reassessed_at TEXT, - snapshotted_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_strategy_history_code ON strategy_history(code, snapshotted_at); - - -- 自选股 - CREATE TABLE IF NOT EXISTS watchlist_stocks ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - name TEXT NOT NULL, - price REAL, -- 当前价格 - entry_low REAL, -- 买入区下限 - entry_high REAL, -- 买入区上限 - stop_loss REAL, -- 止损 - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - source TEXT, -- 来源: alpha_sift/xiaoguo/manual - source_detail TEXT, -- 来源详情 JSON - notes TEXT, -- 备注 - added_by TEXT, -- 谁加的 - added_at TEXT DEFAULT (datetime('now','localtime')), - is_active INTEGER DEFAULT 1, - analysis_json TEXT -- 分析结果 JSON - ); - - -- 候选池 - CREATE TABLE IF NOT EXISTS candidates ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - name TEXT NOT NULL, - sector TEXT, - reason TEXT, - entry_range TEXT, - stop_loss REAL, - target REAL, - zhiwei_star REAL, - zhiwei_reviewed INTEGER DEFAULT 0, - zhiwei_reviewed_at TEXT, - promoted INTEGER DEFAULT 0, - promoted_at TEXT, - dropped INTEGER DEFAULT 0, - drop_reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 候选评分历史 - CREATE TABLE IF NOT EXISTS candidate_score_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES candidates(code), - score REAL NOT NULL, - source TEXT NOT NULL, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_candidate_history ON candidate_score_history(code, created_at); - - -- 价格事件 - CREATE TABLE IF NOT EXISTS price_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - name TEXT, - event_type TEXT NOT NULL, - price REAL, - trigger_value TEXT, - event_label TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')), - date TEXT - ); - CREATE INDEX IF NOT EXISTS idx_events_code ON price_events(code); - CREATE INDEX IF NOT EXISTS idx_events_date ON price_events(date); - - -- 策略评估记录 - CREATE TABLE IF NOT EXISTS strategy_evaluations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - eval_type TEXT NOT NULL, - status TEXT DEFAULT 'pending', - old_stop_loss REAL, - new_stop_loss REAL, - old_tp REAL, - new_tp REAL, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 持仓汇总(portfolio.json 顶层字段) - CREATE TABLE IF NOT EXISTS portfolio_summary ( - id INTEGER PRIMARY KEY CHECK (id = 1), - total_assets REAL, - total_mv REAL, -- 持仓总市值 - stock_value REAL, - cash REAL, -- 可用现金 - frozen_cash REAL DEFAULT 0, -- 冻结资金 - position_pct REAL, - total_pnl REAL, - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - updated_at TEXT - ); - - -- 现金变更日志(每次买卖/出入金记录) - CREATE TABLE IF NOT EXISTS cash_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')), - cash_before REAL, -- 变更前可用现金 - cash_after REAL, -- 变更后可用现金 - frozen_before REAL, -- 变更前冻结资金 - frozen_after REAL, -- 变更后冻结资金 - change_amount REAL, -- 现金变动额(正=入金/卖股,负=出金/买股) - source TEXT NOT NULL, -- 来源: screenshot/manual/import_xls/trade - note TEXT, -- 备注: 例如 "卖出法拉电子 200股" - verified INTEGER DEFAULT 0 -- 是否已验证(0=未验证,1=Dad确认) - ); - - -- 建议时间线(decisions.json advice_timeline[]) - CREATE TABLE IF NOT EXISTS advice_timeline ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT, - direction TEXT, - price REAL, - summary TEXT, - status TEXT, - evaluated INTEGER DEFAULT 0, - result TEXT, - evaluated_at TEXT, - report_id TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_advice_code ON advice_timeline(code); - - -- 准确率统计(accuracy_stats.json) - CREATE TABLE IF NOT EXISTS accuracy_stats ( - id INTEGER PRIMARY KEY CHECK (id = 1), - period_start TEXT, - period_end TEXT, - total_advice INTEGER DEFAULT 0, - correct INTEGER DEFAULT 0, - wrong INTEGER DEFAULT 0, - partial INTEGER DEFAULT 0, - unknown INTEGER DEFAULT 0, - pending INTEGER DEFAULT 0, - ignored INTEGER DEFAULT 0, - evaluated INTEGER DEFAULT 0, - accuracy_pct REAL, - phase1_correct INTEGER DEFAULT 0, - phase1_wrong INTEGER DEFAULT 0, - phase1_pending INTEGER DEFAULT 0, - phase1_accuracy REAL, - phase2_correct INTEGER DEFAULT 0, - phase2_wrong INTEGER DEFAULT 0, - phase2_pending INTEGER DEFAULT 0, - phase2_accuracy REAL, - total_evaluated INTEGER DEFAULT 0, - updated_at TEXT - ); - - -- 策略反馈(strategy_feedback.json) - CREATE TABLE IF NOT EXISTS strategy_feedback ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - name TEXT, - evaluated_at TEXT, - phase1_completed INTEGER DEFAULT 0, - phase1_result TEXT, - phase1_completed_at TEXT, - phase1_price REAL, - phase2_completed INTEGER DEFAULT 0, - phase2_result TEXT, - phase2_completed_at TEXT, - days_in_phase1 INTEGER, - adjustments_json TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_feedback_code ON strategy_feedback(code); - - -- 板块信号(trend_detector 产出) - CREATE TABLE IF NOT EXISTS sector_signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - signal_type TEXT NOT NULL, - sector TEXT NOT NULL, - severity TEXT DEFAULT 'medium', - related_stocks TEXT, - holdings_in_sector TEXT, - watchlist_in_sector TEXT, - trigger_reason TEXT, - snapshot_id INTEGER, - processed INTEGER DEFAULT 0, - detected_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_signal_processed ON sector_signals(processed); - CREATE INDEX IF NOT EXISTS idx_signal_sector ON sector_signals(sector); - - -- 小果情报(xiaoguo_news_processor 产出) - CREATE TABLE IF NOT EXISTS signal_news ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - signal_id INTEGER REFERENCES sector_signals(id), - sector TEXT NOT NULL, - overall_sentiment TEXT, - summary TEXT, - key_articles TEXT, - searched_stocks TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_signal_news_signal ON signal_news(signal_id); - - -- 小果扫描跟踪(去重用) - CREATE TABLE IF NOT EXISTS xiaoguo_scan_tracker ( - code TEXT PRIMARY KEY, - name TEXT, - last_scanned_at TEXT, - found_count INTEGER DEFAULT 0 - ); - - -- 实时价格快照(替代 live_prices.json) - CREATE TABLE IF NOT EXISTS live_prices ( - code TEXT PRIMARY KEY, - price REAL, - change_pct REAL, - updated_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 多周期缓存(替代 multi_tf_cache.json) - CREATE TABLE IF NOT EXISTS mtf_cache ( - code TEXT PRIMARY KEY, - cache_json TEXT, - updated_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 资金流缓存(替代 capital_flow_cache.json) - CREATE TABLE IF NOT EXISTS capital_flow_cache ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cache_json TEXT, - updated_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- Self-TODO 自动化任务表 - CREATE TABLE IF NOT EXISTS todos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - status TEXT DEFAULT 'pending', - priority TEXT DEFAULT 'medium', - source TEXT DEFAULT 'manual', - fix_action TEXT, - retry_count INTEGER DEFAULT 0, - note TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - conn.commit() - - # 迁移:给 signal_news 加 source 字段(幂等) - try: - conn.execute("ALTER TABLE signal_news ADD COLUMN source TEXT DEFAULT 'trend'") - except sqlite3.OperationalError: - pass - - # cash_log migration (2026-07-01) - try: - conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_before REAL") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_after REAL") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE cash_log ADD COLUMN verified INTEGER DEFAULT 0") - except sqlite3.OperationalError: - pass - - # ── 币种约束迁移(2026-06-30)──────────────────────────────── - _currency_migrations = [ - ("holdings", ["price REAL", "market_value REAL", "change_pct REAL", - "currency TEXT NOT NULL DEFAULT 'CNY'"]), - ("holding_strategies", ["name TEXT", "price REAL", "cost REAL", "shares INTEGER DEFAULT 0", - "currency TEXT NOT NULL DEFAULT 'CNY'", - "action TEXT", "timing_signal TEXT", "rr_ratio REAL", - "tech_snapshot TEXT", "stock_category TEXT", - "sector_context TEXT", "status TEXT DEFAULT 'active'", - "trigger_json TEXT", "changelog_json TEXT", - "updated_at TEXT"]), - ("portfolio_summary", ["total_mv REAL", "frozen_cash REAL DEFAULT 0", - "currency TEXT NOT NULL DEFAULT 'CNY'"]), - ("watchlist_stocks", ["price REAL", "entry_low REAL", "entry_high REAL", - "stop_loss REAL", "currency TEXT NOT NULL DEFAULT 'CNY'", - "source TEXT", "source_detail TEXT", "notes TEXT", - "added_by TEXT", "analysis_json TEXT"]), - ] - for table, columns in _currency_migrations: - for col_def in columns: - col_name = col_def.split()[0] - try: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") - except sqlite3.OperationalError: - pass # column already exists - - # ── tag 迁移(2026-07-20):推荐标签 current_recommend / active_manual ── - # 此前 strategy_lifecycle 在 dict 里设置 tag 但 write_holding_strategy 无此列, - # 导致标签在写入时被静默丢弃。补列 + 写入保留。 - try: - conn.execute("ALTER TABLE holding_strategies ADD COLUMN tag TEXT DEFAULT ''") - except sqlite3.OperationalError: - pass - conn.commit() - - -# ═══════════════════════════════════════════════════════════ -# 市场快照写入 -# ═══════════════════════════════════════════════════════════ - -def write_market_snapshot(conn: sqlite3.Connection, market_data: dict) -> tuple[bool, str, Optional[int]]: - """写入一次市场采集到 market_snapshots + sector_snapshots - - Returns: (ok, message, snapshot_id) - """ - try: - cur = conn.execute( - "INSERT INTO market_snapshots (timestamp, source, up_ratio, mood) VALUES (?, ?, ?, ?)", - (market_data["timestamp"], market_data.get("source", "unknown"), - market_data.get("up_ratio", 0), market_data.get("mood", "unknown")), - ) - sid = cur.lastrowid - - sectors = market_data.get("sectors", []) - rows = [(sid, s.get("name", ""), s.get("change", 0), - s.get("up_count"), s.get("down_count"), s.get("net_inflow"), - s.get("lead_stock"), s.get("lead_stock_change"), - s.get("volume"), s.get("turnover")) for s in sectors] - if rows: - conn.executemany( - "INSERT INTO sector_snapshots (snapshot_id, name, change_pct, up_count, down_count, " - "net_inflow, lead_stock, lead_stock_change, volume, turnover) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) - conn.commit() - return True, f"snapshot_id={sid}, sectors={len(rows)}", sid - except Exception as e: - try: - conn.rollback() - except Exception: - pass - return False, str(e), None - - -# ═══════════════════════════════════════════════════════════ -# K线写入 -# ═══════════════════════════════════════════════════════════ - -def write_klines(conn: sqlite3.Connection, code: str, name: str, - daily: list = None, weekly: list = None, monthly: list = None, - fundamentals: dict = None) -> bool: - """将个股K线数据双写 SQLite - - Args: - code: 股票代码 - name: 股票名称 - daily/weekly/monthly: [{date, open, close, high, low, volume}, ...] - fundamentals: {pe, pb, eps, mcap_total, mcap_flow} - """ - try: - # 判断交易所 - raw = str(code) - if len(raw) == 5 and raw.isdigit(): - exchange, stype = "HK", "H" - elif raw.startswith(("6", "5", "9")): - exchange, stype = "SH", "A" - else: - exchange, stype = "SZ", "A" - - # stocks 表(INSERT OR REPLACE) - conn.execute( - "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", - (code, name, exchange, stype, datetime.now().isoformat())) - - # K线数据 - for period, table, data in [ - ("daily", "stock_daily", daily), - ("weekly", "stock_weekly", weekly), - ("monthly", "stock_monthly", monthly), - ]: - if not data: - continue - rows = [(code, d.get("date", ""), d.get("open"), d.get("close"), - d.get("high"), d.get("low"), d.get("volume"), - d.get("amount") if period == "daily" else None) for d in data] - if period == "daily": - conn.executemany( - f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume, amount) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) - else: - conn.executemany( - f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - [(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows]) - - # 基本面 - if fundamentals: - conn.execute( - "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (code, fundamentals.get("pe"), fundamentals.get("pb"), - fundamentals.get("eps"), fundamentals.get("mcap_total"), - fundamentals.get("mcap_flow"), datetime.now().isoformat())) - - conn.commit() - return True - except Exception as e: - try: - conn.rollback() - except Exception: - pass - return False - - -# ═══════════════════════════════════════════════════════════ -# 价格事件写入 -# ═══════════════════════════════════════════════════════════ - -def write_price_event(conn: sqlite3.Connection, code: str, name: str, - event_type: str, price: float, trigger_value: str, - event_label: str = "") -> bool: - """写入一条价格事件""" - try: - now = datetime.now() - conn.execute( - "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (code, name, event_type, round(price, 2), trigger_value, - event_label, now.strftime("%Y-%m-%d"))) - conn.commit() - return True - except Exception: - try: - conn.rollback() - except Exception: - pass - return False - - -# ═══════════════════════════════════════════════════════════ -# 板块成分迁移 -# ═══════════════════════════════════════════════════════════ - -def migrate_stock_sectors(conn: sqlite3.Connection) -> tuple[int, int]: - """从 stock_sector_map.json 迁移到 stock_sectors 表 - - Returns: (migrated_stocks, total_mappings) - """ - sector_map_path = DATA_DIR / "stock_sector_map.json" - if not sector_map_path.exists(): - return 0, 0 - - try: - with open(sector_map_path, encoding="utf-8") as f: - data = json.load(f) - except Exception: - return 0, 0 - - # 过滤元数据字段 - mappings = [(code, sectors) for code, sectors in data.items() - if not code.startswith("_") and isinstance(sectors, list)] - - total = 0 - for code, sectors in mappings: - for sector in sectors: - try: - conn.execute( - "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", - (code, sector)) - total += 1 - except Exception: - pass - conn.commit() - return len(mappings), total - - -# ═══════════════════════════════════════════════════════════ -# 查询辅助 -# ═══════════════════════════════════════════════════════════ - -def query_sector_trend(conn: sqlite3.Connection, name: str, limit: int = 5) -> list[dict]: - """板块最近N次趋势""" - rows = conn.execute(""" - SELECT s.timestamp, ss.change_pct, ss.net_inflow, - ss.up_count, ss.down_count, ss.lead_stock, ss.lead_stock_change - FROM sector_snapshots ss - JOIN market_snapshots s ON ss.snapshot_id = s.id - WHERE ss.name = ? ORDER BY s.timestamp DESC LIMIT ? - """, (name, limit)).fetchall() - return [dict(r) for r in rows] - - -def query_top_inflow(conn: sqlite3.Connection, limit: int = 5) -> list[dict]: - """最新一次资金净流入排行""" - rows = conn.execute(""" - SELECT ss.name, ss.change_pct, ss.net_inflow, ss.lead_stock, s.timestamp - FROM sector_snapshots ss - JOIN market_snapshots s ON ss.snapshot_id = s.id - WHERE s.id = (SELECT MAX(id) FROM market_snapshots) - AND ss.net_inflow IS NOT NULL - ORDER BY ss.net_inflow DESC LIMIT ? - """, (limit,)).fetchall() - return [dict(r) for r in rows] - - -def query_consecutive_inflow(conn: sqlite3.Connection, days: int = 3) -> list[dict]: - """连续N次净流入的板块""" - rows = conn.execute(""" - SELECT name, COUNT(*) as times, ROUND(AVG(net_inflow), 2) as avg_inflow, - ROUND(AVG(change_pct), 2) as avg_change - FROM sector_snapshots ss - JOIN market_snapshots s ON ss.snapshot_id = s.id - WHERE s.id > (SELECT MAX(id) - ? FROM market_snapshots) - AND net_inflow > 0 - GROUP BY name HAVING COUNT(*) >= ? - ORDER BY avg_inflow DESC - """, (days, days)).fetchall() - return [dict(r) for r in rows] - - -def query_market_mood(conn: sqlite3.Connection, limit: int = 10) -> list[dict]: - """市场情绪趋势""" - rows = conn.execute(""" - SELECT timestamp, source, up_ratio, mood - FROM market_snapshots ORDER BY timestamp DESC LIMIT ? - """, (limit,)).fetchall() - return [dict(r) for r in rows] - - -def query_db_stats(conn: sqlite3.Connection) -> dict: - """数据库概览""" - snap_count = conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0] - sector_count = conn.execute("SELECT COUNT(*) FROM sector_snapshots").fetchone()[0] - stock_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] - kline_count = conn.execute("SELECT COUNT(*) FROM stock_daily").fetchone()[0] - event_count = conn.execute("SELECT COUNT(*) FROM price_events").fetchone()[0] - holding_count = conn.execute("SELECT COUNT(*) FROM holdings").fetchone()[0] - candidate_count = conn.execute("SELECT COUNT(*) FROM candidates").fetchone()[0] - latest = conn.execute( - "SELECT timestamp, source FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() - return { - "snapshots": snap_count, "sector_rows": sector_count, - "stocks": stock_count, "daily_klines": kline_count, - "price_events": event_count, "holdings": holding_count, - "candidates": candidate_count, - "latest_snapshot": dict(latest) if latest else None, - } - - -# ═══════════════════════════════════════════════════════════ -# 持仓查询 -# ═══════════════════════════════════════════════════════════ - -def query_holdings(conn: sqlite3.Connection) -> list[dict]: - """持仓列表(含最新策略)""" - rows = conn.execute(""" - SELECT h.code, h.name, h.shares, h.cost, h.position_pct, h.is_active, - h.price, h.change_pct, h.currency, - hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, - hs.reason as action, hs.created_at as strategy_updated - FROM holdings h - LEFT JOIN holding_strategies hs ON h.code = hs.code - AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') - WHERE h.is_active = 1 - """).fetchall() - return [dict(r) for r in rows] - - -def query_holding_by_code(conn: sqlite3.Connection, code: str) -> dict | None: - """单只持仓""" - row = conn.execute(""" - SELECT h.*, hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, - hs.reason as action - FROM holdings h - LEFT JOIN holding_strategies hs ON h.code = hs.code - AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') - WHERE h.code = ? - """, (code,)).fetchone() - return dict(row) if row else None - - -def query_portfolio_summary(conn: sqlite3.Connection) -> dict: - """持仓汇总""" - row = conn.execute("SELECT * FROM portfolio_summary WHERE id = 1").fetchone() - return dict(row) if row else {} - - -# ═══════════════════════════════════════════════════════════ -# 自选股查询 -# ═══════════════════════════════════════════════════════════ - -def query_watchlist(conn: sqlite3.Connection) -> list[dict]: - """自选股列表(含策略)""" - rows = conn.execute(""" - SELECT w.code, w.name, w.added_at, - hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, - hs.reason as action - FROM watchlist_stocks w - LEFT JOIN holding_strategies hs ON w.code = hs.code - AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = w.code AND strategy_type = 'watch') - WHERE w.is_active = 1 - """).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 决策/策略查询 -# ═══════════════════════════════════════════════════════════ - -def query_strategies(conn: sqlite3.Connection, code: str = None) -> list[dict]: - """策略列表(按版本倒序)""" - if code: - rows = conn.execute( - "SELECT * FROM holding_strategies WHERE code = ? ORDER BY version DESC", (code,)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM holding_strategies ORDER BY code, version DESC").fetchall() - return [dict(r) for r in rows] - - -def query_advice_timeline(conn: sqlite3.Connection, code: str = None, limit: int = 50) -> list[dict]: - """建议时间线""" - if code: - rows = conn.execute( - "SELECT * FROM advice_timeline WHERE code = ? ORDER BY date DESC LIMIT ?", - (code, limit)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM advice_timeline ORDER BY date DESC LIMIT ?", (limit,)).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 候选池查询 -# ═══════════════════════════════════════════════════════════ - -def query_candidates(conn: sqlite3.Connection, active_only: bool = True) -> list[dict]: - """候选池列表(含最新评分)""" - where = "WHERE c.dropped = 0" if active_only else "" - rows = conn.execute(f""" - SELECT c.*, (SELECT score FROM candidate_score_history - WHERE code = c.code ORDER BY created_at DESC LIMIT 1) as latest_score - FROM candidates c {where} - ORDER BY c.zhiwei_star DESC NULLS LAST - """).fetchall() - return [dict(r) for r in rows] - - -def query_candidate_scores(conn: sqlite3.Connection, code: str) -> list[dict]: - """某候选的评分历史""" - rows = conn.execute( - "SELECT * FROM candidate_score_history WHERE code = ? ORDER BY created_at", - (code,)).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 价格事件查询 -# ═══════════════════════════════════════════════════════════ - -def query_price_events(conn: sqlite3.Connection, code: str = None, limit: int = 100) -> list[dict]: - """价格事件""" - if code: - rows = conn.execute( - "SELECT * FROM price_events WHERE code = ? ORDER BY created_at DESC LIMIT ?", - (code, limit)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM price_events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() - return [dict(r) for r in rows] - - -def query_price_events_by_date(conn: sqlite3.Connection, date: str) -> list[dict]: - """某天的价格事件""" - rows = conn.execute( - "SELECT * FROM price_events WHERE date = ? ORDER BY created_at DESC", (date,)).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 板块成分查询 -# ═══════════════════════════════════════════════════════════ - -def query_stock_sectors(conn: sqlite3.Connection, code: str) -> list[str]: - """某只股票所属板块""" - rows = conn.execute( - "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,)).fetchall() - return [r[0] for r in rows] - - -def query_sector_stocks(conn: sqlite3.Connection, sector_name: str) -> list[str]: - """某板块包含的股票""" - rows = conn.execute( - "SELECT code FROM stock_sectors WHERE sector_name = ?", (sector_name,)).fetchall() - return [r[0] for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 准确率统计查询 -# ═══════════════════════════════════════════════════════════ - -def query_accuracy_stats(conn: sqlite3.Connection) -> dict: - """准确率统计""" - row = conn.execute("SELECT * FROM accuracy_stats WHERE id = 1").fetchone() - return dict(row) if row else {} - - -# ═══════════════════════════════════════════════════════════ -# 策略反馈查询 -# ═══════════════════════════════════════════════════════════ - -def query_strategy_feedback(conn: sqlite3.Connection, code: str = None) -> list[dict]: - """策略反馈""" - if code: - rows = conn.execute( - "SELECT * FROM strategy_feedback WHERE code = ? ORDER BY evaluated_at DESC", (code,)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM strategy_feedback ORDER BY evaluated_at DESC").fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 策略评估查询 -# ═══════════════════════════════════════════════════════════ - -def query_strategy_evaluations(conn: sqlite3.Connection, code: str = None) -> list[dict]: - """策略评估记录""" - if code: - rows = conn.execute( - "SELECT * FROM strategy_evaluations WHERE code = ? ORDER BY created_at DESC", (code,)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM strategy_evaluations ORDER BY created_at DESC").fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 市场快照查询(最新) -# ═══════════════════════════════════════════════════════════ - -def query_latest_market(conn: sqlite3.Connection) -> dict: - """获取最新一次市场快照(含 sector 详情)""" - row = conn.execute( - "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() - if not row: - return {} - snap = dict(row) - # 关联 sectors - sectors = conn.execute( - "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", - (snap["id"],)).fetchall() - snap["sectors"] = [dict(r) for r in sectors] - snap["top_gainers"] = [dict(r) for r in sectors[:5]] - snap["top_losers"] = [dict(r) for r in sectors[-3:]] - return snap - - -# ═══════════════════════════════════════════════════════════════════ -# 通用工具 -# ═══════════════════════════════════════════════════════════════════ - -def get_price_from_db(code: str) -> tuple[float | None, float | None]: - """从 DB 读取最新价格(price_monitor 维护)。 - 返回 (price, change_pct) 或 (None, None) - - 所有脚本应优先调用此函数,DB 无数据时才拉腾讯 API。 - """ - try: - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - row = db.execute( - "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) - ).fetchone() - if not row: - row = db.execute( - "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) - ).fetchone() - db.close() - if row: - return (row['price'], row['change_pct'] if 'change_pct' in row.keys() else None) - except Exception: - pass - return (None, None) - - -def get_prices_batch_from_db(codes: list[str]) -> dict: - """从 DB 批量读取价格。返回 {code: (price, change_pct)}""" - results = {} - if not codes: - return results - try: - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - for code in codes: - row = db.execute( - "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) - ).fetchone() - if not row: - row = db.execute( - "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) - ).fetchone() - if row and row['price']: - results[str(code)] = (row['price'], row['change_pct'] if 'change_pct' in row.keys() else 0) - db.close() - except Exception: - pass - return results - """最新一次市场快照(含板块数据)""" - snap = conn.execute( - "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() - if not snap: - return {} - snap = dict(snap) - sectors = conn.execute( - "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", - (snap["id"],)).fetchall() - snap["sectors"] = [dict(r) for r in sectors] - # 计算 top_gainers / top_losers - snap["top_gainers"] = [dict(r) for r in sectors[:5]] - snap["top_losers"] = [dict(r) for r in sectors[-3:]] - return snap - - -# ═══════════════════════════════════════════════════════════════════ -# 核心写函数 — 替代 json.dump(),强制币种约束 -# ═══════════════════════════════════════════════════════════════════ - -def snapshot_strategy_history(conn, code: str, source_trigger: str = "write_holding_strategy"): - """在修改前快照当前策略到 strategy_history 表。永不抛异常。""" - try: - row = conn.execute( - "SELECT code, name, decision_type, strategy_type, full_analysis, " - "action, timing_signal, entry_low, entry_high, stop_loss, take_profit, " - "position_advice, rr_ratio, version, reassessed_at " - "FROM holding_strategies WHERE code=? AND status='active'", - (code,) - ).fetchone() - if not row: - return - now = datetime.now().isoformat() - conn.execute(""" - INSERT INTO strategy_history - (code, name, decision_type, strategy_type, full_analysis, action, - timing_signal, entry_low, entry_high, stop_loss, take_profit, - position_advice, rr_ratio, version, source_trigger, reassessed_at, snapshotted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - row[0], row[1], row[2], row[3], - row[4], row[5], row[6], - row[7], row[8], row[9], row[10], - row[11], row[12], row[13], - source_trigger, row[14], now - )) - conn.commit() - # 每只股票只保留最近20条历史 - conn.execute(""" - DELETE FROM strategy_history WHERE code=? AND id NOT IN ( - SELECT id FROM strategy_history WHERE code=? ORDER BY snapshotted_at DESC LIMIT 20 - ) - """, (code, code)) - conn.commit() - except Exception as e: - print(f" [SNAPSHOT] {code} 快照失败: {e}", flush=True) - - -def write_holding_strategy(conn, code: str, name: str, data: dict, - source_trigger: str = "write_holding_strategy") -> tuple[bool, str]: - """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" - try: - # ── 覆写前快照旧行 ── - snapshot_strategy_history(conn, code, source_trigger) - - - currency = data.get('currency', 'CNY') - # Serialize JSON fields - import json as _json - trigger_j = _json.dumps(data.get('trigger', {}), ensure_ascii=False) if isinstance(data.get('trigger'), dict) else str(data.get('trigger', '{}')) - changelog_j = _json.dumps(data.get('changelog', []), ensure_ascii=False) if isinstance(data.get('changelog'), list) else str(data.get('changelog', '[]')) - quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '') - signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '') - - # 在DELETE前保留现有的full_analysis和reassessed_at(防止被regenerate_all等清空) - _existing_fa = data.get('full_analysis', '') - _existing_ra = data.get('reassessed_at', '') - # tag 语义:'tag' 键缺席=保留旧标签;显式传入(含'')= 按传入值(允许清除标签) - _tag_absent = 'tag' not in data - _existing_tag = data.get('tag', '') or '' - if not _existing_fa or _tag_absent: - try: - _old = conn.execute("SELECT full_analysis, reassessed_at, tag FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() - if _old: - if not _existing_fa: - if _old[0]: _existing_fa = _old[0] - if _old[1]: _existing_ra = _old[1] - if _tag_absent and _old[2]: - _existing_tag = _old[2] - except: - pass - - # DELETE + INSERT - conn.execute("DELETE FROM holding_strategies WHERE code=?", (code,)) - conn.execute(""" - INSERT INTO holding_strategies - (code, name, version, price, cost, shares, stop_loss, take_profit, - entry_low, entry_high, currency, strategy_type, action, - timing_signal, rr_ratio, tech_snapshot, stock_category, - sector_context, status, trigger_json, changelog_json, - source, reason, updated_at, - avg_price, decision_timestamp, note, quality_check, - quality_checked_at, quality_issues_json, position_advice, - signal_factors_json, time_horizon, decision_type, - full_analysis, reassessed_at, tag) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, - datetime('now','localtime'), - ?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - code, name, - data.get('version', 1), data.get('price'), data.get('cost'), - data.get('shares', 0), data.get('stop_loss'), data.get('take_profit'), - data.get('entry_low'), data.get('entry_high'), currency, - data.get('strategy_type', 'holding'), data.get('action'), - data.get('timing_signal'), data.get('rr_ratio'), - data.get('tech_snapshot'), data.get('stock_category'), - data.get('sector_context'), data.get('status', 'active'), - trigger_j, changelog_j, - data.get('source'), data.get('reason'), - # new columns - data.get('avg_price', 0), - data.get('timestamp') or data.get('created_at', ''), - data.get('note', ''), - data.get('quality_check', ''), - data.get('quality_checked_at', ''), - quality_issues_j, - data.get('position_advice', ''), - signal_factors_j, - data.get('time_horizon', ''), - data.get('type', data.get('strategy_type', 'holding')), - # 保留full_analysis和reassessed_at - _existing_fa, - _existing_ra, - _existing_tag, - )) - conn.commit() - return True, f"策略 {code} 已写入" - except sqlite3.IntegrityError as e: - return False, f"币种约束: {e}" - except Exception as e: - return False, str(e) - - -def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]: - """批量写入持仓(替代 portfolio.json holdings[])""" - try: - conn.execute("BEGIN IMMEDIATE") - for h in holdings: - currency = str(h.get('currency', 'CNY')).upper() - if currency not in ('CNY', 'HKD'): - return False, f"非法币种: {currency}(必须 CNY 或 HKD)" - conn.execute(""" - INSERT INTO holdings (code, name, shares, cost, price, market_value, - change_pct, currency, position_pct, added_at, is_active) - VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) - ON CONFLICT(code) DO UPDATE SET - name=excluded.name, shares=excluded.shares, cost=excluded.cost, - price=excluded.price, market_value=excluded.market_value, - change_pct=excluded.change_pct, currency=excluded.currency, - position_pct=excluded.position_pct - """, ( - h.get('code'), h.get('name'), h.get('shares', 0), - h.get('cost'), h.get('price'), - h.get('market_value'), h.get('change_pct'), - h.get('currency', 'CNY'), h.get('position_pct'), - )) - conn.commit() - return True, f"已写入 {len(holdings)} 条持仓" - except sqlite3.IntegrityError as e: - conn.rollback() - return False, f"币种约束: {e}" - except sqlite3.OperationalError as e: - return False, f"DB锁冲突(重试耗尽): {e}" -def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]: - """写入持仓汇总(替代 portfolio.json 顶层)""" - try: - conn.execute("BEGIN IMMEDIATE") - conn.execute(""" - INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, - cash, frozen_cash, position_pct, total_pnl, currency, updated_at) - VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) - ON CONFLICT(id) DO UPDATE SET - total_assets=excluded.total_assets, total_mv=excluded.total_mv, - stock_value=excluded.stock_value, cash=excluded.cash, - frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, - total_pnl=excluded.total_pnl, currency=excluded.currency, - updated_at=datetime('now','localtime') - """, ( - data.get('total_assets'), data.get('total_mv'), data.get('stock_value'), - data.get('cash'), data.get('frozen_cash', 0), data.get('position_pct'), - data.get('total_pnl'), data.get('currency', 'CNY'), - )) - conn.commit() - return True, "汇总已写入" - except sqlite3.IntegrityError as e: - return False, f"约束: {e}" - except sqlite3.OperationalError as e: - return False, f"DB锁冲突: {e}" - - -def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: - """写入自选股(写入 watchlist_stocks 表)""" - try: - conn.execute(""" - INSERT INTO watchlist_stocks (code, name, price, entry_low, entry_high, - stop_loss, currency, source, source_detail, notes, added_by, added_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) - ON CONFLICT(code) DO UPDATE SET - name=excluded.name, price=excluded.price, entry_low=excluded.entry_low, - entry_high=excluded.entry_high, stop_loss=excluded.stop_loss, - currency=excluded.currency, source=excluded.source, - source_detail=excluded.source_detail, notes=excluded.notes, - added_by=excluded.added_by - """, ( - stock.get('code'), stock.get('name'), stock.get('price'), - stock.get('entry_low'), stock.get('entry_high'), stock.get('stop_loss'), - stock.get('currency', 'CNY'), stock.get('source'), stock.get('source_detail'), - stock.get('notes'), stock.get('added_by'), - )) - conn.commit() - return True, f"自选 {stock.get('code')} 已写入" - except sqlite3.IntegrityError as e: - return False, f"约束: {e}" - - -def write_cash_log(conn, data: dict) -> tuple[bool, str]: - """记录现金变更(替代手动改 portfolio.json cash 字段)""" - try: - conn.execute(""" - INSERT INTO cash_log (cash_before, cash_after, frozen_before, frozen_after, - change_amount, source, note) - VALUES (?,?,?,?,?,?,?) - """, ( - data.get('cash_before'), data.get('cash_after'), - data.get('frozen_before'), data.get('frozen_after'), - data.get('change_amount'), data.get('source', 'manual'), - data.get('note', ''), - )) - conn.commit() - return True, "现金变更已记录" - except Exception as e: - return False, str(e) - - -def query_cash_log(conn, limit: int = 20) -> list[dict]: - rows = conn.execute( - "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) - ).fetchall() - return [dict(r) for r in rows] - - -# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ - -def write_live_prices(conn, prices: dict): - """写入实时价格快照(替代 live_prices.json)""" - import json - for code, info in prices.items(): - conn.execute( - "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", - (code, info.get('price'), info.get('change_pct')) - ) - -def read_live_prices(conn) -> dict: - rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() - return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} - - -def write_mtf_cache(conn, code: str, data: dict): - """写入多周期缓存(替代 multi_tf_cache.json 单条)""" - import json - conn.execute( - "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", - (code, json.dumps(data, ensure_ascii=False)) - ) - -def read_mtf_cache(conn, code: str) -> dict: - import json - r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() - return json.loads(r['cache_json']) if r else {} - - -def write_capital_flow_cache(conn, data: dict): - """写入资金流缓存(替代 capital_flow_cache.json)""" - import json - conn.execute("DELETE FROM capital_flow_cache") - conn.execute( - "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", - (json.dumps(data, ensure_ascii=False),) - ) - -def read_capital_flow_cache(conn) -> dict: - import json - r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() - return json.loads(r['cache_json']) if r else {} diff --git a/scripts/mo_data.py b/scripts/mo_data.py deleted file mode 100644 index cb168a13..00000000 --- a/scripts/mo_data.py +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env python3 -""" -mo_data.py — MoFin 统一数据层(纯 DB) - -所有数据从 SQLite 读取。不做 JSON fallback。 -JSON 文件已弃用,仅保留为历史备份。 - -用法: - from mo_data import read_portfolio, read_decisions, read_watchlist - - pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构 - dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构 - wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构 -""" - -import sqlite3, json, sys -from datetime import datetime -from pathlib import Path - -DB_PATH = '/home/hmo/MoFin/data/mofin.db' -SCRIPT_DIR = Path('/home/hmo/MoFin/scripts') - - -def _get_db(): - db = sqlite3.connect(DB_PATH) - db.row_factory = sqlite3.Row - return db - - -# ── portfolio ───────────────────────────────────────────────────── - -def read_portfolio(): - """返回 portfolio.json 等价 dict。纯 DB。""" - db = _get_db() - rows = db.execute( - "SELECT code, name, shares, cost, price, market_value, " - "change_pct, currency, position_pct " - "FROM holdings WHERE is_active=1" - ).fetchall() - holdings = [] - for r in rows: - h = dict(r) - h['_currency'] = h.get('currency', 'CNY') - holdings.append(h) - - sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() - summary = dict(sum_row) if sum_row else {} - - db.close() - - return { - "holdings": holdings, - "total_assets": summary.get("total_assets", 0), - "total_mv": summary.get("total_mv", 0), - "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), - "cash": summary.get("cash", 0), - "frozen_cash": summary.get("frozen_cash", 0), - "position_pct": summary.get("position_pct", 0), - "currency": summary.get("currency", "CNY"), - "updated_at": summary.get("updated_at", ""), - } - - -# ── decisions ───────────────────────────────────────────────────── - -def _parse_json(val, default): - if val: - try: return json.loads(val) - except: pass - return default - - -def read_decisions(): - """返回 decisions.json 等价 dict。纯 DB。""" - db = _get_db() - rows = db.execute( - "SELECT code, name, version, price, cost, shares, " - "stop_loss, take_profit, entry_low, entry_high, " - "currency, strategy_type, action, timing_signal, " - "rr_ratio, tech_snapshot, stock_category, sector_context, " - "status, trigger_json, changelog_json, source, reason, " - "created_at, updated_at, " - "avg_price, decision_timestamp, note, quality_check, " - "quality_checked_at, quality_issues_json, position_advice, " - "signal_factors_json, time_horizon, decision_type, tag " - "FROM holding_strategies WHERE status IN ('active','updated') " - "ORDER BY code" - ).fetchall() - - decisions = [] - for r in rows: - d = dict(r) - d['trigger'] = _parse_json(r['trigger_json'], {}) - d['changelog'] = _parse_json(r['changelog_json'], []) - d['quality_issues'] = _parse_json(r['quality_issues_json'], {}) - d['signal_factors'] = _parse_json(r['signal_factors_json'], []) - d['timestamp'] = r['decision_timestamp'] or r['created_at'] or '' - d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略' - decisions.append(d) - - db.close() - - return { - "decisions": decisions, - "total": len(decisions), - "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), - } - - -# ── watchlist ───────────────────────────────────────────────────── - -def read_watchlist(): - """返回 watchlist 等价 dict。纯 DB。 - 从 holding_strategies(自选策略)读取,watchlist_stocks 已废弃。""" - db = _get_db() - # 主数据源:holding_strategies 自选策略 - rows = db.execute( - "SELECT code, name, price, entry_low, entry_high, " - "stop_loss, currency, updated_at " - "FROM holding_strategies WHERE status='active' AND decision_type='自选策略'" - ).fetchall() - - stocks = [] - seen = set() - for r in rows: - code = str(r["code"]) - if code in seen: - continue - seen.add(code) - stocks.append({ - "code": code, - "name": r["name"] or "", - "price": r["price"] or 0, - "entry_low": r["entry_low"] or 0, - "entry_high": r["entry_high"] or 0, - "stop_loss": r["stop_loss"] or 0, - "currency": r["currency"] or "CNY", - "added_at": r["updated_at"] or "", - "analysis": {}, - }) - - return {"stocks": stocks, "total": len(stocks)} - - db.close() - - return { - "stocks": stocks, - "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), - } - - -# ── 便捷别名 ─────────────────────────────────────────────────────── - -def read_portfolio_json(): - return read_portfolio() - -def read_decisions_json(): - return read_decisions() - -def read_watchlist_json(): - return read_watchlist() - - -# ── 统一价格获取(唯一入口,禁止各脚本自拉API)── - -def get_price(code, max_age_minutes=5, use_stale_fallback=True): - """获取单只股票最新价格。 - - 优先级: live_prices(DB) → stock_quote(API兜底) - - live_prices 有且不超过 max_age_minutes → 直接返回 - - 没有或过期 → 调 stock_quote 拉,写回 live_prices - - 都失败 → 返回 (None, None) - - 返回 (price, change_pct),两值都是 float 或 None。 - """ - from mofin_db import get_price_from_db - from datetime import datetime, timedelta - - # 1. 先读 DB - try: - db_price, db_chg = get_price_from_db(code) - if db_price is not None and db_price > 0: - # 检查时效性 - conn = __import__('sqlite3').connect(str(DB_PATH)) - row = conn.execute( - "SELECT updated_at FROM live_prices WHERE code=?", - (str(code).strip(),) - ).fetchone() - conn.close() - if row and row[0]: - try: - updated = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") - age = (datetime.now() - updated).total_seconds() / 60 - if age <= max_age_minutes: - return (db_price, db_chg) - except: - pass - else: - return (db_price, db_chg) - except Exception: - pass - - # 2. DB 没有或过期 → 调 stock_quote - if not use_stale_fallback: - return (None, None) - - try: - import subprocess, json - r = subprocess.run( - [sys.executable, str(SCRIPT_DIR / "stock_quote.py"), str(code)], - capture_output=True, text=True, timeout=15 - ) - if r.returncode == 0: - data = json.loads(r.stdout.strip()) - price = float(data.get("price", 0)) - chg = float(data.get("change_pct", 0)) - if price > 0: - # 写回 live_prices - try: - conn = __import__('sqlite3').connect(str(DB_PATH)) - conn.execute(""" - INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) - VALUES (?, ?, ?, datetime('now','localtime')) - """, (str(code).strip(), price, chg)) - conn.commit() - conn.close() - except: - pass - return (price, chg) - except Exception: - pass - - return (None, None) - - -def get_prices_batch(codes, max_age_minutes=5): - """批量获取价格,返回 {code: (price, change_pct)}""" - from mofin_db import get_prices_batch_from_db - - result = {} - need_api = [] - - # 1. 批量读 DB - try: - db_prices = get_prices_batch_from_db(codes) - for code in codes: - cs = str(code).strip() - if cs in db_prices: - p, c = db_prices[cs] - if p and p > 0: - result[cs] = (p, c) - continue - need_api.append(cs) - except: - need_api = [str(c).strip() for c in codes] - - # 2. 缺失的调 API - if need_api: - try: - import subprocess, json - r = subprocess.run( - [sys.executable, str(SCRIPT_DIR / "stock_quote.py")] + need_api, - capture_output=True, text=True, timeout=30 - ) - if r.returncode == 0: - for line in r.stdout.strip().split("\n"): - if not line: - continue - try: - data = json.loads(line) - code = str(data.get("code", "")).strip() - price = float(data.get("price", 0)) - chg = float(data.get("change_pct", 0)) - if code and price > 0: - result[code] = (price, chg) - except: - pass - except: - pass - - return result - - -# ── cash_log 写入 ────────────────────────────────────────────────── - -def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, - source, note, verified=0): - """记录现金变更到 cash_log 表。""" - change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0 - db = sqlite3.connect(DB_PATH) - try: - cur = db.execute( - """INSERT INTO cash_log - (timestamp, cash_before, cash_after, frozen_before, frozen_after, - change_amount, source, note, verified) - VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""", - (cash_before, cash_after, frozen_before, frozen_after, - change_amount, source, note, verified) - ) - db.commit() - return cur.lastrowid - finally: - db.close() - - -# ── 自检 ─────────────────────────────────────────────────────────── - -if __name__ == "__main__": - pf = read_portfolio() - print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}") - - dec = read_decisions() - print(f"decisions: {len(dec.get('decisions',[]))} entries") - - wl = read_watchlist() - print(f"watchlist: {len(wl.get('stocks',[]))} stocks") diff --git a/scripts/mofin_db.py b/scripts/mofin_db.py deleted file mode 100644 index 98ff7177..00000000 --- a/scripts/mofin_db.py +++ /dev/null @@ -1,1384 +0,0 @@ -#!/usr/bin/env python3 -"""mofin_db.py — MoFin 统一数据库访问层 - -所有脚本通过此模块访问 mofin.db,避免重复建表/连接逻辑。 - -用法: - from mofin_db import get_conn, write_market_snapshot, write_klines, ... - -设计原则: - - 幂等建表(CREATE TABLE IF NOT EXISTS) - - WAL 模式 + 外键约束 - - 所有写操作返回 (success: bool, detail: str) - - JSON 写入由调用方负责,本模块只写 SQLite -""" - -import sqlite3 -import json -import time -import functools -from datetime import datetime -from pathlib import Path -from typing import Optional, Callable - -DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 -DB_PATH = DATA_DIR / "mofin.db" - -# ═══════════════════════════════════════════════════════════ -# 连接管理 -# ═══════════════════════════════════════════════════════════ - -def get_conn() -> sqlite3.Connection: - """获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁,autocommit模式)""" - DATA_DIR.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA foreign_keys=ON") - conn.execute("PRAGMA busy_timeout=30000") - conn.execute("PRAGMA synchronous=NORMAL") - # 每次连接时清理WAL:防止被kill的进程留下残留事务导致后续全部卡死 - try: - conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") - except Exception: - pass - return conn - - -def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (), - max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor: - """执行SQL并自动重试(捕获 database is locked)""" - last_err = None - for attempt in range(max_retries + 1): - try: - return conn.execute(sql, params) - except sqlite3.OperationalError as e: - if "database is locked" not in str(e) and "cannot commit" not in str(e): - raise # 非锁错误直接抛 - last_err = e - if attempt < max_retries: - delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s - time.sleep(delay) - else: - raise sqlite3.OperationalError( - f"DB锁重试{max_retries}次仍失败: {e}" - ) - # unreachable -- both paths in loop either return or raise - if last_err: - raise last_err # type: ignore[misc] - - -def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3, - base_delay: float = 1.0) -> None: - """提交事务并自动重试""" - last_err = None - for attempt in range(max_retries + 1): - try: - conn.commit() - return - except sqlite3.OperationalError as e: - if "database is locked" not in str(e) and "cannot commit" not in str(e): - raise - last_err = e - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - time.sleep(delay) - else: - raise sqlite3.OperationalError( - f"DB提交重试{max_retries}次仍失败: {e}" - ) - raise last_err - - -def retry_db_write(func: Callable) -> Callable: - """装饰器:为 DB 写函数自动添加重试""" - @functools.wraps(func) - def wrapper(*args, **kwargs): - max_retries = 3 - base_delay = 1.0 - last_err = None - for attempt in range(max_retries + 1): - try: - return func(*args, **kwargs) - except sqlite3.OperationalError as e: - if "database is locked" not in str(e) and "cannot commit" not in str(e): - raise - last_err = e - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - time.sleep(delay) - else: - raise sqlite3.OperationalError( - f"DB写重试{max_retries}次仍失败({func.__name__}): {e}" - ) - raise last_err - return wrapper - - -# ═══════════════════════════════════════════════════════════ -# 建表(幂等) -# ═══════════════════════════════════════════════════════════ - -def init_all_tables(conn: sqlite3.Connection): - """创建全部表(幂等,已存在则跳过)""" - conn.executescript(""" - -- 市场快照 - CREATE TABLE IF NOT EXISTS market_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'ths', - up_ratio REAL, - mood TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_snapshots_time ON market_snapshots(timestamp); - - -- 板块快照 - CREATE TABLE IF NOT EXISTS sector_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snapshot_id INTEGER NOT NULL REFERENCES market_snapshots(id), - name TEXT NOT NULL, - change_pct REAL, - up_count INTEGER, - down_count INTEGER, - net_inflow REAL, - lead_stock TEXT, - lead_stock_change REAL, - volume REAL, - turnover REAL - ); - CREATE INDEX IF NOT EXISTS idx_sector_name ON sector_snapshots(name); - CREATE INDEX IF NOT EXISTS idx_sector_snapshot ON sector_snapshots(snapshot_id); - CREATE INDEX IF NOT EXISTS idx_sector_name_time ON sector_snapshots(name, snapshot_id); - - -- 个股 - CREATE TABLE IF NOT EXISTS stocks ( - code TEXT PRIMARY KEY, - name TEXT NOT NULL, - exchange TEXT DEFAULT 'SH', - type TEXT DEFAULT 'A', - updated_at TEXT - ); - - -- K线(日/周/月) - CREATE TABLE IF NOT EXISTS stock_daily ( - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT NOT NULL, - open REAL, close REAL, high REAL, low REAL, - volume REAL, amount REAL, - PRIMARY KEY (code, date) - ); - CREATE TABLE IF NOT EXISTS stock_weekly ( - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT NOT NULL, - open REAL, close REAL, high REAL, low REAL, - volume REAL, - PRIMARY KEY (code, date) - ); - CREATE TABLE IF NOT EXISTS stock_monthly ( - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT NOT NULL, - open REAL, close REAL, high REAL, low REAL, - volume REAL, - PRIMARY KEY (code, date) - ); - - -- 基本面 - CREATE TABLE IF NOT EXISTS stock_fundamentals ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - pe REAL, pb REAL, eps REAL, - mcap_total REAL, mcap_flow REAL, - updated_at TEXT - ); - - -- 板块成分映射 - CREATE TABLE IF NOT EXISTS stock_sectors ( - code TEXT NOT NULL REFERENCES stocks(code), - sector_name TEXT NOT NULL, - source TEXT DEFAULT 'ths', - updated_at TEXT DEFAULT (datetime('now','localtime')), - PRIMARY KEY (code, sector_name) - ); - CREATE INDEX IF NOT EXISTS idx_stock_sector ON stock_sectors(sector_name); - - -- 持仓 - CREATE TABLE IF NOT EXISTS holdings ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - name TEXT NOT NULL, - shares INTEGER NOT NULL, - cost REAL, - price REAL, -- 当前价格 (CNY) - market_value REAL, -- 市值 = shares * price - change_pct REAL, -- 涨跌幅 - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - position_pct REAL, - added_at TEXT, - is_active INTEGER DEFAULT 1, - closed_at TEXT, - close_pnl REAL - ); - - -- 持仓策略(对应 decisions.json decisions[]) - CREATE TABLE IF NOT EXISTS holding_strategies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES holdings(code), - name TEXT, - version INTEGER DEFAULT 1, - price REAL, - cost REAL, - shares INTEGER DEFAULT 0, - stop_loss REAL, - take_profit REAL, - entry_low REAL, - entry_high REAL, - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - strategy_type TEXT DEFAULT 'holding', - action TEXT, - timing_signal TEXT, - rr_ratio REAL, - tech_snapshot TEXT, - stock_category TEXT, - sector_context TEXT, - status TEXT DEFAULT 'active', - trigger_json TEXT, - changelog_json TEXT, - source TEXT, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')), - updated_at TEXT, - superseded_at TEXT, - -- 以下为 decisions.json→DB 迁移新增列 - avg_price REAL, - decision_timestamp TEXT, - note TEXT, - quality_check TEXT, - quality_checked_at TEXT, - quality_issues_json TEXT, - position_advice TEXT, - signal_factors_json TEXT, - time_horizon TEXT, - decision_type TEXT - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); - CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); - - -- 策略历史快照(每次覆写前自动记录) - CREATE TABLE IF NOT EXISTS strategy_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL, - name TEXT, - decision_type TEXT, - strategy_type TEXT, - full_analysis TEXT, - action TEXT, - timing_signal TEXT, - entry_low REAL, - entry_high REAL, - stop_loss REAL, - take_profit REAL, - position_advice TEXT, - rr_ratio REAL, - version INTEGER, - source_trigger TEXT, - reassessed_at TEXT, - snapshotted_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_strategy_history_code ON strategy_history(code, snapshotted_at); - - -- 自选股 - CREATE TABLE IF NOT EXISTS watchlist_stocks ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - name TEXT NOT NULL, - price REAL, -- 当前价格 - entry_low REAL, -- 买入区下限 - entry_high REAL, -- 买入区上限 - stop_loss REAL, -- 止损 - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - source TEXT, -- 来源: alpha_sift/xiaoguo/manual - source_detail TEXT, -- 来源详情 JSON - notes TEXT, -- 备注 - added_by TEXT, -- 谁加的 - added_at TEXT DEFAULT (datetime('now','localtime')), - is_active INTEGER DEFAULT 1, - analysis_json TEXT -- 分析结果 JSON - ); - - -- 候选池 - CREATE TABLE IF NOT EXISTS candidates ( - code TEXT PRIMARY KEY REFERENCES stocks(code), - name TEXT NOT NULL, - sector TEXT, - reason TEXT, - entry_range TEXT, - stop_loss REAL, - target REAL, - zhiwei_star REAL, - zhiwei_reviewed INTEGER DEFAULT 0, - zhiwei_reviewed_at TEXT, - promoted INTEGER DEFAULT 0, - promoted_at TEXT, - dropped INTEGER DEFAULT 0, - drop_reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 候选评分历史 - CREATE TABLE IF NOT EXISTS candidate_score_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES candidates(code), - score REAL NOT NULL, - source TEXT NOT NULL, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_candidate_history ON candidate_score_history(code, created_at); - - -- 价格事件 - CREATE TABLE IF NOT EXISTS price_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - name TEXT, - event_type TEXT NOT NULL, - price REAL, - trigger_value TEXT, - event_label TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')), - date TEXT - ); - CREATE INDEX IF NOT EXISTS idx_events_code ON price_events(code); - CREATE INDEX IF NOT EXISTS idx_events_date ON price_events(date); - - -- 策略评估记录 - CREATE TABLE IF NOT EXISTS strategy_evaluations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - eval_type TEXT NOT NULL, - status TEXT DEFAULT 'pending', - old_stop_loss REAL, - new_stop_loss REAL, - old_tp REAL, - new_tp REAL, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 持仓汇总(portfolio.json 顶层字段) - CREATE TABLE IF NOT EXISTS portfolio_summary ( - id INTEGER PRIMARY KEY CHECK (id = 1), - total_assets REAL, - total_mv REAL, -- 持仓总市值 - stock_value REAL, - cash REAL, -- 可用现金 - frozen_cash REAL DEFAULT 0, -- 冻结资金 - position_pct REAL, - total_pnl REAL, - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - updated_at TEXT - ); - - -- 现金变更日志(每次买卖/出入金记录) - CREATE TABLE IF NOT EXISTS cash_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')), - cash_before REAL, -- 变更前可用现金 - cash_after REAL, -- 变更后可用现金 - frozen_before REAL, -- 变更前冻结资金 - frozen_after REAL, -- 变更后冻结资金 - change_amount REAL, -- 现金变动额(正=入金/卖股,负=出金/买股) - source TEXT NOT NULL, -- 来源: screenshot/manual/import_xls/trade - note TEXT, -- 备注: 例如 "卖出法拉电子 200股" - verified INTEGER DEFAULT 0 -- 是否已验证(0=未验证,1=Dad确认) - ); - - -- 建议时间线(decisions.json advice_timeline[]) - CREATE TABLE IF NOT EXISTS advice_timeline ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - date TEXT, - direction TEXT, - price REAL, - summary TEXT, - status TEXT, - evaluated INTEGER DEFAULT 0, - result TEXT, - evaluated_at TEXT, - report_id TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_advice_code ON advice_timeline(code); - - -- 准确率统计(accuracy_stats.json) - CREATE TABLE IF NOT EXISTS accuracy_stats ( - id INTEGER PRIMARY KEY CHECK (id = 1), - period_start TEXT, - period_end TEXT, - total_advice INTEGER DEFAULT 0, - correct INTEGER DEFAULT 0, - wrong INTEGER DEFAULT 0, - partial INTEGER DEFAULT 0, - unknown INTEGER DEFAULT 0, - pending INTEGER DEFAULT 0, - ignored INTEGER DEFAULT 0, - evaluated INTEGER DEFAULT 0, - accuracy_pct REAL, - phase1_correct INTEGER DEFAULT 0, - phase1_wrong INTEGER DEFAULT 0, - phase1_pending INTEGER DEFAULT 0, - phase1_accuracy REAL, - phase2_correct INTEGER DEFAULT 0, - phase2_wrong INTEGER DEFAULT 0, - phase2_pending INTEGER DEFAULT 0, - phase2_accuracy REAL, - total_evaluated INTEGER DEFAULT 0, - updated_at TEXT - ); - - -- 策略反馈(strategy_feedback.json) - CREATE TABLE IF NOT EXISTS strategy_feedback ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES stocks(code), - name TEXT, - evaluated_at TEXT, - phase1_completed INTEGER DEFAULT 0, - phase1_result TEXT, - phase1_completed_at TEXT, - phase1_price REAL, - phase2_completed INTEGER DEFAULT 0, - phase2_result TEXT, - phase2_completed_at TEXT, - days_in_phase1 INTEGER, - adjustments_json TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_feedback_code ON strategy_feedback(code); - - -- 板块信号(trend_detector 产出) - CREATE TABLE IF NOT EXISTS sector_signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - signal_type TEXT NOT NULL, - sector TEXT NOT NULL, - severity TEXT DEFAULT 'medium', - related_stocks TEXT, - holdings_in_sector TEXT, - watchlist_in_sector TEXT, - trigger_reason TEXT, - snapshot_id INTEGER, - processed INTEGER DEFAULT 0, - detected_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_signal_processed ON sector_signals(processed); - CREATE INDEX IF NOT EXISTS idx_signal_sector ON sector_signals(sector); - - -- 小果情报(xiaoguo_news_processor 产出) - CREATE TABLE IF NOT EXISTS signal_news ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - signal_id INTEGER REFERENCES sector_signals(id), - sector TEXT NOT NULL, - overall_sentiment TEXT, - summary TEXT, - key_articles TEXT, - searched_stocks TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')) - ); - CREATE INDEX IF NOT EXISTS idx_signal_news_signal ON signal_news(signal_id); - - -- 小果扫描跟踪(去重用) - CREATE TABLE IF NOT EXISTS xiaoguo_scan_tracker ( - code TEXT PRIMARY KEY, - name TEXT, - last_scanned_at TEXT, - found_count INTEGER DEFAULT 0 - ); - - -- 实时价格快照(替代 live_prices.json) - CREATE TABLE IF NOT EXISTS live_prices ( - code TEXT PRIMARY KEY, - price REAL, - change_pct REAL, - updated_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 多周期缓存(替代 multi_tf_cache.json) - CREATE TABLE IF NOT EXISTS mtf_cache ( - code TEXT PRIMARY KEY, - cache_json TEXT, - updated_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- 资金流缓存(替代 capital_flow_cache.json) - CREATE TABLE IF NOT EXISTS capital_flow_cache ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cache_json TEXT, - updated_at TEXT DEFAULT (datetime('now','localtime')) - ); - - -- Self-TODO 自动化任务表 - CREATE TABLE IF NOT EXISTS todos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - status TEXT DEFAULT 'pending', - priority TEXT DEFAULT 'medium', - source TEXT DEFAULT 'manual', - fix_action TEXT, - retry_count INTEGER DEFAULT 0, - note TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - conn.commit() - - # 迁移:给 signal_news 加 source 字段(幂等) - try: - conn.execute("ALTER TABLE signal_news ADD COLUMN source TEXT DEFAULT 'trend'") - except sqlite3.OperationalError: - pass - - # cash_log migration (2026-07-01) - try: - conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_before REAL") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_after REAL") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE cash_log ADD COLUMN verified INTEGER DEFAULT 0") - except sqlite3.OperationalError: - pass - - # ── 币种约束迁移(2026-06-30)──────────────────────────────── - _currency_migrations = [ - ("holdings", ["price REAL", "market_value REAL", "change_pct REAL", - "currency TEXT NOT NULL DEFAULT 'CNY'"]), - ("holding_strategies", ["name TEXT", "price REAL", "cost REAL", "shares INTEGER DEFAULT 0", - "currency TEXT NOT NULL DEFAULT 'CNY'", - "action TEXT", "timing_signal TEXT", "rr_ratio REAL", - "tech_snapshot TEXT", "stock_category TEXT", - "sector_context TEXT", "status TEXT DEFAULT 'active'", - "trigger_json TEXT", "changelog_json TEXT", - "updated_at TEXT"]), - ("portfolio_summary", ["total_mv REAL", "frozen_cash REAL DEFAULT 0", - "currency TEXT NOT NULL DEFAULT 'CNY'"]), - ("watchlist_stocks", ["price REAL", "entry_low REAL", "entry_high REAL", - "stop_loss REAL", "currency TEXT NOT NULL DEFAULT 'CNY'", - "source TEXT", "source_detail TEXT", "notes TEXT", - "added_by TEXT", "analysis_json TEXT"]), - ] - for table, columns in _currency_migrations: - for col_def in columns: - col_name = col_def.split()[0] - try: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") - except sqlite3.OperationalError: - pass # column already exists - - # ── tag 迁移(2026-07-20):推荐标签 current_recommend / active_manual ── - # 此前 strategy_lifecycle 在 dict 里设置 tag 但 write_holding_strategy 无此列, - # 导致标签在写入时被静默丢弃。补列 + 写入保留。 - try: - conn.execute("ALTER TABLE holding_strategies ADD COLUMN tag TEXT DEFAULT ''") - except sqlite3.OperationalError: - pass - conn.commit() - - -# ═══════════════════════════════════════════════════════════ -# 市场快照写入 -# ═══════════════════════════════════════════════════════════ - -def write_market_snapshot(conn: sqlite3.Connection, market_data: dict) -> tuple[bool, str, Optional[int]]: - """写入一次市场采集到 market_snapshots + sector_snapshots - - Returns: (ok, message, snapshot_id) - """ - try: - cur = conn.execute( - "INSERT INTO market_snapshots (timestamp, source, up_ratio, mood) VALUES (?, ?, ?, ?)", - (market_data["timestamp"], market_data.get("source", "unknown"), - market_data.get("up_ratio", 0), market_data.get("mood", "unknown")), - ) - sid = cur.lastrowid - - sectors = market_data.get("sectors", []) - rows = [(sid, s.get("name", ""), s.get("change", 0), - s.get("up_count"), s.get("down_count"), s.get("net_inflow"), - s.get("lead_stock"), s.get("lead_stock_change"), - s.get("volume"), s.get("turnover")) for s in sectors] - if rows: - conn.executemany( - "INSERT INTO sector_snapshots (snapshot_id, name, change_pct, up_count, down_count, " - "net_inflow, lead_stock, lead_stock_change, volume, turnover) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) - conn.commit() - return True, f"snapshot_id={sid}, sectors={len(rows)}", sid - except Exception as e: - try: - conn.rollback() - except Exception: - pass - return False, str(e), None - - -# ═══════════════════════════════════════════════════════════ -# K线写入 -# ═══════════════════════════════════════════════════════════ - -def write_klines(conn: sqlite3.Connection, code: str, name: str, - daily: list = None, weekly: list = None, monthly: list = None, - fundamentals: dict = None) -> bool: - """将个股K线数据双写 SQLite - - Args: - code: 股票代码 - name: 股票名称 - daily/weekly/monthly: [{date, open, close, high, low, volume}, ...] - fundamentals: {pe, pb, eps, mcap_total, mcap_flow} - """ - try: - # 判断交易所 - raw = str(code) - if len(raw) == 5 and raw.isdigit(): - exchange, stype = "HK", "H" - elif raw.startswith(("6", "5", "9")): - exchange, stype = "SH", "A" - else: - exchange, stype = "SZ", "A" - - # stocks 表(INSERT OR REPLACE) - conn.execute( - "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", - (code, name, exchange, stype, datetime.now().isoformat())) - - # K线数据 - for period, table, data in [ - ("daily", "stock_daily", daily), - ("weekly", "stock_weekly", weekly), - ("monthly", "stock_monthly", monthly), - ]: - if not data: - continue - rows = [(code, d.get("date", ""), d.get("open"), d.get("close"), - d.get("high"), d.get("low"), d.get("volume"), - d.get("amount") if period == "daily" else None) for d in data] - if period == "daily": - conn.executemany( - f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume, amount) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) - else: - conn.executemany( - f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - [(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows]) - - # 基本面 - if fundamentals: - conn.execute( - "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (code, fundamentals.get("pe"), fundamentals.get("pb"), - fundamentals.get("eps"), fundamentals.get("mcap_total"), - fundamentals.get("mcap_flow"), datetime.now().isoformat())) - - conn.commit() - return True - except Exception as e: - try: - conn.rollback() - except Exception: - pass - return False - - -# ═══════════════════════════════════════════════════════════ -# 价格事件写入 -# ═══════════════════════════════════════════════════════════ - -def write_price_event(conn: sqlite3.Connection, code: str, name: str, - event_type: str, price: float, trigger_value: str, - event_label: str = "") -> bool: - """写入一条价格事件""" - try: - now = datetime.now() - conn.execute( - "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (code, name, event_type, round(price, 2), trigger_value, - event_label, now.strftime("%Y-%m-%d"))) - conn.commit() - return True - except Exception: - try: - conn.rollback() - except Exception: - pass - return False - - -# ═══════════════════════════════════════════════════════════ -# 板块成分迁移 -# ═══════════════════════════════════════════════════════════ - -def migrate_stock_sectors(conn: sqlite3.Connection) -> tuple[int, int]: - """从 stock_sector_map.json 迁移到 stock_sectors 表 - - Returns: (migrated_stocks, total_mappings) - """ - sector_map_path = DATA_DIR / "stock_sector_map.json" - if not sector_map_path.exists(): - return 0, 0 - - try: - with open(sector_map_path, encoding="utf-8") as f: - data = json.load(f) - except Exception: - return 0, 0 - - # 过滤元数据字段 - mappings = [(code, sectors) for code, sectors in data.items() - if not code.startswith("_") and isinstance(sectors, list)] - - total = 0 - for code, sectors in mappings: - for sector in sectors: - try: - conn.execute( - "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", - (code, sector)) - total += 1 - except Exception: - pass - conn.commit() - return len(mappings), total - - -# ═══════════════════════════════════════════════════════════ -# 查询辅助 -# ═══════════════════════════════════════════════════════════ - -def query_sector_trend(conn: sqlite3.Connection, name: str, limit: int = 5) -> list[dict]: - """板块最近N次趋势""" - rows = conn.execute(""" - SELECT s.timestamp, ss.change_pct, ss.net_inflow, - ss.up_count, ss.down_count, ss.lead_stock, ss.lead_stock_change - FROM sector_snapshots ss - JOIN market_snapshots s ON ss.snapshot_id = s.id - WHERE ss.name = ? ORDER BY s.timestamp DESC LIMIT ? - """, (name, limit)).fetchall() - return [dict(r) for r in rows] - - -def query_top_inflow(conn: sqlite3.Connection, limit: int = 5) -> list[dict]: - """最新一次资金净流入排行""" - rows = conn.execute(""" - SELECT ss.name, ss.change_pct, ss.net_inflow, ss.lead_stock, s.timestamp - FROM sector_snapshots ss - JOIN market_snapshots s ON ss.snapshot_id = s.id - WHERE s.id = (SELECT MAX(id) FROM market_snapshots) - AND ss.net_inflow IS NOT NULL - ORDER BY ss.net_inflow DESC LIMIT ? - """, (limit,)).fetchall() - return [dict(r) for r in rows] - - -def query_consecutive_inflow(conn: sqlite3.Connection, days: int = 3) -> list[dict]: - """连续N次净流入的板块""" - rows = conn.execute(""" - SELECT name, COUNT(*) as times, ROUND(AVG(net_inflow), 2) as avg_inflow, - ROUND(AVG(change_pct), 2) as avg_change - FROM sector_snapshots ss - JOIN market_snapshots s ON ss.snapshot_id = s.id - WHERE s.id > (SELECT MAX(id) - ? FROM market_snapshots) - AND net_inflow > 0 - GROUP BY name HAVING COUNT(*) >= ? - ORDER BY avg_inflow DESC - """, (days, days)).fetchall() - return [dict(r) for r in rows] - - -def query_market_mood(conn: sqlite3.Connection, limit: int = 10) -> list[dict]: - """市场情绪趋势""" - rows = conn.execute(""" - SELECT timestamp, source, up_ratio, mood - FROM market_snapshots ORDER BY timestamp DESC LIMIT ? - """, (limit,)).fetchall() - return [dict(r) for r in rows] - - -def query_db_stats(conn: sqlite3.Connection) -> dict: - """数据库概览""" - snap_count = conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0] - sector_count = conn.execute("SELECT COUNT(*) FROM sector_snapshots").fetchone()[0] - stock_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] - kline_count = conn.execute("SELECT COUNT(*) FROM stock_daily").fetchone()[0] - event_count = conn.execute("SELECT COUNT(*) FROM price_events").fetchone()[0] - holding_count = conn.execute("SELECT COUNT(*) FROM holdings").fetchone()[0] - candidate_count = conn.execute("SELECT COUNT(*) FROM candidates").fetchone()[0] - latest = conn.execute( - "SELECT timestamp, source FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() - return { - "snapshots": snap_count, "sector_rows": sector_count, - "stocks": stock_count, "daily_klines": kline_count, - "price_events": event_count, "holdings": holding_count, - "candidates": candidate_count, - "latest_snapshot": dict(latest) if latest else None, - } - - -# ═══════════════════════════════════════════════════════════ -# 持仓查询 -# ═══════════════════════════════════════════════════════════ - -def query_holdings(conn: sqlite3.Connection) -> list[dict]: - """持仓列表(含最新策略)""" - rows = conn.execute(""" - SELECT h.code, h.name, h.shares, h.cost, h.position_pct, h.is_active, - h.price, h.change_pct, h.currency, - hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, - hs.reason as action, hs.created_at as strategy_updated - FROM holdings h - LEFT JOIN holding_strategies hs ON h.code = hs.code - AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') - WHERE h.is_active = 1 - """).fetchall() - return [dict(r) for r in rows] - - -def query_holding_by_code(conn: sqlite3.Connection, code: str) -> dict | None: - """单只持仓""" - row = conn.execute(""" - SELECT h.*, hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, - hs.reason as action - FROM holdings h - LEFT JOIN holding_strategies hs ON h.code = hs.code - AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') - WHERE h.code = ? - """, (code,)).fetchone() - return dict(row) if row else None - - -def query_portfolio_summary(conn: sqlite3.Connection) -> dict: - """持仓汇总""" - row = conn.execute("SELECT * FROM portfolio_summary WHERE id = 1").fetchone() - return dict(row) if row else {} - - -# ═══════════════════════════════════════════════════════════ -# 自选股查询 -# ═══════════════════════════════════════════════════════════ - -def query_watchlist(conn: sqlite3.Connection) -> list[dict]: - """自选股列表(含策略)""" - rows = conn.execute(""" - SELECT w.code, w.name, w.added_at, - hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, - hs.reason as action - FROM watchlist_stocks w - LEFT JOIN holding_strategies hs ON w.code = hs.code - AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = w.code AND strategy_type = 'watch') - WHERE w.is_active = 1 - """).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 决策/策略查询 -# ═══════════════════════════════════════════════════════════ - -def query_strategies(conn: sqlite3.Connection, code: str = None) -> list[dict]: - """策略列表(按版本倒序)""" - if code: - rows = conn.execute( - "SELECT * FROM holding_strategies WHERE code = ? ORDER BY version DESC", (code,)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM holding_strategies ORDER BY code, version DESC").fetchall() - return [dict(r) for r in rows] - - -def query_advice_timeline(conn: sqlite3.Connection, code: str = None, limit: int = 50) -> list[dict]: - """建议时间线""" - if code: - rows = conn.execute( - "SELECT * FROM advice_timeline WHERE code = ? ORDER BY date DESC LIMIT ?", - (code, limit)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM advice_timeline ORDER BY date DESC LIMIT ?", (limit,)).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 候选池查询 -# ═══════════════════════════════════════════════════════════ - -def query_candidates(conn: sqlite3.Connection, active_only: bool = True) -> list[dict]: - """候选池列表(含最新评分)""" - where = "WHERE c.dropped = 0" if active_only else "" - rows = conn.execute(f""" - SELECT c.*, (SELECT score FROM candidate_score_history - WHERE code = c.code ORDER BY created_at DESC LIMIT 1) as latest_score - FROM candidates c {where} - ORDER BY c.zhiwei_star DESC NULLS LAST - """).fetchall() - return [dict(r) for r in rows] - - -def query_candidate_scores(conn: sqlite3.Connection, code: str) -> list[dict]: - """某候选的评分历史""" - rows = conn.execute( - "SELECT * FROM candidate_score_history WHERE code = ? ORDER BY created_at", - (code,)).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 价格事件查询 -# ═══════════════════════════════════════════════════════════ - -def query_price_events(conn: sqlite3.Connection, code: str = None, limit: int = 100) -> list[dict]: - """价格事件""" - if code: - rows = conn.execute( - "SELECT * FROM price_events WHERE code = ? ORDER BY created_at DESC LIMIT ?", - (code, limit)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM price_events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() - return [dict(r) for r in rows] - - -def query_price_events_by_date(conn: sqlite3.Connection, date: str) -> list[dict]: - """某天的价格事件""" - rows = conn.execute( - "SELECT * FROM price_events WHERE date = ? ORDER BY created_at DESC", (date,)).fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 板块成分查询 -# ═══════════════════════════════════════════════════════════ - -def query_stock_sectors(conn: sqlite3.Connection, code: str) -> list[str]: - """某只股票所属板块""" - rows = conn.execute( - "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,)).fetchall() - return [r[0] for r in rows] - - -def query_sector_stocks(conn: sqlite3.Connection, sector_name: str) -> list[str]: - """某板块包含的股票""" - rows = conn.execute( - "SELECT code FROM stock_sectors WHERE sector_name = ?", (sector_name,)).fetchall() - return [r[0] for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 准确率统计查询 -# ═══════════════════════════════════════════════════════════ - -def query_accuracy_stats(conn: sqlite3.Connection) -> dict: - """准确率统计""" - row = conn.execute("SELECT * FROM accuracy_stats WHERE id = 1").fetchone() - return dict(row) if row else {} - - -# ═══════════════════════════════════════════════════════════ -# 策略反馈查询 -# ═══════════════════════════════════════════════════════════ - -def query_strategy_feedback(conn: sqlite3.Connection, code: str = None) -> list[dict]: - """策略反馈""" - if code: - rows = conn.execute( - "SELECT * FROM strategy_feedback WHERE code = ? ORDER BY evaluated_at DESC", (code,)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM strategy_feedback ORDER BY evaluated_at DESC").fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 策略评估查询 -# ═══════════════════════════════════════════════════════════ - -def query_strategy_evaluations(conn: sqlite3.Connection, code: str = None) -> list[dict]: - """策略评估记录""" - if code: - rows = conn.execute( - "SELECT * FROM strategy_evaluations WHERE code = ? ORDER BY created_at DESC", (code,)).fetchall() - else: - rows = conn.execute( - "SELECT * FROM strategy_evaluations ORDER BY created_at DESC").fetchall() - return [dict(r) for r in rows] - - -# ═══════════════════════════════════════════════════════════ -# 市场快照查询(最新) -# ═══════════════════════════════════════════════════════════ - -def query_latest_market(conn: sqlite3.Connection) -> dict: - """获取最新一次市场快照(含 sector 详情)""" - row = conn.execute( - "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() - if not row: - return {} - snap = dict(row) - # 关联 sectors - sectors = conn.execute( - "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", - (snap["id"],)).fetchall() - snap["sectors"] = [dict(r) for r in sectors] - snap["top_gainers"] = [dict(r) for r in sectors[:5]] - snap["top_losers"] = [dict(r) for r in sectors[-3:]] - return snap - - -# ═══════════════════════════════════════════════════════════════════ -# 通用工具 -# ═══════════════════════════════════════════════════════════════════ - -def get_price_from_db(code: str) -> tuple[float | None, float | None]: - """从 DB 读取最新价格(price_monitor 维护)。 - 返回 (price, change_pct) 或 (None, None) - - 所有脚本应优先调用此函数,DB 无数据时才拉腾讯 API。 - """ - try: - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - row = db.execute( - "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) - ).fetchone() - if not row: - row = db.execute( - "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) - ).fetchone() - db.close() - if row: - return (row['price'], row['change_pct'] if 'change_pct' in row.keys() else None) - except Exception: - pass - return (None, None) - - -def get_prices_batch_from_db(codes: list[str]) -> dict: - """从 DB 批量读取价格。返回 {code: (price, change_pct)}""" - results = {} - if not codes: - return results - try: - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - for code in codes: - row = db.execute( - "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) - ).fetchone() - if not row: - row = db.execute( - "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) - ).fetchone() - if row and row['price']: - results[str(code)] = (row['price'], row['change_pct'] if 'change_pct' in row.keys() else 0) - db.close() - except Exception: - pass - return results - """最新一次市场快照(含板块数据)""" - snap = conn.execute( - "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() - if not snap: - return {} - snap = dict(snap) - sectors = conn.execute( - "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", - (snap["id"],)).fetchall() - snap["sectors"] = [dict(r) for r in sectors] - # 计算 top_gainers / top_losers - snap["top_gainers"] = [dict(r) for r in sectors[:5]] - snap["top_losers"] = [dict(r) for r in sectors[-3:]] - return snap - - -# ═══════════════════════════════════════════════════════════════════ -# 核心写函数 — 替代 json.dump(),强制币种约束 -# ═══════════════════════════════════════════════════════════════════ - -def snapshot_strategy_history(conn, code: str, source_trigger: str = "write_holding_strategy"): - """在修改前快照当前策略到 strategy_history 表。永不抛异常。""" - try: - row = conn.execute( - "SELECT code, name, decision_type, strategy_type, full_analysis, " - "action, timing_signal, entry_low, entry_high, stop_loss, take_profit, " - "position_advice, rr_ratio, version, reassessed_at " - "FROM holding_strategies WHERE code=? AND status='active'", - (code,) - ).fetchone() - if not row: - return - now = datetime.now().isoformat() - conn.execute(""" - INSERT INTO strategy_history - (code, name, decision_type, strategy_type, full_analysis, action, - timing_signal, entry_low, entry_high, stop_loss, take_profit, - position_advice, rr_ratio, version, source_trigger, reassessed_at, snapshotted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - row[0], row[1], row[2], row[3], - row[4], row[5], row[6], - row[7], row[8], row[9], row[10], - row[11], row[12], row[13], - source_trigger, row[14], now - )) - conn.commit() - # 每只股票只保留最近20条历史 - conn.execute(""" - DELETE FROM strategy_history WHERE code=? AND id NOT IN ( - SELECT id FROM strategy_history WHERE code=? ORDER BY snapshotted_at DESC LIMIT 20 - ) - """, (code, code)) - conn.commit() - except Exception as e: - print(f" [SNAPSHOT] {code} 快照失败: {e}", flush=True) - - -def write_holding_strategy(conn, code: str, name: str, data: dict, - source_trigger: str = "write_holding_strategy") -> tuple[bool, str]: - """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" - try: - # ── 覆写前快照旧行 ── - snapshot_strategy_history(conn, code, source_trigger) - - - currency = data.get('currency', 'CNY') - # Serialize JSON fields - import json as _json - trigger_j = _json.dumps(data.get('trigger', {}), ensure_ascii=False) if isinstance(data.get('trigger'), dict) else str(data.get('trigger', '{}')) - changelog_j = _json.dumps(data.get('changelog', []), ensure_ascii=False) if isinstance(data.get('changelog'), list) else str(data.get('changelog', '[]')) - quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '') - signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '') - - # 在DELETE前保留现有的full_analysis和reassessed_at(防止被regenerate_all等清空) - _existing_fa = data.get('full_analysis', '') - _existing_ra = data.get('reassessed_at', '') - # tag 语义:'tag' 键缺席=保留旧标签;显式传入(含'')= 按传入值(允许清除标签) - _tag_absent = 'tag' not in data - _existing_tag = data.get('tag', '') or '' - if not _existing_fa or _tag_absent: - try: - _old = conn.execute("SELECT full_analysis, reassessed_at, tag FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() - if _old: - if not _existing_fa: - if _old[0]: _existing_fa = _old[0] - if _old[1]: _existing_ra = _old[1] - if _tag_absent and _old[2]: - _existing_tag = _old[2] - except: - pass - - # DELETE + INSERT - conn.execute("DELETE FROM holding_strategies WHERE code=?", (code,)) - conn.execute(""" - INSERT INTO holding_strategies - (code, name, version, price, cost, shares, stop_loss, take_profit, - entry_low, entry_high, currency, strategy_type, action, - timing_signal, rr_ratio, tech_snapshot, stock_category, - sector_context, status, trigger_json, changelog_json, - source, reason, updated_at, - avg_price, decision_timestamp, note, quality_check, - quality_checked_at, quality_issues_json, position_advice, - signal_factors_json, time_horizon, decision_type, - full_analysis, reassessed_at, tag) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, - datetime('now','localtime'), - ?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - code, name, - data.get('version', 1), data.get('price'), data.get('cost'), - data.get('shares', 0), data.get('stop_loss'), data.get('take_profit'), - data.get('entry_low'), data.get('entry_high'), currency, - data.get('strategy_type', 'holding'), data.get('action'), - data.get('timing_signal'), data.get('rr_ratio'), - data.get('tech_snapshot'), data.get('stock_category'), - data.get('sector_context'), data.get('status', 'active'), - trigger_j, changelog_j, - data.get('source'), data.get('reason'), - # new columns - data.get('avg_price', 0), - data.get('timestamp') or data.get('created_at', ''), - data.get('note', ''), - data.get('quality_check', ''), - data.get('quality_checked_at', ''), - quality_issues_j, - data.get('position_advice', ''), - signal_factors_j, - data.get('time_horizon', ''), - data.get('type', data.get('strategy_type', 'holding')), - # 保留full_analysis和reassessed_at - _existing_fa, - _existing_ra, - _existing_tag, - )) - conn.commit() - return True, f"策略 {code} 已写入" - except sqlite3.IntegrityError as e: - return False, f"币种约束: {e}" - except Exception as e: - return False, str(e) - - -def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]: - """批量写入持仓(替代 portfolio.json holdings[])""" - try: - conn.execute("BEGIN IMMEDIATE") - for h in holdings: - currency = str(h.get('currency', 'CNY')).upper() - if currency not in ('CNY', 'HKD'): - return False, f"非法币种: {currency}(必须 CNY 或 HKD)" - conn.execute(""" - INSERT INTO holdings (code, name, shares, cost, price, market_value, - change_pct, currency, position_pct, added_at, is_active) - VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) - ON CONFLICT(code) DO UPDATE SET - name=excluded.name, shares=excluded.shares, cost=excluded.cost, - price=excluded.price, market_value=excluded.market_value, - change_pct=excluded.change_pct, currency=excluded.currency, - position_pct=excluded.position_pct - """, ( - h.get('code'), h.get('name'), h.get('shares', 0), - h.get('cost'), h.get('price'), - h.get('market_value'), h.get('change_pct'), - h.get('currency', 'CNY'), h.get('position_pct'), - )) - conn.commit() - return True, f"已写入 {len(holdings)} 条持仓" - except sqlite3.IntegrityError as e: - conn.rollback() - return False, f"币种约束: {e}" - except sqlite3.OperationalError as e: - return False, f"DB锁冲突(重试耗尽): {e}" -def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]: - """写入持仓汇总(替代 portfolio.json 顶层)""" - try: - conn.execute("BEGIN IMMEDIATE") - conn.execute(""" - INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, - cash, frozen_cash, position_pct, total_pnl, currency, updated_at) - VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) - ON CONFLICT(id) DO UPDATE SET - total_assets=excluded.total_assets, total_mv=excluded.total_mv, - stock_value=excluded.stock_value, cash=excluded.cash, - frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, - total_pnl=excluded.total_pnl, currency=excluded.currency, - updated_at=datetime('now','localtime') - """, ( - data.get('total_assets'), data.get('total_mv'), data.get('stock_value'), - data.get('cash'), data.get('frozen_cash', 0), data.get('position_pct'), - data.get('total_pnl'), data.get('currency', 'CNY'), - )) - conn.commit() - return True, "汇总已写入" - except sqlite3.IntegrityError as e: - return False, f"约束: {e}" - except sqlite3.OperationalError as e: - return False, f"DB锁冲突: {e}" - - -def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: - """写入自选股(写入 watchlist_stocks 表)""" - try: - conn.execute(""" - INSERT INTO watchlist_stocks (code, name, price, entry_low, entry_high, - stop_loss, currency, source, source_detail, notes, added_by, added_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) - ON CONFLICT(code) DO UPDATE SET - name=excluded.name, price=excluded.price, entry_low=excluded.entry_low, - entry_high=excluded.entry_high, stop_loss=excluded.stop_loss, - currency=excluded.currency, source=excluded.source, - source_detail=excluded.source_detail, notes=excluded.notes, - added_by=excluded.added_by - """, ( - stock.get('code'), stock.get('name'), stock.get('price'), - stock.get('entry_low'), stock.get('entry_high'), stock.get('stop_loss'), - stock.get('currency', 'CNY'), stock.get('source'), stock.get('source_detail'), - stock.get('notes'), stock.get('added_by'), - )) - conn.commit() - return True, f"自选 {stock.get('code')} 已写入" - except sqlite3.IntegrityError as e: - return False, f"约束: {e}" - - -def write_cash_log(conn, data: dict) -> tuple[bool, str]: - """记录现金变更(替代手动改 portfolio.json cash 字段)""" - try: - conn.execute(""" - INSERT INTO cash_log (cash_before, cash_after, frozen_before, frozen_after, - change_amount, source, note) - VALUES (?,?,?,?,?,?,?) - """, ( - data.get('cash_before'), data.get('cash_after'), - data.get('frozen_before'), data.get('frozen_after'), - data.get('change_amount'), data.get('source', 'manual'), - data.get('note', ''), - )) - conn.commit() - return True, "现金变更已记录" - except Exception as e: - return False, str(e) - - -def query_cash_log(conn, limit: int = 20) -> list[dict]: - rows = conn.execute( - "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) - ).fetchall() - return [dict(r) for r in rows] - - -# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ - -def write_live_prices(conn, prices: dict): - """写入实时价格快照(替代 live_prices.json)""" - import json - for code, info in prices.items(): - conn.execute( - "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", - (code, info.get('price'), info.get('change_pct')) - ) - -def read_live_prices(conn) -> dict: - rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() - return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} - - -def write_mtf_cache(conn, code: str, data: dict): - """写入多周期缓存(替代 multi_tf_cache.json 单条)""" - import json - conn.execute( - "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", - (code, json.dumps(data, ensure_ascii=False)) - ) - -def read_mtf_cache(conn, code: str) -> dict: - import json - r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() - return json.loads(r['cache_json']) if r else {} - - -def write_capital_flow_cache(conn, data: dict): - """写入资金流缓存(替代 capital_flow_cache.json)""" - import json - conn.execute("DELETE FROM capital_flow_cache") - conn.execute( - "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", - (json.dumps(data, ensure_ascii=False),) - ) - -def read_capital_flow_cache(conn) -> dict: - import json - r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() - return json.loads(r['cache_json']) if r else {}