feat(盯盘): 推荐操作区域置顶独立 + tag与XMPP动作级信号自动同步

- mofin_db: write_holding_strategy 内置tag同步语义——动作级信号
  (买入/可买入/可加仓/卖出/止盈)→current_recommend; 信号降级→
  清除current_recommend; active_manual人工标记永不被自动流覆盖/清除
- 新增 sync_recommend_tag() 供裸SQL调用方
- batch_reassess.save_result / per_stock stage-2 调用同步
- 盯盘Tab: '重点推荐'更名'推荐操作', 区域独立琥珀色视觉, 置顶
同步语义: XMPP买入信号(ACTION级告警)的个股=推荐操作区域个股,
信号消失(重评降级)时区域同步消失
This commit is contained in:
hmo
2026-07-21 23:31:57 +08:00
parent 05a60cf0d4
commit 9914ff9457
4 changed files with 61 additions and 13 deletions
+4 -1
View File
@@ -17,7 +17,7 @@ from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, "/home/hmo/MoFin")
from llm_client import call_llm, REASSESS_MODEL, gateway_alive, ocg_alive
from mofin_db import snapshot_strategy_history
from mofin_db import snapshot_strategy_history, sync_recommend_tag
DB = "/home/hmo/MoFin/data/mofin.db"
COOLDOWN_HOURS = 1
@@ -359,6 +359,9 @@ def save_result(code, full_text, parsed):
sql = f"UPDATE holding_strategies SET {', '.join(updates)} WHERE code=? AND status='active'"
conn.execute(sql, params)
conn.commit()
# ── 推荐操作 tag 同步(与 XMPP 动作级信号同源)──
sync_recommend_tag(conn, code, parsed.get("signal", ""))
# 买入信号→推XMPP通知(在conn close前执行)
if parsed.get("signal") == "买入":
+8 -2
View File
@@ -557,8 +557,14 @@ def main():
if _sig_line:
_sig = '买入' if '买入' in _sig_line[0] else '关注' if '关注' in _sig_line[0] else '观望' if '观望' in _sig_line[0] else '卖出' if '卖出' in _sig_line[0] else ''
if _sig:
__import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db').execute(
"UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", (_sig, code)).connection.commit()
_ts_conn = __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db')
_ts_conn.execute(
"UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", (_sig, code))
_ts_conn.commit()
# 推荐操作 tag 同步(与 XMPP 动作级信号同源)
from mofin_db import sync_recommend_tag
sync_recommend_tag(_ts_conn, code, _sig)
_ts_conn.close()
print(f" ✅ LLM信号={_sig} 已写入")
# 买入信号→推XMPP
if _sig == "买入":
+41 -5
View File
@@ -1108,6 +1108,28 @@ def get_prices_batch_from_db(codes: list[str]) -> dict:
# 核心写函数 — 替代 json.dump(),强制币种约束
# ═══════════════════════════════════════════════════════════════════
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)。"""
_ACTION_SIGNALS = ("买入", "可买入", "可加仓", "卖出", "止盈")
try:
if timing_signal in _ACTION_SIGNALS:
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()
elif timing_signal:
conn.execute(
"UPDATE holding_strategies SET tag='' "
"WHERE code=? AND status='active' AND tag='current_recommend'",
(code,))
conn.commit()
except Exception as e:
print(f" [TAG SYNC] {code} 失败: {e}", flush=True)
def snapshot_strategy_history(conn, code: str, source_trigger: str = "write_holding_strategy"):
"""在修改前快照当前策略到 strategy_history 表。永不抛异常。"""
try:
@@ -1162,12 +1184,17 @@ def write_holding_strategy(conn, code: str, name: str, data: dict,
quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '')
signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '')
# 在DELETE前保留现有的full_analysis和reassessed_at(防止被regenerate_all等清空)
# ── 推荐操作 tag 同步语义(与 XMPP 动作级信号同源,红线#12)──
# 动作级信号 → tag=current_recommend(进盯盘"推荐操作"区)
# 信号降级 → 清除 current_recommend(区域同步消失)
# active_manual(人工标记)永远不被自动流程覆盖或清除
_ACTION_SIGNALS = ("买入", "可买入", "可加仓", "卖出", "止盈")
_existing_fa = data.get('full_analysis', '')
_existing_ra = data.get('reassessed_at', '')
# tag 语义:'tag' 键缺席=保留旧标签;显式传入(含'')= 按传入值(允许清除标签)
_tag_absent = 'tag' not in data
_existing_tag = data.get('tag', '') or ''
_new_sig = data.get('timing_signal', '') or ''
_explicit_tag = data.get('tag', None)
_old_tag = ''
if not _existing_fa or _tag_absent:
try:
_old = conn.execute("SELECT full_analysis, reassessed_at, tag FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone()
@@ -1175,10 +1202,19 @@ def write_holding_strategy(conn, code: str, name: str, data: dict,
if not _existing_fa:
if _old[0]: _existing_fa = _old[0]
if _old[1]: _existing_ra = _old[1]
if _tag_absent and _old[2]:
_existing_tag = _old[2]
_old_tag = _old[2] or ''
except:
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:
_existing_tag = 'current_recommend' # 动作级信号 → 自动推荐
elif _new_sig and _old_tag == 'current_recommend':
_existing_tag = '' # 信号降级 → 清除自动推荐
else:
_existing_tag = _old_tag # 其余保留
# ── 类型守卫:shares 必须是数值,防止字符串写入导致下游崩溃 ──
_shares = data.get('shares', 0)
+8 -5
View File
@@ -1383,9 +1383,9 @@ async function renderWatch() {
return;
}
// 分组渲染
// 分组渲染(推荐操作区域独立于持仓/自选,置顶且与XMPP推荐同步)
const groups = [
{ label: '🔥 重点推荐', g: 0 },
{ label: '🔥 推荐操作', g: 0 },
{ label: '💼 持仓策略', g: 1 },
{ label: '⭐ 自选策略', g: 2 },
];
@@ -1408,9 +1408,12 @@ async function renderWatch() {
const items = stocks.filter(s => s.sort_group === grp.g);
if (items.length === 0) continue;
// 分组标题行
html += '<tr class="border-b border-slate-700">' +
'<td colspan="10" class="p-2 font-semibold text-slate-300">' + grp.label + ' (' + items.length + ')</td>' +
// 分组标题行(推荐操作区域独立视觉)
const hdrCls = grp.g === 0
? 'border-y border-amber-500/50 bg-amber-900/25'
: 'border-b border-slate-700';
html += '<tr class="' + hdrCls + '">' +
'<td colspan="10" class="p-2 font-semibold ' + (grp.g === 0 ? 'text-amber-300' : 'text-slate-300') + '">' + grp.label + ' (' + items.length + ')</td>' +
'</tr>';
for (const s of items) {