diff --git a/branch_scanner.py b/branch_scanner.py index 6bacac3..70bc40b 100644 --- a/branch_scanner.py +++ b/branch_scanner.py @@ -19,7 +19,6 @@ DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json" EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" -XMPP_URL = "http://127.0.0.1:5805/" def get_price(code): @@ -91,14 +90,6 @@ def check_condition(branch, scenario_id, price): return True -def push_alert(msg): - try: - payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode() - urlopen(XMPP_URL, data=payload, timeout=3) - except Exception: - pass - - def main(): now = datetime.now() today = now.strftime("%Y-%m-%d") @@ -106,15 +97,25 @@ def main(): # 盘后才扫无意义 if hour < 9 or hour > 16: - print("SILENT: 非交易时段") return 0 scenario = get_scenario() sid = scenario.get("id", "unknown") + slabel = scenario.get("label", "未知") data = load_decisions() decisions = data.get("decisions", []) + # 读上次扫描的状态(记录情景+各股最优分支) + state_path = "/home/hmo/web-dashboard/data/scanner_state.json" + last_state = {"scenario": "", "branches": {}} + try: + with open(state_path) as f: + last_state = json.load(f) + except Exception: + pass + triggered = [] + alerts = [] for entry in decisions: code = entry.get("code", "") tree = entry.get("strategy_tree", {}) @@ -126,39 +127,78 @@ def main(): if not price: continue - # 找出所有适用的分支,选最优(优先级数字最低) + # 找出最优适用分支 best = None for br in sorted(branches, key=lambda b: b.get("priority", 999)): if check_condition(br, sid, price): best = br break - + if best: best["trigger_count"] = best.get("trigger_count", 0) + 1 best["last_triggered"] = today - triggered.append((code, entry.get("name", ""), best)) + triggered.append(code) + + # 状态变化检测:上次不是这个分支→推送 + action_type = best.get("action", {}).get("type", "hold") + br_id = best.get("id", "") + last_br = last_state.get("branches", {}).get(code, "") + last_action = last_state.get("branches", {}).get(f"{code}_action", "") + changed = (br_id != last_br) + + # 推送条件:分支变化(情景变化已隐含)或止损首次触发 + priority = best.get("priority", 99) + should_alert = False + if priority == 0: # 止损 + # 只有止损新出现才推(上次不是止损 or 上次扫描不存在) + should_alert = (changed or not last_state.get("branches")) + elif sid != last_state.get("scenario"): + should_alert = True + elif changed: + should_alert = True + + if should_alert: + rationale = best.get("rationale", "") + count = best.get("trigger_count", 1) + alerts.append(f" {code} {entry.get('name','')}: {action_type}({rationale})") if triggered: save_decisions(data) - print(f"[SCAN] {now.strftime('%H:%M')} 情景={sid} | {len(triggered)}个分支被触发") - # 推送重要触发 - alerts = [] - for code, name, br in triggered: - action = br.get("action", {}) - action_type = action.get("type", "hold") - priority = br.get("priority", 99) - rationale = br.get("rationale", "") - count = br.get("trigger_count", 1) - if action_type != "hold": - alerts.append(f" {code} {name}: {action_type}({rationale})触发{count}次") + # 保存当前状态供下次对比 + new_state = { + "scenario": sid, + "scenario_label": slabel, + "branches": {}, + "updated_at": now.isoformat(), + } + for e in decisions: + code = e.get("code", "") + tree = e.get("strategy_tree", {}) + branches = tree.get("branches", []) + best = None + for br in sorted(branches, key=lambda b: b.get("priority", 999)): + if check_condition(br, sid, get_price(code)): + best = br + break + if best: + new_state["branches"][code] = best.get("id", "") + new_state["branches"][f"{code}_action"] = best.get("action", {}).get("type", "") + try: + with open(state_path, "w") as f: + json.dump(new_state, f, indent=2) + except Exception: + pass - if alerts: - msg = f"【分支扫描】{now.strftime('%H:%M')} | 情景{sid}\n" + "\n".join(alerts) - push_alert(msg) - else: - print(f"[SCAN] {now.strftime('%H:%M')} | 情景{sid} | 无触发") + # 输出:变化才出声,平常静默 + if alerts: + scenario_shift = f"【情景切换】{last_state.get('scenario','')}→{sid}" if sid != last_state.get("scenario") else "" + header = f"【分支扫描】{now.strftime('%H:%M')}" + (f" | {scenario_shift}" if scenario_shift else "") + msg = header + "\n" + "\n".join(alerts) + print(msg) + return 0 + # 无变化→静默 return 0 diff --git a/data/decisions.json b/data/decisions.json index 5ba255e..8c31ec8 100644 --- a/data/decisions.json +++ b/data/decisions.json @@ -147,7 +147,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -226,7 +226,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -411,7 +411,7 @@ }, "priority": 0, "rationale": "止损保护本金", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -705,7 +705,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -852,7 +852,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 2, + "trigger_count": 6, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -4831,7 +4831,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -5035,7 +5035,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 2, + "trigger_count": 6, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -5197,7 +5197,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -5401,7 +5401,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -5642,7 +5642,7 @@ }, "priority": 4, "rationale": "达到目标价,减半仓锁定利润", - "trigger_count": 1, + "trigger_count": 5, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -5802,7 +5802,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -5978,7 +5978,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -6253,7 +6253,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -6351,7 +6351,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 6, + "trigger_count": 10, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -6606,7 +6606,7 @@ }, "priority": 4, "rationale": "达到目标价,减半仓锁定利润", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -6759,7 +6759,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 1, + "trigger_count": 2, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -6823,7 +6823,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 6, "success_rate": null, "last_triggered": "2026-06-24" } @@ -6913,7 +6913,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 2, + "trigger_count": 6, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -7117,7 +7117,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -7358,7 +7358,7 @@ }, "priority": 4, "rationale": "达到目标价,减半仓锁定利润", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -7505,7 +7505,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -7716,7 +7716,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -7856,7 +7856,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 1, + "trigger_count": 5, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -8060,7 +8060,7 @@ }, "priority": 1, "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 2, + "trigger_count": 6, "success_rate": null, "last_triggered": "2026-06-24" }, @@ -8328,7 +8328,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -8518,7 +8518,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } @@ -8653,7 +8653,7 @@ }, "priority": 99, "rationale": "没有分支匹配时的默认动作", - "trigger_count": 3, + "trigger_count": 7, "success_rate": null, "last_triggered": "2026-06-24" } diff --git a/data/portfolio.json b/data/portfolio.json index b02854b..27329c6 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -26,8 +26,8 @@ "action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓", "timing_signal": "大盘中性,行业偏弱,蓝筹,持有" }, - "price": 416.6, - "change_pct": 0.68 + "price": 416.4, + "change_pct": 0.39 }, { "code": "00981", @@ -55,8 +55,8 @@ "action_note": "⚠️盈亏比偏低(1:1.3),不建议加仓", "timing_signal": "大盘中性,行业中性,高估值,蓝筹,持有" }, - "price": 83.9, - "change_pct": 7.77 + "price": 83.95, + "change_pct": 7.84 }, { "code": "01088", @@ -84,8 +84,8 @@ "action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓", "timing_signal": "大盘中性,行业中性,蓝筹,持有" }, - "price": 41.54, - "change_pct": -1.28 + "price": 41.52, + "change_pct": -1.09 }, { "code": "01211", @@ -113,8 +113,8 @@ "action_note": "深套持有", "timing_signal": "大盘中性,行业偏弱,蓝筹,持有" }, - "price": 75.1, - "change_pct": -0.99 + "price": 75.0, + "change_pct": -1.12 }, { "code": "01478", @@ -142,7 +142,7 @@ "action_note": "深套持有", "timing_signal": "大盘中性,行业中性,低估值,持有" }, - "price": 7.67, + "price": 7.68, "change_pct": -2.79 }, { @@ -171,8 +171,8 @@ "action_note": "短炒强趋势持", "timing_signal": "大盘中性,行业偏弱,高估值,强趋势持" }, - "price": 94.8, - "change_pct": 8.19 + "price": 96.75, + "change_pct": 10.89 }, { "code": "02202", @@ -201,7 +201,7 @@ "timing_signal": "大盘中性,行业中性,高估值,持有" }, "price": 2.34, - "change_pct": -2.92 + "change_pct": -2.5 }, { "code": "02388", @@ -229,8 +229,8 @@ "action_note": "", "timing_signal": "大盘中性,行业偏强,低估值,蓝筹,持有" }, - "price": 46.58, - "change_pct": -0.89 + "price": 46.4, + "change_pct": -1.02 }, { "code": "09988", @@ -258,8 +258,8 @@ "action_note": "深套持有", "timing_signal": "大盘中性,行业偏弱,蓝筹,持有" }, - "price": 98.45, - "change_pct": -0.51 + "price": 98.25, + "change_pct": -0.66 }, { "code": "300035", @@ -287,8 +287,8 @@ "action_note": "深套持有", "timing_signal": "大盘中性,行业偏弱,持有" }, - "price": 16.06, - "change_pct": -1.83 + "price": 16.21, + "change_pct": -0.92 }, { "code": "300548", @@ -316,8 +316,8 @@ "action_note": "短炒强趋势持", "timing_signal": "大盘中性,行业中性,高估值,强趋势持" }, - "price": 283.8, - "change_pct": -0.78 + "price": 282.89, + "change_pct": -1.09 }, { "code": "300690", @@ -345,8 +345,8 @@ "action_note": "⚠️盈亏比偏低(1:1.5),不建议加仓", "timing_signal": "大盘中性,行业偏弱,持有" }, - "price": 22.79, - "change_pct": -3.55 + "price": 22.82, + "change_pct": -3.47 }, { "code": "300750", @@ -374,8 +374,8 @@ "action_note": "", "timing_signal": "大盘中性,行业偏弱,蓝筹,持有" }, - "price": 392.99, - "change_pct": 0.12 + "price": 393.04, + "change_pct": 0.14 }, { "code": "518880", @@ -403,8 +403,8 @@ "action_note": "深套持有", "timing_signal": "大盘中性,行业偏弱,持有" }, - "price": 8.46, - "change_pct": -0.98 + "price": 8.45, + "change_pct": -0.97 }, { "code": "600036", @@ -433,7 +433,7 @@ "timing_signal": "大盘中性,行业偏强,低估值,蓝筹,弱势持有" }, "price": 37.04, - "change_pct": -0.96 + "change_pct": -0.94 }, { "code": "600563", @@ -490,8 +490,8 @@ "action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓", "timing_signal": "大盘中性,行业偏强,持有" }, - "price": 10.47, - "change_pct": -1.69 + "price": 10.49, + "change_pct": -1.5 }, { "code": "601899", @@ -519,8 +519,8 @@ "action_note": "深套持有", "timing_signal": "大盘中性,行业偏弱,低估值,蓝筹,持有" }, - "price": 27.51, - "change_pct": -0.94 + "price": 27.58, + "change_pct": -0.61 }, { "code": "603259", @@ -548,8 +548,8 @@ "action_note": "", "timing_signal": "大盘中性,行业偏强,低估值,持有" }, - "price": 116.45, - "change_pct": 9.54 + "price": 116.26, + "change_pct": 9.36 }, { "code": "688411", @@ -577,8 +577,8 @@ "action_note": "", "timing_signal": "持有" }, - "price": 266.91, - "change_pct": -1.96 + "price": 267.1, + "change_pct": -2.11 }, { "code": "688981", @@ -606,8 +606,8 @@ "action_note": "", "timing_signal": "大盘中性,行业中性,高估值,蓝筹,持有" }, - "price": 151.13, - "change_pct": 6.65 + "price": 150.91, + "change_pct": 6.5 } ] } \ No newline at end of file diff --git a/scripts/branch_scanner.py b/scripts/branch_scanner.py index 6bacac3..70bc40b 100644 --- a/scripts/branch_scanner.py +++ b/scripts/branch_scanner.py @@ -19,7 +19,6 @@ DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json" EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" -XMPP_URL = "http://127.0.0.1:5805/" def get_price(code): @@ -91,14 +90,6 @@ def check_condition(branch, scenario_id, price): return True -def push_alert(msg): - try: - payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode() - urlopen(XMPP_URL, data=payload, timeout=3) - except Exception: - pass - - def main(): now = datetime.now() today = now.strftime("%Y-%m-%d") @@ -106,15 +97,25 @@ def main(): # 盘后才扫无意义 if hour < 9 or hour > 16: - print("SILENT: 非交易时段") return 0 scenario = get_scenario() sid = scenario.get("id", "unknown") + slabel = scenario.get("label", "未知") data = load_decisions() decisions = data.get("decisions", []) + # 读上次扫描的状态(记录情景+各股最优分支) + state_path = "/home/hmo/web-dashboard/data/scanner_state.json" + last_state = {"scenario": "", "branches": {}} + try: + with open(state_path) as f: + last_state = json.load(f) + except Exception: + pass + triggered = [] + alerts = [] for entry in decisions: code = entry.get("code", "") tree = entry.get("strategy_tree", {}) @@ -126,39 +127,78 @@ def main(): if not price: continue - # 找出所有适用的分支,选最优(优先级数字最低) + # 找出最优适用分支 best = None for br in sorted(branches, key=lambda b: b.get("priority", 999)): if check_condition(br, sid, price): best = br break - + if best: best["trigger_count"] = best.get("trigger_count", 0) + 1 best["last_triggered"] = today - triggered.append((code, entry.get("name", ""), best)) + triggered.append(code) + + # 状态变化检测:上次不是这个分支→推送 + action_type = best.get("action", {}).get("type", "hold") + br_id = best.get("id", "") + last_br = last_state.get("branches", {}).get(code, "") + last_action = last_state.get("branches", {}).get(f"{code}_action", "") + changed = (br_id != last_br) + + # 推送条件:分支变化(情景变化已隐含)或止损首次触发 + priority = best.get("priority", 99) + should_alert = False + if priority == 0: # 止损 + # 只有止损新出现才推(上次不是止损 or 上次扫描不存在) + should_alert = (changed or not last_state.get("branches")) + elif sid != last_state.get("scenario"): + should_alert = True + elif changed: + should_alert = True + + if should_alert: + rationale = best.get("rationale", "") + count = best.get("trigger_count", 1) + alerts.append(f" {code} {entry.get('name','')}: {action_type}({rationale})") if triggered: save_decisions(data) - print(f"[SCAN] {now.strftime('%H:%M')} 情景={sid} | {len(triggered)}个分支被触发") - # 推送重要触发 - alerts = [] - for code, name, br in triggered: - action = br.get("action", {}) - action_type = action.get("type", "hold") - priority = br.get("priority", 99) - rationale = br.get("rationale", "") - count = br.get("trigger_count", 1) - if action_type != "hold": - alerts.append(f" {code} {name}: {action_type}({rationale})触发{count}次") + # 保存当前状态供下次对比 + new_state = { + "scenario": sid, + "scenario_label": slabel, + "branches": {}, + "updated_at": now.isoformat(), + } + for e in decisions: + code = e.get("code", "") + tree = e.get("strategy_tree", {}) + branches = tree.get("branches", []) + best = None + for br in sorted(branches, key=lambda b: b.get("priority", 999)): + if check_condition(br, sid, get_price(code)): + best = br + break + if best: + new_state["branches"][code] = best.get("id", "") + new_state["branches"][f"{code}_action"] = best.get("action", {}).get("type", "") + try: + with open(state_path, "w") as f: + json.dump(new_state, f, indent=2) + except Exception: + pass - if alerts: - msg = f"【分支扫描】{now.strftime('%H:%M')} | 情景{sid}\n" + "\n".join(alerts) - push_alert(msg) - else: - print(f"[SCAN] {now.strftime('%H:%M')} | 情景{sid} | 无触发") + # 输出:变化才出声,平常静默 + if alerts: + scenario_shift = f"【情景切换】{last_state.get('scenario','')}→{sid}" if sid != last_state.get("scenario") else "" + header = f"【分支扫描】{now.strftime('%H:%M')}" + (f" | {scenario_shift}" if scenario_shift else "") + msg = header + "\n" + "\n".join(alerts) + print(msg) + return 0 + # 无变化→静默 return 0