From b2636a81622361bccfdcb66ad0e5537c724b35af Mon Sep 17 00:00:00 2001 From: hmo Date: Tue, 28 Jul 2026 12:38:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20RR<2=E8=87=AA=E5=8A=A8=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B9=B0=E5=85=A5=E2=86=92=E5=85=B3=E6=B3=A8+prompt=E5=8A=A0?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E9=98=BB=E5=8A=9B=E6=AD=A2=E7=9B=88+?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E9=87=8D=E8=AF=8457=E5=8F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mofin_db.py | 4626 ++++++++++++++++++++++++++------------------------- 1 file changed, 2316 insertions(+), 2310 deletions(-) diff --git a/mofin_db.py b/mofin_db.py index bd923997..5ecec45d 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -1,2312 +1,2318 @@ -#!/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 - if 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不一致。 - _should_tag = True - if 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): - """版本化策略追踪:每次策略变更(sync_recommend_tag 后)自动记录新版本。 - 只在 tag='current_recommend' 时记录;与上一条相比有实质变更才追加新版本。""" - 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 tag != 'current_recommend': - 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() - 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'): +#!/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 + if 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 变→评分同步刷新 + # RR<2.0 且信号为买入 → 降级为关注(2026-07-28 老爸:RR<2 的买入不推荐) + if rr_mid < 2.0: + _sig_r = conn.execute("SELECT timing_signal FROM holding_strategies WHERE code=? AND status=?", (code, "active")).fetchone() + if _sig_r and _sig_r[0] in ("买入", "可买入", "可加仓"): + conn.execute("UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status=?", ("关注", code, "active")) + print(f" [RR-DOWNGRADE] {code} RR={rr_mid:.2f}<2.0, 买入→关注", flush=True) + 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不一致。 + _should_tag = True + if 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): + """版本化策略追踪:每次策略变更(sync_recommend_tag 后)自动记录新版本。 + 只在 tag='current_recommend' 时记录;与上一条相比有实质变更才追加新版本。""" + 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 tag != 'current_recommend': + 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() + 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: + 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 {} + + # ── 类型守卫: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 {}