diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py index 9d3ea89c..e72ec2c3 100644 --- a/deploy/profile-scripts/price_monitor.py +++ b/deploy/profile-scripts/price_monitor.py @@ -773,6 +773,15 @@ def run_once(round_label=""): # 取消超时定时器(正常完成) signal.alarm(0) + # ── 策略追踪评估(2026-07-27 老爸:检查推荐是否触发止盈/止损)── + try: + import mofin_db + tracked = mofin_db.check_strategy_outcomes(conn) + if tracked: + print(f" [TRACK] {tracked}条推荐触发止盈/止损", flush=True) + except Exception as e: + print(f" [TRACK] 检查失败: {e}", flush=True) + # 清理进程锁 try: os.remove("/tmp/price_monitor.lock") diff --git a/mofin_db.py b/mofin_db.py index d97a1556..ef57ce11 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -285,6 +285,46 @@ def init_all_tables(conn: sqlite3.Connection): ); 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), @@ -1373,13 +1413,14 @@ def sync_recommend_tag(conn, code: str, timing_signal: str): _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() - _cash = conn.execute("SELECT cash FROM portfolio_summary WHERE id=1").fetchone() - if _lp and _lp[0] and _cash and _cash[0]: + _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]) - _cash_val = float(_cash[0]) - _shares_raw = _cash_val * _pct / 100.0 / _price + _total = float(_ta[0]) + _shares_raw = _total * _pct / 100.0 / _price if _shares_raw >= 100: _shares = int(_shares_raw / 100) * 100 # A股整手 else: @@ -1426,6 +1467,8 @@ def sync_recommend_tag(conn, code: str, timing_signal: str): "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) @@ -1495,6 +1538,117 @@ def enqueue_recommend(conn, code: str): 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 只附带策略依据摘要+按现金的操盘建议。""" @@ -1967,6 +2121,9 @@ def write_holding_strategy(conn, code: str, name: str, data: dict, _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'): diff --git a/server.py b/server.py index 257f3431..f001214a 100644 --- a/server.py +++ b/server.py @@ -488,6 +488,43 @@ def api_watchlist(): return jsonify({"error": str(e)}), 500 +@app.route("/api/tracking") +def get_tracking(): + """策略追踪评估:所有推荐的历史记录和结果""" + import sqlite3 + conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + conn.row_factory = sqlite3.Row + rows = conn.execute(""" + SELECT st.*, lp.price as current_price + FROM strategy_tracking st + LEFT JOIN live_prices lp ON st.code = lp.code + ORDER BY + CASE st.status WHEN 'active' THEN 0 WHEN 'hit_tp' THEN 1 WHEN 'hit_sl' THEN 2 ELSE 3 END, + st.tracked_at DESC + """).fetchall() + tracks = [] + for r in rows: + d = dict(r) + # 理论盈亏:按买入区中值买入,当前价 vs 中值 + if d.get('entry_mid') and d.get('current_price') and d.get('status') == 'active': + mid = float(d['entry_mid']) + price = float(d['current_price']) + d['theoretical_pnl'] = round((price - mid) / mid * 100, 2) + tracks.append(d) + conn.close() + return jsonify({ + "tracks": tracks, + "stats": { + "total": len(tracks), + "active": sum(1 for r in tracks if r["status"] == "active"), + "hit_tp": sum(1 for r in tracks if r["status"] == "hit_tp"), + "hit_sl": sum(1 for r in tracks if r["status"] == "hit_sl"), + "expired": sum(1 for r in tracks if r["status"] == "expired"), + "manual": sum(1 for r in tracks if r["status"] == "manual_close"), + } + }) + + @app.route("/api/overview") def api_overview(): """概览数据""" @@ -1692,4 +1729,4 @@ register_routes(app) if __name__ == "__main__": port = int(os.environ.get("PORT", 8899)) print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}") - app.run(host="0.0.0.0", port=port, debug=False) \ No newline at end of file + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/static/index.html b/static/index.html index 523cc849..237c5535 100644 --- a/static/index.html +++ b/static/index.html @@ -612,89 +612,64 @@ function renderMarket() { } // ── Evaluation Tab ── +let evalTimer = null; function renderEvaluation() { const el = document.getElementById('tab-evaluation'); - Promise.all([ - fetchJSON('/api/evaluation'), - fetchJSON('/api/stats/accuracy'), - ]).then(([evals, stats]) => { - const p1 = stats.phase1 || {}; - const p2 = stats.phase2 || {}; + if (evalTimer) clearInterval(evalTimer); + fetchJSON('/api/tracking').then(data => { + const tracks = data.tracks || []; + const stats = data.stats || {}; + + const statusBadge = s => { + const m = {active:'🟡进行中',hit_tp:'🟢止盈',hit_sl:'🔴止损',expired:'⚫已过期',manual_close:'🔵手动平仓'}; + return m[s] || s; + }; + el.innerHTML = `
| 股票 | 策略 | -理论盈亏 | 实际盈亏 | -状态 | -
|---|---|---|---|---|
${x.name} ${x.code} |
- ${x.current || (x.advice_evaluation && x.advice_evaluation[0] && x.advice_evaluation[0].current_advice || '') || '—'} | -${(t.theoretical_pnl_pct||0).toFixed(1)}% | -${(a.actual_pnl_pct||0).toFixed(1)}% | -${icon} ${status.replace(/_/g,' ')} | -
| 股票 | +推荐时间 | +评分 | +RR | +买入区(中值) | +止损/止盈 | +推荐价 | +推荐仓位 | +理论盈亏 | +实际盈亏 | +状态 | +|
|---|---|---|---|---|---|---|---|---|---|---|---|
| 暂无追踪记录 | |||||||||||
| ' + (t.name||'') + ' ' + t.code + ' | ' +
+ '' + (t.tracked_at||'').slice(0,16) + ' | ' + + '' + (t.rec_score||'—') + ' | ' + + '' + (t.rr_ratio ? t.rr_ratio.toFixed(2) : '—') + ' | ' + + '' + (t.entry_low||'—') + ' → ' + mid + ' ← ' + (t.entry_high||'—') + ' | ' + + '损' + (t.stop_loss||'—') + ' 盈' + (t.take_profit||'—') + ' | ' +
+ '' + (t.price_at_track||'—') + ' | ' + + '' + (t.position_advice||'—') + ' | ' + + '' + tPnl + ' | ' + + '' + actualInfo + ' | ' + + '' + statusBadge(t.status) + (t.close_reason ? ' ' + t.close_reason + '' : '') + ' | ' +
+ '|