diff --git a/.gitignore b/.gitignore index c8ba1be7..dea92e02 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ data/price_history.json deploy/profile-scripts/data/ scripts/mofin_db.py scripts/mo_data.py +mofin_db.py +mo_data.py diff --git a/mo_data.py b/mo_data.py deleted file mode 100644 index cb168a13..00000000 --- a/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/mofin_db.py b/mofin_db.py deleted file mode 100644 index 42c7cb6a..00000000 --- a/mofin_db.py +++ /dev/null @@ -1,2341 +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); - - -- 策略追踪评估(2026-07-27 老爸:每条推荐操作的完整生命周期跟踪) - -- 每个版本一条记录,策略变更时自动追加新版本 - CREATE TABLE IF NOT EXISTS strategy_tracking ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL, - name TEXT, - version_seq INTEGER DEFAULT 1, -- 该股票的第几个策略版本 - -- 推荐时的快照 - tracked_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), - timing_signal TEXT, - rec_score INTEGER DEFAULT 0, - rr_ratio REAL, - entry_low REAL, - entry_high REAL, - entry_mid REAL, - stop_loss REAL, - take_profit REAL, - position_advice TEXT, - price_at_track REAL, -- 记录时的市价 - -- 区别于前一版本的变化摘要 - change_summary TEXT, - -- 结果跟踪 - status TEXT DEFAULT 'active' CHECK(status IN ('active','hit_tp','hit_sl','expired','manual_close')), - closed_at TEXT, - close_price REAL, - close_reason TEXT, - theoretical_pnl REAL, -- 理论盈亏%(基于中值买入价) - -- 实操数据(由用户或导入脚本填入) - actual_action TEXT, -- "买入600股@148.86" - actual_entry REAL, - actual_shares INTEGER, - actual_exit REAL, - actual_pnl REAL, - actual_exit_reason TEXT, - notes TEXT - ); - CREATE INDEX IF NOT EXISTS idx_track_code ON strategy_tracking(code); - CREATE INDEX IF NOT EXISTS idx_track_status ON strategy_tracking(status); - CREATE INDEX IF NOT EXISTS idx_track_date ON strategy_tracking(tracked_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 - # ── 三值 RR 迁移(2026-07-22 老爸):买入区下沿/中值/上沿三个 RR ── - # rr_ratio 保留=中值 RR(排序/门槛沿用),新增 rr_low / rr_high 展示用。 - for _col in ("rr_low REAL DEFAULT 0", "rr_high REAL DEFAULT 0"): - try: - conn.execute(f"ALTER TABLE holding_strategies ADD COLUMN {_col}") - except sqlite3.OperationalError: - pass - # ── rec_score 迁移(2026-07-27):五维复合推荐评分 0-100 ── - try: - conn.execute("ALTER TABLE holding_strategies ADD COLUMN rec_score INTEGER DEFAULT 0") - 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 reconcile_signal_from_analysis(conn, code: str) -> str: - """以已存 full_analysis 为唯一事实源,重算 timing_signal 并写回。 - 根治"信号与分析脱节"(per_stock 分开写信号/分析导致的 信号=买入但分析=观望)。 - 返回最终信号。无裁决行 → 清空动作级信号(防陈旧买入残留)。""" - try: - row = conn.execute("SELECT full_analysis, timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if not row: - return "" - fa, old_sig = row[0] or "", row[1] or "" - verdict = "" - for line in fa.split("\n"): - if "【综合结论】" in line: - for s in ("买入", "可买入", "可加仓", "卖出", "止盈", "关注", "观望", "持有", "弱势持有"): - if s in line: - verdict = s - break - break - if verdict: - new_sig = verdict - # ── 矛盾降级(2026-07-24 老爸:688660综合结论=买入但操作建议说"继续空仓观望暂不执行")── - # 【综合结论】给方向,【操作建议】/【建议仓位】给执行。执行层明确否定买入时, - # 以执行为准——信号降级为关注,tag/exec 一律不许升。 - if new_sig in ("买入", "可买入", "可加仓"): - _NEG_ADVICE = ("继续空仓", "暂不执行", "不宜买入", "不买入", "不建仓", "不新建仓", - "等待企稳", "暂缓买入", "保持空仓", "维持空仓", "不建议买入", "空仓观望", - "不建议操作", "等待价格回落", "等待回调", "高于买入区上沿") - for line in fa.split("\n"): - if "【建议仓位】" in line and ("不新建仓" in line or "不建仓" in line): - new_sig = "关注" - print(f" [RECONCILE] {code} 建议仓位=不建仓('{line.strip()[:40]}'),信号降级为关注", flush=True) - break - if ("【操作建议】" in line or line.strip().startswith("【操作建议】")) \ - and any(k in line for k in _NEG_ADVICE): - new_sig = "关注" - print(f" [RECONCILE] {code} 操作建议否定买入('{line.strip()[:40]}'),信号降级为关注", flush=True) - break - elif old_sig in ("买入", "可买入", "可加仓"): - new_sig = "" # 无裁决且陈旧动作信号 → 清除 - else: - new_sig = old_sig - if new_sig != old_sig: - conn.execute("UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", - (new_sig, code)) - conn.commit() - # 信号变了 → tag 跟着对齐 - sync_recommend_tag(conn, code, new_sig) - print(f" [RECONCILE] {code} 信号 {old_sig}→{new_sig}(以分析为准)", flush=True) - return new_sig - except Exception as e: - print(f" [RECONCILE] {code} 异常: {e}", flush=True) - return "" - - -def recompute_rr(conn, code: str) -> float: - """用买入区+已存止损/止盈重算三值 RR 并写回(rr_low/rr_ratio中值/rr_high)。 - 根治"LLM 不输出 RR → rr_ratio 永远 0"的断链(红线:RR 由系统算,不信 LLM)。 - 公式: RR(x) = (上方目标 - x) / (x - 止损);x 分别取买入区下沿/中值/上沿。 - 上方目标基于中值参考价统一定(不因 x 不同而漂移),保证三值自洽: - rr_low > rr_ratio > rr_high 恒成立,展示"在同一阻力下不同入场价的敏感度"。 - 目标 = min(止盈, 20日新高若高于中值)(2026-07-24 老爸: - 前高挡在中间时止盈是放空炮,真实RR必须对最近上方阻力先结算)。 - rr_ratio=中值 RR 用于排序与2.0门槛;rr_low/rr_high 展示入场价敏感度。 - 区间缺失 → 中值兜底现价(low/high=0);损/盈缺失或 x<=止损 → 该值=0。""" - try: - row = conn.execute( - "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if not row: - return 0.0 - el, eh, sl, tp = (row[0] or 0), (row[1] or 0), (row[2] or 0), (row[3] or 0) - - # 卖出/止盈信号:RR(买在区间的盈亏比)对卖出无意义,直接返回0(2026-07-27 老爸) - try: - _sig_r = conn.execute("SELECT timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if _sig_r and _sig_r[0] in ("卖出", "止盈"): - conn.execute("UPDATE holding_strategies SET rr_ratio=0, rr_low=0, rr_high=0 WHERE code=? AND status='active'", (code,)) - conn.commit() - return 0.0 - except Exception: - pass - - # 20日新高(前高阻力) - high_20d = 0.0 - try: - r20 = conn.execute( - "SELECT MAX(high) FROM (SELECT high FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 20)", - (code,)).fetchone() - if r20 and r20[0]: - high_20d = float(r20[0]) - except Exception: - pass - - # 基准参考价 = 区间中值(决定上方目标,三值共用) - ref = (el + eh) / 2.0 if el > 0 and eh > el else 0 - target = tp - # 已持仓股不适用20日新高阻力(用户已按原始推荐买入,RR应保持原值) - _owned = conn.execute("SELECT shares FROM holdings WHERE code=? AND is_active=1 AND shares>0", (code,)).fetchone() - if not _owned and ref > 0 and high_20d > ref and high_20d < tp: - target = high_20d - - def _rr(x): - if sl > 0 and target > 0 and x > sl and target > x: - v = round((target - x) / (x - sl), 2) - return v if v > 0 else 0.0 - return 0.0 - - rr_low = rr_mid = rr_high = 0.0 - if el > 0 and eh > el: - rr_low = _rr(el) # 下沿买入:最乐观 - rr_mid = _rr((el + eh) / 2.0) - rr_high = _rr(eh) # 上沿买入:最保守 - else: - # 区间缺失 → 中值兜底现价 - try: - pr = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - if pr and (pr[0] or 0) > 0: - rr_mid = _rr(float(pr[0])) - except Exception: - pass - conn.execute( - "UPDATE holding_strategies SET rr_ratio=?, rr_low=?, rr_high=? WHERE code=? AND status='active'", - (rr_mid, rr_low, rr_high, code)) - conn.commit() - compute_rec_score(conn, code) # RR 变→评分同步刷新 - return rr_mid - except Exception as e: - print(f" [RR] {code} 重算失败: {e}", flush=True) - return 0.0 - - -def compute_rec_score(conn, code: str) -> int: - """五维复合推荐评分 0-100。RR高≠值得买,趋势+行业+信号综合判断。 - 维度:RR(0-35) + 信号(0-25) + 趋势(0-20) + 行业(0-10) + 区间(0-10)""" - try: - row = conn.execute( - "SELECT rr_ratio, timing_signal, tech_snapshot, sector_context, entry_low, entry_high " - "FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if not row: - return 0 - rr, sig, tech, sector, el, eh = row - rr = rr or 0; el = el or 0; eh = eh or 0 - - # ── 1. RR (0-35) ── - if rr >= 3.0: s_rr = 35 - elif rr >= 2.5: s_rr = 28 - elif rr >= 2.0: s_rr = 20 - elif rr >= 1.5: s_rr = 10 - else: s_rr = 0 - - # ── 2. 信号强度 (0-25) ── - sig_map = {"买入": 25, "可买入": 20, "可加仓": 15} - s_sig = sig_map.get(sig, 0) - - # ── 3. 技术趋势 (0-20) ── - tech_str = str(tech or '') - # 形态判定 - if '/bullish' in tech_str or '看涨' in tech_str: - s_trend = 15 - elif '/bearish' in tech_str or '看跌' in tech_str: - s_trend = 8 - else: - s_trend = 12 - # MA 排列加成 - import re as _re_ma - ma_vals = {} - for m in _re_ma.finditer(r'MA(\d+)=([\d.]+)', tech_str): - ma_vals[int(m.group(1))] = float(m.group(2)) - if all(k in ma_vals for k in [5,10,20,60]): - if ma_vals[5] > ma_vals[10] > ma_vals[20] > ma_vals[60]: - s_trend += 5 # 多头排列 - elif ma_vals[5] < ma_vals[10] < ma_vals[20] < ma_vals[60]: - s_trend -= 3 # 空头排列 - s_trend = max(0, min(20, s_trend)) - - # ── 4. 行业强弱 (0-10) ── - sec_str = str(sector or '') - if '领涨' in sec_str: - s_sec = 9 - elif '偏强' in sec_str or '上涨' in sec_str: - s_sec = 7 - elif '偏弱' in sec_str or '下跌' in sec_str: - s_sec = 3 - else: - s_sec = 5 - - # ── 5. 买入区间质量 (0-10) ── - s_zone = 0 - if el > 0 and eh > el: - zone_pct = (eh - el) / el * 100 - if zone_pct >= 5: s_zone = 10 - elif zone_pct >= 3: s_zone = 7 - elif zone_pct >= 2: s_zone = 4 - else: s_zone = 2 - - total = s_rr + s_sig + s_trend + s_sec + s_zone - conn.execute( - "UPDATE holding_strategies SET rec_score=? WHERE code=? AND status='active'", - (total, code)) - conn.commit() - # 高评分自动打推荐 tag(补 LLM 未打 tag 的缺口) - if total >= 50 and rr >= 2.0 and sig in ("买入", "可买入", "可加仓"): - _pos_v = conn.execute( - "SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if _pos_v and _pos_v[0] and '%' in str(_pos_v[0]): - conn.execute( - "UPDATE holding_strategies SET tag='current_recommend' WHERE code=? AND status='active' AND (tag IS NULL OR tag='')", - (code,)) - conn.commit() - return total - except Exception as e: - print(f" [SCORE] {code} 评分失败: {e}", flush=True) - return 0 - - -def sync_recommend_tag(conn, code: str, timing_signal: str): - """裸 SQL 调用方(batch_reassess / per_stock_reassess)的推荐 tag 同步。 - 动作级信号 → current_recommend;信号降级 → 清除 current_recommend; - active_manual(人工标记)永不动。与 XMPP 动作级告警同源(红线#12)。""" - try: - recompute_rr(conn, code) # 先入先算:保证 tag/入队/盯盘排序拿到新鲜 RR - _row = conn.execute( - "SELECT tag, timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - _old_tag = (_row[0] or '') if _row else '' - _old_sig = (_row[1] or '') if _row else '' - # 卖出/止盈 仅对持仓股算动作信号(没持仓卖什么) - _ACTION_BUY = ("买入", "可买入", "可加仓") - _ACTION_SELL = ("卖出", "止盈") - if timing_signal in _ACTION_SELL: - _h = conn.execute("SELECT shares FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() - if not (_h and (_h[0] or 0) > 0): - timing_signal = "" # 非持仓的卖出/止盈不算动作信号 - # ── 仓位自动补全(2026-07-27 老爸:不明确就让它明确,不是丢弃)── - # LLM 经常输出"减仓或观望/中等仓位"等模糊表述,系统按公式自动计算。 - # 基础仓位 by RR(<1.5→不推荐,1.5~3→8%,3~5→12%,5+→15%) × 成长系数0.85(兜底) - # → 最终范围5-20% - if timing_signal in _ACTION_BUY: - import re as _re2 - _pos_r = conn.execute( - "SELECT rr_ratio, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if _pos_r and not _re2.search(r'\d+(?:\.\d+)?\s*%', _pos_r[1] or ''): - _rr_pos = float(_pos_r[0] or 0) - if _rr_pos < 1.5: - _pct = 0 # RR不推荐 - elif _rr_pos < 3: - _pct = 8 - elif _rr_pos < 5: - _pct = 12 - else: - _pct = 15 - # 系数兜底:成长股0.85(最保守),大盘系数1.0(中性) - _pct = round(_pct * 0.85, 0) - _pct = max(5, min(20, _pct)) - _pos_auto = f"{int(_pct)}%(系统按RR{_rr_pos:.1f}自动计算,见原建议仓位)" - # ── 股数换算(2026-07-27 老爸:方便快速操作)── - # 2026-07-27 修正:按总资产算仓位,不用现金(现金波动剧烈) - try: - _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - _ta = conn.execute("SELECT total_assets FROM portfolio_summary WHERE id=1").fetchone() - if _lp and _lp[0] and _ta and _ta[0]: - _price = float(_lp[0]) - _total = float(_ta[0]) - _shares_raw = _total * _pct / 100.0 / _price - if _shares_raw >= 100: - _shares = int(_shares_raw / 100) * 100 # A股整手 - else: - _shares = int(_shares_raw) - if _shares > 0: - _pos_auto = f"{int(_pct)}% ≈ {_shares}股(系统按RR{_rr_pos:.1f}自动计算)" - except Exception: - pass - conn.execute( - "UPDATE holding_strategies SET position_advice=? WHERE code=? AND status='active'", - (_pos_auto, code)) - conn.commit() - print(f" [AUTO-POS] {code} 仓位'{_pos_r[1]}'→'{_pos_auto}'", flush=True) - if timing_signal in _ACTION_BUY or timing_signal in _ACTION_SELL: - # ── 买入RR质量门禁(2026-07-27 老爸:RR<2.0的买入不值得推荐,tag都不能打,防盯盘垃圾)── - # 此前tag先打、enqueue再查RR,结果RR<2.0的tag已落盯盘,与XMPP不一致。 - # 已持仓股不再重复推荐(2026-07-28 老爸:已买了的票该在持仓不在推荐) - _should_tag = True - _owned = conn.execute( - "SELECT shares FROM holdings WHERE code=? AND is_active=1 AND shares>0", - (code,)).fetchone() - if _owned and _owned[0] and _owned[0] > 0: - _should_tag = False # 已持仓,不再重复推荐 - if _old_tag == 'current_recommend': - conn.execute("UPDATE holding_strategies SET tag='' WHERE code=? AND status='active'", (code,)) - conn.commit() - elif timing_signal in _ACTION_BUY: - _rr_chk = conn.execute( - "SELECT rr_ratio FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if _rr_chk and (_rr_chk[0] or 0) < 2.0: - print(f" [TAG SYNC] {code} RR={_rr_chk[0]:.2f}<2.0,不打推荐tag", flush=True) - _should_tag = False - if _should_tag: - conn.execute( - "UPDATE holding_strategies SET tag='current_recommend' " - "WHERE code=? AND status='active' AND (tag IS NULL OR tag != 'active_manual')", - (code,)) - conn.commit() - else: - # RR不达标 → 清除旧tag(如果打了) - if _old_tag == 'current_recommend': - conn.execute("UPDATE holding_strategies SET tag='' WHERE code=? AND status='active'", (code,)) - conn.commit() - # 入队条件(2026-07-27 老爸:tag新鲜转成、信号转入动作级、或卖出信号 — 三个场景均需入队) - _old_action = _old_sig in _ACTION_BUY or _old_sig in _ACTION_SELL - _new_action = timing_signal in _ACTION_BUY or timing_signal in _ACTION_SELL - if (_should_tag and _old_tag != 'current_recommend') or (not _old_action and _new_action): - enqueue_recommend(conn, code) # 新推荐 → 摘要队列(batch 结束统一发) - elif _old_tag == 'current_recommend': - # 空信号或非动作信号 → 清除自动推荐(active_manual 不动) - conn.execute( - "UPDATE holding_strategies SET tag='' " - "WHERE code=? AND status='active' AND tag='current_recommend'", - (code,)) - conn.commit() - # ── 策略版本追踪(2026-07-27 老爸:每次tag变更都记录到评估表)── - track_strategy_version(conn, code) - except Exception as e: - print(f" [TAG SYNC] {code} 失败: {e}", flush=True) - - -def enqueue_recommend(conn, code: str): - """新推荐入摘要队列(防逐只轰炸)。batch_reassess 跑完后 flush_rec_digest 统一发一条。 - 校验(2026-07-24 老爸"阿猫阿狗"事件后加严): - 1. tag=current_recommend 且信号为动作级 - 2. RR(中值)>=2.0(1.5边缘的平庸推荐一律拦下) - 3. position_advice 必须含明确仓位%("减仓或观望/不新建仓"不算推荐) - 4. 买入区必须有效(区—~—/0~0 不入) - 5. 买入信号时现价不得在区上沿 5% 以上(追高不买)""" - try: - import json as _j, re as _re - from datetime import datetime as _dt - row = conn.execute( - "SELECT name, timing_signal, tag, entry_low, entry_high, stop_loss, take_profit, " - "rr_ratio, rr_low, rr_high, position_advice, full_analysis, rec_score FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if not row: - return - name, sig, tag, el, eh, sl, tp, rr, rr_lo, rr_hi, pos, fa, score = row - if tag != 'current_recommend' or sig not in ("买入", "可买入", "可加仓", "卖出", "止盈"): - print(f" [REC] {code} 非有效推荐(tag={tag},sig={sig}),不入队", flush=True) - return False - # ── 买入类质量闸(卖出/止盈不受 RR/仓位限制——那是风控动作)── - if sig in ("买入", "可买入", "可加仓"): - if (rr or 0) < 2.0: - print(f" [REC] {code} RR={rr}<2.0 平庸推荐,不入队", flush=True) - return False - if not _re.search(r'\d+(?:\.\d+)?\s*%', pos or ''): - print(f" [REC] {code} 仓位非明确%({pos}),不入队", flush=True) - return False - if not (el and eh and el > 0 and eh > el): - print(f" [REC] {code} 买入区缺失/无效({el}~{eh}),不入队", flush=True) - return False - try: - _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - if _lp and _lp[0] and _lp[0] > eh * 1.05: - print(f" [REC] {code} 现价{_lp[0]}超区上沿{eh}5%,追高不入队", flush=True) - return False - except Exception: - pass - # 提取【最终新策略】段作为推荐依据摘要 - fa_text = fa or "" - strat = "" - for marker in ("【最终新策略】", "【综合结论】"): - idx = fa_text.find(marker) - if idx >= 0: - strat = fa_text[idx:idx + 450] - break - qf = '/home/hmo/MoFin/gateway/logs/rec_digest_queue.jsonl' - import os as _os - _os.makedirs(_os.path.dirname(qf), exist_ok=True) - with open(qf, 'a', encoding='utf-8') as f: - f.write(_j.dumps({"code": code, "name": name, "signal": sig, - "entry_low": el, "entry_high": eh, "stop_loss": sl, - "take_profit": tp, "rr": rr, "rr_low": rr_lo, "rr_high": rr_hi, - "position": pos, "score": score or 0, - "strategy_excerpt": strat, - "full_analysis": fa_text[:2500], - "ts": _dt.now().isoformat()}, ensure_ascii=False) + "\n") - print(f" [REC] {code} 已入推荐摘要队列", flush=True) - return True - except Exception as e: - print(f" [REC] {code} 入队失败: {e}", flush=True) - return False - - -def track_strategy_version(conn, code: str): - """版本化策略追踪:每次策略变更自动记录新版本。 - 跟踪所有策略状态(不限于 tag='current_recommend'),tag 清除时也记录。""" - try: - row = conn.execute( - "SELECT name, timing_signal, rec_score, rr_ratio, entry_low, entry_high, " - "stop_loss, take_profit, position_advice, tag FROM holding_strategies " - "WHERE code=? AND status='active'", (code,)).fetchone() - if not row: - return - name, sig, score, rr, el, eh, sl, tp, pos, tag = row - # 空壳策略(无信号/无买入区)不追踪 - if not sig or (not el and not eh): - return - - lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - price = lp[0] if lp and lp[0] else 0 - mid = round((el + eh) / 2, 2) if el > 0 and eh > el else 0 - - # 查上一个版本 - prev = conn.execute( - "SELECT entry_low, entry_high, stop_loss, take_profit, rr_ratio, rec_score, " - "status FROM strategy_tracking WHERE code=? ORDER BY id DESC LIMIT 1", - (code,)).fetchone() - - # 计算版本号 - # 计算版本号 - last_ver = conn.execute( - "SELECT MAX(version_seq) FROM strategy_tracking WHERE code=?", (code,)).fetchone() - ver = (last_ver[0] or 0) + 1 if last_ver else 1 - - if prev and prev[6] == 'active': # 上一版本还在进行中 - if (abs((prev[0] or 0) - (el or 0)) < 0.01 and - abs((prev[1] or 0) - (eh or 0)) < 0.01 and - abs((prev[2] or 0) - (sl or 0)) < 0.01 and - abs((prev[3] or 0) - (tp or 0)) < 0.01): - # 参数没变,只更新评分和RR - conn.execute( - "UPDATE strategy_tracking SET rec_score=?, rr_ratio=?, price_at_track=?, " - "timing_signal=?, position_advice=? WHERE id=(" - "SELECT id FROM strategy_tracking WHERE code=? ORDER BY id DESC LIMIT 1)", - (score, rr, price, sig, pos, code)) - conn.commit() - return - - # 有变更 → 追加新版本 - change = "" - if prev: - parts = [] - if abs((prev[0] or 0) - (el or 0)) > 0.5: parts.append(f"区{prev[0]}→{el}") - if abs((prev[2] or 0) - (sl or 0)) > 0.5: parts.append(f"损{prev[2]}→{sl}") - if abs((prev[3] or 0) - (tp or 0)) > 0.5: parts.append(f"盈{prev[3]}→{tp}") - if abs((prev[5] or 0) - (score or 0)) >= 5: parts.append(f"评分{prev[5]}→{score}") - change = "; ".join(parts) if parts else "" - - conn.execute(""" - INSERT INTO strategy_tracking - (code, name, version_seq, tracked_at, timing_signal, rec_score, rr_ratio, - entry_low, entry_high, entry_mid, stop_loss, take_profit, - position_advice, price_at_track, change_summary) - VALUES (?,?,?,datetime('now','localtime'),?,?,?,?,?,?,?,?,?,?,?) - """, (code, name, ver, sig, score, rr, el, eh, mid, sl, tp, pos, price, change)) - conn.commit() - if change: - print(f" [TRACK] {code} v{ver}: {change}", flush=True) - except Exception as e: - print(f" [TRACK] {code} 版本记录失败: {e}", flush=True) - - -def check_strategy_outcomes(conn): - """检查所有 active 追踪版本是否触发 SL/TP,自动关闭并记录理论盈亏""" - active = conn.execute(""" - SELECT id, code, entry_mid, stop_loss, take_profit, rr_ratio - FROM strategy_tracking WHERE status='active' - """).fetchall() - - updated = 0 - for r in active: - tid, code, mid, sl, tp, rr = r - lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - if not lp or not lp[0]: - continue - price = float(lp[0]) - mid = mid or price # fallback - - closed = False - if tp and tp > 0 and price >= tp: - pnl_pct = round((tp - mid) / mid * 100, 1) if mid > 0 else 0 - conn.execute(""" - UPDATE strategy_tracking SET status='hit_tp', closed_at=datetime('now','localtime'), - close_price=?, close_reason='止盈触发', theoretical_pnl=? - WHERE id=? - """, (price, pnl_pct, tid)) - print(f" [TRACK] {code} v{tid} 止盈! {price}≥{tp} +{pnl_pct}%", flush=True) - closed = True - elif sl and sl > 0 and price <= sl: - pnl_pct = round((sl - mid) / mid * 100, 1) if mid > 0 else -5 - conn.execute(""" - UPDATE strategy_tracking SET status='hit_sl', closed_at=datetime('now','localtime'), - close_price=?, close_reason='止损触发', theoretical_pnl=? - WHERE id=? - """, (price, pnl_pct, tid)) - print(f" [TRACK] {code} v{tid} 止损! {price}≤{sl} {pnl_pct}%", flush=True) - closed = True - - if closed: - updated += 1 - - if updated: - conn.commit() - # ── 统计验证(2026-07-28 老爸:胜率/夏普/最大回撤)── - try: - closed = conn.execute(""" - SELECT theoretical_pnl FROM strategy_tracking - WHERE status IN ("hit_tp", "hit_sl", "manual_close") AND theoretical_pnl IS NOT NULL - """).fetchall() - if closed: - wins = [p[0] for p in closed if p[0] > 0] - losses = [abs(p[0]) for p in closed if p[0] < 0] - win_rate = len(wins) / len(closed) if closed else 0.55 - avg_win = sum(wins) / len(wins) if wins else 0 - avg_loss = sum(losses) / len(losses) if losses else 0 - sharpe = (avg_win * win_rate - avg_loss * (1 - win_rate)) / (avg_loss if avg_loss > 0 else 1) if avg_loss > 0 else 0 - max_dd = max([abs(p[0]) for p in closed if p[0] < 0], default=0) - print(f" [STATS] 胜率{win_rate:.0%} 夏普{sharpe:.2f} 最大回撤{max_dd:.1f}%", flush=True) - except Exception as _se: - print(f" [STATS] 统计失败: {_se}", flush=True) - return updated - - -def flush_rec_digest(max_items=5): - """把队列里的新推荐聚成一条 XMPP 摘要(RR 降序,最多 max_items 只)。 - 头部 1-2 只附带策略依据摘要+按现金的操盘建议。""" - import json as _j, os as _os, sqlite3 as _sq - qf = '/home/hmo/MoFin/gateway/logs/rec_digest_queue.jsonl' - if not _os.path.exists(qf): - return 0 - try: - with open(qf, encoding='utf-8') as f: - items = [_j.loads(l) for l in f if l.strip()] - except Exception: - return 0 - if not items: - return 0 - _os.remove(qf) - - # ── 快照回库校验(2026-07-24 老爸:推荐和XMPP同步)── - # 队列是打标瞬间的快照;flush 前回库读实时 信号/RR/tag, - # 信号降级为弱信号或RR跌破2.0的条目直接丢弃——XMPP说的必须和盯盘一致。 - import sqlite3 as _sq0 - from datetime import datetime as _ddt - _now = _ddt.now() - _h, _m, _w = _now.hour, _now.minute, _now.weekday() - _market_open = _w < 5 and ((_h == 9 and _m >= 30) or (10 <= _h < 15)) - _vconn = _sq0.connect("/home/hmo/MoFin/data/mofin.db") - _WEAK = ("信号不充分", "关注", "弱势持有", "观望", "持有", "") - _live = [] - for it in items: - r = _vconn.execute( - "SELECT timing_signal, rr_ratio, tag, reassessed_at FROM holding_strategies WHERE code=? AND status='active'", - (it['code'],)).fetchone() - if not r: - print(f" [REC] {it['code']} 已不在库,丢弃", flush=True) - continue - cur_sig, cur_rr, cur_tag, cur_ra = r[0] or "", r[1] or 0, r[2] or "", r[3] or "" - if cur_tag != 'current_recommend': - print(f" [REC] {it['code']} tag已撤销({cur_tag}),丢弃", flush=True) - continue - # ── 数据时效校验(2026-07-27 老爸:盘前分析盘中推送=过期数据误导)── - # 分析时间在开盘前且现在已开盘 → 触发盘中重评(用实时数据分析,不推旧分析) - if _market_open and cur_ra: - try: - _ra_dt = _ddt.fromisoformat(str(cur_ra)[:19]) - # 分析在 08:00-09:29 之间做的 = 盘前分析 → 盘中触发重评 - if (_ra_dt.hour >= 8 and (_ra_dt.hour < 9 or (_ra_dt.hour == 9 and _ra_dt.minute < 30))) \ - and (_ddt.now() - _ra_dt).total_seconds() > 120: - try: - import subprocess as _sp - _re = _sp.run( - ["python3", "/home/hmo/MoFin/deploy/profile-scripts/per_stock_reassess.py", it['code']], - capture_output=True, text=True, timeout=90) - if _re.returncode == 0: - print(f" [REC] {it['code']} 盘中重评完成,用实时数据更新策略", flush=True) - # 重新读库获取更新后数据 - r = _vconn.execute( - "SELECT timing_signal, rr_ratio, tag FROM holding_strategies WHERE code=? AND status='active'", - (it['code'],)).fetchone() - if r: - cur_sig, cur_rr, cur_tag = r[0] or "", r[1] or 0, r[2] or "" - if cur_tag != 'current_recommend': - print(f" [REC] {it['code']} 重评后tag撤销,丢弃", flush=True) - continue - else: - print(f" [REC] {it['code']} 盘中重评失败(rc={_re.returncode}),标记警告", flush=True) - it['_stale_warn'] = "⚠️ 盘前分析(已开盘未能及时重评,请结合实时盘面判断)" - except subprocess.TimeoutExpired: - print(f" [REC] {it['code']} 盘中重评超时,标记警告", flush=True) - it['_stale_warn'] = "⚠️ 盘前分析(已开盘未能及时重评,请结合实时盘面判断)" - except Exception: - pass - if it.get('signal') in ("买入", "可买入", "可加仓"): - if cur_sig in _WEAK: - print(f" [REC] {it['code']} 信号降级为'{cur_sig}',丢弃", flush=True) - continue - if cur_rr < 2.0: - print(f" [REC] {it['code']} 实时RR={cur_rr}<2.0,丢弃", flush=True) - continue - it['signal'] = cur_sig # 用实时信号发 - it['rr'] = cur_rr - _live.append(it) - _vconn.close() - items = _live - if not items: - print(" [REC] 快照校验后无有效推荐,不发digest", flush=True) - return 0 - - _SELL_SIGS = ("卖出", "止盈") - # 卖出/止盈是释放现金的操作,不占买入预算,单独一组排最前 - sells = [x for x in items if x.get('signal') in _SELL_SIGS] - buys_all = [x for x in items if x.get('signal') not in _SELL_SIGS] - buys_all.sort(key=lambda x: (x.get('score') or 0, x.get('rr') or 0), reverse=True) - items = sells + buys_all - top = items[:max_items] - - # ── 现金预算(决定操盘建议 + 换仓策略):只对买入项计算,卖出不占预算 ── - cash_note = "" - rotation_note = "" - buys = [] - queued = [] - try: - conn = _sq.connect("/home/hmo/MoFin/data/mofin.db") - conn.row_factory = _sq.Row - r = conn.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone() - if r and r[1]: - cash, total = r[0] or 0, r[1] - budget_pct = cash / total * 100 - cum = 0.0 - buys = [] - queued = [] - for it in buys_all: # 只遍历买入项;sells 永远可执行 - import re as _re - m = _re.search(r'(\d+(?:\.\d+)?)\s*%', it.get('position') or '') - pct = float(m.group(1)) if m else 8.0 - if (it.get('rr') or 0) >= 2.0 and cum + pct <= budget_pct + 1e-9: - buys.append((it, pct)) - cum += pct - else: - queued.append((it, pct)) - cash_note = (f"现金{cash/10000:.1f}万({budget_pct:.1f}%)|按预算本次可执行: " - + ("、".join(f"{b[0].get('name') or b[0]['code']}≈{b[1]:.0f}%" for b in buys) if buys else "无") - + (f"(合计≈{cum:.0f}%)" if buys else "")) - # ── 换仓策略:有排队推荐时,找可减的弱持仓来腾挪 ── - if queued: - weak = conn.execute(""" - SELECT hs.code, hs.name, hs.timing_signal, h.position_pct, h.cost, lp.price, lp.change_pct - FROM holding_strategies hs - JOIN holdings h ON hs.code = h.code AND h.is_active = 1 - LEFT JOIN live_prices lp ON hs.code = lp.code - WHERE hs.status='active' AND h.shares > 0 - AND hs.timing_signal IN ('弱势持有','观望','持有') - ORDER BY CASE hs.timing_signal WHEN '弱势持有' THEN 0 WHEN '观望' THEN 1 ELSE 2 END, - h.position_pct DESC - """).fetchall() - if weak: - need_pct = queued[0][1] - plan = [] - freed = 0.0 - for w in weak: - if freed >= need_pct: - break - plan.append(w) - freed += w["position_pct"] or 0 - q0 = queued[0][0] - _names = "+".join(str(w['name']) for w in plan) - _sigs = ",".join(sorted({w['timing_signal'] for w in plan})) - rotation_note = ("🔄 换仓建议:现金不足买 " + str(q0.get('name') or q0['code']) - + f"(RR={q0.get('rr') or 0})→ 可减 {_names}" - + f"({_sigs},腾出≈{freed:.0f}%仓位)换入") - conn.close() - except Exception as _re: - print(f" [REC] 换仓计算异常: {_re}", flush=True) - - lines = [f"📈 新增推荐 {len(items)} 只(按RR排序):"] - # 与盯盘推荐区一致的 可执行/排队 徽章(2026-07-24 老爸:推荐和XMPP同步) - _exec_codes = {b[0]['code'] for b in buys} | {s['code'] for s in sells} - for i, it in enumerate(top): - _rr_mid = it.get('rr') or 0 - _rr_lo, _rr_hi = it.get('rr_low') or 0, it.get('rr_high') or 0 - if _rr_lo and _rr_hi and _rr_lo != _rr_hi: - _lo_val = min(_rr_lo, _rr_hi) - _hi_val = max(_rr_lo, _rr_hi) - _rr_txt = f"RR={_rr_mid}({_lo_val}~{_hi_val})" - else: - _rr_txt = f"RR={_rr_mid}" - _el = it.get('entry_low') or 0 - _eh = it.get('entry_high') or 0 - _mid = f"{(_el+_eh)/2:.2f}" if _el > 0 and _eh > _el else "—" - _badge = "💰可执行" if it['code'] in _exec_codes else "⏳排队" - _score = it.get('score') or 0 - _score_txt = f" [{_score}分]" if _score else "" - lines.append(f"• {_badge}{_score_txt} {it.get('name') or it['code']}({it['code']}) {it['signal']}" - f" 区{_el or '—'}→{_mid}←{_eh or '—'}" - f" 损{it.get('stop_loss') or '—'} 盈{it.get('take_profit') or '—'}" - f" {_rr_txt} 仓位{it.get('position') or '—'}") - if it.get('_stale_warn'): - lines.append(f" ⚠️ {it['_stale_warn']}") - # 所有推荐都附完整策略依据 - if it.get('strategy_excerpt'): - lines.append(f" 依据: {it['strategy_excerpt']}") - elif it.get('full_analysis'): - lines.append(f" 依据: {it['full_analysis']}") - if len(items) > max_items: - lines.append(f"…另有 {len(items) - max_items} 只详见盯盘推荐操作区") - if cash_note: - lines.append("💰 " + cash_note) - if rotation_note: - lines.append(rotation_note) - try: - import sys as _s, os as _o2 - _s.path.insert(0, '/home/hmo/MoFin/deploy/profile-scripts') - from alert_helper import notify, ACTION - return notify("推荐操作", "\n".join(lines), ACTION) - except Exception as e: - print(f" [REC] 摘要推送失败: {e}", flush=True) - return False - - -def push_recommend_alert(conn, code: str): - """推荐操作 XMPP 推送(tag 转为 current_recommend 时调用,全路径统一)。 - 质量门禁:实时价>0、区间有效(下沿<上沿<下沿x3)、现价不超上沿5%、 - 损<下沿且在(0.5x~1.0x)现价内、盈>上沿>损。不过不推。""" - try: - row = conn.execute( - "SELECT name, timing_signal, entry_low, entry_high, stop_loss, take_profit, " - "rr_ratio, position_advice FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if not row: - return False - name, sig, el, eh, sl, tp, rr, pos = row - lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - price = lp[0] if lp and lp[0] else 0 - el, eh, sl, tp = el or 0, eh or 0, sl or 0, tp or 0 - # ── 门禁 ── - if price <= 0: - print(f" [ALERT] {code} 无实时价,不推", flush=True); return False - if not (el > 0 and eh > el and eh < el * 3): - print(f" [ALERT] {code} 区间无效({el}~{eh}),不推", flush=True); return False - if price > eh * 1.05: - print(f" [ALERT] {code} 现价{price}高超上沿{eh}5%,不推", flush=True); return False - if not (sl > 0 and sl < el and price * 0.5 <= sl <= price): - print(f" [ALERT] {code} 止损{sl}不合理,不推", flush=True); return False - if not (tp > eh and tp > sl): - print(f" [ALERT] {code} 止盈{tp}不合理,不推", flush=True); return False - import sys as _s, os as _o - _s.path.insert(0, _o.path.dirname(_o.path.abspath(__file__))) - from alert_helper import notify, ACTION - _mid_xmpp = f"{(el+eh)/2:.2f}" if el > 0 and eh > el else "—" - msg = (f"📈 {name or code}({code}) 价{price}→12维{sig}!" - f"区间{el}→{_mid_xmpp}←{eh} 损{sl} 盈{tp} RR={rr or 0} 仓位{pos or '-'}") - return notify("买入信号", msg, ACTION) - except Exception as e: - print(f" [ALERT] {code} 推送异常: {e}", flush=True) - return False - - -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', '') - - # ── 推荐操作 tag 同步语义(与 XMPP 动作级信号同源,红线#12)── - # 动作级信号 → tag=current_recommend(进盯盘"推荐操作"区) - # 信号降级 → 清除 current_recommend(区域同步消失) - # active_manual(人工标记)永远不被自动流程覆盖或清除 - _ACTION_SIGNALS = ("买入", "可买入", "可加仓", "卖出", "止盈") - _RISK_SIGNALS = ("卖出", "止盈") - _existing_fa = data.get('full_analysis', '') - _existing_ra = data.get('reassessed_at', '') - _tag_absent = 'tag' not in data - _new_sig = data.get('timing_signal', '') or '' - _explicit_tag = data.get('tag', None) - _old_tag = '' - _old_sig = '' - _old_ra = '' - _old_action = '' - if True: - try: - _old = conn.execute("SELECT full_analysis, reassessed_at, tag, timing_signal, action 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] - _old_tag = _old[2] or '' - _old_sig = _old[3] or '' - _old_ra = _old[1] or '' - _old_action = _old[4] or '' - except: - pass - # ── 信号权威层级(2026-07-22):新鲜(<20h)12维动作级信号, - # 技术路径(regenerate_all/price_monitor)无权降级为 关注/信号不充分/持有。 - # 只有 LLM 路径(batch_12d/per_stock_12d)可以覆盖。 - # 2026-07-27 老爸补:卖出/止盈的保护漏了(技术路径把卖出→持有导致盯盘显示矛盾)── - _TECHNICAL_PATHS = ('write_holding_strategy',) - if source_trigger in _TECHNICAL_PATHS \ - and _old_sig in ("买入", "可买入", "可加仓", "卖出", "止盈") \ - and _new_sig not in _ACTION_SIGNALS and _old_ra: - try: - from datetime import datetime as _ddt, timedelta as _dtd - _ra_dt = _ddt.fromisoformat(str(_old_ra)[:19]) - if (_ddt.now() - _ra_dt) < _dtd(hours=20): - print(f" [AUTHORITY] {code} 保留新鲜12维信号'{_old_sig}'({_old_ra[:16]})," - f"拒绝技术路径降级为'{_new_sig}'", flush=True) - _new_sig = _old_sig - data['timing_signal'] = _old_sig - except Exception: - pass - # ── 策略参数权威保护(2026-07-27 老爸:技术路径每2分钟覆写12维的Zone/SL/TP/Position→RR波动→盯盘和XMPP不一致)── - # 新鲜(<20h)12维分析的技术参数+仓位不允许被技术路径覆写。 - # 2026-07-27 坑:_old_ra=None 时权威保护永不触发(很多股票的reassessed_at为空), - # 导致系统自动计算的仓位被反复踩回"中等仓位"。加入兜底:仓位含"%(系统按"即永保。 - if source_trigger in _TECHNICAL_PATHS: - # 兜底:系统自动计算的仓位永久保护(不含 %(系统按 的不保护,即只有 LLM 仓和系统仓被保护) - _old_pos = conn.execute( - "SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - _old_pos_val = (_old_pos[0] or '') if _old_pos else '' - if _old_pos_val and '系统按' in str(_old_pos_val): - data['position_advice'] = _old_pos_val - # 被保护仓位触发时顺便保护参数(无论 _old_ra 是否空) - _op = conn.execute( - "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if _op and float(_op[0] or 0) > 0: - data['entry_low'] = float(_op[0]) - data['entry_high'] = float(_op[1]) - data['stop_loss'] = float(_op[2]) - data['take_profit'] = float(_op[3]) - print(f" [AUTHORITY-POS] {code} 保护系统仓位'{_old_pos_val[:30]}'", flush=True) - elif _old_ra: - try: - from datetime import datetime as _ddt3, timedelta as _dtd3 - _ra_dt3 = _ddt3.fromisoformat(str(_old_ra)[:19]) - if (_ddt3.now() - _ra_dt3) < _dtd3(hours=20): - _old_params = conn.execute( - "SELECT entry_low, entry_high, stop_loss, take_profit, position_advice FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if _old_params: - _keys = ['entry_low','entry_high','stop_loss','take_profit','position_advice'] - _vals = [v if v else '' for v in _old_params] - for i, k in enumerate(_keys): - if i < 4 and float(_vals[i] or 0) > 0: - data[k] = float(_vals[i]) - elif i == 4 and str(_vals[i]).strip(): - data[k] = str(_vals[i]) - print(f" [AUTHORITY-PARAM] {code} 保留12维参数(区{_vals[0]}~{_vals[1]} 损{_vals[2]} 盈{_vals[3]} pos={_vals[4]})", flush=True) - except Exception: - pass - # ── action 权限保护(与信号同一权威层级,2026-07-22)── - # 技术路径不得覆盖新鲜(<20h)12维 action。 - # 根治:技术路径写的"盈亏比不足1:1.5不建议买入"旧 action 与12维买入分析同框矛盾。 - if source_trigger not in ('batch_12d', 'per_stock_12d') and _old_action and _old_ra: - try: - from datetime import datetime as _ddt2, timedelta as _dtd2 - if (_ddt2.now() - _ddt2.fromisoformat(str(_old_ra)[:19])) < _dtd2(hours=20): - data['action'] = _old_action - except Exception: - pass - if _old_tag == 'active_manual': - _existing_tag = 'active_manual' # 人工标记不可动 - elif _explicit_tag is not None: - _existing_tag = _explicit_tag # 显式传入优先(含''清除) - elif _new_sig in _ACTION_SIGNALS and source_trigger in ('batch_12d', 'per_stock_12d'): - _existing_tag = 'current_recommend' # 仅 LLM 路径可创建推荐(防技术路径抖动) - elif _new_sig and _old_tag == 'current_recommend' and source_trigger in ('batch_12d', 'per_stock_12d'): - _existing_tag = '' # 仅 LLM 路径可撤销推荐 - else: - _existing_tag = _old_tag # 技术路径一律不动 tag - - # ── 类型守卫:shares 必须是数值,防止字符串写入导致下游崩溃 ── - _shares = data.get('shares', 0) - if not isinstance(_shares, (int, float)): - print(f" [TYPE GUARD] {code} shares类型异常({type(_shares).__name__}={_shares!r}),重置为0", flush=True) - _shares = 0 - - # ── action 权限保护已在上方信号权威块中统一处理 ── - - # ── UPSERT(2026-07-23 老爸:新增计算列不该用DELETE+INSERT,该用UPDATE)── - # 只写本函数拥有的列;rr_low/rr_high(recompute_rr拥有)、superseded_at(data_governance - # 拥有)、created_at(创建时间)不在写集内 → 天然保留,未来新增计算列自动免疫。 - # 同时消除 DELETE→INSERT 之间崩溃=行丢失的原子性窗口,以及 created_at 被重置的副作用。 - 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'), - ?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(code) DO UPDATE SET - name=excluded.name, version=excluded.version, price=excluded.price, - cost=excluded.cost, shares=excluded.shares, - stop_loss=excluded.stop_loss, take_profit=excluded.take_profit, - entry_low=excluded.entry_low, entry_high=excluded.entry_high, - currency=excluded.currency, strategy_type=excluded.strategy_type, - action=excluded.action, timing_signal=excluded.timing_signal, - rr_ratio=excluded.rr_ratio, tech_snapshot=excluded.tech_snapshot, - stock_category=excluded.stock_category, sector_context=excluded.sector_context, - status=excluded.status, trigger_json=excluded.trigger_json, - changelog_json=excluded.changelog_json, source=excluded.source, - reason=excluded.reason, updated_at=excluded.updated_at, - avg_price=excluded.avg_price, decision_timestamp=excluded.decision_timestamp, - note=excluded.note, quality_check=excluded.quality_check, - quality_checked_at=excluded.quality_checked_at, - quality_issues_json=excluded.quality_issues_json, - position_advice=excluded.position_advice, - signal_factors_json=excluded.signal_factors_json, - time_horizon=excluded.time_horizon, decision_type=excluded.decision_type, - full_analysis=excluded.full_analysis, reassessed_at=excluded.reassessed_at, - tag=excluded.tag - """, ( - code, name, - data.get('version', 1), data.get('price'), data.get('cost'), - _shares, 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() - # ── 策略版本追踪(每次策略写入后自动记录,2026-07-27)── - if _existing_tag == 'current_recommend': - track_strategy_version(conn, code) - # ── 推荐转场:LLM路径新转为 current_recommend → 记入摘要队列(不逐只推送)── - if _existing_tag == 'current_recommend' and _old_tag != 'current_recommend' \ - and source_trigger in ('batch_12d', 'per_stock_12d'): - enqueue_recommend(conn, code) - 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() - # ── 同步 holding_strategies(2026-07-27 老爸:导入持仓后盯盘应即时出现)── - for h in holdings: - code = h.get('code') - name = h.get('name', '') - shares = h.get('shares') or 0 - if not code or shares <= 0: - continue - existing = conn.execute( - "SELECT id, decision_type FROM holding_strategies WHERE code=? AND status='active'", - (code,)).fetchone() - if not existing: - # 全新持仓:创建基础策略条目 - conn.execute(""" - INSERT INTO holding_strategies (code, name, decision_type, strategy_type, - status, timing_signal, created_at) - VALUES (?, ?, '持仓策略', 'holding', 'active', '关注', datetime('now','localtime')) - """, (code, name)) - elif existing[1] != '持仓策略': - # 已有条目但类型不对(自选转持仓) - conn.execute( - "UPDATE holding_strategies SET decision_type='持仓策略' WHERE code=? AND status='active'", - (code,)) - 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 {}