branch_scanner 彻底静默:自成长采集器不出操作信号

branch_scanner 是自成长数据收集器,不输出也不推送。
操作信号由 stale_push_wlin / price_monitor / 开盘收盘简报 按cron-report-format规范输出。
scanner只做三件事:
1. 每15分钟扫价格+评估分支
2. trigger_count + 1(决策树数据)
3. 更新状态快照
This commit is contained in:
知微
2026-06-24 10:39:28 +08:00
parent 68e806cfa1
commit 80b854b945
4 changed files with 133 additions and 301 deletions
+36 -120
View File
@@ -1,28 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
branch_scanner.py — 盘中分支扫描器 branch_scanner.py — 分支自成长数据采集器(全静默)
每15分钟跑一轮 核心功能(三件事,全部后台静默执行)
1. 读取所有有 strategy_tree 的股票 1. 每轮扫描42只股票,评估当前情景下各分支的适用性
2. 获取实时价格 2. 适用分支 → trigger_count + 1,记录 last_triggered
3. 评估每个分支在当前情景下是否适用 3. 保存当前状态到 scanner_state.json 供下次对比
4. 适用分支 → 记录 trigger_count + 推送信号
自成长核心组件:让分支条件得到实际验证 无输出 → 静默运行。触发数据积累在 decisions.json
操作信号由 stale_push_wlin / price_monitor / 开盘收盘简报 另路输出。
数据流向(自成长):每15分钟branch_scanner积累trigger_count →
每日prune_branches评估低效分支 → decisions.json修剪 → 分支越来越有效
""" """
import json, sys, os, re import json, sys, re
from datetime import datetime, date from datetime import datetime
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json"
MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json"
EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
def get_price(code): def get_price(code):
"""腾讯API实时价格"""
mkt = "sh" if code.startswith("6") or code.startswith("5") else "sz" mkt = "sh" if code.startswith("6") or code.startswith("5") else "sz"
url = f"http://qt.gtimg.cn/q={mkt}{code}" url = f"http://qt.gtimg.cn/q={mkt}{code}"
req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
@@ -32,12 +32,10 @@ def get_price(code):
if len(parts) > 3: if len(parts) > 3:
return float(parts[3]) return float(parts[3])
except Exception: except Exception:
pass return None
return None
def get_scenario(): def get_scenario():
"""读当前情景"""
try: try:
sys.path.insert(0, "/home/hmo/MoFin") sys.path.insert(0, "/home/hmo/MoFin")
from strategy_tree import detect_scenario from strategy_tree import detect_scenario
@@ -46,26 +44,11 @@ def get_scenario():
return {"id": "unknown", "label": "未知", "confidence": 0} return {"id": "unknown", "label": "未知", "confidence": 0}
def load_decisions():
try:
with open(DECISIONS_PATH) as f:
return json.load(f)
except Exception:
return {"decisions": []}
def save_decisions(data):
with open(DECISIONS_PATH, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def check_condition(branch, scenario_id, price): def check_condition(branch, scenario_id, price):
"""检查分支条件是否满足 — 同步 strategy_tree._check_branch_condition 逻辑"""
cond = branch.get("condition", {}) cond = branch.get("condition", {})
required_scenario = cond.get("scenario", "") required_scenario = cond.get("scenario", "")
if required_scenario and required_scenario != scenario_id: if required_scenario and required_scenario != scenario_id:
return False return False
price_cond = cond.get("price", "") price_cond = cond.get("price", "")
if price_cond and price: if price_cond and price:
ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond) ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond)
@@ -75,8 +58,6 @@ def check_condition(branch, scenario_id, price):
if op == ">" and not (price > val): return False if op == ">" and not (price > val): return False
if op == "<=" and not (price <= val): return False if op == "<=" and not (price <= val): return False
if op == ">=" and not (price >= val): return False if op == ">=" and not (price >= val): return False
# 买入区下界(price_lower)检查 — 之前遗漏
price_lower = cond.get("price_lower", "") price_lower = cond.get("price_lower", "")
if price_lower and price: if price_lower and price:
ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower) ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower)
@@ -86,119 +67,54 @@ def check_condition(branch, scenario_id, price):
if op == ">" and not (price > val): return False if op == ">" and not (price > val): return False
if op == "<=" and not (price <= val): return False if op == "<=" and not (price <= val): return False
if op == ">=" and not (price >= val): return False if op == ">=" and not (price >= val): return False
return True return True
def main(): def main():
now = datetime.now() now = datetime.now()
today = now.strftime("%Y-%m-%d") if now.hour < 9 or now.hour > 16:
hour = now.hour
# 盘后才扫无意义
if hour < 9 or hour > 16:
return 0 return 0
scenario = get_scenario() scenario = get_scenario()
sid = scenario.get("id", "unknown") sid = scenario.get("id", "unknown")
slabel = scenario.get("label", "未知")
data = load_decisions() with open(DECISIONS_PATH) as f:
data = json.load(f)
decisions = data.get("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: for entry in decisions:
code = entry.get("code", "") code = entry.get("code", "")
tree = entry.get("strategy_tree", {}) tree = entry.get("strategy_tree", {})
branches = tree.get("branches", []) branches = tree.get("branches", [])
if not branches: if not branches:
continue continue
price = get_price(code) price = get_price(code)
if not price: if not price:
continue continue
# 找出最优适用分支
best = None
for br in sorted(branches, key=lambda b: b.get("priority", 999)): for br in sorted(branches, key=lambda b: b.get("priority", 999)):
if check_condition(br, sid, price): if check_condition(br, sid, price):
best = br br["trigger_count"] = br.get("trigger_count", 0) + 1
br["last_triggered"] = now.strftime("%Y-%m-%d")
break break
if best: with open(DECISIONS_PATH, "w") as f:
best["trigger_count"] = best.get("trigger_count", 0) + 1 json.dump(data, f, indent=2, ensure_ascii=False)
best["last_triggered"] = today
triggered.append(code)
# 状态变化检测:上次不是这个分支→推送 # 更新状态快照
action_type = best.get("action", {}).get("type", "hold") state = {"scenario": sid, "updated_at": now.isoformat(), "branches": {}}
br_id = best.get("id", "") for e in decisions:
last_br = last_state.get("branches", {}).get(code, "") code = e.get("code", "")
last_action = last_state.get("branches", {}).get(f"{code}_action", "") tree = e.get("strategy_tree", {})
changed = (br_id != last_br) for br in sorted(tree.get("branches", []), key=lambda b: b.get("priority", 999)):
if check_condition(br, sid, get_price(code)):
state["branches"][code] = br.get("id", "")
break
try:
with open(SCANNER_STATE, "w") as f:
json.dump(state, f, indent=2)
except Exception:
pass
# 推送条件:分支变化(情景变化已隐含)或止损首次触发
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)
# 保存当前状态供下次对比
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:
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 return 0
+26 -26
View File
@@ -147,7 +147,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -226,7 +226,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -411,7 +411,7 @@
}, },
"priority": 0, "priority": 0,
"rationale": "止损保护本金", "rationale": "止损保护本金",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -705,7 +705,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -852,7 +852,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 6, "trigger_count": 7,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -4831,7 +4831,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -5035,7 +5035,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 6, "trigger_count": 7,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -5197,7 +5197,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -5401,7 +5401,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -5642,7 +5642,7 @@
}, },
"priority": 4, "priority": 4,
"rationale": "达到目标价,减半仓锁定利润", "rationale": "达到目标价,减半仓锁定利润",
"trigger_count": 5, "trigger_count": 6,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -5802,7 +5802,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -5978,7 +5978,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -6253,7 +6253,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -6351,7 +6351,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 10, "trigger_count": 11,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -6606,7 +6606,7 @@
}, },
"priority": 4, "priority": 4,
"rationale": "达到目标价,减半仓锁定利润", "rationale": "达到目标价,减半仓锁定利润",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -6823,7 +6823,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 6, "trigger_count": 7,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -6913,7 +6913,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 6, "trigger_count": 7,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -7117,7 +7117,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -7358,7 +7358,7 @@
}, },
"priority": 4, "priority": 4,
"rationale": "达到目标价,减半仓锁定利润", "rationale": "达到目标价,减半仓锁定利润",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -7505,7 +7505,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -7716,7 +7716,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -7920,7 +7920,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 3, "trigger_count": 4,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -8060,7 +8060,7 @@
}, },
"priority": 1, "priority": 1,
"rationale": "价格回调到支撑区,弱势市场低吸", "rationale": "价格回调到支撑区,弱势市场低吸",
"trigger_count": 6, "trigger_count": 7,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
}, },
@@ -8328,7 +8328,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -8518,7 +8518,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
@@ -8653,7 +8653,7 @@
}, },
"priority": 99, "priority": 99,
"rationale": "没有分支匹配时的默认动作", "rationale": "没有分支匹配时的默认动作",
"trigger_count": 7, "trigger_count": 8,
"success_rate": null, "success_rate": null,
"last_triggered": "2026-06-24" "last_triggered": "2026-06-24"
} }
+35 -35
View File
@@ -55,8 +55,8 @@
"action_note": "⚠️盈亏比偏低(1:1.3),不建议加仓", "action_note": "⚠️盈亏比偏低(1:1.3),不建议加仓",
"timing_signal": "大盘中性,行业中性,高估值,蓝筹,持有" "timing_signal": "大盘中性,行业中性,高估值,蓝筹,持有"
}, },
"price": 83.95, "price": 83.65,
"change_pct": 7.84 "change_pct": 7.45
}, },
{ {
"code": "01088", "code": "01088",
@@ -85,7 +85,7 @@
"timing_signal": "大盘中性,行业中性,蓝筹,持有" "timing_signal": "大盘中性,行业中性,蓝筹,持有"
}, },
"price": 41.52, "price": 41.52,
"change_pct": -1.09 "change_pct": -1.28
}, },
{ {
"code": "01211", "code": "01211",
@@ -113,8 +113,8 @@
"action_note": "深套持有", "action_note": "深套持有",
"timing_signal": "大盘中性,行业偏弱,蓝筹,持有" "timing_signal": "大盘中性,行业偏弱,蓝筹,持有"
}, },
"price": 75.0, "price": 74.95,
"change_pct": -1.12 "change_pct": -1.19
}, },
{ {
"code": "01478", "code": "01478",
@@ -142,8 +142,8 @@
"action_note": "深套持有", "action_note": "深套持有",
"timing_signal": "大盘中性,行业中性,低估值,持有" "timing_signal": "大盘中性,行业中性,低估值,持有"
}, },
"price": 7.68, "price": 7.67,
"change_pct": -2.79 "change_pct": -2.66
}, },
{ {
"code": "01888", "code": "01888",
@@ -171,8 +171,8 @@
"action_note": "短炒强趋势持", "action_note": "短炒强趋势持",
"timing_signal": "大盘中性,行业偏弱,高估值,强趋势持" "timing_signal": "大盘中性,行业偏弱,高估值,强趋势持"
}, },
"price": 96.75, "price": 96.15,
"change_pct": 10.89 "change_pct": 10.2
}, },
{ {
"code": "02202", "code": "02202",
@@ -201,7 +201,7 @@
"timing_signal": "大盘中性,行业中性,高估值,持有" "timing_signal": "大盘中性,行业中性,高估值,持有"
}, },
"price": 2.34, "price": 2.34,
"change_pct": -2.5 "change_pct": -2.92
}, },
{ {
"code": "02388", "code": "02388",
@@ -229,8 +229,8 @@
"action_note": "", "action_note": "",
"timing_signal": "大盘中性,行业偏强,低估值,蓝筹,持有" "timing_signal": "大盘中性,行业偏强,低估值,蓝筹,持有"
}, },
"price": 46.4, "price": 46.32,
"change_pct": -1.02 "change_pct": -1.45
}, },
{ {
"code": "09988", "code": "09988",
@@ -258,8 +258,8 @@
"action_note": "深套持有", "action_note": "深套持有",
"timing_signal": "大盘中性,行业偏弱,蓝筹,持有" "timing_signal": "大盘中性,行业偏弱,蓝筹,持有"
}, },
"price": 98.25, "price": 98.45,
"change_pct": -0.66 "change_pct": -0.51
}, },
{ {
"code": "300035", "code": "300035",
@@ -287,8 +287,8 @@
"action_note": "深套持有", "action_note": "深套持有",
"timing_signal": "大盘中性,行业偏弱,持有" "timing_signal": "大盘中性,行业偏弱,持有"
}, },
"price": 16.21, "price": 16.17,
"change_pct": -0.92 "change_pct": -0.98
}, },
{ {
"code": "300548", "code": "300548",
@@ -316,8 +316,8 @@
"action_note": "短炒强趋势持", "action_note": "短炒强趋势持",
"timing_signal": "大盘中性,行业中性,高估值,强趋势持" "timing_signal": "大盘中性,行业中性,高估值,强趋势持"
}, },
"price": 282.89, "price": 282.02,
"change_pct": -1.09 "change_pct": -1.4
}, },
{ {
"code": "300690", "code": "300690",
@@ -345,8 +345,8 @@
"action_note": "⚠️盈亏比偏低(1:1.5),不建议加仓", "action_note": "⚠️盈亏比偏低(1:1.5),不建议加仓",
"timing_signal": "大盘中性,行业偏弱,持有" "timing_signal": "大盘中性,行业偏弱,持有"
}, },
"price": 22.82, "price": 22.83,
"change_pct": -3.47 "change_pct": -3.39
}, },
{ {
"code": "300750", "code": "300750",
@@ -374,8 +374,8 @@
"action_note": "", "action_note": "",
"timing_signal": "大盘中性,行业偏弱,蓝筹,持有" "timing_signal": "大盘中性,行业偏弱,蓝筹,持有"
}, },
"price": 393.04, "price": 392.25,
"change_pct": 0.14 "change_pct": -0.07
}, },
{ {
"code": "518880", "code": "518880",
@@ -404,7 +404,7 @@
"timing_signal": "大盘中性,行业偏弱,持有" "timing_signal": "大盘中性,行业偏弱,持有"
}, },
"price": 8.45, "price": 8.45,
"change_pct": -0.97 "change_pct": -1.07
}, },
{ {
"code": "600036", "code": "600036",
@@ -432,8 +432,8 @@
"action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓", "action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓",
"timing_signal": "大盘中性,行业偏强,低估值,蓝筹,弱势持有" "timing_signal": "大盘中性,行业偏强,低估值,蓝筹,弱势持有"
}, },
"price": 37.04, "price": 37.07,
"change_pct": -0.94 "change_pct": -0.88
}, },
{ {
"code": "600563", "code": "600563",
@@ -490,8 +490,8 @@
"action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓", "action_note": "⚠️盈亏比偏低(1:1.2),不建议加仓",
"timing_signal": "大盘中性,行业偏强,持有" "timing_signal": "大盘中性,行业偏强,持有"
}, },
"price": 10.49, "price": 10.47,
"change_pct": -1.5 "change_pct": -1.69
}, },
{ {
"code": "601899", "code": "601899",
@@ -519,8 +519,8 @@
"action_note": "深套持有", "action_note": "深套持有",
"timing_signal": "大盘中性,行业偏弱,低估值,蓝筹,持有" "timing_signal": "大盘中性,行业偏弱,低估值,蓝筹,持有"
}, },
"price": 27.58, "price": 27.56,
"change_pct": -0.61 "change_pct": -0.68
}, },
{ {
"code": "603259", "code": "603259",
@@ -548,8 +548,8 @@
"action_note": "", "action_note": "",
"timing_signal": "大盘中性,行业偏强,低估值,持有" "timing_signal": "大盘中性,行业偏强,低估值,持有"
}, },
"price": 116.26, "price": 116.18,
"change_pct": 9.36 "change_pct": 9.28
}, },
{ {
"code": "688411", "code": "688411",
@@ -577,8 +577,8 @@
"action_note": "", "action_note": "",
"timing_signal": "持有" "timing_signal": "持有"
}, },
"price": 267.1, "price": 267.18,
"change_pct": -2.11 "change_pct": -1.86
}, },
{ {
"code": "688981", "code": "688981",
@@ -606,8 +606,8 @@
"action_note": "", "action_note": "",
"timing_signal": "大盘中性,行业中性,高估值,蓝筹,持有" "timing_signal": "大盘中性,行业中性,高估值,蓝筹,持有"
}, },
"price": 150.91, "price": 151.24,
"change_pct": 6.5 "change_pct": 6.73
} }
] ]
} }
+36 -120
View File
@@ -1,28 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
branch_scanner.py — 盘中分支扫描器 branch_scanner.py — 分支自成长数据采集器(全静默)
每15分钟跑一轮 核心功能(三件事,全部后台静默执行)
1. 读取所有有 strategy_tree 的股票 1. 每轮扫描42只股票,评估当前情景下各分支的适用性
2. 获取实时价格 2. 适用分支 → trigger_count + 1,记录 last_triggered
3. 评估每个分支在当前情景下是否适用 3. 保存当前状态到 scanner_state.json 供下次对比
4. 适用分支 → 记录 trigger_count + 推送信号
自成长核心组件:让分支条件得到实际验证 无输出 → 静默运行。触发数据积累在 decisions.json
操作信号由 stale_push_wlin / price_monitor / 开盘收盘简报 另路输出。
数据流向(自成长):每15分钟branch_scanner积累trigger_count →
每日prune_branches评估低效分支 → decisions.json修剪 → 分支越来越有效
""" """
import json, sys, os, re import json, sys, re
from datetime import datetime, date from datetime import datetime
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json"
MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json"
EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
def get_price(code): def get_price(code):
"""腾讯API实时价格"""
mkt = "sh" if code.startswith("6") or code.startswith("5") else "sz" mkt = "sh" if code.startswith("6") or code.startswith("5") else "sz"
url = f"http://qt.gtimg.cn/q={mkt}{code}" url = f"http://qt.gtimg.cn/q={mkt}{code}"
req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
@@ -32,12 +32,10 @@ def get_price(code):
if len(parts) > 3: if len(parts) > 3:
return float(parts[3]) return float(parts[3])
except Exception: except Exception:
pass return None
return None
def get_scenario(): def get_scenario():
"""读当前情景"""
try: try:
sys.path.insert(0, "/home/hmo/MoFin") sys.path.insert(0, "/home/hmo/MoFin")
from strategy_tree import detect_scenario from strategy_tree import detect_scenario
@@ -46,26 +44,11 @@ def get_scenario():
return {"id": "unknown", "label": "未知", "confidence": 0} return {"id": "unknown", "label": "未知", "confidence": 0}
def load_decisions():
try:
with open(DECISIONS_PATH) as f:
return json.load(f)
except Exception:
return {"decisions": []}
def save_decisions(data):
with open(DECISIONS_PATH, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def check_condition(branch, scenario_id, price): def check_condition(branch, scenario_id, price):
"""检查分支条件是否满足 — 同步 strategy_tree._check_branch_condition 逻辑"""
cond = branch.get("condition", {}) cond = branch.get("condition", {})
required_scenario = cond.get("scenario", "") required_scenario = cond.get("scenario", "")
if required_scenario and required_scenario != scenario_id: if required_scenario and required_scenario != scenario_id:
return False return False
price_cond = cond.get("price", "") price_cond = cond.get("price", "")
if price_cond and price: if price_cond and price:
ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond) ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond)
@@ -75,8 +58,6 @@ def check_condition(branch, scenario_id, price):
if op == ">" and not (price > val): return False if op == ">" and not (price > val): return False
if op == "<=" and not (price <= val): return False if op == "<=" and not (price <= val): return False
if op == ">=" and not (price >= val): return False if op == ">=" and not (price >= val): return False
# 买入区下界(price_lower)检查 — 之前遗漏
price_lower = cond.get("price_lower", "") price_lower = cond.get("price_lower", "")
if price_lower and price: if price_lower and price:
ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower) ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower)
@@ -86,119 +67,54 @@ def check_condition(branch, scenario_id, price):
if op == ">" and not (price > val): return False if op == ">" and not (price > val): return False
if op == "<=" and not (price <= val): return False if op == "<=" and not (price <= val): return False
if op == ">=" and not (price >= val): return False if op == ">=" and not (price >= val): return False
return True return True
def main(): def main():
now = datetime.now() now = datetime.now()
today = now.strftime("%Y-%m-%d") if now.hour < 9 or now.hour > 16:
hour = now.hour
# 盘后才扫无意义
if hour < 9 or hour > 16:
return 0 return 0
scenario = get_scenario() scenario = get_scenario()
sid = scenario.get("id", "unknown") sid = scenario.get("id", "unknown")
slabel = scenario.get("label", "未知")
data = load_decisions() with open(DECISIONS_PATH) as f:
data = json.load(f)
decisions = data.get("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: for entry in decisions:
code = entry.get("code", "") code = entry.get("code", "")
tree = entry.get("strategy_tree", {}) tree = entry.get("strategy_tree", {})
branches = tree.get("branches", []) branches = tree.get("branches", [])
if not branches: if not branches:
continue continue
price = get_price(code) price = get_price(code)
if not price: if not price:
continue continue
# 找出最优适用分支
best = None
for br in sorted(branches, key=lambda b: b.get("priority", 999)): for br in sorted(branches, key=lambda b: b.get("priority", 999)):
if check_condition(br, sid, price): if check_condition(br, sid, price):
best = br br["trigger_count"] = br.get("trigger_count", 0) + 1
br["last_triggered"] = now.strftime("%Y-%m-%d")
break break
if best: with open(DECISIONS_PATH, "w") as f:
best["trigger_count"] = best.get("trigger_count", 0) + 1 json.dump(data, f, indent=2, ensure_ascii=False)
best["last_triggered"] = today
triggered.append(code)
# 状态变化检测:上次不是这个分支→推送 # 更新状态快照
action_type = best.get("action", {}).get("type", "hold") state = {"scenario": sid, "updated_at": now.isoformat(), "branches": {}}
br_id = best.get("id", "") for e in decisions:
last_br = last_state.get("branches", {}).get(code, "") code = e.get("code", "")
last_action = last_state.get("branches", {}).get(f"{code}_action", "") tree = e.get("strategy_tree", {})
changed = (br_id != last_br) for br in sorted(tree.get("branches", []), key=lambda b: b.get("priority", 999)):
if check_condition(br, sid, get_price(code)):
state["branches"][code] = br.get("id", "")
break
try:
with open(SCANNER_STATE, "w") as f:
json.dump(state, f, indent=2)
except Exception:
pass
# 推送条件:分支变化(情景变化已隐含)或止损首次触发
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)
# 保存当前状态供下次对比
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:
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 return 0