diff --git a/strategy_lifecycle.py b/strategy_lifecycle.py index 49963bd2..12dce8e9 100644 --- a/strategy_lifecycle.py +++ b/strategy_lifecycle.py @@ -1,2590 +1,2592 @@ -#!/usr/bin/env python3 -"""策略生命周期管理系统 — 技术面驱动版本 v2 - -核心原则: -1. 止损放在合理的技术位,不拍数字 -2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1 -3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力 -4. 买入区间:弱支撑~弱压力之间 -5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买 -""" - -import json -import urllib.request -import os -import sys -import re -from datetime import datetime -import technical_analysis as ta -import multi_timeframe as mtf -from mo_data import read_portfolio, read_decisions, read_watchlist -from mo_models import is_hk_stock, to_cny, get_hk_rate -from strategy_tree import detect_scenario - -# ─── 策略准入门禁 — 硬性质量红线 ─────────────────────────────── -# 每一条策略写入前必须过此门禁。不过的不得写入DB/JSON, -# 必须触发重评修复。代码层面硬拦截,不依赖prompt或文档。 -# -# 规则列表 + 严重程度 + 修复建议 -STRATEGY_QUALITY_GATES = [ - { - "id": "GATE_LOSS_EXISTS", - "desc": "止损必须存在且>0", - "check": lambda d: (d.get("stop_loss") or 0) > 0, - "severity": "CRITICAL", - "fix": "调用 technical_analysis 计算支撑位设置止损" - }, - { - "id": "GATE_PROFIT_EXISTS", - "desc": "止盈必须存在且>0(纯自选股可放宽)", - "check": lambda d: (d.get("take_profit") or 0) > 0, - "severity": "CRITICAL", - "fix": "调用 technical_analysis 计算阻力位设置止盈目标" - }, - { - "id": "GATE_SL_GTE_LOW", - "desc": "止损必须 ≤ 买入区下沿", - "check": lambda d: (d.get("stop_loss") or 0) <= (d.get("entry_low") or 99999), - "severity": "HIGH", - "fix": "止损不能高于买入区,调整止损至买入区以下" - }, - { - "id": "GATE_ENTRY_RANGE", - "desc": "买入区下沿 < 上沿", - "check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0), - "severity": "CRITICAL", - "fix": "entry_low=现价×0.95, entry_high=现价×1.05 取近似区间" - }, - { - "id": "GATE_RR_COMPUTED", - "desc": "买入推荐必须含RR", - "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) > 0, - "severity": "HIGH", - "fix": "RR = (止盈-现价)/(现价-止损),数据齐全后自动算" - }, - { - "id": "GATE_RR_MINIMUM", - "desc": "买入推荐RR≥1.5(非买入信号跳过)", - "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) >= 1.5, - "severity": "HIGH", - "fix": "RR不足→signal降级为'信号不充分',不进推荐区" - }, - { - "id": "GATE_SIGNAL_SHORT", - "desc": "timing_signal 必须是短词(2-4字)", - "check": lambda d: len((d.get("timing_signal") or "").strip().split()) <= 4 and (d.get("timing_signal") or "") not in ("neutral", ""), - "severity": "MEDIUM", - "fix": "使用短词:买入/加仓/观望/持有/关注/信号不充分" - }, - { - "id": "GATE_TECH_SNAPSHOT", - "desc": "tech_snapshot 必须包含技术位数值", - "check": lambda d: bool(d.get("tech_snapshot")) and any(c in d["tech_snapshot"] for c in "支撑阻力压强"), - "severity": "MEDIUM", - "fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值" - }, - { - "id": "GATE_CURRENCY_SET", - "desc": "港股必须标 currency=HKD(个股存原币种,汇总时由calc_total_assets转CNY)", - "check": lambda d: not is_hk_stock(d.get("code","")) or d.get("currency") == "HKD", - "severity": "HIGH", - "fix": "设置 d['currency']='HKD'" - }, - # --- 第4条 CRITICAL 红线:9维交叉验证 (2026-07-02 Dad要求) --- - # 策略不能只有价格数字,必须有证据经过了多维分析: - # 横切面: 大盘+行业+个股 | 纵切面: 基本面+消息面+技术面+资金流 - # 代码层面可验证: sector_context(行业) + signal_factors(多因子) 或 tech_snapshot - { - "id": "GATE_9D_ANALYSIS", - "desc": "策略必须经过多维分析(sector_context + signal_factors)", - "check": lambda d: ( - bool(d.get("sector_context") and str(d.get("sector_context","")).strip() not in ("neutral","","N/A","-")) - and ( - bool(d.get("signal_factors") and isinstance(d.get("signal_factors"), (list,tuple)) and len(d["signal_factors"]) >= 1) - or bool(d.get("tech_snapshot") and any(c in str(d.get("tech_snapshot","")) for c in "支撑阻力压强")) - ) - ), - "severity": "CRITICAL", - "fix": "重新运行 reassess_with_context() 完整重评确保 sector_context/signal_factors/tech_snapshot 均已填充" - }, -] - - -def _hk_stock(code): - return bool(len(str(code)) == 5 and str(code)[0] in ('0','1')) - -def _is_buy_signal_str(signal): - """买入/加仓/建仓类信号""" - if not signal: - return False - return any(kw in signal for kw in ["买入", "加仓", "建仓"]) - -# _is_buy_signal is defined later in this file (~line 1240) - -def validate_strategy(d, debug=True): - """策略评审:硬性门禁检查 - - 返回 (passed: bool, failures: list) - 任一 CRITICAL 失败 → 拒绝写入,标记 TODO 触发重评 - 任一 HIGH 失败 → 标记 quality_check=failed,写入但不出现在推荐区 - MEDIUM 失败 → 记录但不拦截 - """ - failures = [] - for gate in STRATEGY_QUALITY_GATES: - try: - ok = gate["check"](d) - except Exception as e: - ok = False - if debug: - print(f" [VALIDATE] {gate['id']} 检查异常: {e}", flush=True) - if not ok: - failures.append(gate) - if debug: - print(f" [VALIDATE] ✗ {gate['id']} ({gate['severity']}): {gate['desc']}", flush=True) - - passed = all(f["severity"] != "CRITICAL" for f in failures) - - if debug: - criticals = [f for f in failures if f["severity"] == "CRITICAL"] - highs = [f for f in failures if f["severity"] == "HIGH"] - if passed: - print(f" [VALIDATE] ✅ 通过 ({len(failures)}条警告)" if failures else " [VALIDATE] ✅ 全通过", flush=True) - else: - print(f" [VALIDATE] ❌ {len(criticals)}条CRITICAL未通过 → 拒绝写入", flush=True) - - return passed, failures - - -def enforce_strategy_quality(code, name, result): - """策略写入前的强制质量门禁 - - 三段自动修复: - - Round 1: 技术分析(ta.full_analysis/chip_sr) - - Round 2: DB + 价格百分比推算 - - Round 3: 最低可用策略标记强推 - 3轮全不过 → review_needed - """ - price = result.get("price", 0) or result.get("current", 0) or result.get("last_price", 0) - code_str = str(code) - import sqlite3 # 本函数多处使用 - - def _db_sector(): - """从 DB 取行业名""" - try: - _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - r = _db.execute("SELECT sector_name FROM stock_sectors WHERE code=?", (code_str,)).fetchone() - _db.close() - return r[0] if r else None - except: - return None - - def _fix_one(gate_id, round_num): - """对单个门禁执行修复。round_num越大修复越激进。""" - if gate_id == "GATE_LOSS_EXISTS" and (result.get("stop_loss") or 0) <= 0: - if round_num <= 2: - # Round 1-2: 技术分析算支撑 - tech = ta.full_analysis(code) - if tech and "support_resistance" in tech: - sr = tech["support_resistance"] - ws = sr.get("weak_support") - ss = sr.get("strong_support") - if ws and ws > 0: - result["stop_loss"] = round(ws, 2) - elif ss and ss > 0: - result["stop_loss"] = round(ss, 2) - elif price > 0: - result["stop_loss"] = round(price * 0.95, 2) - elif price > 0: - result["stop_loss"] = round(price * 0.95, 2) - else: - # Round 3: 强制fallback - if price > 0: - result["stop_loss"] = round(price * 0.90, 2) # 更宽 - else: - result["stop_loss"] = 1 - print(f" R{round_num} 止损={result.get('stop_loss',0)}", flush=True) - - if gate_id == "GATE_PROFIT_EXISTS" and (result.get("take_profit") or 0) <= 0: - if round_num <= 2: - tech = ta.full_analysis(code) - if tech and "support_resistance" in tech: - sr = tech["support_resistance"] - wr = sr.get("weak_resist") - sr_resist = sr.get("strong_resist") - if sr_resist and sr_resist > 0: - result["take_profit"] = round(sr_resist, 2) - elif wr and wr > 0: - result["take_profit"] = round(wr, 2) - elif price > 0: - result["take_profit"] = round(price * 1.08, 2) - elif price > 0: - result["take_profit"] = round(price * 1.08, 2) - else: - if price > 0: - result["take_profit"] = round(price * 1.20, 2) # 更宽 - else: - # price=0 → 从DB或API获取 - try: - import sqlite3 as _s3 - _db = _s3.connect("/home/hmo/web-dashboard/data/mofin.db", timeout=5) - _r = _db.execute("SELECT price FROM holdings WHERE code=?", (code_str,)).fetchone() - _db.close() - if _r and _r[0] and _r[0] > 0: - result["take_profit"] = round(float(_r[0]) * 1.20, 2) - else: - result["take_profit"] = 2 # 真的兜底 - except: - result["take_profit"] = 2 - print(f" R{round_num} 止盈={result.get('take_profit',0)}", flush=True) - - if gate_id == "GATE_ENTRY_RANGE" and ((result.get("entry_low") or 0) >= (result.get("entry_high") or 0) or (result.get("entry_low") or 0) <= 0): - p = price or 100 - sl = result.get("stop_loss", 0) - tp = result.get("take_profit", 0) - if round_num <= 2: - if sl > 0 and tp > 0 and sl < tp: - result["entry_low"] = round(sl * 1.02, 2) - result["entry_high"] = round(tp * 0.85, 2) - if result["entry_low"] >= result["entry_high"]: - result["entry_low"] = round(p * 0.95, 2) - result["entry_high"] = round(p * 0.99, 2) - else: - result["entry_low"] = round(p * 0.93, 2) - result["entry_high"] = round(p * 1.02, 2) - else: - result["entry_low"] = round(p * 0.90, 2) - result["entry_high"] = round(p * 1.10, 2) - print(f" R{round_num} 买入区={result['entry_low']}~{result['entry_high']}", flush=True) - - if gate_id == "GATE_9D_ANALYSIS": - # 行业 - if not result.get("sector_context") or str(result.get("sector_context","")).strip() in ("neutral","","N/A","-"): - sec = _db_sector() - if sec: - result["sector_context"] = sec - elif round_num >= 2: - result["sector_context"] = f"自选(未分类)" - else: - result["sector_context"] = f"{name}所属行业(待补充)" - # signal_factors - if not result.get("signal_factors") or (isinstance(result.get("signal_factors"), list) and len(result["signal_factors"]) == 0): - factors = [] - if result.get("timing_signal"): - factors.append(f"信号:{result['timing_signal']}") - if result.get("rr_ratio", 0) > 0: - factors.append(f"RR:{result['rr_ratio']}") - if result.get("stop_loss", 0) > 0 and result.get("take_profit", 0) > 0: - factors.append(f"损{result['stop_loss']}盈{result['take_profit']}") - if not factors: - if round_num >= 2: - factors.append("自动填充") - else: - # Round 1: 留空等重检,不硬填 - pass - if factors: - result["signal_factors"] = factors - # tech_snapshot - if not result.get("tech_snapshot") or not any(c in str(result.get("tech_snapshot","")) for c in "支撑阻力压强"): - sl = result.get("stop_loss", 0) - tp = result.get("take_profit", 0) - if sl > 0 and tp > 0: - result["tech_snapshot"] = f"自动:损{sl}盈{tp}" - elif price: - result["tech_snapshot"] = f"自动:价{price}" - elif round_num >= 2: - result["tech_snapshot"] = "自动生成(未补全技术位)" - print(f" R{round_num} 9维分析: sector={result.get('sector_context','')[:20]} factors={result.get('signal_factors',[])}", flush=True) - - # 循环重试 - MAX_RETRIES = 3 - passed, failures = validate_strategy(result) - retry_count = 0 - - for _retry_num in range(1, MAX_RETRIES + 1): - if passed: - break - - critical_issues = [f["id"] for f in failures if f["severity"] == "CRITICAL"] - if not critical_issues: - # 没有CRITICAL了,只有HIGH/MEDIUM → 可以放行 - passed = True - break - - retry_count = _retry_num - print(f" [RETRY {retry_count}/{MAX_RETRIES}] {name}({code}) → 修复: {critical_issues}", flush=True) - - for gate_id in critical_issues: - _fix_one(gate_id, retry_count) - - # 重检 - passed, failures = validate_strategy(result) - - # --- 最终结果 --- - if passed: - print(f" ✅ {name}({code}) 质量门禁通过 ({retry_count}轮重试)", flush=True) - result["quality_check"] = "passed" - result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") - - # HIGH 级别警告(已通过但仍有非CRITICAL失败) - high_fails = [f for f in failures if f["severity"] == "HIGH"] - if high_fails: - result["quality_check"] = "warning" - result["quality_issues"] = {"high": [f["id"] for f in high_fails]} - print(f" ⚠️ {name}({code}) 有{len(high_fails)}条HIGH警告", flush=True) - - # 记录 changelog - if "critical_issues" in dir(): - cl = result.setdefault("changelog", []) - cl.append({ - "time": datetime.now().strftime("%Y-%m-%d %H:%M"), - "event": f"质量门禁通过 (重试{retry_count}轮)", - }) - - result["status"] = "active" - return True - else: - # 3轮全不过 → review_needed - remaining_critical = [f["id"] for f in failures if f["severity"] == "CRITICAL"] - result["quality_check"] = "failed" - result["quality_issues"] = { - "critical": remaining_critical, - "all": [f["id"] for f in failures], - } - result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") - result["status"] = "review_needed" - result["timing_signal"] = "信号不充分" - - cl = result.setdefault("changelog", []) - cl.append({ - "time": datetime.now().strftime("%Y-%m-%d %H:%M"), - "event": f"质量门禁3轮全拒 → review_needed ({remaining_critical})", - }) - - print(f" 🚫 {name}({code}) 3轮修复后仍有 {remaining_critical} → review_needed", flush=True) - return False - - -# is_hk_stock 已从 mo_models 导入(见文件头部 import),不再在此复写。 - - -def calc_atr(code, period=14): - """从腾讯API K线数据计算ATR(period),返回ATR值或None""" - try: - url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk{code},day,,,60,qfq" - req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=5).read().decode('utf-8') - data = json.loads(resp) - bars = data.get('data', {}).get(f'hk{code}', {}).get('day', []) - if len(bars) < period + 1: - return None - trs = [] - for i in range(1, min(len(bars), period + 1)): - try: - high = float(bars[i][2]) - low = float(bars[i][3]) - prev_close = float(bars[i-1][4]) if len(bars[i-1]) > 4 else float(bars[i-1][3]) - tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) - trs.append(tr) - except (ValueError, IndexError): - continue - if not trs: - return None - return round(sum(trs) / len(trs), 2) - except Exception: - return None - - -def calc_chip_sr(code, price): - """从筹码分布计算支撑/阻力位。 - - 返回: {"chip_ss": 筹码强支撑, "chip_sr": 筹码强阻力} 或 None - 筹码强支撑 = 当前价下方成交量最大的价格区间 - 筹码强阻力 = 当前价上方成交量最大的价格区间 - - 用法: - sr = calc_chip_sr("600519", 1193) - if sr: - print(f"筹码支撑{sr['chip_ss']} 筹码阻力{sr['chip_sr']}") - """ - if not price or price <= 0: - return None - try: - # 复用chip_factors的筹码分布构建 - import sys as _sys - _sys.path.insert(0, "/home/hmo/MoFin/scripts") - from chip_factors import ChipFactors - cf = ChipFactors() - chip = cf._build_chip_distribution(code) - if not chip: - return None - total = sum(chip.values()) - if total <= 0: - return None - # 2%区间聚合 - step = max(round(price * 0.02, 2), 1.0) - bins = {} - for p, v in chip.items(): - k = round(p / step) * step - bins[k] = bins.get(k, 0) + v - sb = sorted(bins.items()) - below = [(p, v) for p, v in sb if p < price] - above = [(p, v) for p, v in sb if p >= price] - if not below or not above: - return None - - # 支撑 = 下方成交量最大的密集区 - chip_ss = max(below, key=lambda x: x[1])[0] - # 阻力 = 上方成交量最大的密集区 - chip_sr = max(above, key=lambda x: x[1])[0] - - return {"chip_ss": chip_ss, "chip_sr": chip_sr} - except Exception as e: - print(f" ⚠️ 筹码S/R计算失败: {e}", file=sys.stderr) - return None - -# 提示词版本追踪 -try: - from prompt_manager.tracking import record_strategy_generation - HAS_PROMPT_TRACKING = True -except ImportError: - HAS_PROMPT_TRACKING = False - -def safe_json_load(path, default=None): - """安全加载 JSON,遇到坏数据自动修复""" - if not os.path.exists(path): - return default if default is not None else {} - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError: - # 使用 json_validator 增强修复(更健壮的错误处理) - with open(path, "r", encoding="utf-8") as f: - raw = f.read() - - try: - import sys - sys.path.insert(0, "/home/hmo/MoFin") - from json_validator import validate_json_syntax, _record_format_error - - score, errors, parsed = validate_json_syntax(raw) - if parsed is not None and score >= 60: - if errors: - _record_format_error(raw, errors, 0) - return parsed - - _record_format_error(raw, errors, 0) - print(f"[WARN] {path} JSON 自动修复失败 (score={score})", file=sys.stderr) - return default if default is not None else {} - except ImportError: - pass - - # 尝试修复:字符串内未转义的换行符,去多余括号 - fixed = raw - result = [] - in_str = False - for ch in fixed: - if ch == '"': - in_str = not in_str - result.append(ch) - elif in_str and ch in '\n\r': - result.append('\\n') - else: - result.append(ch) - fixed = ''.join(result) - fixed = fixed.rstrip('}') - if not fixed.endswith('}'): - fixed += '}' - try: - return json.loads(fixed) - except json.JSONDecodeError as e: - print(f"[WARN] {path} 自动修复失败: {e}", file=sys.stderr) - return default if default is not None else {} -KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md" -MACRO_CONTEXT_PATH = "/home/hmo/web-dashboard/data/macro_context.json" -MARKET_CONTEXT_PATH = "/home/hmo/web-dashboard/data/market.json" -STOCK_SECTOR_MAP_PATH = "/home/hmo/web-dashboard/data/stock_sector_map.json" - - -def load_stock_sector_map(): - """读取个股归属行业映射 - - stock_sector_map.json 格式: {code: [sector1, sector2, ...]} - 跳过 _note, _created_at 等元数据键。 - """ - # 优先从 SQLite 读取 - try: - from mofin_db import get_conn, query_sector_stocks - conn = get_conn() - # 从 stock_sectors 表反向构建 code→[sectors] 映射 - rows = conn.execute("SELECT code, sector_name FROM stock_sectors ORDER BY code").fetchall() - conn.close() - code_to_sectors = {} - for code, sector in rows: - if code not in code_to_sectors: - code_to_sectors[code] = [] - code_to_sectors[code].append(sector) - return code_to_sectors - except Exception: - pass - try: - with open(STOCK_SECTOR_MAP_PATH) as f: - data = json.load(f) - code_to_sectors = {} - for key, value in data.items(): - if key.startswith("_"): - continue - if isinstance(value, list): - code_to_sectors[key] = value - return code_to_sectors - except Exception: - return {} - - -def load_market_context(): - """读取市场上下文,优先 SQLite,回退 market.json""" - # 优先从 SQLite 读取 - try: - from mofin_db import get_conn, query_latest_market - conn = get_conn() - market = query_latest_market(conn) - conn.close() - if market and market.get("sectors"): - sector_perf = {} - for s in market["sectors"]: - name = s.get("name", "") - if name: - sector_perf[name] = { - "change": s.get("change_pct", 0), - "up_count": s.get("up_count", 0), - "down_count": s.get("down_count", 0), - "net_inflow": s.get("net_inflow", 0), - "lead_stock": s.get("lead_stock", ""), - "lead_stock_change": s.get("lead_stock_change", 0), - } - return { - "sector_perf": sector_perf, - "breadth": market.get("up_ratio", 50), - "mood": market.get("mood", "neutral"), - "top_gainers": {g["name"]: g["change_pct"] for g in market.get("top_gainers", [])}, - "top_losers": {g["name"]: g["change_pct"] for g in market.get("top_losers", [])}, - "total_sectors": len(market["sectors"]), - "market_timestamp": market.get("timestamp", ""), - } - except Exception: - pass - try: - with open(MARKET_CONTEXT_PATH) as f: - market = json.load(f) - sectors = market.get("sectors", []) - sector_perf = {} - for s in sectors: - name = s.get("name", "") - if name: - sector_perf[name] = { - "change": s.get("change", 0), - "up_count": s.get("up_count", 0), - "down_count": s.get("down_count", 0), - "net_inflow": s.get("net_inflow", 0), - "lead_stock": s.get("lead_stock", ""), - "lead_stock_change": s.get("lead_stock_change", 0), - } - top_gainers = {s.get("name", ""): s.get("change", 0) - for s in market.get("top_gainers", [])} - top_losers = {s.get("name", ""): s.get("change", 0) - for s in market.get("top_losers", [])} - return { - "sector_perf": sector_perf, - "breadth": market.get("up_ratio", 50), - "mood": market.get("mood", "neutral"), - "top_gainers": top_gainers, - "top_losers": top_losers, - "total_sectors": market.get("total_sectors", 0), - "market_timestamp": market.get("timestamp", ""), - } - except Exception: - return { - "sector_perf": {}, - "breadth": 50, - "mood": "neutral", - "top_gainers": {}, - "top_losers": {}, - "total_sectors": 0, - "market_timestamp": "", - } - - -def compute_sector_adjustment(code, market_ctx, stock_sector_map): - """根据个股所属行业的市场表现+小果情感,返回调整系数 - - 返回 dict: - stop_bias: 止损调整系数(<1.0收紧, >1.0放宽) - target_bias: 止盈调整系数 - note: 行业背景一句话 - sector_name: 匹配到的行业名称 - sector_change: 行业涨跌幅 - """ - # 默认无调整 - adj = {"stop_bias": 1.0, "target_bias": 1.0, "note": "", - "sector_name": "", "sector_change": 0} - - sectors_for_code = stock_sector_map.get(code, []) - if not sectors_for_code: - return adj - - sector_perf = market_ctx.get("sector_perf", {}) - breadth = market_ctx.get("breadth", 50) - - # 找第一个能匹配到的行业 - for sec in sectors_for_code: - if sec in sector_perf: - perf = sector_perf[sec] - chg = perf.get("change", 0) - adj["sector_name"] = sec - adj["sector_change"] = chg - - # 行业暴跌 > 3% - if chg <= -3: - adj["stop_bias"] = 0.92 # 止损收紧8% - adj["target_bias"] = 0.90 # 止盈下调10% - adj["note"] = f"行业{sec}大跌{chg:+.1f}%,收紧止损" - # 行业大跌 1~3% - elif chg <= -1: - adj["stop_bias"] = 0.96 - adj["target_bias"] = 0.95 - adj["note"] = f"行业{sec}下跌{chg:+.1f}%,适度防御" - # 行业大涨 > 3% - elif chg >= 3: - adj["stop_bias"] = 1.05 # 止损放宽5%(给趋势空间) - adj["target_bias"] = 1.03 - adj["note"] = f"行业{sec}大涨{chg:+.1f}%,可适度积极" - # 行业上涨 1~3% - elif chg >= 1: - adj["stop_bias"] = 1.02 - adj["note"] = f"行业{sec}上涨{chg:+.1f}%,正常" - else: - adj["note"] = f"行业{sec}{chg:+.1f}%,中性" - break - # 尝试处理命名差异:market.json中的行业名可能多了"板块"后缀 - for market_sec_name in sector_perf: - if sec in market_sec_name or market_sec_name in sec: - perf = sector_perf[market_sec_name] - chg = perf.get("change", 0) - adj["sector_name"] = market_sec_name - adj["sector_change"] = chg - if chg <= -3: - adj["stop_bias"] = 0.92 - adj["target_bias"] = 0.90 - adj["note"] = f"行业{market_sec_name}大跌{chg:+.1f}%,收紧止损" - elif chg <= -1: - adj["stop_bias"] = 0.96 - adj["target_bias"] = 0.95 - adj["note"] = f"行业{market_sec_name}下跌{chg:+.1f}%,适度防御" - elif chg >= 3: - adj["stop_bias"] = 1.05 - adj["target_bias"] = 1.03 - adj["note"] = f"行业{market_sec_name}大涨{chg:+.1f}%,可适度积极" - elif chg >= 1: - adj["stop_bias"] = 1.02 - adj["note"] = f"行业{market_sec_name}上涨{chg:+.1f}%,正常" - else: - adj["note"] = f"行业{market_sec_name}{chg:+.1f}%,中性" - break - - # 如果breath<30% (大盘极弱),再加一层收紧 - if breadth < 30: - adj["stop_bias"] *= 0.97 # 再收紧3% - breadth_note = "大盘仅{}%个股上涨".format(int(breadth)) - adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note - elif breadth < 40: - adj["stop_bias"] *= 0.99 - breadth_note = "大盘偏弱({}%上涨)".format(int(breadth)) - adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note - - # 小果情感约束:利空置信度>80%时收紧止损 - try: - xiaoguo_path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" - if os.path.exists(xiaoguo_path): - xg = json.load(open(xiaoguo_path)) - stock_sentiment = xg.get("stocks", {}).get(code, {}) - if stock_sentiment: - sentiment = stock_sentiment.get("sentiment", "") - confidence = stock_sentiment.get("confidence", 0) - summary = stock_sentiment.get("summary", "") - if sentiment == "negative" and confidence > 0.8: - adj["stop_bias"] = min(adj["stop_bias"], 0.95) - adj["note"] += f" | 小果利空{confidence:.0%}:{summary[:30]}" - except Exception: - pass - - return adj - - -def load_macro_context(): - """读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON""" - try: - from mofin_db import get_conn - conn = get_conn() - row = conn.execute( - "SELECT indices, structure FROM macro_context_log " - "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" - ).fetchone() - conn.close() - if row: - indices = json.loads(row[0]) if row[0] else {} - structure = json.loads(row[1]) if row[1] else {} - overall = structure.get("overall", "neutral") - desc = structure.get("description", "") - else: - raise ValueError("no db data") - except Exception: - try: - with open(MACRO_CONTEXT_PATH) as f: - ctx = json.load(f) - overall = ctx.get("structure", {}).get("overall", "neutral") - desc = ctx.get("structure", {}).get("description", "") - except Exception: - return 1.0, "宏观未加载" - if "bearish" in overall: - return 0.8, f"宏观{desc}" - elif overall == "bullish": - return 1.05, f"宏观{desc}" - elif overall == "strong_bullish": - return 1.1, f"宏观{desc}" - else: - return 1.0, f"宏观{desc}" - - -def batch_fetch_prices(codes): - """获取实时价格。优先从 DB 读取(price_monitor 每 2 分钟更新),失败才拉腾讯 API。""" - if not codes: - return {} - - all_results = {} - - # 主通道:从 DB 读取(price_monitor 唯一价格入口) - try: - from mofin_db import get_conn - db = get_conn() - for raw_code in codes: - raw_code = str(raw_code).split('_')[0] - if not raw_code: continue - row = db.execute( - "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (raw_code,) - ).fetchone() - if not row: - row = db.execute( - "SELECT price, change_pct FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,) - ).fetchone() - if row and row['price']: - all_results[raw_code] = { - "price": row['price'], - "close": row['price'], # 用现价近似昨收,仅用于sentiment计算 - "high": row['price'], - "low": row['price'], - "code": raw_code, - } - db.close() - if all_results: - return all_results - except Exception: - pass - - # Fallback: 腾讯 API(仅当 DB 无数据时) - batch_size = 15 - for batch_start in range(0, len(codes), batch_size): - batch = codes[batch_start:batch_start + batch_size] - symbols = [] - code_map = {} - for raw_code in batch: - raw_code = str(raw_code).split('_')[0] - if not raw_code: - continue - if len(raw_code) == 5 and raw_code.isdigit(): - prefix = "hk" - elif raw_code.startswith(("6", "5")): - prefix = "sh" - else: - prefix = "sz" - sym = f"{prefix}{raw_code}" - symbols.append(sym) - code_map[sym] = raw_code - if not symbols: - continue - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - max_retries = 2 - for attempt in range(max_retries + 1): - try: - r = urllib.request.urlopen(url, timeout=10) - text = r.read().decode("gbk") - except Exception as e: - if attempt < max_retries: - continue - print(f" batch_fetch_prices error: {e}", file=sys.stderr) - continue - - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - sym = line.split("=", 1)[0].strip().lstrip("v_") - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 35: - continue - orig_code = code_map.get(sym) - if not orig_code: - continue - def f(i): - try: - return float(fields[i]) if fields[i].strip() else 0.0 - except: - return 0.0 - price_raw = f(3) - # 港股:腾讯 API 返回 HKD,需转 CNY - if is_hk_stock(orig_code) and price_raw > 0: - price_raw = to_cny(price_raw) - all_results[orig_code] = { - "price": price_raw, "close": f(4), "high": f(33), "low": f(34), - "code": orig_code, - } - except Exception: - continue - break # Success - break retry loop - - return all_results - - -def get_price_tencent(code): - """获取实时价格。优先 DB(price_monitor 维护),失败才拉腾讯。港股价格已是 CNY。""" - raw_code = str(code).split('_')[0] - if not raw_code: - return None - - # 主通道: DB - try: - from mofin_db import get_conn - db = get_conn() - row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (raw_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", (raw_code,)).fetchone() - if row and row['price']: - db.close() - return row['price'] - db.close() - except Exception: - pass - - # Fallback: 腾讯 API - try: - from mo_models import to_cny, is_hk_stock - except ImportError: - to_cny = lambda v, r=None: v - is_hk_stock = lambda c: len(str(c).strip()) == 5 and str(c).strip().isdigit() - try: - if is_hk_stock(raw_code): - prefix = "hk" - elif raw_code.startswith("6") or raw_code.startswith("5"): - prefix = "sh" - else: - prefix = "sz" - url = f"http://qt.gtimg.cn/q={prefix}{raw_code}" - r = urllib.request.urlopen(url, timeout=5) - fields = r.read().decode("gbk").split('"')[1].split("~") - def f(i): - try: - return float(fields[i]) if fields[i].strip() else 0.0 - except: - return 0.0 - price = f(3) - if is_hk_stock(raw_code) and price > 0: - price = to_cny(price) - return { - "price": price, "close": f(4), "high": f(33), "low": f(34), - "code": raw_code, - } - except Exception as e: - print(f" get_price error {code}: {e}", file=sys.stderr) - return None - - -def reassess_strategy(code, name, price, cost, shares, current_action, - volume_signal="", sentiment="neutral", - is_watchlist=False): - """根据技术分析重评策略""" - - tech = ta.full_analysis(code) - if tech and "support_resistance" in tech: - sr = tech["support_resistance"] - candle = tech.get("candlestick", {}) - vol = tech.get("volume", {}) - ss = sr.get("strong_support") - ws = sr.get("weak_support") - wr = sr.get("weak_resist") - sr_resist = sr.get("strong_resist") - pivot = sr.get("pivot") - effective_range = sr.get("effective_range") - print(f" TECH: 强撑={ss} 弱撑={ws} 枢轴={pivot} 弱压={wr} 强压={sr_resist} 有效区间={effective_range}") - else: - print(f" ⚠️ 技术分析不可用", file=sys.stderr) - ss = ws = wr = sr_resist = pivot = None - candle = {} - vol = {} - - # ----- 多周期技术分析(周线/月线/均线) ----- - mtf_analysis = {} - mtf_adj = {} - try: - mtf_result = mtf.full_multi_tf_analysis(code) - if mtf_result.get("daily") and mtf_result["daily"].get("count", 0) >= 5: - mtf_analysis = mtf_result - mtf_adj = mtf_result.get("strategy_adjustment", {}) - daily_mas = mtf_result.get("daily", {}).get("mas", {}) - weekly = mtf_result.get("weekly", {}) - monthly = mtf_result.get("monthly", {}) - trend_align = mtf_adj.get("trend_alignment", "未知") - print(f" 多周期: {trend_align} | " - f"MA5={daily_mas.get('ma5','?')} MA20={daily_mas.get('ma20','?')} MA60={daily_mas.get('ma60','?')} | " - f"周线{weekly.get('trend',{}).get('description','?')} 月线{monthly.get('trend',{}).get('description','?')}") - except Exception as e: - print(f" 多周期分析失败: {e}", file=sys.stderr) - - # ----- 筹码分布支撑/阻力(中长线参考,加情景权重) ----- - chip_sr = None - chip_weight = 0.5 # 默认中等权重 - regime = detect_scenario() - regime_id = regime.get("id", "weak_consolidation") - - # 情景决定筹码因子权重 - if regime_id == "weak_consolidation": - chip_weight = 0.9 # 震荡市筹码最准 - elif regime_id == "bullish_recovery": - chip_weight = 0.4 # 上涨趋势筹码阻力可能被突破 - elif regime_id == "sharp_decline": - chip_weight = 0.2 # 急跌中筹码支撑可能失效 - elif regime_id == "sector_rotation": - chip_weight = 0.6 # 轮动市中筹码有一定参考 - - try: - chip_sr = calc_chip_sr(code, price) - if chip_sr: - print(f" 筹码: 撑={chip_sr['chip_ss']:.0f} 阻={chip_sr['chip_sr']:.0f} | 情景={regime_id} 权重={chip_weight:.1f}") - # 与枢轴点对比 - if ss and ws and pivot and chip_sr['chip_ss'] > 0 and chip_sr['chip_sr'] > 0: - chip_ss_pct = (price - chip_sr['chip_ss']) / price * 100 - chip_sr_pct = (chip_sr['chip_sr'] - price) / price * 100 - - # 共振检测:筹码支撑 vs 枢轴弱支撑(都是最近支撑位) - if ws and ws > 0: - resonance_ss = abs(chip_ss_pct - ((price - ws) / price * 100)) < 3 - else: - resonance_ss = False - # 共振检测:筹码阻力 vs 枢轴弱阻力(都是最近阻力位) - if wr and wr > 0: - resonance_sr = abs(chip_sr_pct - ((wr - price) / price * 100)) < 3 - else: - resonance_sr = False - - if resonance_ss and chip_weight >= 0.5: - print(f" ⚡ 支撑共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_ss']:.0f}") - elif chip_weight < 0.5: - print(f" 📎 支撑一致但权重低({chip_weight:.1f}): {regime_id}下筹码支撑不可靠") - - if resonance_sr and chip_weight >= 0.5: - print(f" ⚡ 阻力共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_sr']:.0f}") - elif chip_weight < 0.5: - print(f" 📎 阻力一致但权重低({chip_weight:.1f}): {regime_id}下筹码阻力不可靠") - except Exception: - pass - - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_new_entry = (cost == 0) or (shares == 0) - is_deep_loss = profit_pct < -20 - - # ----- 股票分类(短炒/中短线/中长线/弱势/深套) ----- - stock_category = "中短线" - time_horizon = "2周~3月" - position_advice = "中等仓位" - try: - mtf_cache = mtf._load_mtf_cache() - stock_data = mtf_cache.get(code, {}) - daily_klines = stock_data.get("daily", []) - fund = stock_data.get("fundamentals", {}) - closes = [d["close"] for d in daily_klines] if daily_klines else [] - - if len(closes) >= 10: - cur = closes[-1] - ma20 = sum(closes[-20:])/20 if len(closes)>=20 else 0 - ma60 = sum(closes[-60:])/60 if len(closes)>=60 else 0 - highs = [d["high"] for d in daily_klines[-20:]] - lows = [d["low"] for d in daily_klines[-20:]] - volatility = ((max(highs)-min(lows))/min(lows)*100) if min(lows)>0 else 0 - pe = fund.get("pe") or 0 - eps = fund.get("eps") or 0 - mcap = fund.get("mcap_total") or 0 - is_high_vol = volatility > 30 - is_high_pe = pe > 100 or pe < 0 - is_value = 0 < pe < 20 and eps > 0.5 - - if is_deep_loss: - stock_category = "深套" - time_horizon = "长期" - position_advice = "不补不割" - elif is_high_vol and is_high_pe: - stock_category = "短炒" - time_horizon = "数日~2周" - position_advice = "小仓快进快出" - elif cur < ma20 and cur < ma60 and ma20 > 0: - stock_category = "弱势" - time_horizon = "观望" - position_advice = "减仓或观望" - elif (is_value or mcap > 1000) and cur > ma20: - stock_category = "中长线" - time_horizon = "数月~1年" - position_advice = "正常配置" - elif volatility > 20: - stock_category = "中短线" - time_horizon = "2~6周" - position_advice = "中等仓位" - except Exception: - pass - - print(f" 分类: {stock_category} | {time_horizon} | {position_advice}") - - # ----- 短炒+强趋势检测:短炒分类但多周期多头时用移动止损代替弱支撑止损 ----- - is_short_term_strong_trend = False - if stock_category == "短炒": - trend_align = mtf_adj.get("trend_alignment", "") - strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] - if any(ind in trend_align for ind in strong_trend_indicators): - is_short_term_strong_trend = True - print(f" ⚡ 短炒+强趋势检测: 趋势={trend_align} → 启用移动止损, 不止盈") - position_advice = "小仓强趋势让利润跑" - - # ----- 止损设置(含最小距离3%保护) ----- - if is_new_entry: - # 新买入推荐:止损 = 弱支撑(约2-3%跌幅,合理可控) - if ws and ws > 0: - new_stop = round(ws, 2) - else: - new_stop = round(price * 0.96, 2) - elif is_deep_loss: - # 深套:止损 = 强支撑再下移(不轻易割) - if ss and ss > 0: - new_stop = round(min(ss, price * 0.85), 2) - else: - new_stop = round(price * 0.85, 2) - else: - # 已持仓正常:止损 = 强支撑 - if is_short_term_strong_trend: - # 短炒+强趋势:用移动止损(距现价-5%),不止盈让利润跑 - trailing_sl = round(max(ws or 0, price * 0.95), 2) if ws else round(price * 0.95, 2) - new_stop = trailing_sl - print(f" 短炒强趋势移动止损: {new_stop} (距现价-{(1-new_stop/price)*100:.1f}%)") - elif ss and ss > 0: - new_stop = round(ss, 2) - else: - new_stop = round(price * 0.88, 2) - - # 已盈利仓位(>5%):用较紧的移动止损保护利润,但不超过成本线 - if profit_pct > 5 and not is_new_entry and not is_deep_loss: - # 取 max(弱支撑, 成本线, 当前价×0.95) 作为止损 - cost_protect = cost if cost > 0 else 0 - trailing_stop = round(max(ws or 0, cost_protect, price * 0.95), 2) - if trailing_stop > new_stop: - new_stop = trailing_stop - print(f" 已启用移动止损: {new_stop} (保护+{profit_pct:.1f}%利润)", file=sys.stderr) - - # 最小止损距离 —— 随趋势强度调整(2026-06-23 震度保护规则) - # 强趋势(多周期看多 + MA多头排列):最小1.5%下行空间 - # 普通/弱势:最小3%下行空间 - is_strong_trend = False - trend_align = mtf_adj.get("trend_alignment", "") - strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] - try: - if any(ind in trend_align for ind in strong_trend_indicators) and ma20 > ma60 and cur >= ma20: - is_strong_trend = True - except (NameError, TypeError): - pass # ma20/ma60/cur may be unbound if MTF data insufficient - - if is_strong_trend: - min_stop_gap = 0.015 # 1.5% - else: - min_stop_gap = 0.03 # 3% - - min_stop = round(price * (1 - min_stop_gap), 2) - if new_stop > min_stop and not is_deep_loss: - old_stop = new_stop - new_stop = min_stop - if old_stop != new_stop: - print(f" 最小止损 {round(min_stop_gap*100)}%间距约束: {old_stop}→{new_stop} (趋势{'强' if is_strong_trend else '普通'})") - - # 港股附加:ATR波动率校验 — 止损距现价不得小于 1×ATR(14) - if is_hk_stock(code): - atr = calc_atr(code) - if atr and atr > 0: - min_atr_stop = round(price - atr, 2) - if new_stop > min_atr_stop: - old_stop_val = new_stop - new_stop = min_atr_stop - print(f" 港股ATR波动率校验({atr:.2f}): 止损 {old_stop_val}→{new_stop} (1×ATR间距)") - - # ----- 止盈设置 ----- - if is_short_term_strong_trend and not is_new_entry: - # 短炒+强趋势:不止盈让利润跑 - mtf_tp = mtf_adj.get("take_profit_reference", {}) - if mtf_tp and mtf_tp.get("level", 0) > price * 1.2: - new_target = round(mtf_tp["level"], 2) - else: - new_target = 0 # 无多周期阻力时不编造止盈 - print(f" 短炒强趋势不止盈: 止盈设为{new_target} (+{(new_target/price-1)*100:.0f}%)") - elif sr_resist and sr_resist > 0: - new_target = round(sr_resist, 2) - else: - new_target = 0 # 无技术面数据时不编造止盈 - - # ----- 风险回报比校验 ----- - stop_distance = price - new_stop if price > new_stop else price * 0.02 - target_distance = new_target - price if new_target > price else 0 - - # 1:2 检查 - min_target_distance = stop_distance * 2.0 - if target_distance < min_target_distance: - # 尝试更高的阻力位,但不超过下一个真实压力位 - candidate_targets = [] - if wr and wr > price and wr != sr_resist: - candidate_targets.append(wr) - if sr_resist and sr_resist > price: - candidate_targets.append(sr_resist) - # 检查有效区间,如果有更高的自然目标位 - if effective_range and price < effective_range * 0.9: - candidate_targets.append(effective_range) - - found = False - for level in candidate_targets: - if (level - price) >= min_target_distance: - new_target = level - found = True - break - - # 如果仍然不满足,检查是否至少能到 1:1.5 - min15_distance = stop_distance * 1.5 - if not found: - for level in candidate_targets: - if (level - price) >= min15_distance: - new_target = level - found = True - break - - # ----- 风险回报比最终计算 ----- - risk = max(price - new_stop, price * 0.01) - reward = max(new_target - price, 0) - rr_ratio = reward / risk if risk > 0 else 0 - - # ----- 状态判断 ----- - if is_deep_loss: - status = "updated" - action_note = "深套持有" - elif is_new_entry: - if rr_ratio < 1.5: - status = "review" - action_note = "⚠️盈亏比不足1:1.5,不建议买入" - elif rr_ratio < 2.0: - status = "updated" - action_note = "⚠️盈亏比偏低(1:{:.1f}),谨慎买入".format(rr_ratio) - else: - status = "updated" - action_note = "" - else: - if rr_ratio < 0.5: - status = "updated" - action_note = "⚠️盈亏比极低,关注" - elif rr_ratio < 1.5: - status = "updated" - action_note = "⚠️盈亏比偏低(1:{:.1f}),不建议加仓".format(rr_ratio) - else: - status = "updated" - action_note = "" - - # 短炒+强趋势:在action_note追加标记 - if is_short_term_strong_trend and not is_new_entry and not is_deep_loss: - extra_note = "短炒强趋势持" if "深套" not in action_note else "" - if extra_note: - action_note = f"{action_note} | {extra_note}" if action_note else extra_note - - # ----- 买入区间(有盈亏比严格约束) ----- - max_acceptable_entry = None # 最大可接受买入价(满足R/R约束) - - if new_target and new_stop and new_target > new_stop and not is_deep_loss: - # 买入价的R/R约束: - # 要求 (target - entry) / (entry - stop) >= min_rr - # 即 entry <= (target + min_rr * stop) / (1 + min_rr) - min_rr = 1.0 # 至少1:1,才不亏 - recommend_rr = 1.5 # 推荐1:1.5以上 - - max_for_recommend = (new_target + recommend_rr * new_stop) / (1 + recommend_rr) - max_for_neutral = (new_target + min_rr * new_stop) / (1 + min_rr) - - if is_new_entry: - # 新买入:要求1:1.5+ - max_acceptable_entry = max_for_recommend - else: - # 已持仓加仓:至少1:1 - max_acceptable_entry = max_for_neutral - - if is_new_entry: - # 新买入:买入区 = 弱支撑附近(不是当前价附近!) - # 只在价格跌到弱支撑附近时才推买入 - entry_low = round(price * 0.98, 2) - entry_high = round(price * 1.02, 2) - if max_acceptable_entry and entry_high > max_acceptable_entry: - entry_high = round(max_acceptable_entry, 2) - # 确保买入区不小于1% - if entry_high - entry_low < price * 0.01: - if max_acceptable_entry and price <= max_acceptable_entry: - entry_low = round(max(price * 0.99, new_stop), 2) - entry_high = round(min(price * 1.01, max_acceptable_entry), 2) - elif ws and ws > 0 and wr and wr > 0 and not is_deep_loss: - # 已持仓正常:买入区 = 弱支撑~弱支撑上方5%(给合理回调空间) - # 上限不能低于成本价×0.95(保护已有持仓不被高位逼空) - entry_low = round(ws, 2) - entry_max = round(ws * 1.05, 2) # 比弱支撑高5%,有足够空间 - # 如果当前价已远离买入区,保持买入区不变(不因价格涨了就收窄) - min_upper = round(cost * 0.95, 2) if cost > 0 else 0 - if entry_max < min_upper: - entry_max = min_upper - if max_acceptable_entry: - entry_high = round(min(entry_max, max_acceptable_entry), 2) - else: - entry_high = entry_max - # 如果当前价已远离买入区(高于买入区上沿),禁止加仓推荐 - if price > entry_high: - # 买入区锁定在弱支撑位,但标记为"价格远离" - pass - # 如果买入区过窄,标记但不扩展(加仓必须在支撑位) - if entry_high - entry_low < price * 0.005: - entry_low = round(ws * 0.995, 2) - entry_high = round(ws * 1.005, 2) - else: - entry_low = round(price * 0.90, 2) - entry_high = round(price * 1.05, 2) - - # 买入区间稳定性保护:上边界单次变动不超过5% - if 'entry_high' in dir() and entry_high: - # 读取当前策略中已有的买入区上界,如果有且变化过大则限制 - old_entry_high = None - if 'current_action' in dir() and current_action: - import re - m = re.search(r'买入区[\d.]+~([\d.]+)', current_action) - if m: - old_entry_high = float(m.group(1)) - if old_entry_high and old_entry_high > 0: - max_change = old_entry_high * 0.95 # 单次最多下降5% - if entry_high < max_change: - entry_high = round(max_change, 2) - - # ----- 买入时机信号(三维分析:大盘+行业+个股,基本面+消息面+技术面+资金流)----- - # [2026-07-01] 扩展:不再只看volume_signal + candlestick_sentiment - # 融合大盘趋势、行业板块强弱、基本面估值作为修正因子 - volume_signal = vol.get("volume_signal", "") - candlestick_sentiment = candle.get("sentiment", "neutral") - timing_signal = "neutral" - - # --- 三维分析数据装载 --- - # 因子1: 大盘环境(从macro_context_log读) - market_bearish = False - market_bullish = False - try: - import sqlite3 - _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - _mc = _db.execute( - "SELECT structure FROM macro_context_log WHERE has_valid_data=1 ORDER BY rowid DESC LIMIT 1" - ).fetchone() - if _mc and _mc[0]: - _s = json.loads(_mc[0]) - _overall = _s.get("overall", "") - if "bearish" in _overall: - market_bearish = True - elif _overall == "bullish": - market_bullish = True - _db.close() - except Exception: - pass - - # 因子2: 行业板块强弱 - sector_strong = False - sector_weak = False - try: - _db2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - _rows2 = _db2.execute( - "SELECT name, change_pct FROM sector_snapshots ORDER BY change_pct DESC" - ).fetchall() - if _rows2: - # 找到该股所属行业(简单匹配name或通过stock_sectors) - _my_sectors = _db2.execute( - "SELECT sector_name FROM stock_sectors WHERE code=?", - (code,) - ).fetchall() - if _my_sectors: - for (_sn,) in _my_sectors: - for r_name, r_chg in _rows2: - if _sn in r_name or r_name in _sn: - _rank = [r[0] for r in _rows2].index(r_name) if r_name in [x[0] for x in _rows2] else -1 - _total = len(_rows2) - if _rank >= 0: - if _rank < _total * 0.2: - sector_strong = True - if _rank > _total * 0.8: - sector_weak = True - break - _db2.close() - except Exception: - pass - - # 因子3: 基本面估值 - is_value_stock = False - try: - _db3 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - _fd = _db3.execute( - "SELECT pe, eps FROM stock_fundamentals WHERE code=?", (code,) - ).fetchone() - if _fd: - _pe, _eps = _fd - is_value_stock = (0 < (_pe or 0) < 25 and (_eps or 0) > 0.3) - _db3.close() - except Exception: - pass - - # --- 三维修正规则 --- - # 大盘偏弱时收紧买入信号,大盘偏强时放宽 - # 行业领先加分,行业落后减分 - # 低估值加分(有安全边际) - - def _adjust_timing(signal, market_b, market_bb, sec_s, sec_w, is_val): - """根据三维因子修正 timing_signal""" - # 大盘偏弱时降级买入信号 - if market_b: - if signal in ("买入", "加仓"): - if not sec_s: # 大盘弱+行业不强→降级 - return "关注" - # 大盘偏强时放宽 - if market_bb: - if signal == "关注" and (sec_s or is_val): - return "买入" - # 行业弱势时降级买入信号 - if sec_w: - if signal in ("买入", "加仓"): - return "关注" - # 行业强势+低估时升级关注 - if sec_s and is_val: - if signal == "关注": - return "买入" - return signal - - if is_new_entry: - # 新买入时机 - if volume_signal == "主动买盘占优" and candlestick_sentiment == "bullish": - timing_signal = "买入" - elif volume_signal == "主动卖盘占优": - timing_signal = "观望" - elif volume_signal == "买卖均衡" and ws and price <= ws * 1.03: - timing_signal = "买入" - elif candlestick_sentiment == "bullish": - timing_signal = "买入" - elif ws and price < ws * 1.02: - timing_signal = "关注" - # 新买入时三维修正:大盘向上+行业强→升级,大盘弱→降级 - _pre_signal = timing_signal - timing_signal = _adjust_timing(timing_signal, market_bearish, market_bullish, - sector_strong, sector_weak, is_value_stock) - if timing_signal != _pre_signal: - print(f" 三维修正(新入): {_pre_signal}→{timing_signal} " - f"| 大盘{'弱' if market_bearish else '强' if market_bullish else '中性'}" - f"| 行业{'强' if sector_strong else '弱' if sector_weak else '中性'}" - f"| 估值{'低' if is_value_stock else '一般'}") - else: - # 已持仓时机(用于加仓/减仓参考) - if is_short_term_strong_trend: - # 短炒+强趋势:强趋势持有,禁止加仓信号 - timing_signal = "持有" - elif profit_pct > 5: - # 已盈利 - if volume_signal == "主动买盘占优": - timing_signal = "持有" - elif volume_signal == "主动卖盘占优" and not is_new_entry: - timing_signal = "关注" - else: - timing_signal = "持有" - elif profit_pct > 0: - # 微盈 - if volume_signal == "主动买盘占优": - timing_signal = "持有" - elif ws and price <= ws * 1.02: - timing_signal = "加仓" - else: - timing_signal = "持有" - else: - # 浮亏 - if volume_signal == "主动卖盘占优" and ss and price <= ss * 1.03: - timing_signal = "关注" - elif volume_signal == "主动买盘占优" and sr_resist and price >= sr_resist * 0.97: - timing_signal = "关注" - elif volume_signal == "买卖均衡" and ws and price <= ws * 1.02: - timing_signal = "加仓" - else: - timing_signal = "持有" - - # ----- 【v3.2新增】分类约束:弱势/深套禁止输出买入/加仓类信号 ----- - if stock_category == "弱势" or is_deep_loss: - buy_signals = ["买入", "加仓", "可追"] - if any(s in timing_signal for s in buy_signals): - old_signal = timing_signal - timing_signal = "弱势持有" if stock_category == "弱势" else "深套持有" - print(f" 分类约束: {stock_category} 原信号\"{old_signal}\" → \"{timing_signal}\"") - - # ----- 构造 action 描述(供 cron prompt 使用) ----- - action_parts = [] - if profit_pct < -20: - action_parts.append("深套持有") - elif profit_pct < -10: - action_parts.append("持有观察") - elif profit_pct < 0: - action_parts.append("持有观察") - elif profit_pct < 5: - action_parts.append("盈利持有") - else: - action_parts.append("盈利良好") - - if action_note: - action_parts.append(action_note) - - if is_watchlist: - # 自选股(未入场):有止损参考+买入区,内部算RR需要止盈位 - action_parts.append(f"目标参考{new_target}") - action_parts.append(f"止损参考{new_stop}") - action_parts.append(f"买入区{entry_low}~{entry_high}") - elif is_new_entry: - action_parts.append(f"损{new_stop}") - action_parts.append(f"盈{new_target}") - action_parts.append(f"买{entry_low}~{entry_high}") - else: - action_parts.append(f"止损{new_stop}") - action_parts.append(f"目标{new_target}") - action_parts.append(f"买入区{entry_low}~{entry_high}") - - if timing_signal != "neutral": - action_parts.append(f"信号:{timing_signal}") - - new_action = " | ".join(action_parts) - - # 技术面快照 - tech_snapshot = "" - if candle: - tech_snapshot = (f"形态:{candle.get('pattern','?')}/{candle.get('sentiment','?')} " - f"量价:{vol.get('volume_signal','?')} " - f"强撑:{ss} 弱撑:{ws} 弱压:{wr} 强压:{sr_resist}") - # 加入均线信息(如果可用) - try: - dm = mtf_analysis.get("daily", {}).get("mas", {}) - ma_parts = [] - for m in ['ma5', 'ma10', 'ma20', 'ma60']: - v = dm.get(m) - if v: - ma_parts.append(f"{m.upper()}={v}") - if ma_parts: - tech_snapshot += " | " + " ".join(ma_parts) - except (NameError, AttributeError): - pass - - # 多周期快照(追加到 tech_snapshot) - mtf_context = "" - if mtf_adj: - trend_align = mtf_adj.get("trend_alignment", "") - daily_mas = mtf_analysis.get("daily", {}).get("mas", {}) - ma20 = daily_mas.get("ma20") - ma60 = daily_mas.get("ma60") - stop_ref = mtf_adj.get("stop_loss_reference", {}) - take_ref = mtf_adj.get("take_profit_reference", {}) - - parts = [] - if trend_align: - parts.append(trend_align) - if ma20: - parts.append(f"MA20={ma20}") - if ma60: - parts.append(f"MA60={ma60}") - if stop_ref: - parts.append(f"长撑:{stop_ref.get('source','?')}={stop_ref['level']}") - if take_ref: - parts.append(f"长压:{take_ref.get('source','?')}={take_ref['level']}") - mtf_context = " | ".join(parts) - - now_str = datetime.now().strftime('%Y-%m-%d %H:%M') - return { - 'price': price, # 2026-07-07 修复:加入price供质量门禁使用 - 'stop_loss': new_stop, - 'take_profit': new_target, - 'entry_low': entry_low, - 'entry_high': entry_high, - 'action': new_action, - 'status': status, - 'tech_snapshot': tech_snapshot, - 'timing_signal': timing_signal, - 'rr_ratio': round(rr_ratio, 2), - 'action_note': action_note, - 'reassessed_at': now_str, - 'multi_tf_context': mtf_context, # 多周期上下文 - 'stock_category': stock_category, # 股票分类:短炒/中短线/中长线/弱势/深套 - 'time_horizon': time_horizon, # 时间跨度 - 'position_advice': position_advice, # 仓位建议 - } - - -def load_stock_news_sentiment(code): - """加载小果消息面情感""" - try: - path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" - if not os.path.exists(path): - return {} - xg = json.load(open(path)) - return xg.get("stocks", {}).get(code, {}) - except Exception: - return {} - - -def load_fundamentals(code): - """加载个股基本面""" - try: - cache = mtf._load_mtf_cache() - return cache.get(code, {}).get("fundamentals", {}) or {} - except Exception: - return {} - - -def _get_portfolio_risk_state(): - """读取 portfolio 组合风险状态(2026-06-23 引擎协调)""" - try: - # 数据一致性检查:警告多副本(2026-06-23 bugfix) - _check_portfolio_consistency() - p = read_portfolio() - pos_pct = p.get('position_pct', 0) - cash = p.get('cash', 0) - holdings = p.get('holdings', []) - weak_cnt = sum(1 for h in holdings if h.get('change_pct', 0) < -15) - total = len(holdings) or 1 - weak_ratio = weak_cnt / total - return { - 'position_pct': pos_pct, - 'cash': cash, - 'is_high_position': pos_pct > 80, - 'is_very_high_position': pos_pct > 90, - 'is_high_weak': weak_ratio > 0.35, - 'weak_ratio': round(weak_ratio * 100), - 'total_holdings': total, - } - except: - return {} - - -def _is_buy_signal(signal): - """判断信号是否为买入/持有类(用于防洗盘)""" - if not signal: - return False - buy_keywords = ['买入', '持有', '加仓', '关注'] - for kw in buy_keywords: - if kw in signal: - return True - return False - - -def _check_portfolio_consistency(): - """数据一致性检查:如果存在多份 portfolio.json 则报警(2026-06-23 bugfix)""" - main = '/home/hmo/web-dashboard/data/portfolio.json' - main_cash = None - try: - import json - main_cash = json.load(open(main)).get('cash') - except Exception: - return - for path in [ - '/home/hmo/data/portfolio.json', - '/home/hmo/projects/MoFin/data/portfolio.json', - '/home/hmo/web-dashboard.bak/data/portfolio.json', - ]: - if os.path.exists(path): - try: - other = json.load(open(path)) - if other.get('cash') != main_cash: - print(f"⚠️ 数据一致性: {os.path.realpath(path)} cash={other.get('cash')} ≠ 主文件 cash={main_cash} (需清理)", file=sys.stderr) - except Exception: - pass - - -def _check_contradiction(code, today_only=True): - """反馈循环核——检查本股是否有刚卖出的记录 - - 返回 dict or None: - - sold_reason: 'portfolio_trim'|'stop_loss' - - sold_at: 卖出日期 - - days_ago: 卖出距今交易日数 - - is_today: 是否今日卖出 - - tag: 追加到信号的标注 - """ - try: - from datetime import datetime, date - dec = read_decisions() - for e in dec.get('decisions', []): - if e.get('code') != code: - continue - sold_at = e.get('sold_at', '') - if not sold_at: - return None - try: - sd = datetime.strptime(sold_at, '%Y-%m-%d').date() - td = date.today() - days = (td - sd).days - except: - return None - - reason = e.get('sold_reason', 'portfolio_trim') - if reason == 'stop_loss': - tag = '止损离场(逻辑破坏,短期不关注)' - else: - tag = '组合减仓后关注(已清仓,等回踩确认)' - - return { - 'sold_reason': reason, - 'sold_at': sold_at, - 'days_ago': days, - 'is_today': days == 0, - 'tag': tag, - } - except: - return None - return None - - -def _get_sell_priority_list(): - """减仓优先级排序:深套>亏损>微盈>盈利(2026-06-23 反馈循环) - - 返回 [(code, name, change_pct, position_pct, priority_label), ...] - 按卖出的优先顺序排列(最先应该卖的在最前) - """ - try: - p = read_portfolio() - holdings = p.get('holdings', []) - ranked = [] - for h in holdings: - chg = h.get('change_pct', 0) - pos = h.get('position_pct', 0) - if chg < -30: - label = '深套(>30%),优先减' - rank = 0 - elif chg < -20: - label = '深套(>20%),优先减' - rank = 1 - elif chg < -10: - label = '亏损,建议减' - rank = 2 - elif chg < 0: - label = '微亏,可减' - rank = 3 - elif chg < 10: - label = '微盈,持有' - rank = 4 - else: - label = '盈利,最后减' - rank = 5 - ranked.append((rank, h['code'], h.get('name',''), chg, pos, label)) - ranked.sort(key=lambda x: (x[0], -x[4])) # 优先 rank, 其次仓位大优先 - return [{'code':c,'name':n,'change_pct':chg,'position_pct':pos,'label':l} - for r,c,n,chg,pos,l in ranked] - except: - return [] - - -def enrich_timing_signal(base_signal, macro_desc="", sector_note="", - profit_pct=0, stock_category="", is_new_entry=False, - fundamentals=None, news_sentiment=None, - timing_signal_override=None, - portfolio_context=None, - rr_ratio=0): # 2026-06-24 新参:盈亏比约束 - """多因子合成timing_signal——大盘+行业+基本面+技术+组合风险+盈亏比 - - 返回 (enriched_signal, factors_list) - - enriched_signal: 可读的多因子信号描述 - - factors_list: 各因子的摘要列表(用于后续显示) - """ - # 如果已手动设定,尊重手动 - if timing_signal_override and timing_signal_override != "neutral": - return timing_signal_override, [timing_signal_override] - - factors = [] - - # 1. 大盘因子 - if "偏强" in macro_desc or "大涨" in macro_desc or "bullish" in macro_desc.lower(): - macro_txt = "大盘偏强" - factors.append(macro_txt) - elif "偏弱" in macro_desc or "大跌" in macro_desc or "bearish" in macro_desc.lower(): - macro_txt = "大盘偏弱" - factors.append(macro_txt) - elif macro_desc and macro_desc != "宏观未加载": - factors.append("大盘中性") - - # 2. 行业因子 - if sector_note: - # 把"行业X大跌3%+"简化为"行业偏弱","行业X大涨3%+"简化为"行业偏强" - if "大跌" in sector_note or "下跌" in sector_note: - factors.append("行业偏弱") - elif "大涨" in sector_note: - factors.append("行业偏强") - elif "上涨" in sector_note: - factors.append("行业偏强") - else: - factors.append("行业中性") - - # 3. 基本面因子 - if fundamentals: - pe = fundamentals.get("pe", 0) - eps = fundamentals.get("eps", 0) - profit_growth = fundamentals.get("profit_growth", fundamentals.get("yoy_profit", "")) - revenue_growth = fundamentals.get("revenue_growth", fundamentals.get("yoy_revenue", "")) - mcap = fundamentals.get("mcap_total", 0) - - pe = pe or 0 - eps = eps or 0 - profit_growth_str = str(profit_growth or "") - revenue_growth_str = str(revenue_growth or "") - - # 净利增长 - for val in [profit_growth_str, revenue_growth_str]: - try: - v = float(val.replace("%", "").replace("+", "")) - if v > 50: - factors.append("净利增50%+") - break - elif v > 20: - factors.append(f"净利增{int(v)}%") - break - elif v < -20: - factors.append("净利降20%+") - break - except (ValueError, AttributeError): - continue - - # PE估值 - if 0 < pe < 15: - factors.append("低估值") - elif pe > 100 or pe < 0: - factors.append("高估值") - - # 市值 - if mcap and mcap > 5000: - factors.append("蓝筹") - - # 4. 消息面因子(小果情感) - if news_sentiment: - ns = news_sentiment.get("sentiment", "") - nc = news_sentiment.get("confidence", 0) - if ns == "positive" and nc >= 0.7: - kws = news_sentiment.get("keywords", []) - kw_str = f"({'/'.join(kws[:3])})" if kws else "" - factors.append(f"消息偏多{kw_str}") - elif ns == "negative" and nc >= 0.7: - kws = news_sentiment.get("keywords", []) - kw_str = f"({'/'.join(kws[:3])})" if kws else "" - factors.append(f"消息偏空{kw_str}") - - # 5. 技术面(基础信号) - if base_signal and base_signal != "neutral": - factors.append(base_signal) - - # 5.5 组合风险因子(2026-06-23 双引擎协调) - if portfolio_context and not is_new_entry: - if portfolio_context.get('is_very_high_position'): - factors.append("组合仓位极重(>90%)") - elif portfolio_context.get('is_high_position'): - factors.append("组合仓位偏重(>80%)") - if portfolio_context.get('is_high_weak'): - factors.append(f"弱势占{portfolio_context.get('weak_ratio')}%") - elif portfolio_context and is_new_entry: - # 新买入推荐:注明组合上下文 - if portfolio_context.get('is_high_position'): - factors.append(f"仓{portfolio_context.get('position_pct')}%现金有限") - elif portfolio_context.get('is_high_weak'): - factors.append("组合风险信号") - - # 5.7 盈亏比因子(2026-06-24 新增——RR<1.5降级买入信号) - if rr_ratio > 0: - if rr_ratio < 1.5: - factors.append(f"RR{rr_ratio}过低") - elif rr_ratio >= 3: - factors.append(f"RR{rr_ratio}") - # 1.5~3之间:中性,不特别标注 - - # 如果没有足够因素,返回信号不充分 - if not factors: - return "信号不充分", [] - - # 信号只应包含明确的买卖方向,不能从行业/大盘等上下文因子拼凑 - # base_signal 存在且非 neutral → 用 base_signal - # 否则 → 信号不充分(不拿 factors[-1] 当信号) - if base_signal and base_signal != "neutral": - clean_signal = base_signal - else: - # 从 factors 中找第一个有效的操作方向信号 - valid_direction = {"买入", "加仓", "观望", "持有", "关注", "信号不充分"} - signal_found = "" - for f in reversed(factors): - if f in valid_direction: - signal_found = f - break - clean_signal = signal_found if signal_found else "信号不充分" - - # 6. RR约束降级(2026-06-24 新增) - # 买入/加仓信号但RR<1.5 → 降级为"信号不充分" - buy_signals = {"买入", "加仓"} - if clean_signal in buy_signals and 0 < rr_ratio < 1.5: - clean_signal = "信号不充分" - factors.append("RR过低降级") - - return clean_signal, factors - - -def reassess_with_context(code, name, price, cost, shares, current_action, - volume_signal="", sentiment="neutral", is_watchlist=False): - """reassess_strategy + 多因子信号合成(大盘+行业+技术) - - 为 per_stock_reassess 等单只场景提供一站式多因子分析 - """ - result = reassess_strategy( - code, name, price, cost, shares, - current_action, volume_signal, sentiment, is_watchlist - ) - if not result: - return result - - # 加载宏观+行业+消息+基本面上下文 - try: - macro_bias, macro_desc = load_macro_context() - market_ctx = load_market_context() - stock_sector_map = load_stock_sector_map() - sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) - sector_note = sector_adj.get("note", "") - news_sentiment = load_stock_news_sentiment(code) - fund = load_fundamentals(code) - except Exception: - macro_desc = "" - sector_note = "" - news_sentiment = {} - fund = {} - - # ── DSA 集成:注入大盘复盘 + 新闻情报 ────────────────────────── - try: - from mo_bridge import enrich_analysis_context - region = "hk" if len(str(code)) == 5 and str(code)[0] in ('0','1') else "cn" - dsa_ctx = enrich_analysis_context(stock_code=code, stock_name=name, - region=region, include_news=True) - if dsa_ctx: - macro_desc = (macro_desc + "\n\n" + dsa_ctx).strip() - except Exception: - pass # DSA 不可用时静默跳过 - - enriched, factors = enrich_timing_signal( - base_signal=result.get("timing_signal", ""), - macro_desc=macro_desc, - sector_note=sector_note, - profit_pct=(price - cost) / cost * 100 if cost else 0, - stock_category=result.get("stock_category", ""), - is_new_entry=is_watchlist, - fundamentals=fund, - news_sentiment=news_sentiment, - portfolio_context=_get_portfolio_risk_state(), - rr_ratio=result.get("rr_ratio", 0), - ) - result["timing_signal"] = enriched - result["signal_factors"] = factors - - # 6. 防洗盘:信号不要一天一翻(2026-06-23) - # 如果旧信号是买入/持有类,新信号是谨慎/等待类,但中期趋势未破→维持旧信号 - try: - dec = read_decisions() - for e in dec.get('decisions', []): - if e.get('code') == code: - old_signal = e.get('timing_signal', '') - if old_signal and _is_buy_signal(old_signal) and not _is_buy_signal(enriched): - # 中等趋势检查:MA5 > MA20 + 多周期看多 - mtf = result.get('multi_tf_context', '') - if '看多' in mtf or '多头' in mtf: - try: - closes = [float(k.split()[2]) for k in mtf.split('|') if 'MA5' in k] - except: - closes = [] - has_uptrend = 'MA5' in mtf and 'MA20' in mtf - if has_uptrend: - print(f" 防洗盘: {old_signal}→保持旧信号(中期趋势完整)") - result["timing_signal"] = f"{old_signal}(正常回调价稳)" - sf = result.get("signal_factors") or [] - if "正常回调价稳" not in sf: - result["signal_factors"] = sf + ["正常回调价稳"] - break - except Exception as e: - print(f" 防洗盘跳过: {e}") - - # 7. 反馈循环核:检查本股是否有刚卖出的记录(2026-06-23) - contradiction = _check_contradiction(code) - if contradiction and contradiction.get('is_today'): - # 今日刚卖出 → 不屏蔽信号,但必须自标注矛盾 - print(f" 反馈循环: {contradiction.get('tag')} (sold_at={contradiction.get('sold_at')})") - if _is_buy_signal(result.get('timing_signal', '')): - result['action_note'] = contradiction['tag'] - # 在 timing_signal 中追加反馈标注,供报告层可见 - curr_signal = result.get('timing_signal', '') - if '⚠️' not in curr_signal: - result['timing_signal'] = f"⚠️{contradiction['tag']}|{curr_signal}" - elif contradiction: - # 非今日卖出但近期卖出 → 标注已清仓 - print(f" 近期清仓: sold_at={contradiction.get('sold_at')} ({contradiction.get('days_ago')}日前)") - if _is_buy_signal(result.get('timing_signal', '')): - curr_signal = result.get('timing_signal', '') - if '已清仓' not in curr_signal: - result['timing_signal'] = f"已清仓,{curr_signal}" - - # 重建 action 文本(同步多因子信号) - try: - if new_action_needs_refresh(result, {"source": "auto"}, price): - _refresh_action_text(result, price, name) - except Exception: - pass - - # ── 策略质量门禁 ── - enforce_strategy_quality(code, name, result) - - return result - - -def new_action_needs_refresh(result, old_entry, price): - """判断宏观/行业调整后是否需要刷新action文本""" - # 自选股和手动策略不做调整,不需要刷新 - if old_entry.get("source") == "manual": - return False - return True - - -def _refresh_action_text(result, price, name): - """根据调整后的止损/止盈重建action文本""" - sl = result.get("stop_loss", 0) - tp = result.get("take_profit", 0) - el = result.get("entry_low", 0) - eh = result.get("entry_high", 0) - ts = result.get("timing_signal", "") - an = result.get("action_note", "") - old_action = result.get("action", "") - - # 保持原action的前缀(持有状态部分不变) - # action格式一般是: "状态 | 止损X | 目标Y | 买入区X~Y | 信号:Z" - parts = old_action.split(" | ") - new_parts = [] - for p in parts: - p = p.strip() - # 替换止损数字 - if p.startswith("止损") or p.startswith("止损参考"): - if sl: - p = f"止损{sl}" if "止损参考" not in old_action.split(" | ")[0] else f"止损参考{sl}" - # 替换目标/止盈数字 - if p.startswith("目标") or p.startswith("止盈"): - if tp: - p = f"目标{tp}" - # 替换买入区数字 - if "买入区" in p and "~" in p: - if el and eh: - p = f"买入区{el}~{eh}" - new_parts.append(p) - result["action"] = " | ".join(new_parts) - - -def check_sector_alerts(market_ctx, stock_sector_map, holdings, wl): - """行业轮动主动预警:检测板块崩盘级别信号→查持仓→输出预警 - - 返回 list of alerts: [{code, name, sector, chg, action}] - """ - alerts = [] - if not market_ctx: - return alerts - - sector_perf = market_ctx.get("sector_perf", {}) - - # 找出所有跌幅>3%的行业 - crashing_sectors = {name: data for name, data in sector_perf.items() - if data.get("change", 0) <= -3} - - if not crashing_sectors: - return alerts - - # 构建 code→持仓信息 的映射 - holding_map = {} - for h in holdings: - c = h.get("code", "") - if c: - holding_map[c] = {"name": h.get("name", c), "type": "持仓"} - for s in wl.get("stocks", []): - c = s.get("code", "") - if c and c not in holding_map: - holding_map[c] = {"name": s.get("name", c), "type": "自选"} - - # 对每个暴跌行业,查持仓中是否有股票属于该行业 - for sec_name, sec_data in sorted(crashing_sectors.items(), - key=lambda x: x[1].get("change", 0)): - chg = sec_data.get("change", 0) - for code, sectors in stock_sector_map.items(): - if code in holding_map and sec_name in sectors: - info = holding_map[code] - alerts.append({ - "code": code, - "name": info["name"], - "sector": sec_name, - "sector_change": chg, - "type": info["type"], - "action": f"行业{sec_name}跌{chg:+.1f}%,{info['type']}需关注", - }) - - alerts.sort(key=lambda a: a["sector_change"]) - return alerts - - -def regenerate_all(stdout=True): - """全量重评所有持仓+自选策略""" - # 优先从 SQLite 读取 - try: - from mofin_db import get_conn, query_holdings, query_watchlist - conn = get_conn() - holdings = query_holdings(conn) - wl_stocks = query_watchlist(conn) - conn.close() - pf = {"holdings": holdings} - wl = {"stocks": wl_stocks} - except Exception: - try: - pf = read_portfolio() - wl = read_watchlist() - except Exception: - pf = {} - wl = {} - - all_stocks = {} - for item in pf.get("holdings", []): - code = item.get("code", "") - if code: - all_stocks[code] = {"source": "portfolio", "data": item} - for item in wl.get("stocks", []): - code = item.get("code", "") - if code and code not in all_stocks: - all_stocks[code] = {"source": "watchlist", "data": item} - - total = len(all_stocks) - ok = 0 - errors = 0 - results = [] - decisions = [] - - # 加载现有 decisions.json 以便追踪变更 - decisions_path = "/home/hmo/web-dashboard/data/decisions.json" - try: - existing_decisions = {d["code"]: d for d in read_decisions().get("decisions", []) if d.get("code")} - except: - existing_decisions = {} - - # 加载宏观上下文(影响策略参数调整) - macro_bias, macro_desc = load_macro_context() - if stdout: - print(f" 宏观参考: {macro_desc} (bias={macro_bias})") - - # 加载市场上下文 — 行业板块表现 + 大盘宽度(策略参数调整用) - market_ctx = load_market_context() - stock_sector_map = load_stock_sector_map() - market_breadth = market_ctx.get("breadth", 50) - market_mood = market_ctx.get("mood", "neutral") - if stdout: - sectors_found = sum(1 for c in all_stocks if stock_sector_map.get(c)) - print(f" 市场参考: {market_mood} 上涨比{market_breadth}% 已匹配{sectors_found}/{total}只个股行业") - - # 批量预取所有价格(一次API调用 vs 之前N次) - prices_map = batch_fetch_prices(list(all_stocks.keys())) - if stdout: - print(f" 批量获取价格: {len(prices_map)}/{total} 成功") - - for code, info in sorted(all_stocks.items()): - stock = info["data"] - name = stock.get("name", code) - cost = stock.get("cost", 0) or 0 - shares = stock.get("shares", 0) or 0 - source = info["source"] - - q = prices_map.get(code) - if not q or not q.get("price"): - results.append({"code": code, "name": name, "error": "腾讯API无数据"}) - errors += 1 - if stdout: - print(f" ❌ {name}({code}): 腾讯API无数据") - continue - - price = q["price"] - profit_pct = (price - cost) / cost * 100 if cost else 0 - current_action = stock.get("analysis", {}).get("action", "") - close_yest = q.get("close", 0) - sentiment = "neutral" - if close_yest and price > close_yest * 1.02: - sentiment = "bullish" - elif close_yest and price < close_yest * 0.98: - sentiment = "bearish" - - try: - is_wl = (source == "watchlist") - result = reassess_strategy( - code, name, price, cost, shares, - current_action, volume_signal="中性", sentiment=sentiment, - is_watchlist=(source == "watchlist"), - ) - - # --- Manual param preservation: 用户手动策略永不覆盖 --- - old_entry = existing_decisions.get(code, {}) - if old_entry.get("source") == "manual": - # 仅覆盖策略参数,技术分析/信号/价格照常保留 - for key in ["entry_low", "entry_high", "stop_loss", "take_profit"]: - if key in old_entry and old_entry[key] is not None: - result[key] = old_entry[key] - # 重算盈亏比(基于手动参数) - manual_stop = result.get("stop_loss", 0) or 0 - manual_target = result.get("take_profit", 0) or 0 - risk = max(price - manual_stop, price * 0.01) if manual_stop > 0 else price * 0.01 - reward = max(manual_target - price, 0) if manual_target > 0 else 0 - result["rr_ratio"] = round(reward / risk, 2) if risk > 0 else 0 - # 重建 action 文本(引用手动参数,不引用自动计算的) - profit_pct = (price - cost) / cost * 100 if cost else 0 - manual_action_parts = [] - if profit_pct < -20: - manual_action_parts.append("深套持有") - elif profit_pct < -10: - manual_action_parts.append("持有观察") - elif profit_pct < 0: - manual_action_parts.append("持有观察") - elif profit_pct < 5: - manual_action_parts.append("盈利持有") - else: - manual_action_parts.append("盈利良好") - if result.get("action_note"): - manual_action_parts.append(result["action_note"]) - if is_wl: - if manual_stop > 0: - manual_action_parts.append(f"止损参考{manual_stop}") - manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") - else: - if manual_stop > 0: - manual_action_parts.append(f"止损{manual_stop}") - if manual_target > 0: - manual_action_parts.append(f"目标{manual_target}") - manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") - ts = result.get("timing_signal", "") - if ts and ts != "neutral": - manual_action_parts.append(f"信号:{ts}") - result["action"] = " | ".join(manual_action_parts) - result["status"] = "manual" # 标记为手动管理,变更追踪不受影响 - if stdout: - print(f" [手动保留] {name}({code}) 策略参数未覆盖") - - # 宏观偏差调整:收盘后重评时根据宏观方向微调止损/止盈 - # 自选股不做止盈宏观调整(无持仓) - # 手动策略不做宏观偏差调整(尊重用户设定) - if macro_bias != 1.0 and not is_wl and old_entry.get("source") != "manual": - old_stop = result.get("stop_loss", 0) - old_target = result.get("take_profit", 0) - if macro_bias < 1.0 and old_stop > 0: # 宏观偏弱 → 收紧止损 - # 止损上移(但保留最小3%间距) - adjusted_stop = round(old_stop * (1 + (1 - macro_bias) * 0.3), 2) - min_stop = round(price * 0.97, 2) - result["stop_loss"] = min(adjusted_stop, min_stop) - if old_target > 0: - result["take_profit"] = round(old_target * (1 - (1 - macro_bias) * 0.2), 2) - elif macro_bias > 1.0 and old_target > 0: # 宏观偏强 → 止盈上调让利润跑 - result["take_profit"] = round(old_target * (1 + (macro_bias - 1) * 0.3), 2) - - # 行业偏差调整:根据个股所在行业的市场表现微调止损/止盈 - # 手动策略不做行业调整(尊重用户设定) - sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) - sector_note = sector_adj.get("note", "") - if sector_note and old_entry.get("source") != "manual": - old_stop = result.get("stop_loss", 0) - old_target = result.get("take_profit", 0) - stop_bias = sector_adj.get("stop_bias", 1.0) - target_bias = sector_adj.get("target_bias", 1.0) - if stop_bias != 1.0 and old_stop > 0: - # 行业偏差调整(在宏观调整之后叠加) - adjusted = round(old_stop * stop_bias, 2) - # 保留最小3%间距 - min_stop = round(price * 0.97, 2) - result["stop_loss"] = min(adjusted, min_stop) - if target_bias != 1.0 and old_target > 0 and not is_wl: - result["take_profit"] = round(old_target * target_bias, 2) - - # 加载消息面+基本面(逐个股) - news_sentiment = load_stock_news_sentiment(code) - fund = load_fundamentals(code) - - # 多因子合成 timing_signal:大盘+行业+消息+基本面+技术 - if old_entry.get("source") != "manual": - enriched, _ = enrich_timing_signal( - base_signal=result.get("timing_signal", ""), - macro_desc=macro_desc, - sector_note=sector_note, - profit_pct=profit_pct, - stock_category=result.get("stock_category", ""), - is_new_entry=(source == "watchlist"), - fundamentals=fund, - news_sentiment=news_sentiment, - rr_ratio=result.get("rr_ratio", 0), - ) - result["timing_signal"] = enriched - - # 在宏观/行业/多因子调整后重建 action 文本(同步调整后的止损/止盈数字) - if new_action_needs_refresh(result, old_entry, price): - _refresh_action_text(result, price, name) - - extra = { - "rr_ratio": result.get("rr_ratio"), - "action_note": result.get("action_note", ""), - "timing_signal": result.get("timing_signal", ""), - } - analysis = { - "stop_loss": result["stop_loss"], - "take_profit": result["take_profit"], - "entry_low": result["entry_low"], - "entry_high": result["entry_high"], - "action": result["action"], - "tech_snapshot": result.get("tech_snapshot", ""), - "multi_tf_context": result.get("multi_tf_context", ""), - "reassessed_at": result["reassessed_at"], - "status": result["status"], - **extra, - } - stock["analysis"] = analysis - # 同步 top-level 字段 → zone_breach/price_monitor 依赖这些字段 - # (2026-06-24 bugfix: analysis 子对象有但顶层没有,导致新持仓的止损检测盲区) - stock["stop_loss"] = result.get("stop_loss", 0) - stock["take_profit"] = result.get("take_profit", 0) - stock["entry_low"] = result.get("entry_low", 0) - stock["entry_high"] = result.get("entry_high", 0) - # 同步 trigger 字段 -> price_monitor 依赖 - sl = result.get("stop_loss", 0) - tp = result.get("take_profit", 0) - el = result.get("entry_low", 0) - eh = result.get("entry_high", 0) - trig = {} - if sl and float(sl) > 0: - trig["stop_loss"] = float(sl) - if el and eh and float(el) > 0 and float(eh) > 0: - trig["entry_zone"] = f"{float(el)}~{float(eh)}" - if tp and float(tp) > 0: - trig["take_profit_zone"] = f"0~{float(tp)}" - stock["trigger"] = trig - results.append({ - "code": code, "name": name, - "price": price, "cost": cost, - "action": result["action"], - "stop_loss": result["stop_loss"], - "take_profit": result["take_profit"], - "rr_ratio": result["rr_ratio"], - }) - ok += 1 - if stdout: - rr_str = f" RR={result['rr_ratio']}" if "rr_ratio" in result else "" - print(f" ✅ {name}({code}) {price} {result['action']}{rr_str}") - - # 记录所有股票的决策日志(含变更追踪) - status_display = result.get("status", "active") - # 构建行业上下文 - sector_ctx_str = "" - sec_name = sector_adj.get("sector_name", "") - sec_chg = sector_adj.get("sector_change", 0) - if sec_name: - sector_ctx_str = f"行业{sec_name}{sec_chg:+.1f}%" - if sector_adj.get("note"): - # note 已包含大盘宽度信息 - sector_ctx_str = sector_adj["note"] - elif market_breadth < 40: - # 无行业映射时至少记录大盘宽度 - sector_ctx_str = f"大盘上涨比{market_breadth}%" - new_entry = { - "code": code, "name": name, "price": price, - "cost": old_entry.get("cost", cost) if old_entry else cost, # 优先保留旧成本(holding.xls权威) - "shares": shares, # 当前实际持仓股数(不继承旧决策的可能为0的值) - "avg_price": old_entry.get("avg_price", 0), # 保留持仓均价 - "currency": "HKD" if is_hk_stock(str(code)) else "CNY", - "action": result["action"], - "stop_loss": result.get("stop_loss"), - "entry_low": result["entry_low"], - "entry_high": result["entry_high"], - "tech_snapshot": result.get("tech_snapshot", ""), - "timing_signal": result.get("timing_signal", ""), - "rr_ratio": result.get("rr_ratio", 0), - "status": status_display, - "note": result.get("action_note", ""), - "timestamp": result["reassessed_at"], - "updated_at": result["reassessed_at"], - "type": "自选策略" if is_wl else "持仓策略", - "source": old_entry.get("source", "auto"), # manual/auto,继承旧标记 - "sector_context": sector_ctx_str, # 市场上下文:行业表现+大盘宽度 - "stock_category": result.get("stock_category", "中短线"), # 组合监测用 - "position_advice": result.get("position_advice", "中等仓位"), - "time_horizon": result.get("time_horizon", "2周~3月"), - } - new_entry["trigger"] = trig - # created_at: 首次创建时设置,后续 preserve - old_entry = existing_decisions.get(code, {}) - if old_entry.get("created_at"): - new_entry["created_at"] = old_entry["created_at"] - else: - new_entry["created_at"] = result["reassessed_at"] - # 保留 last_reassessed_price(per_stock_reassess 维护的防抖字段) - if old_entry.get("last_reassessed_price"): - new_entry["last_reassessed_price"] = old_entry["last_reassessed_price"] - # 自选股也写止盈位(用于RR校验),但标签用"目标参考"非"止盈" - new_entry["take_profit"] = result.get("take_profit") - - # --- 变更追踪 --- - old_action = old_entry.get("action", "") - old_stop = old_entry.get("stop_loss") - old_target = old_entry.get("take_profit") - - # 构建旧策略摘要和变更理由 - update_reason = "" - changelog_entry = None - - if old_action and old_action != result["action"]: - # 策略有变化 → 记录变更 - old_summary = old_action - new_summary = result["action"] - - # 判断触发原因 - if abs(price - old_entry.get("price", price)) / max(price, 0.01) > 0.03: - trigger = f"价格变动({old_entry.get('price','?')}→{price})" - elif result.get("timing_signal") and result["timing_signal"] != old_entry.get("timing_signal", ""): - trigger = f"技术信号变化: {result['timing_signal']}" - else: - trigger = "技术面重评" - - # 格式化的变更理由(自选股只看止损,不看止盈) - diff_parts = [] - if old_stop and result["stop_loss"] != old_stop: - diff_parts.append(f"止损{old_stop}→{result['stop_loss']}") - if not is_wl and old_target and result.get("take_profit") and result["take_profit"] != old_target: - diff_parts.append(f"止盈{old_target}→{result['take_profit']}") - if diff_parts: - update_reason = f"{trigger}: {', '.join(diff_parts)} | {result.get('tech_snapshot','')[:60]}" - else: - update_reason = f"{trigger}: 策略文字调整" - - changelog_entry = { - "date": result["reassessed_at"], - "old_action": old_action, - "new_action": result["action"], - "reason": update_reason, - "trigger": trigger, - } - new_entry["updated_reason"] = update_reason - - elif not old_action: - # 首次创建策略 - update_reason = f"初始策略创建 | {result.get('tech_snapshot','')[:60]}" - changelog_entry = { - "date": result["reassessed_at"], - "old_action": "", - "new_action": result["action"], - "reason": update_reason, - "trigger": "初始创建", - } - - # 合并changelog - old_changelog = old_entry.get("changelog", []) if old_entry else [] - if changelog_entry: - new_entry["changelog"] = old_changelog + [changelog_entry] - else: - new_entry["changelog"] = old_changelog - - # 保留执行记录 - if old_entry and old_entry.get("execution"): - new_entry["execution"] = old_entry["execution"] - elif stock.get("analysis", {}).get("status") == "executing": - new_entry["execution"] = { - "status": "executing", - "entry_price": cost if cost else 0, - "shares": shares, - "notes": "", - } - - # --- 自动标记 current_recommend --- - # 只在真正执行中的持仓才自动推荐:execution.status 为 executing 或 partial_exit - exec_status = old_entry.get("execution", {}).get("status", "") if old_entry else "" - is_active = exec_status in ("executing", "partial_exit") - - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss_stock = profit_pct < -20 - rr = result.get("rr_ratio", 0) - ts = result.get("timing_signal", "") - note = result.get("action_note", "") - - # 计算是否在/接近买入区 - entry_low_val = result.get("entry_low", 0) - entry_high_val = result.get("entry_high", 0) - in_buy_zone = (entry_low_val > 0 and entry_high_val > 0 and - entry_low_val <= price <= entry_high_val) - near_buy_zone_low = (entry_low_val > 0 and - price >= entry_low_val * 0.98 and - price <= entry_high_val) - - # 推荐条件:必须是执行中的持仓 + 基本面条件达标 - is_recommendable = ( - is_active - and not is_deep_loss_stock - and rr >= 1.5 - and ts != "neutral" - and "不建议" not in note - ) - if is_recommendable: - new_entry["tag"] = "current_recommend" - else: - # 不清除 active_manual(用户手动标记),只清除自动推荐的 - old_tag = old_entry.get("tag", "") if old_entry else "" - if old_tag != "active_manual": - new_entry.pop("tag", None) - - decisions.append(new_entry) - - except Exception as e: - results.append({"code": code, "name": name, "error": str(e)}) - errors += 1 - if stdout: - print(f" ❌ {name}({code}): {e}") - - # 写回数据文件 — 保留现有字段(现金、总资产等)不丢 - try: - existing_pf = read_portfolio() - except Exception: - existing_pf = {} - # 保留 price/change_pct — price_monitor 维护的实时价,regenerate_all 不应清除 - _existing_holdings_map = {} - for _h in existing_pf.get('holdings', []): - if _h.get('code'): - _existing_holdings_map[_h['code']] = _h - _new_holdings = pf.get("holdings", []) - for _h in _new_holdings: - _code = _h.get('code') - if _code and _code in _existing_holdings_map: - _old = _existing_holdings_map[_code] - _h['price'] = _old.get('price', 0) - _h['change_pct'] = _old.get('change_pct', 0) - existing_pf["holdings"] = _new_holdings - existing_pf["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M') - - # ── Watchlist ↔ Holdings 双向自动迁移(2026-06-27 Dad要求)── - # ① 持仓已有 → 从自选移除(买入自动清除) - wl_codes = {s.get("code") for s in wl.get("stocks", []) if s.get("code")} - pf_codes = {h.get("code") for h in _new_holdings if h.get("code") and h.get("shares", 0) > 0} - removed_from_wl = [] - for h_code in wl_codes & pf_codes: - # 持仓>0且量够 → 自选移除 - wl["stocks"] = [s for s in wl.get("stocks", []) if s.get("code") != h_code] - removed_from_wl.append(h_code) - if removed_from_wl and stdout: - print(f" 自选→持仓自动移除: {', '.join(removed_from_wl)}") - - # ② 清仓/卖光 → 加回自选(只要仍有关注价值) - added_to_wl = [] - old_pf_codes = {_h.get("code") for _h in existing_pf.get("holdings", []) if _h.get("code")} - sold_codes = old_pf_codes - pf_codes # 曾持仓但现在没有(或不在了) - for sc in sold_codes: - # 已有自选就不重复加 - if sc in wl_codes: - continue - # 从现有decisions看是否有关注价值 - for d in decisions: - if d.get("code") == sc and d.get("entry_low") and d.get("entry_high"): - wl["stocks"].append({ - "code": sc, "name": d.get("name", sc), - "entry_low": d.get("entry_low"), "entry_high": d.get("entry_high"), - "stop_loss": d.get("stop_loss", 0), - "analysis": {"action": d.get("action", ""), "tech_snapshot": d.get("tech_snapshot", "")} - }) - added_to_wl.append(sc) - break - if added_to_wl and stdout: - print(f" 清仓→自选自动加入: {', '.join(added_to_wl)}") - - # 重新计算 portfolio 汇总(保留已存在的 cash,用最新价格算市值) - try: - total_mv = 0.0 - total_cost = 0.0 - for h in existing_pf.get('holdings', []): - p = h.get('price') or 0 - s = h.get('shares') or 0 - c = h.get('cost') or 0 - total_mv += p * s - total_cost += c * s - if p and s and total_mv > 0: - h['market_value'] = round(p * s, 2) - old_cash = existing_pf.get('cash') or 80476 # fallback 6/23 backup - frozen_cash = existing_pf.get('frozen_cash') or 0 - existing_pf['cash'] = old_cash - existing_pf['total_mv'] = round(total_mv, 2) - existing_pf['total_assets'] = round(total_mv + old_cash + frozen_cash, 2) - existing_pf['total_pnl'] = round(total_mv - total_cost, 2) - existing_pf['position_pct'] = round(total_mv / (total_mv + old_cash + frozen_cash) * 100, 2) if (total_mv + old_cash + frozen_cash) > 0 else 0 - except Exception as e: - print(f" [汇总计算失败] {e}", flush=True) - - # DB 写入(替代 JSON dump — 强制币种约束) - try: - from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy - conn = get_conn() - write_holdings_batch(conn, existing_pf.get('holdings', [])) - write_portfolio_summary(conn, existing_pf) - for s in wl.get('stocks', []): - s.setdefault('currency', 'CNY') - write_watchlist_stock(conn, s) - for d in decisions: - # ── 策略质量门禁 ── - code = d.get('code', '') - name = d.get('name', '') - enforce_strategy_quality(code, name, d) - write_holding_strategy(conn, code, name, d) - conn.close() - except Exception as e: - print(f" [DB写入失败] {e}", flush=True) - - # 记录策略→提示词版本关联 - if HAS_PROMPT_TRACKING: - try: - for d in decisions: - if d.get("code") and d.get("action"): - record_strategy_generation( - d["code"], d.get("name", ""), d.get("action", "") - ) - except Exception as e: - if stdout: - print(f" ⚠️ 提示词版本追踪失败: {e}", file=sys.stderr) - - # 刷新多周期缓存到磁盘 - try: - import multi_timeframe as _mtf - _mtf.flush_mtf_cache() - except Exception: - pass - - summary = {"total": total, "ok": ok, "errors": errors} - if stdout: - print(f"\n✅ 全量重评完成: {ok}/{total}成功, {errors}错误") - return summary - - -if __name__ == "__main__": - regenerate_all() +#!/usr/bin/env python3 +"""策略生命周期管理系统 — 技术面驱动版本 v2 + +核心原则: +1. 止损放在合理的技术位,不拍数字 +2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1 +3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力 +4. 买入区间:弱支撑~弱压力之间 +5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买 +""" + +import json +import urllib.request +import os +import sys +import re +from datetime import datetime +import technical_analysis as ta +import multi_timeframe as mtf +from mo_data import read_portfolio, read_decisions, read_watchlist +from mo_models import is_hk_stock, to_cny, get_hk_rate +from strategy_tree import detect_scenario + +# ─── 策略准入门禁 — 硬性质量红线 ─────────────────────────────── +# 每一条策略写入前必须过此门禁。不过的不得写入DB/JSON, +# 必须触发重评修复。代码层面硬拦截,不依赖prompt或文档。 +# +# 规则列表 + 严重程度 + 修复建议 +STRATEGY_QUALITY_GATES = [ + { + "id": "GATE_LOSS_EXISTS", + "desc": "止损必须存在且>0", + "check": lambda d: (d.get("stop_loss") or 0) > 0, + "severity": "CRITICAL", + "fix": "调用 technical_analysis 计算支撑位设置止损" + }, + { + "id": "GATE_PROFIT_EXISTS", + "desc": "止盈必须存在且>0(纯自选股可放宽)", + "check": lambda d: (d.get("take_profit") or 0) > 0, + "severity": "CRITICAL", + "fix": "调用 technical_analysis 计算阻力位设置止盈目标" + }, + { + "id": "GATE_SL_GTE_LOW", + "desc": "止损必须 ≤ 买入区下沿", + "check": lambda d: (d.get("stop_loss") or 0) <= (d.get("entry_low") or 99999), + "severity": "HIGH", + "fix": "止损不能高于买入区,调整止损至买入区以下" + }, + { + "id": "GATE_ENTRY_RANGE", + "desc": "买入区下沿 < 上沿", + "check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0), + "severity": "CRITICAL", + "fix": "entry_low=现价×0.95, entry_high=现价×1.05 取近似区间" + }, + { + "id": "GATE_RR_COMPUTED", + "desc": "买入推荐必须含RR", + "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) > 0, + "severity": "HIGH", + "fix": "RR = (止盈-现价)/(现价-止损),数据齐全后自动算" + }, + { + "id": "GATE_RR_MINIMUM", + "desc": "买入推荐RR≥1.5(非买入信号跳过)", + "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) >= 1.5, + "severity": "HIGH", + "fix": "RR不足→signal降级为'信号不充分',不进推荐区" + }, + { + "id": "GATE_SIGNAL_SHORT", + "desc": "timing_signal 必须是短词(2-4字)", + "check": lambda d: len((d.get("timing_signal") or "").strip().split()) <= 4 and (d.get("timing_signal") or "") not in ("neutral", ""), + "severity": "MEDIUM", + "fix": "使用短词:买入/加仓/观望/持有/关注/信号不充分" + }, + { + "id": "GATE_TECH_SNAPSHOT", + "desc": "tech_snapshot 必须包含技术位数值", + "check": lambda d: bool(d.get("tech_snapshot")) and any(c in d["tech_snapshot"] for c in "支撑阻力压强"), + "severity": "MEDIUM", + "fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值" + }, + { + "id": "GATE_CURRENCY_SET", + "desc": "港股必须标 currency=HKD(个股存原币种,汇总时由calc_total_assets转CNY)", + "check": lambda d: not is_hk_stock(d.get("code","")) or d.get("currency") == "HKD", + "severity": "HIGH", + "fix": "设置 d['currency']='HKD'" + }, + # --- 第4条 CRITICAL 红线:9维交叉验证 (2026-07-02 Dad要求) --- + # 策略不能只有价格数字,必须有证据经过了多维分析: + # 横切面: 大盘+行业+个股 | 纵切面: 基本面+消息面+技术面+资金流 + # 代码层面可验证: sector_context(行业) + signal_factors(多因子) 或 tech_snapshot + { + "id": "GATE_9D_ANALYSIS", + "desc": "策略必须经过多维分析(sector_context + signal_factors)", + "check": lambda d: ( + bool(d.get("sector_context") and str(d.get("sector_context","")).strip() not in ("neutral","","N/A","-")) + and ( + bool(d.get("signal_factors") and isinstance(d.get("signal_factors"), (list,tuple)) and len(d["signal_factors"]) >= 1) + or bool(d.get("tech_snapshot") and any(c in str(d.get("tech_snapshot","")) for c in "支撑阻力压强")) + ) + ), + "severity": "CRITICAL", + "fix": "重新运行 reassess_with_context() 完整重评确保 sector_context/signal_factors/tech_snapshot 均已填充" + }, +] + + +def _hk_stock(code): + return bool(len(str(code)) == 5 and str(code)[0] in ('0','1')) + +def _is_buy_signal_str(signal): + """买入/加仓/建仓类信号""" + if not signal: + return False + return any(kw in signal for kw in ["买入", "加仓", "建仓"]) + +# _is_buy_signal is defined later in this file (~line 1240) + +def validate_strategy(d, debug=True): + """策略评审:硬性门禁检查 + + 返回 (passed: bool, failures: list) + 任一 CRITICAL 失败 → 拒绝写入,标记 TODO 触发重评 + 任一 HIGH 失败 → 标记 quality_check=failed,写入但不出现在推荐区 + MEDIUM 失败 → 记录但不拦截 + """ + failures = [] + for gate in STRATEGY_QUALITY_GATES: + try: + ok = gate["check"](d) + except Exception as e: + ok = False + if debug: + print(f" [VALIDATE] {gate['id']} 检查异常: {e}", flush=True) + if not ok: + failures.append(gate) + if debug: + print(f" [VALIDATE] ✗ {gate['id']} ({gate['severity']}): {gate['desc']}", flush=True) + + passed = all(f["severity"] != "CRITICAL" for f in failures) + + if debug: + criticals = [f for f in failures if f["severity"] == "CRITICAL"] + highs = [f for f in failures if f["severity"] == "HIGH"] + if passed: + print(f" [VALIDATE] ✅ 通过 ({len(failures)}条警告)" if failures else " [VALIDATE] ✅ 全通过", flush=True) + else: + print(f" [VALIDATE] ❌ {len(criticals)}条CRITICAL未通过 → 拒绝写入", flush=True) + + return passed, failures + + +def enforce_strategy_quality(code, name, result): + """策略写入前的强制质量门禁 + + 三段自动修复: + - Round 1: 技术分析(ta.full_analysis/chip_sr) + - Round 2: DB + 价格百分比推算 + - Round 3: 最低可用策略标记强推 + 3轮全不过 → review_needed + """ + price = result.get("price", 0) or result.get("current", 0) or result.get("last_price", 0) + code_str = str(code) + import sqlite3 # 本函数多处使用 + + def _db_sector(): + """从 DB 取行业名""" + try: + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + r = _db.execute("SELECT sector_name FROM stock_sectors WHERE code=?", (code_str,)).fetchone() + _db.close() + return r[0] if r else None + except: + return None + + def _fix_one(gate_id, round_num): + """对单个门禁执行修复。round_num越大修复越激进。""" + if gate_id == "GATE_LOSS_EXISTS" and (result.get("stop_loss") or 0) <= 0: + if round_num <= 2: + # Round 1-2: 技术分析算支撑 + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + ws = sr.get("weak_support") + ss = sr.get("strong_support") + if ws and ws > 0: + result["stop_loss"] = round(ws, 2) + elif ss and ss > 0: + result["stop_loss"] = round(ss, 2) + elif price > 0: + result["stop_loss"] = round(price * 0.95, 2) + elif price > 0: + result["stop_loss"] = round(price * 0.95, 2) + else: + # Round 3: 强制fallback + if price > 0: + result["stop_loss"] = round(price * 0.90, 2) # 更宽 + else: + result["stop_loss"] = 1 + print(f" R{round_num} 止损={result.get('stop_loss',0)}", flush=True) + + if gate_id == "GATE_PROFIT_EXISTS" and (result.get("take_profit") or 0) <= 0: + if round_num <= 2: + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + wr = sr.get("weak_resist") + sr_resist = sr.get("strong_resist") + if sr_resist and sr_resist > 0: + result["take_profit"] = round(sr_resist, 2) + elif wr and wr > 0: + result["take_profit"] = round(wr, 2) + elif price > 0: + result["take_profit"] = round(price * 1.08, 2) + elif price > 0: + result["take_profit"] = round(price * 1.08, 2) + else: + if price > 0: + result["take_profit"] = round(price * 1.20, 2) # 更宽 + else: + result["take_profit"] = 2 + print(f" R{round_num} 止盈={result.get('take_profit',0)}", flush=True) + + if gate_id == "GATE_ENTRY_RANGE" and ((result.get("entry_low") or 0) >= (result.get("entry_high") or 0) or (result.get("entry_low") or 0) <= 0): + p = price or 100 + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + if round_num <= 2: + if sl > 0 and tp > 0 and sl < tp: + result["entry_low"] = round(sl * 1.02, 2) + result["entry_high"] = round(tp * 0.85, 2) + if result["entry_low"] >= result["entry_high"]: + result["entry_low"] = round(p * 0.95, 2) + result["entry_high"] = round(p * 0.99, 2) + else: + result["entry_low"] = round(p * 0.93, 2) + result["entry_high"] = round(p * 1.02, 2) + else: + result["entry_low"] = round(p * 0.90, 2) + result["entry_high"] = round(p * 1.10, 2) + print(f" R{round_num} 买入区={result['entry_low']}~{result['entry_high']}", flush=True) + + if gate_id == "GATE_9D_ANALYSIS": + # 行业 + if not result.get("sector_context") or str(result.get("sector_context","")).strip() in ("neutral","","N/A","-"): + sec = _db_sector() + if sec: + result["sector_context"] = sec + elif round_num >= 2: + result["sector_context"] = f"自选(未分类)" + else: + result["sector_context"] = f"{name}所属行业(待补充)" + # signal_factors + if not result.get("signal_factors") or (isinstance(result.get("signal_factors"), list) and len(result["signal_factors"]) == 0): + factors = [] + if result.get("timing_signal"): + factors.append(f"信号:{result['timing_signal']}") + if result.get("rr_ratio", 0) > 0: + factors.append(f"RR:{result['rr_ratio']}") + if result.get("stop_loss", 0) > 0 and result.get("take_profit", 0) > 0: + factors.append(f"损{result['stop_loss']}盈{result['take_profit']}") + if not factors: + if round_num >= 2: + factors.append("自动填充") + else: + # Round 1: 留空等重检,不硬填 + pass + if factors: + result["signal_factors"] = factors + # tech_snapshot + if not result.get("tech_snapshot") or not any(c in str(result.get("tech_snapshot","")) for c in "支撑阻力压强"): + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + if sl > 0 and tp > 0: + result["tech_snapshot"] = f"自动:损{sl}盈{tp}" + elif price: + result["tech_snapshot"] = f"自动:价{price}" + elif round_num >= 2: + result["tech_snapshot"] = "自动生成(未补全技术位)" + print(f" R{round_num} 9维分析: sector={result.get('sector_context','')[:20]} factors={result.get('signal_factors',[])}", flush=True) + + # 循环重试 + MAX_RETRIES = 3 + passed, failures = validate_strategy(result) + retry_count = 0 + + for _retry_num in range(1, MAX_RETRIES + 1): + if passed: + break + + critical_issues = [f["id"] for f in failures if f["severity"] == "CRITICAL"] + if not critical_issues: + # 没有CRITICAL了,只有HIGH/MEDIUM → 可以放行 + passed = True + break + + retry_count = _retry_num + print(f" [RETRY {retry_count}/{MAX_RETRIES}] {name}({code}) → 修复: {critical_issues}", flush=True) + + for gate_id in critical_issues: + _fix_one(gate_id, retry_count) + + # 重检 + passed, failures = validate_strategy(result) + + # --- 最终结果 --- + if passed: + print(f" ✅ {name}({code}) 质量门禁通过 ({retry_count}轮重试)", flush=True) + result["quality_check"] = "passed" + result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + # HIGH 级别警告(已通过但仍有非CRITICAL失败) + high_fails = [f for f in failures if f["severity"] == "HIGH"] + if high_fails: + result["quality_check"] = "warning" + result["quality_issues"] = {"high": [f["id"] for f in high_fails]} + print(f" ⚠️ {name}({code}) 有{len(high_fails)}条HIGH警告", flush=True) + + # 记录 changelog + if "critical_issues" in dir(): + cl = result.setdefault("changelog", []) + cl.append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "event": f"质量门禁通过 (重试{retry_count}轮)", + }) + + result["status"] = "active" + return True + else: + # 3轮全不过 → review_needed + remaining_critical = [f["id"] for f in failures if f["severity"] == "CRITICAL"] + result["quality_check"] = "failed" + result["quality_issues"] = { + "critical": remaining_critical, + "all": [f["id"] for f in failures], + } + result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + result["status"] = "review_needed" + result["timing_signal"] = "信号不充分" + + cl = result.setdefault("changelog", []) + cl.append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "event": f"质量门禁3轮全拒 → review_needed ({remaining_critical})", + }) + + print(f" 🚫 {name}({code}) 3轮修复后仍有 {remaining_critical} → review_needed", flush=True) + return False + + +# is_hk_stock 已从 mo_models 导入(见文件头部 import),不再在此复写。 + + +def calc_atr(code, period=14): + """从腾讯API K线数据计算ATR(period),返回ATR值或None""" + try: + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk{code},day,,,60,qfq" + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=5).read().decode('utf-8') + data = json.loads(resp) + bars = data.get('data', {}).get(f'hk{code}', {}).get('day', []) + if len(bars) < period + 1: + return None + trs = [] + for i in range(1, min(len(bars), period + 1)): + try: + high = float(bars[i][2]) + low = float(bars[i][3]) + prev_close = float(bars[i-1][4]) if len(bars[i-1]) > 4 else float(bars[i-1][3]) + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + trs.append(tr) + except (ValueError, IndexError): + continue + if not trs: + return None + return round(sum(trs) / len(trs), 2) + except Exception: + return None + + +def calc_chip_sr(code, price): + """从筹码分布计算支撑/阻力位。 + + 返回: {"chip_ss": 筹码强支撑, "chip_sr": 筹码强阻力} 或 None + 筹码强支撑 = 当前价下方成交量最大的价格区间 + 筹码强阻力 = 当前价上方成交量最大的价格区间 + + 用法: + sr = calc_chip_sr("600519", 1193) + if sr: + print(f"筹码支撑{sr['chip_ss']} 筹码阻力{sr['chip_sr']}") + """ + if not price or price <= 0: + return None + try: + # 复用chip_factors的筹码分布构建 + import sys as _sys + _sys.path.insert(0, "/home/hmo/MoFin/scripts") + from chip_factors import ChipFactors + cf = ChipFactors() + chip = cf._build_chip_distribution(code) + if not chip: + return None + total = sum(chip.values()) + if total <= 0: + return None + # 2%区间聚合 + step = max(round(price * 0.02, 2), 1.0) + bins = {} + for p, v in chip.items(): + k = round(p / step) * step + bins[k] = bins.get(k, 0) + v + sb = sorted(bins.items()) + below = [(p, v) for p, v in sb if p < price] + above = [(p, v) for p, v in sb if p >= price] + if not below or not above: + return None + + # 支撑 = 下方成交量最大的密集区 + chip_ss = max(below, key=lambda x: x[1])[0] + # 阻力 = 上方成交量最大的密集区 + chip_sr = max(above, key=lambda x: x[1])[0] + + return {"chip_ss": chip_ss, "chip_sr": chip_sr} + except Exception as e: + print(f" ⚠️ 筹码S/R计算失败: {e}", file=sys.stderr) + return None + +# 提示词版本追踪 +try: + from prompt_manager.tracking import record_strategy_generation + HAS_PROMPT_TRACKING = True +except ImportError: + HAS_PROMPT_TRACKING = False + +def safe_json_load(path, default=None): + """安全加载 JSON,遇到坏数据自动修复""" + if not os.path.exists(path): + return default if default is not None else {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + # 尝试修复:替换字符串内未转义的换行符,去多余括号 + with open(path, "r", encoding="utf-8") as f: + raw = f.read() + fixed = raw + + # 修复1: 字符串内未转义的换行 -> \\n + result = [] + in_str = False + for ch in fixed: + if ch == '"': + in_str = not in_str + result.append(ch) + elif in_str and ch in '\n\r': + result.append('\\n') + else: + result.append(ch) + fixed = ''.join(result) + + # 修复2: 去掉多余的尾部括号 + fixed = fixed.rstrip('}') + # 补回正确的闭合 + if not fixed.endswith('}'): + fixed += '}' + + try: + return json.loads(fixed) + except json.JSONDecodeError as e: + print(f"[WARN] watchlist.json 自动修复失败: {e}", file=sys.stderr) + return default if default is not None else {} +KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md" +MACRO_CONTEXT_PATH = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_CONTEXT_PATH = "/home/hmo/web-dashboard/data/market.json" +STOCK_SECTOR_MAP_PATH = "/home/hmo/web-dashboard/data/stock_sector_map.json" + + +def load_stock_sector_map(): + """读取个股归属行业映射 + + stock_sector_map.json 格式: {code: [sector1, sector2, ...]} + 跳过 _note, _created_at 等元数据键。 + """ + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_sector_stocks + conn = get_conn() + # 从 stock_sectors 表反向构建 code→[sectors] 映射 + rows = conn.execute("SELECT code, sector_name FROM stock_sectors ORDER BY code").fetchall() + conn.close() + code_to_sectors = {} + for code, sector in rows: + if code not in code_to_sectors: + code_to_sectors[code] = [] + code_to_sectors[code].append(sector) + return code_to_sectors + except Exception: + pass + try: + with open(STOCK_SECTOR_MAP_PATH) as f: + data = json.load(f) + code_to_sectors = {} + for key, value in data.items(): + if key.startswith("_"): + continue + if isinstance(value, list): + code_to_sectors[key] = value + return code_to_sectors + except Exception: + return {} + + +def load_market_context(): + """读取市场上下文,优先 SQLite,回退 market.json""" + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + market = query_latest_market(conn) + conn.close() + if market and market.get("sectors"): + sector_perf = {} + for s in market["sectors"]: + name = s.get("name", "") + if name: + sector_perf[name] = { + "change": s.get("change_pct", 0), + "up_count": s.get("up_count", 0), + "down_count": s.get("down_count", 0), + "net_inflow": s.get("net_inflow", 0), + "lead_stock": s.get("lead_stock", ""), + "lead_stock_change": s.get("lead_stock_change", 0), + } + return { + "sector_perf": sector_perf, + "breadth": market.get("up_ratio", 50), + "mood": market.get("mood", "neutral"), + "top_gainers": {g["name"]: g["change_pct"] for g in market.get("top_gainers", [])}, + "top_losers": {g["name"]: g["change_pct"] for g in market.get("top_losers", [])}, + "total_sectors": len(market["sectors"]), + "market_timestamp": market.get("timestamp", ""), + } + except Exception: + pass + try: + with open(MARKET_CONTEXT_PATH) as f: + market = json.load(f) + sectors = market.get("sectors", []) + sector_perf = {} + for s in sectors: + name = s.get("name", "") + if name: + sector_perf[name] = { + "change": s.get("change", 0), + "up_count": s.get("up_count", 0), + "down_count": s.get("down_count", 0), + "net_inflow": s.get("net_inflow", 0), + "lead_stock": s.get("lead_stock", ""), + "lead_stock_change": s.get("lead_stock_change", 0), + } + top_gainers = {s.get("name", ""): s.get("change", 0) + for s in market.get("top_gainers", [])} + top_losers = {s.get("name", ""): s.get("change", 0) + for s in market.get("top_losers", [])} + return { + "sector_perf": sector_perf, + "breadth": market.get("up_ratio", 50), + "mood": market.get("mood", "neutral"), + "top_gainers": top_gainers, + "top_losers": top_losers, + "total_sectors": market.get("total_sectors", 0), + "market_timestamp": market.get("timestamp", ""), + } + except Exception: + return { + "sector_perf": {}, + "breadth": 50, + "mood": "neutral", + "top_gainers": {}, + "top_losers": {}, + "total_sectors": 0, + "market_timestamp": "", + } + + +def compute_sector_adjustment(code, market_ctx, stock_sector_map): + """根据个股所属行业的市场表现+小果情感,返回调整系数 + + 返回 dict: + stop_bias: 止损调整系数(<1.0收紧, >1.0放宽) + target_bias: 止盈调整系数 + note: 行业背景一句话 + sector_name: 匹配到的行业名称 + sector_change: 行业涨跌幅 + """ + # 默认无调整 + adj = {"stop_bias": 1.0, "target_bias": 1.0, "note": "", + "sector_name": "", "sector_change": 0} + + sectors_for_code = stock_sector_map.get(code, []) + if not sectors_for_code: + return adj + + sector_perf = market_ctx.get("sector_perf", {}) + breadth = market_ctx.get("breadth", 50) + + # 找第一个能匹配到的行业 + for sec in sectors_for_code: + if sec in sector_perf: + perf = sector_perf[sec] + chg = perf.get("change", 0) + adj["sector_name"] = sec + adj["sector_change"] = chg + + # 行业暴跌 > 3% + if chg <= -3: + adj["stop_bias"] = 0.92 # 止损收紧8% + adj["target_bias"] = 0.90 # 止盈下调10% + adj["note"] = f"行业{sec}大跌{chg:+.1f}%,收紧止损" + # 行业大跌 1~3% + elif chg <= -1: + adj["stop_bias"] = 0.96 + adj["target_bias"] = 0.95 + adj["note"] = f"行业{sec}下跌{chg:+.1f}%,适度防御" + # 行业大涨 > 3% + elif chg >= 3: + adj["stop_bias"] = 1.05 # 止损放宽5%(给趋势空间) + adj["target_bias"] = 1.03 + adj["note"] = f"行业{sec}大涨{chg:+.1f}%,可适度积极" + # 行业上涨 1~3% + elif chg >= 1: + adj["stop_bias"] = 1.02 + adj["note"] = f"行业{sec}上涨{chg:+.1f}%,正常" + else: + adj["note"] = f"行业{sec}{chg:+.1f}%,中性" + break + # 尝试处理命名差异:market.json中的行业名可能多了"板块"后缀 + for market_sec_name in sector_perf: + if sec in market_sec_name or market_sec_name in sec: + perf = sector_perf[market_sec_name] + chg = perf.get("change", 0) + adj["sector_name"] = market_sec_name + adj["sector_change"] = chg + if chg <= -3: + adj["stop_bias"] = 0.92 + adj["target_bias"] = 0.90 + adj["note"] = f"行业{market_sec_name}大跌{chg:+.1f}%,收紧止损" + elif chg <= -1: + adj["stop_bias"] = 0.96 + adj["target_bias"] = 0.95 + adj["note"] = f"行业{market_sec_name}下跌{chg:+.1f}%,适度防御" + elif chg >= 3: + adj["stop_bias"] = 1.05 + adj["target_bias"] = 1.03 + adj["note"] = f"行业{market_sec_name}大涨{chg:+.1f}%,可适度积极" + elif chg >= 1: + adj["stop_bias"] = 1.02 + adj["note"] = f"行业{market_sec_name}上涨{chg:+.1f}%,正常" + else: + adj["note"] = f"行业{market_sec_name}{chg:+.1f}%,中性" + break + + # 如果breath<30% (大盘极弱),再加一层收紧 + if breadth < 30: + adj["stop_bias"] *= 0.97 # 再收紧3% + breadth_note = "大盘仅{}%个股上涨".format(int(breadth)) + adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note + elif breadth < 40: + adj["stop_bias"] *= 0.99 + breadth_note = "大盘偏弱({}%上涨)".format(int(breadth)) + adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note + + # 小果情感约束:利空置信度>80%时收紧止损 + try: + xiaoguo_path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" + if os.path.exists(xiaoguo_path): + xg = json.load(open(xiaoguo_path)) + stock_sentiment = xg.get("stocks", {}).get(code, {}) + if stock_sentiment: + sentiment = stock_sentiment.get("sentiment", "") + confidence = stock_sentiment.get("confidence", 0) + summary = stock_sentiment.get("summary", "") + if sentiment == "negative" and confidence > 0.8: + adj["stop_bias"] = min(adj["stop_bias"], 0.95) + adj["note"] += f" | 小果利空{confidence:.0%}:{summary[:30]}" + except Exception: + pass + + return adj + + +def load_macro_context(): + """读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON""" + try: + import sqlite3 + from pathlib import Path + conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db")) + row = conn.execute( + "SELECT indices, structure FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row: + indices = json.loads(row[0]) if row[0] else {} + structure = json.loads(row[1]) if row[1] else {} + overall = structure.get("overall", "neutral") + desc = structure.get("description", "") + else: + raise ValueError("no db data") + except Exception: + try: + with open(MACRO_CONTEXT_PATH) as f: + ctx = json.load(f) + overall = ctx.get("structure", {}).get("overall", "neutral") + desc = ctx.get("structure", {}).get("description", "") + except Exception: + return 1.0, "宏观未加载" + if "bearish" in overall: + return 0.8, f"宏观{desc}" + elif overall == "bullish": + return 1.05, f"宏观{desc}" + elif overall == "strong_bullish": + return 1.1, f"宏观{desc}" + else: + return 1.0, f"宏观{desc}" + + +def batch_fetch_prices(codes): + """获取实时价格。优先从 DB 读取(price_monitor 每 2 分钟更新),失败才拉腾讯 API。""" + if not codes: + return {} + + all_results = {} + + # 主通道:从 DB 读取(price_monitor 唯一价格入口) + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + for raw_code in codes: + raw_code = str(raw_code).split('_')[0] + if not raw_code: continue + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (raw_code,) + ).fetchone() + if not row: + row = db.execute( + "SELECT price, change_pct FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,) + ).fetchone() + if row and row['price']: + all_results[raw_code] = { + "price": row['price'], + "close": row['price'], # 用现价近似昨收,仅用于sentiment计算 + "high": row['price'], + "low": row['price'], + "code": raw_code, + } + db.close() + if all_results: + return all_results + except Exception: + pass + + # Fallback: 腾讯 API(仅当 DB 无数据时) + batch_size = 15 + for batch_start in range(0, len(codes), batch_size): + batch = codes[batch_start:batch_start + batch_size] + symbols = [] + code_map = {} + for raw_code in batch: + raw_code = str(raw_code).split('_')[0] + if not raw_code: + continue + if len(raw_code) == 5 and raw_code.isdigit(): + prefix = "hk" + elif raw_code.startswith(("6", "5")): + prefix = "sh" + else: + prefix = "sz" + sym = f"{prefix}{raw_code}" + symbols.append(sym) + code_map[sym] = raw_code + if not symbols: + continue + + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + max_retries = 2 + for attempt in range(max_retries + 1): + try: + r = urllib.request.urlopen(url, timeout=10) + text = r.read().decode("gbk") + except Exception as e: + if attempt < max_retries: + continue + print(f" batch_fetch_prices error: {e}", file=sys.stderr) + continue + + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + try: + sym = line.split("=", 1)[0].strip().lstrip("v_") + raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw_value.split("~") + if len(fields) < 35: + continue + orig_code = code_map.get(sym) + if not orig_code: + continue + def f(i): + try: + return float(fields[i]) if fields[i].strip() else 0.0 + except: + return 0.0 + price_raw = f(3) + # 港股:腾讯 API 返回 HKD,需转 CNY + if is_hk_stock(orig_code) and price_raw > 0: + price_raw = to_cny(price_raw) + all_results[orig_code] = { + "price": price_raw, "close": f(4), "high": f(33), "low": f(34), + "code": orig_code, + } + except Exception: + continue + break # Success - break retry loop + + return all_results + + +def get_price_tencent(code): + """获取实时价格。优先 DB(price_monitor 维护),失败才拉腾讯。港股价格已是 CNY。""" + raw_code = str(code).split('_')[0] + if not raw_code: + return None + + # 主通道: DB + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (raw_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", (raw_code,)).fetchone() + if row and row['price']: + db.close() + return row['price'] + db.close() + except Exception: + pass + + # Fallback: 腾讯 API + try: + from mo_models import to_cny, is_hk_stock + except ImportError: + to_cny = lambda v, r=None: v + is_hk_stock = lambda c: len(str(c).strip()) == 5 and str(c).strip().isdigit() + try: + if is_hk_stock(raw_code): + prefix = "hk" + elif raw_code.startswith("6") or raw_code.startswith("5"): + prefix = "sh" + else: + prefix = "sz" + url = f"http://qt.gtimg.cn/q={prefix}{raw_code}" + r = urllib.request.urlopen(url, timeout=5) + fields = r.read().decode("gbk").split('"')[1].split("~") + def f(i): + try: + return float(fields[i]) if fields[i].strip() else 0.0 + except: + return 0.0 + price = f(3) + if is_hk_stock(raw_code) and price > 0: + price = to_cny(price) + return { + "price": price, "close": f(4), "high": f(33), "low": f(34), + "code": raw_code, + } + except Exception as e: + print(f" get_price error {code}: {e}", file=sys.stderr) + return None + + +def reassess_strategy(code, name, price, cost, shares, current_action, + volume_signal="", sentiment="neutral", + is_watchlist=False): + """根据技术分析重评策略""" + + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + candle = tech.get("candlestick", {}) + vol = tech.get("volume", {}) + vol_deep = tech.get("volume_deep", {}) + ss = sr.get("strong_support") + ws = sr.get("weak_support") + wr = sr.get("weak_resist") + sr_resist = sr.get("strong_resist") + pivot = sr.get("pivot") + effective_range = sr.get("effective_range") + print(f" TECH: 强撑={ss} 弱撑={ws} 枢轴={pivot} 弱压={wr} 强压={sr_resist} 有效区间={effective_range}") + else: + print(f" ⚠️ 技术分析不可用", file=sys.stderr) + ss = ws = wr = sr_resist = pivot = None + candle = {} + vol = {} + + # ----- 多周期技术分析(周线/月线/均线) ----- + mtf_analysis = {} + mtf_adj = {} + try: + mtf_result = mtf.full_multi_tf_analysis(code) + if mtf_result.get("daily") and mtf_result["daily"].get("count", 0) >= 5: + mtf_analysis = mtf_result + mtf_adj = mtf_result.get("strategy_adjustment", {}) + daily_mas = mtf_result.get("daily", {}).get("mas", {}) + weekly = mtf_result.get("weekly", {}) + monthly = mtf_result.get("monthly", {}) + trend_align = mtf_adj.get("trend_alignment", "未知") + print(f" 多周期: {trend_align} | " + f"MA5={daily_mas.get('ma5','?')} MA20={daily_mas.get('ma20','?')} MA60={daily_mas.get('ma60','?')} | " + f"周线{weekly.get('trend',{}).get('description','?')} 月线{monthly.get('trend',{}).get('description','?')}") + except Exception as e: + print(f" 多周期分析失败: {e}", file=sys.stderr) + + # ----- 筹码分布支撑/阻力(中长线参考,加情景权重) ----- + chip_sr = None + chip_weight = 0.5 # 默认中等权重 + regime = detect_scenario() + regime_id = regime.get("id", "weak_consolidation") + + # 情景决定筹码因子权重 + if regime_id == "weak_consolidation": + chip_weight = 0.9 # 震荡市筹码最准 + elif regime_id == "bullish_recovery": + chip_weight = 0.4 # 上涨趋势筹码阻力可能被突破 + elif regime_id == "sharp_decline": + chip_weight = 0.2 # 急跌中筹码支撑可能失效 + elif regime_id == "sector_rotation": + chip_weight = 0.6 # 轮动市中筹码有一定参考 + + try: + chip_sr = calc_chip_sr(code, price) + if chip_sr: + print(f" 筹码: 撑={chip_sr['chip_ss']:.0f} 阻={chip_sr['chip_sr']:.0f} | 情景={regime_id} 权重={chip_weight:.1f}") + # 与枢轴点对比 + if ss and ws and pivot and chip_sr['chip_ss'] > 0 and chip_sr['chip_sr'] > 0: + chip_ss_pct = (price - chip_sr['chip_ss']) / price * 100 + chip_sr_pct = (chip_sr['chip_sr'] - price) / price * 100 + + # 共振检测:筹码支撑 vs 枢轴弱支撑(都是最近支撑位) + if ws and ws > 0: + resonance_ss = abs(chip_ss_pct - ((price - ws) / price * 100)) < 3 + else: + resonance_ss = False + # 共振检测:筹码阻力 vs 枢轴弱阻力(都是最近阻力位) + if wr and wr > 0: + resonance_sr = abs(chip_sr_pct - ((wr - price) / price * 100)) < 3 + else: + resonance_sr = False + + if resonance_ss and chip_weight >= 0.5: + print(f" ⚡ 支撑共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_ss']:.0f}") + elif chip_weight < 0.5: + print(f" 📎 支撑一致但权重低({chip_weight:.1f}): {regime_id}下筹码支撑不可靠") + + if resonance_sr and chip_weight >= 0.5: + print(f" ⚡ 阻力共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_sr']:.0f}") + elif chip_weight < 0.5: + print(f" 📎 阻力一致但权重低({chip_weight:.1f}): {regime_id}下筹码阻力不可靠") + except Exception: + pass + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_new_entry = (cost == 0) or (shares == 0) + is_deep_loss = profit_pct < -20 + + # ----- 股票分类(短炒/中短线/中长线/弱势/深套) ----- + stock_category = "中短线" + time_horizon = "2周~3月" + position_advice = "中等仓位" + try: + mtf_cache = mtf._load_mtf_cache() + stock_data = mtf_cache.get(code, {}) + daily_klines = stock_data.get("daily", []) + fund = stock_data.get("fundamentals", {}) + closes = [d["close"] for d in daily_klines] if daily_klines else [] + + if len(closes) >= 10: + cur = closes[-1] + ma20 = sum(closes[-20:])/20 if len(closes)>=20 else 0 + ma60 = sum(closes[-60:])/60 if len(closes)>=60 else 0 + highs = [d["high"] for d in daily_klines[-20:]] + lows = [d["low"] for d in daily_klines[-20:]] + volatility = ((max(highs)-min(lows))/min(lows)*100) if min(lows)>0 else 0 + pe = fund.get("pe") or 0 + eps = fund.get("eps") or 0 + mcap = fund.get("mcap_total") or 0 + is_high_vol = volatility > 30 + is_high_pe = pe > 100 or pe < 0 + is_value = 0 < pe < 20 and eps > 0.5 + + if is_deep_loss: + stock_category = "深套" + time_horizon = "长期" + position_advice = "不补不割" + elif is_high_vol and is_high_pe: + stock_category = "短炒" + time_horizon = "数日~2周" + position_advice = "小仓快进快出" + elif cur < ma20 and cur < ma60 and ma20 > 0: + stock_category = "弱势" + time_horizon = "观望" + position_advice = "减仓或观望" + elif (is_value or mcap > 1000) and cur > ma20: + stock_category = "中长线" + time_horizon = "数月~1年" + position_advice = "正常配置" + elif volatility > 20: + stock_category = "中短线" + time_horizon = "2~6周" + position_advice = "中等仓位" + except Exception: + pass + + print(f" 分类: {stock_category} | {time_horizon} | {position_advice}") + + # ----- 短炒+强趋势检测:短炒分类但多周期多头时用移动止损代替弱支撑止损 ----- + is_short_term_strong_trend = False + if stock_category == "短炒": + trend_align = mtf_adj.get("trend_alignment", "") + strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] + if any(ind in trend_align for ind in strong_trend_indicators): + is_short_term_strong_trend = True + print(f" ⚡ 短炒+强趋势检测: 趋势={trend_align} → 启用移动止损, 不止盈") + position_advice = "小仓强趋势让利润跑" + + # ----- 止损设置(含最小距离3%保护) ----- + if is_new_entry: + # 新买入推荐:止损 = 弱支撑(约2-3%跌幅,合理可控) + if ws and ws > 0: + new_stop = round(ws, 2) + else: + new_stop = round(price * 0.96, 2) + elif is_deep_loss: + # 深套:止损 = 强支撑再下移(不轻易割) + if ss and ss > 0: + new_stop = round(min(ss, price * 0.85), 2) + else: + new_stop = round(price * 0.85, 2) + else: + # 已持仓正常:止损 = 强支撑 + if is_short_term_strong_trend: + # 短炒+强趋势:用移动止损(距现价-5%),不止盈让利润跑 + trailing_sl = round(max(ws or 0, price * 0.95), 2) if ws else round(price * 0.95, 2) + new_stop = trailing_sl + print(f" 短炒强趋势移动止损: {new_stop} (距现价-{(1-new_stop/price)*100:.1f}%)") + elif ss and ss > 0: + new_stop = round(ss, 2) + else: + new_stop = round(price * 0.88, 2) + + # 已盈利仓位(>5%):用较紧的移动止损保护利润,但不超过成本线 + if profit_pct > 5 and not is_new_entry and not is_deep_loss: + # 取 max(弱支撑, 成本线, 当前价×0.95) 作为止损 + cost_protect = cost if cost > 0 else 0 + trailing_stop = round(max(ws or 0, cost_protect, price * 0.95), 2) + if trailing_stop > new_stop: + new_stop = trailing_stop + print(f" 已启用移动止损: {new_stop} (保护+{profit_pct:.1f}%利润)", file=sys.stderr) + + # 最小止损距离 —— 随趋势强度调整(2026-06-23 震度保护规则) + # 强趋势(多周期看多 + MA多头排列):最小1.5%下行空间 + # 普通/弱势:最小3%下行空间 + is_strong_trend = False + trend_align = mtf_adj.get("trend_alignment", "") + strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] + try: + if any(ind in trend_align for ind in strong_trend_indicators) and ma20 > ma60 and cur >= ma20: + is_strong_trend = True + except (NameError, TypeError): + pass # ma20/ma60/cur may be unbound if MTF data insufficient + + if is_strong_trend: + min_stop_gap = 0.015 # 1.5% + else: + min_stop_gap = 0.03 # 3% + + min_stop = round(price * (1 - min_stop_gap), 2) + if new_stop > min_stop and not is_deep_loss: + old_stop = new_stop + new_stop = min_stop + if old_stop != new_stop: + print(f" 最小止损 {round(min_stop_gap*100)}%间距约束: {old_stop}→{new_stop} (趋势{'强' if is_strong_trend else '普通'})") + + # 港股附加:ATR波动率校验 — 止损距现价不得小于 1×ATR(14) + if is_hk_stock(code): + atr = calc_atr(code) + if atr and atr > 0: + min_atr_stop = round(price - atr, 2) + if new_stop > min_atr_stop: + old_stop_val = new_stop + new_stop = min_atr_stop + print(f" 港股ATR波动率校验({atr:.2f}): 止损 {old_stop_val}→{new_stop} (1×ATR间距)") + + # ----- 止盈设置 ----- + if is_short_term_strong_trend and not is_new_entry: + # 短炒+强趋势:不止盈让利润跑 + mtf_tp = mtf_adj.get("take_profit_reference", {}) + if mtf_tp and mtf_tp.get("level", 0) > price * 1.2: + new_target = round(mtf_tp["level"], 2) + else: + new_target = 0 # 无多周期阻力时不编造止盈 + print(f" 短炒强趋势不止盈: 止盈设为{new_target} (+{(new_target/price-1)*100:.0f}%)") + elif sr_resist and sr_resist > 0: + new_target = round(sr_resist, 2) + else: + new_target = 0 # 无技术面数据时不编造止盈 + + # ----- 风险回报比校验 ----- + stop_distance = price - new_stop if price > new_stop else price * 0.02 + target_distance = new_target - price if new_target > price else 0 + + # 1:2 检查 + min_target_distance = stop_distance * 2.0 + if target_distance < min_target_distance: + # 尝试更高的阻力位,但不超过下一个真实压力位 + candidate_targets = [] + if wr and wr > price and wr != sr_resist: + candidate_targets.append(wr) + if sr_resist and sr_resist > price: + candidate_targets.append(sr_resist) + # 检查有效区间,如果有更高的自然目标位 + if effective_range and price < effective_range * 0.9: + candidate_targets.append(effective_range) + + found = False + for level in candidate_targets: + if (level - price) >= min_target_distance: + new_target = level + found = True + break + + # 如果仍然不满足,检查是否至少能到 1:1.5 + min15_distance = stop_distance * 1.5 + if not found: + for level in candidate_targets: + if (level - price) >= min15_distance: + new_target = level + found = True + break + + # ----- 风险回报比最终计算 ----- + risk = max(price - new_stop, price * 0.01) + reward = max(new_target - price, 0) + rr_ratio = reward / risk if risk > 0 else 0 + + # ----- 状态判断 ----- + if is_deep_loss: + status = "updated" + action_note = "深套持有" + elif is_new_entry: + if rr_ratio < 1.5: + status = "review" + action_note = "⚠️盈亏比不足1:1.5,不建议买入" + elif rr_ratio < 2.0: + status = "updated" + action_note = "⚠️盈亏比偏低(1:{:.1f}),谨慎买入".format(rr_ratio) + else: + status = "updated" + action_note = "" + else: + if rr_ratio < 0.5: + status = "updated" + action_note = "⚠️盈亏比极低,关注" + elif rr_ratio < 1.5: + status = "updated" + action_note = "⚠️盈亏比偏低(1:{:.1f}),不建议加仓".format(rr_ratio) + else: + status = "updated" + action_note = "" + + # 短炒+强趋势:在action_note追加标记 + if is_short_term_strong_trend and not is_new_entry and not is_deep_loss: + extra_note = "短炒强趋势持" if "深套" not in action_note else "" + if extra_note: + action_note = f"{action_note} | {extra_note}" if action_note else extra_note + + # ----- 买入区间(有盈亏比严格约束) ----- + max_acceptable_entry = None # 最大可接受买入价(满足R/R约束) + + if new_target and new_stop and new_target > new_stop and not is_deep_loss: + # 买入价的R/R约束: + # 要求 (target - entry) / (entry - stop) >= min_rr + # 即 entry <= (target + min_rr * stop) / (1 + min_rr) + min_rr = 1.0 # 至少1:1,才不亏 + recommend_rr = 1.5 # 推荐1:1.5以上 + + max_for_recommend = (new_target + recommend_rr * new_stop) / (1 + recommend_rr) + max_for_neutral = (new_target + min_rr * new_stop) / (1 + min_rr) + + if is_new_entry: + # 新买入:要求1:1.5+ + max_acceptable_entry = max_for_recommend + else: + # 已持仓加仓:至少1:1 + max_acceptable_entry = max_for_neutral + + if is_new_entry: + # 新买入:买入区 = 弱支撑附近(不是当前价附近!) + # 只在价格跌到弱支撑附近时才推买入 + entry_low = round(price * 0.98, 2) + entry_high = round(price * 1.02, 2) + if max_acceptable_entry and entry_high > max_acceptable_entry: + entry_high = round(max_acceptable_entry, 2) + # 确保买入区不小于1% + if entry_high - entry_low < price * 0.01: + if max_acceptable_entry and price <= max_acceptable_entry: + entry_low = round(max(price * 0.99, new_stop), 2) + entry_high = round(min(price * 1.01, max_acceptable_entry), 2) + elif ws and ws > 0 and wr and wr > 0 and not is_deep_loss: + # 已持仓正常:买入区 = 弱支撑~弱支撑上方5%(给合理回调空间) + # 上限不能低于成本价×0.95(保护已有持仓不被高位逼空) + entry_low = round(ws, 2) + entry_max = round(ws * 1.05, 2) # 比弱支撑高5%,有足够空间 + # 如果当前价已远离买入区,保持买入区不变(不因价格涨了就收窄) + min_upper = round(cost * 0.95, 2) if cost > 0 else 0 + if entry_max < min_upper: + entry_max = min_upper + if max_acceptable_entry: + entry_high = round(min(entry_max, max_acceptable_entry), 2) + else: + entry_high = entry_max + # 如果当前价已远离买入区(高于买入区上沿),禁止加仓推荐 + if price > entry_high: + # 买入区锁定在弱支撑位,但标记为"价格远离" + pass + # 如果买入区过窄,标记但不扩展(加仓必须在支撑位) + if entry_high - entry_low < price * 0.005: + entry_low = round(ws * 0.995, 2) + entry_high = round(ws * 1.005, 2) + else: + entry_low = round(price * 0.90, 2) + entry_high = round(price * 1.05, 2) + + # 买入区间稳定性保护:上边界单次变动不超过5% + if 'entry_high' in dir() and entry_high: + # 读取当前策略中已有的买入区上界,如果有且变化过大则限制 + old_entry_high = None + if 'current_action' in dir() and current_action: + import re + m = re.search(r'买入区[\d.]+~([\d.]+)', current_action) + if m: + old_entry_high = float(m.group(1)) + if old_entry_high and old_entry_high > 0: + max_change = old_entry_high * 0.95 # 单次最多下降5% + if entry_high < max_change: + entry_high = round(max_change, 2) + + # ----- 买入时机信号(三维分析:大盘+行业+个股,基本面+消息面+技术面+资金流)----- + # [2026-07-01] 扩展:不再只看volume_signal + candlestick_sentiment + # 融合大盘趋势、行业板块强弱、基本面估值作为修正因子 + volume_signal = vol.get("volume_signal", "") + candlestick_sentiment = candle.get("sentiment", "neutral") + + # ----- 深度量价分析(2026-07-08 新增:放量建仓/出货/洗盘检测)----- + deep_vol = {} + vol_pattern_note = "" + try: + from technical_analysis import analyze_volume_deep + deep_vol = analyze_volume_deep(code) + if deep_vol.get("volume_signal"): + vp = deep_vol.get("price_vol_description", "") + patterns = deep_vol.get("patterns", {}) + vol_pattern_note = vp + # 合并到volume_signal + if volume_signal: + volume_signal += f" | {vp}" if vp else "" + else: + volume_signal = vp or "" + except Exception: + pass + + timing_signal = "neutral" + + # --- 三维分析数据装载 --- + # 因子1: 大盘环境(从macro_context_log读) + market_bearish = False + market_bullish = False + try: + import sqlite3 + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _mc = _db.execute( + "SELECT structure FROM macro_context_log WHERE has_valid_data=1 ORDER BY rowid DESC LIMIT 1" + ).fetchone() + if _mc and _mc[0]: + _s = json.loads(_mc[0]) + _overall = _s.get("overall", "") + if "bearish" in _overall: + market_bearish = True + elif _overall == "bullish": + market_bullish = True + _db.close() + except Exception: + pass + + # 因子2: 行业板块强弱 + sector_strong = False + sector_weak = False + try: + _db2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _rows2 = _db2.execute( + "SELECT name, change_pct FROM sector_snapshots ORDER BY change_pct DESC" + ).fetchall() + if _rows2: + # 找到该股所属行业(简单匹配name或通过stock_sectors) + _my_sectors = _db2.execute( + "SELECT sector_name FROM stock_sectors WHERE code=?", + (code,) + ).fetchall() + if _my_sectors: + for (_sn,) in _my_sectors: + for r_name, r_chg in _rows2: + if _sn in r_name or r_name in _sn: + _rank = [r[0] for r in _rows2].index(r_name) if r_name in [x[0] for x in _rows2] else -1 + _total = len(_rows2) + if _rank >= 0: + if _rank < _total * 0.2: + sector_strong = True + if _rank > _total * 0.8: + sector_weak = True + break + _db2.close() + except Exception: + pass + + # 因子3: 基本面估值 + is_value_stock = False + try: + _db3 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _fd = _db3.execute( + "SELECT pe, eps FROM stock_fundamentals WHERE code=?", (code,) + ).fetchone() + if _fd: + _pe, _eps = _fd + is_value_stock = (0 < (_pe or 0) < 25 and (_eps or 0) > 0.3) + _db3.close() + except Exception: + pass + + # --- 三维修正规则 --- + # 大盘偏弱时收紧买入信号,大盘偏强时放宽 + # 行业领先加分,行业落后减分 + # 低估值加分(有安全边际) + + def _adjust_timing(signal, market_b, market_bb, sec_s, sec_w, is_val): + """根据三维因子修正 timing_signal""" + # 大盘偏弱时降级买入信号 + if market_b: + if signal in ("买入", "加仓"): + if not sec_s: # 大盘弱+行业不强→降级 + return "关注" + # 大盘偏强时放宽 + if market_bb: + if signal == "关注" and (sec_s or is_val): + return "买入" + # 行业弱势时降级买入信号 + if sec_w: + if signal in ("买入", "加仓"): + return "关注" + # 行业强势+低估时升级关注 + if sec_s and is_val: + if signal == "关注": + return "买入" + return signal + + if is_new_entry: + # 新买入时机 + if volume_signal == "主动买盘占优" and candlestick_sentiment == "bullish": + timing_signal = "买入" + elif volume_signal == "主动卖盘占优": + timing_signal = "观望" + elif volume_signal == "买卖均衡" and ws and price <= ws * 1.03: + timing_signal = "买入" + elif candlestick_sentiment == "bullish": + timing_signal = "买入" + elif ws and price < ws * 1.02: + timing_signal = "关注" + # 新买入时三维修正:大盘向上+行业强→升级,大盘弱→降级 + _pre_signal = timing_signal + timing_signal = _adjust_timing(timing_signal, market_bearish, market_bullish, + sector_strong, sector_weak, is_value_stock) + if timing_signal != _pre_signal: + print(f" 三维修正(新入): {_pre_signal}→{timing_signal} " + f"| 大盘{'弱' if market_bearish else '强' if market_bullish else '中性'}" + f"| 行业{'强' if sector_strong else '弱' if sector_weak else '中性'}" + f"| 估值{'低' if is_value_stock else '一般'}") + else: + # 已持仓时机(用于加仓/减仓参考) + if is_short_term_strong_trend: + # 短炒+强趋势:强趋势持有,禁止加仓信号 + timing_signal = "持有" + elif profit_pct > 5: + # 已盈利 + if volume_signal == "主动买盘占优": + timing_signal = "持有" + elif volume_signal == "主动卖盘占优" and not is_new_entry: + timing_signal = "关注" + else: + timing_signal = "持有" + elif profit_pct > 0: + # 微盈 + if volume_signal == "主动买盘占优": + timing_signal = "持有" + elif ws and price <= ws * 1.02: + timing_signal = "加仓" + else: + timing_signal = "持有" + else: + # 浮亏 + if volume_signal == "主动卖盘占优" and ss and price <= ss * 1.03: + timing_signal = "关注" + elif volume_signal == "主动买盘占优" and sr_resist and price >= sr_resist * 0.97: + timing_signal = "关注" + elif volume_signal == "买卖均衡" and ws and price <= ws * 1.02: + timing_signal = "加仓" + else: + timing_signal = "持有" + + # ----- 【v3.2新增】分类约束:弱势/深套禁止输出买入/加仓类信号 ----- + if stock_category == "弱势" or is_deep_loss: + buy_signals = ["买入", "加仓", "可追"] + if any(s in timing_signal for s in buy_signals): + old_signal = timing_signal + timing_signal = "弱势持有" if stock_category == "弱势" else "深套持有" + print(f" 分类约束: {stock_category} 原信号\"{old_signal}\" → \"{timing_signal}\"") + + # ----- 构造 action 描述(供 cron prompt 使用) ----- + action_parts = [] + if profit_pct < -20: + action_parts.append("深套持有") + elif profit_pct < -10: + action_parts.append("持有观察") + elif profit_pct < 0: + action_parts.append("持有观察") + elif profit_pct < 5: + action_parts.append("盈利持有") + else: + action_parts.append("盈利良好") + + if action_note: + action_parts.append(action_note) + + # 量价分析描述(如有,注入action) + if vol_pattern_note: + action_parts.append(vol_pattern_note) + + if is_watchlist: + # 自选股(未入场):有止损参考+买入区,内部算RR需要止盈位 + action_parts.append(f"目标参考{new_target}") + action_parts.append(f"止损参考{new_stop}") + action_parts.append(f"买入区{entry_low}~{entry_high}") + elif is_new_entry: + action_parts.append(f"损{new_stop}") + action_parts.append(f"盈{new_target}") + action_parts.append(f"买{entry_low}~{entry_high}") + else: + action_parts.append(f"止损{new_stop}") + action_parts.append(f"目标{new_target}") + action_parts.append(f"买入区{entry_low}~{entry_high}") + + if timing_signal != "neutral": + action_parts.append(f"信号:{timing_signal}") + + new_action = " | ".join(action_parts) + + # 技术面快照 + tech_snapshot = "" + if candle: + tech_snapshot = (f"形态:{candle.get('pattern','?')}/{candle.get('sentiment','?')} " + f"量价:{vol.get('volume_signal','?')} " + f"强撑:{ss} 弱撑:{ws} 弱压:{wr} 强压:{sr_resist}") + # 加入均线信息(如果可用) + try: + dm = mtf_analysis.get("daily", {}).get("mas", {}) + ma_parts = [] + for m in ['ma5', 'ma10', 'ma20', 'ma60']: + v = dm.get(m) + if v: + ma_parts.append(f"{m.upper()}={v}") + if ma_parts: + tech_snapshot += " | " + " ".join(ma_parts) + except (NameError, AttributeError): + pass + + # 多周期快照(追加到 tech_snapshot) + mtf_context = "" + if mtf_adj: + trend_align = mtf_adj.get("trend_alignment", "") + daily_mas = mtf_analysis.get("daily", {}).get("mas", {}) + ma20 = daily_mas.get("ma20") + ma60 = daily_mas.get("ma60") + stop_ref = mtf_adj.get("stop_loss_reference", {}) + take_ref = mtf_adj.get("take_profit_reference", {}) + + parts = [] + if trend_align: + parts.append(trend_align) + if ma20: + parts.append(f"MA20={ma20}") + if ma60: + parts.append(f"MA60={ma60}") + if stop_ref: + parts.append(f"长撑:{stop_ref.get('source','?')}={stop_ref['level']}") + if take_ref: + parts.append(f"长压:{take_ref.get('source','?')}={take_ref['level']}") + mtf_context = " | ".join(parts) + + now_str = datetime.now().strftime('%Y-%m-%d %H:%M') + return { + 'stop_loss': new_stop, + 'take_profit': new_target, + 'entry_low': entry_low, + 'entry_high': entry_high, + 'action': new_action, + 'status': status, + 'tech_snapshot': tech_snapshot, + 'timing_signal': timing_signal, + 'rr_ratio': round(rr_ratio, 2), + 'action_note': action_note, + 'reassessed_at': now_str, + 'multi_tf_context': mtf_context, # 多周期上下文 + 'stock_category': stock_category, # 股票分类:短炒/中短线/中长线/弱势/深套 + 'time_horizon': time_horizon, # 时间跨度 + 'position_advice': position_advice, # 仓位建议 + } + + +def load_stock_news_sentiment(code): + """加载小果消息面情感""" + try: + path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" + if not os.path.exists(path): + return {} + xg = json.load(open(path)) + return xg.get("stocks", {}).get(code, {}) + except Exception: + return {} + + +def load_fundamentals(code): + """加载个股基本面""" + try: + cache = mtf._load_mtf_cache() + return cache.get(code, {}).get("fundamentals", {}) or {} + except Exception: + return {} + + +def _get_portfolio_risk_state(): + """读取 portfolio 组合风险状态(2026-06-23 引擎协调)""" + try: + # 数据一致性检查:警告多副本(2026-06-23 bugfix) + _check_portfolio_consistency() + p = read_portfolio() + pos_pct = p.get('position_pct', 0) + cash = p.get('cash', 0) + holdings = p.get('holdings', []) + weak_cnt = sum(1 for h in holdings if h.get('change_pct', 0) < -15) + total = len(holdings) or 1 + weak_ratio = weak_cnt / total + return { + 'position_pct': pos_pct, + 'cash': cash, + 'is_high_position': pos_pct > 80, + 'is_very_high_position': pos_pct > 90, + 'is_high_weak': weak_ratio > 0.35, + 'weak_ratio': round(weak_ratio * 100), + 'total_holdings': total, + } + except: + return {} + + +def _is_buy_signal(signal): + """判断信号是否为买入/持有类(用于防洗盘)""" + if not signal: + return False + buy_keywords = ['买入', '持有', '加仓', '关注'] + for kw in buy_keywords: + if kw in signal: + return True + return False + + +def _check_portfolio_consistency(): + """数据一致性检查:如果存在多份 portfolio.json 则报警(2026-06-23 bugfix)""" + main = '/home/hmo/web-dashboard/data/portfolio.json' + main_cash = None + try: + import json + main_cash = json.load(open(main)).get('cash') + except Exception: + return + for path in [ + '/home/hmo/data/portfolio.json', + '/home/hmo/projects/MoFin/data/portfolio.json', + '/home/hmo/web-dashboard.bak/data/portfolio.json', + ]: + if os.path.exists(path): + try: + other = json.load(open(path)) + if other.get('cash') != main_cash: + print(f"⚠️ 数据一致性: {os.path.realpath(path)} cash={other.get('cash')} ≠ 主文件 cash={main_cash} (需清理)", file=sys.stderr) + except Exception: + pass + + +def _check_contradiction(code, today_only=True): + """反馈循环核——检查本股是否有刚卖出的记录 + + 返回 dict or None: + - sold_reason: 'portfolio_trim'|'stop_loss' + - sold_at: 卖出日期 + - days_ago: 卖出距今交易日数 + - is_today: 是否今日卖出 + - tag: 追加到信号的标注 + """ + try: + from datetime import datetime, date + dec = read_decisions() + for e in dec.get('decisions', []): + if e.get('code') != code: + continue + sold_at = e.get('sold_at', '') + if not sold_at: + return None + try: + sd = datetime.strptime(sold_at, '%Y-%m-%d').date() + td = date.today() + days = (td - sd).days + except: + return None + + reason = e.get('sold_reason', 'portfolio_trim') + if reason == 'stop_loss': + tag = '止损离场(逻辑破坏,短期不关注)' + else: + tag = '组合减仓后关注(已清仓,等回踩确认)' + + return { + 'sold_reason': reason, + 'sold_at': sold_at, + 'days_ago': days, + 'is_today': days == 0, + 'tag': tag, + } + except: + return None + return None + + +def _get_sell_priority_list(): + """减仓优先级排序:深套>亏损>微盈>盈利(2026-06-23 反馈循环) + + 返回 [(code, name, change_pct, position_pct, priority_label), ...] + 按卖出的优先顺序排列(最先应该卖的在最前) + """ + try: + p = read_portfolio() + holdings = p.get('holdings', []) + ranked = [] + for h in holdings: + chg = h.get('change_pct', 0) + pos = h.get('position_pct', 0) + if chg < -30: + label = '深套(>30%),优先减' + rank = 0 + elif chg < -20: + label = '深套(>20%),优先减' + rank = 1 + elif chg < -10: + label = '亏损,建议减' + rank = 2 + elif chg < 0: + label = '微亏,可减' + rank = 3 + elif chg < 10: + label = '微盈,持有' + rank = 4 + else: + label = '盈利,最后减' + rank = 5 + ranked.append((rank, h['code'], h.get('name',''), chg, pos, label)) + ranked.sort(key=lambda x: (x[0], -x[4])) # 优先 rank, 其次仓位大优先 + return [{'code':c,'name':n,'change_pct':chg,'position_pct':pos,'label':l} + for r,c,n,chg,pos,l in ranked] + except: + return [] + + +def enrich_timing_signal(base_signal, macro_desc="", sector_note="", + profit_pct=0, stock_category="", is_new_entry=False, + fundamentals=None, news_sentiment=None, + timing_signal_override=None, + portfolio_context=None, + rr_ratio=0): # 2026-06-24 新参:盈亏比约束 + """多因子合成timing_signal——大盘+行业+基本面+技术+组合风险+盈亏比 + + 返回 (enriched_signal, factors_list) + - enriched_signal: 可读的多因子信号描述 + - factors_list: 各因子的摘要列表(用于后续显示) + """ + # 如果已手动设定,尊重手动 + if timing_signal_override and timing_signal_override != "neutral": + return timing_signal_override, [timing_signal_override] + + factors = [] + + # 1. 大盘因子 + if "偏强" in macro_desc or "大涨" in macro_desc or "bullish" in macro_desc.lower(): + macro_txt = "大盘偏强" + factors.append(macro_txt) + elif "偏弱" in macro_desc or "大跌" in macro_desc or "bearish" in macro_desc.lower(): + macro_txt = "大盘偏弱" + factors.append(macro_txt) + elif macro_desc and macro_desc != "宏观未加载": + factors.append("大盘中性") + + # 2. 行业因子 + if sector_note: + # 把"行业X大跌3%+"简化为"行业偏弱","行业X大涨3%+"简化为"行业偏强" + if "大跌" in sector_note or "下跌" in sector_note: + factors.append("行业偏弱") + elif "大涨" in sector_note: + factors.append("行业偏强") + elif "上涨" in sector_note: + factors.append("行业偏强") + else: + factors.append("行业中性") + + # 3. 基本面因子 + if fundamentals: + pe = fundamentals.get("pe", 0) + eps = fundamentals.get("eps", 0) + profit_growth = fundamentals.get("profit_growth", fundamentals.get("yoy_profit", "")) + revenue_growth = fundamentals.get("revenue_growth", fundamentals.get("yoy_revenue", "")) + mcap = fundamentals.get("mcap_total", 0) + + pe = pe or 0 + eps = eps or 0 + profit_growth_str = str(profit_growth or "") + revenue_growth_str = str(revenue_growth or "") + + # 净利增长 + for val in [profit_growth_str, revenue_growth_str]: + try: + v = float(val.replace("%", "").replace("+", "")) + if v > 50: + factors.append("净利增50%+") + break + elif v > 20: + factors.append(f"净利增{int(v)}%") + break + elif v < -20: + factors.append("净利降20%+") + break + except (ValueError, AttributeError): + continue + + # PE估值 + if 0 < pe < 15: + factors.append("低估值") + elif pe > 100 or pe < 0: + factors.append("高估值") + + # 市值 + if mcap and mcap > 5000: + factors.append("蓝筹") + + # 4. 消息面因子(小果情感) + if news_sentiment: + ns = news_sentiment.get("sentiment", "") + nc = news_sentiment.get("confidence", 0) + if ns == "positive" and nc >= 0.7: + kws = news_sentiment.get("keywords", []) + kw_str = f"({'/'.join(kws[:3])})" if kws else "" + factors.append(f"消息偏多{kw_str}") + elif ns == "negative" and nc >= 0.7: + kws = news_sentiment.get("keywords", []) + kw_str = f"({'/'.join(kws[:3])})" if kws else "" + factors.append(f"消息偏空{kw_str}") + + # 5. 技术面(基础信号) + if base_signal and base_signal != "neutral": + factors.append(base_signal) + + # 5.5 组合风险因子(2026-06-23 双引擎协调) + if portfolio_context and not is_new_entry: + if portfolio_context.get('is_very_high_position'): + factors.append("组合仓位极重(>90%)") + elif portfolio_context.get('is_high_position'): + factors.append("组合仓位偏重(>80%)") + if portfolio_context.get('is_high_weak'): + factors.append(f"弱势占{portfolio_context.get('weak_ratio')}%") + elif portfolio_context and is_new_entry: + # 新买入推荐:注明组合上下文 + if portfolio_context.get('is_high_position'): + factors.append(f"仓{portfolio_context.get('position_pct')}%现金有限") + elif portfolio_context.get('is_high_weak'): + factors.append("组合风险信号") + + # 5.7 盈亏比因子(2026-06-24 新增——RR<1.5降级买入信号) + if rr_ratio > 0: + if rr_ratio < 1.5: + factors.append(f"RR{rr_ratio}过低") + elif rr_ratio >= 3: + factors.append(f"RR{rr_ratio}") + # 1.5~3之间:中性,不特别标注 + + # 如果没有足够因素,返回信号不充分 + if not factors: + return "信号不充分", [] + + # 信号只应包含明确的买卖方向,不能从行业/大盘等上下文因子拼凑 + # base_signal 存在且非 neutral → 用 base_signal + # 否则 → 信号不充分(不拿 factors[-1] 当信号) + if base_signal and base_signal != "neutral": + clean_signal = base_signal + else: + # 从 factors 中找第一个有效的操作方向信号 + valid_direction = {"买入", "加仓", "观望", "持有", "关注", "信号不充分"} + signal_found = "" + for f in reversed(factors): + if f in valid_direction: + signal_found = f + break + clean_signal = signal_found if signal_found else "信号不充分" + + # 6. RR约束降级(2026-06-24 新增) + # 买入/加仓信号但RR<1.5 → 降级为"信号不充分" + buy_signals = {"买入", "加仓"} + if clean_signal in buy_signals and 0 < rr_ratio < 1.5: + clean_signal = "信号不充分" + factors.append("RR过低降级") + + return clean_signal, factors + + +def reassess_with_context(code, name, price, cost, shares, current_action, + volume_signal="", sentiment="neutral", is_watchlist=False): + """reassess_strategy + 多因子信号合成(大盘+行业+技术) + + 为 per_stock_reassess 等单只场景提供一站式多因子分析 + """ + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal, sentiment, is_watchlist + ) + if not result: + return result + + # 加载宏观+行业+消息+基本面上下文 + try: + macro_bias, macro_desc = load_macro_context() + market_ctx = load_market_context() + stock_sector_map = load_stock_sector_map() + sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) + sector_note = sector_adj.get("note", "") + news_sentiment = load_stock_news_sentiment(code) + fund = load_fundamentals(code) + except Exception: + macro_desc = "" + sector_note = "" + news_sentiment = {} + fund = {} + + # ── DSA 集成:注入大盘复盘 + 新闻情报 ────────────────────────── + try: + from mo_bridge import enrich_analysis_context + region = "hk" if len(str(code)) == 5 and str(code)[0] in ('0','1') else "cn" + dsa_ctx = enrich_analysis_context(stock_code=code, stock_name=name, + region=region, include_news=True) + if dsa_ctx: + macro_desc = (macro_desc + "\n\n" + dsa_ctx).strip() + except Exception: + pass # DSA 不可用时静默跳过 + + enriched, factors = enrich_timing_signal( + base_signal=result.get("timing_signal", ""), + macro_desc=macro_desc, + sector_note=sector_note, + profit_pct=(price - cost) / cost * 100 if cost else 0, + stock_category=result.get("stock_category", ""), + is_new_entry=is_watchlist, + fundamentals=fund, + news_sentiment=news_sentiment, + portfolio_context=_get_portfolio_risk_state(), + rr_ratio=result.get("rr_ratio", 0), + ) + result["timing_signal"] = enriched + result["signal_factors"] = factors + + # 6. 防洗盘:信号不要一天一翻(2026-06-23) + # 如果旧信号是买入/持有类,新信号是谨慎/等待类,但中期趋势未破→维持旧信号 + try: + dec = read_decisions() + for e in dec.get('decisions', []): + if e.get('code') == code: + old_signal = e.get('timing_signal', '') + if old_signal and _is_buy_signal(old_signal) and not _is_buy_signal(enriched): + # 中等趋势检查:MA5 > MA20 + 多周期看多 + mtf = result.get('multi_tf_context', '') + if '看多' in mtf or '多头' in mtf: + try: + closes = [float(k.split()[2]) for k in mtf.split('|') if 'MA5' in k] + except: + closes = [] + has_uptrend = 'MA5' in mtf and 'MA20' in mtf + if has_uptrend: + print(f" 防洗盘: {old_signal}→保持旧信号(中期趋势完整)") + result["timing_signal"] = f"{old_signal}(正常回调价稳)" + sf = result.get("signal_factors") or [] + if "正常回调价稳" not in sf: + result["signal_factors"] = sf + ["正常回调价稳"] + break + except Exception as e: + print(f" 防洗盘跳过: {e}") + + # 7. 反馈循环核:检查本股是否有刚卖出的记录(2026-06-23) + contradiction = _check_contradiction(code) + if contradiction and contradiction.get('is_today'): + # 今日刚卖出 → 不屏蔽信号,但必须自标注矛盾 + print(f" 反馈循环: {contradiction.get('tag')} (sold_at={contradiction.get('sold_at')})") + if _is_buy_signal(result.get('timing_signal', '')): + result['action_note'] = contradiction['tag'] + # 在 timing_signal 中追加反馈标注,供报告层可见 + curr_signal = result.get('timing_signal', '') + if '⚠️' not in curr_signal: + result['timing_signal'] = f"⚠️{contradiction['tag']}|{curr_signal}" + elif contradiction: + # 非今日卖出但近期卖出 → 标注已清仓 + print(f" 近期清仓: sold_at={contradiction.get('sold_at')} ({contradiction.get('days_ago')}日前)") + if _is_buy_signal(result.get('timing_signal', '')): + curr_signal = result.get('timing_signal', '') + if '已清仓' not in curr_signal: + result['timing_signal'] = f"已清仓,{curr_signal}" + + # 重建 action 文本(同步多因子信号) + try: + if new_action_needs_refresh(result, {"source": "auto"}, price): + _refresh_action_text(result, price, name) + except Exception: + pass + + # ── 策略质量门禁 ── + enforce_strategy_quality(code, name, result) + + return result + + +def new_action_needs_refresh(result, old_entry, price): + """判断宏观/行业调整后是否需要刷新action文本""" + # 自选股和手动策略不做调整,不需要刷新 + if old_entry.get("source") == "manual": + return False + return True + + +def _refresh_action_text(result, price, name): + """根据调整后的止损/止盈重建action文本""" + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + el = result.get("entry_low", 0) + eh = result.get("entry_high", 0) + ts = result.get("timing_signal", "") + an = result.get("action_note", "") + old_action = result.get("action", "") + + # 保持原action的前缀(持有状态部分不变) + # action格式一般是: "状态 | 止损X | 目标Y | 买入区X~Y | 信号:Z" + parts = old_action.split(" | ") + new_parts = [] + for p in parts: + p = p.strip() + # 替换止损数字 + if p.startswith("止损") or p.startswith("止损参考"): + if sl: + p = f"止损{sl}" if "止损参考" not in old_action.split(" | ")[0] else f"止损参考{sl}" + # 替换目标/止盈数字 + if p.startswith("目标") or p.startswith("止盈"): + if tp: + p = f"目标{tp}" + # 替换买入区数字 + if "买入区" in p and "~" in p: + if el and eh: + p = f"买入区{el}~{eh}" + new_parts.append(p) + result["action"] = " | ".join(new_parts) + + +def check_sector_alerts(market_ctx, stock_sector_map, holdings, wl): + """行业轮动主动预警:检测板块崩盘级别信号→查持仓→输出预警 + + 返回 list of alerts: [{code, name, sector, chg, action}] + """ + alerts = [] + if not market_ctx: + return alerts + + sector_perf = market_ctx.get("sector_perf", {}) + + # 找出所有跌幅>3%的行业 + crashing_sectors = {name: data for name, data in sector_perf.items() + if data.get("change", 0) <= -3} + + if not crashing_sectors: + return alerts + + # 构建 code→持仓信息 的映射 + holding_map = {} + for h in holdings: + c = h.get("code", "") + if c: + holding_map[c] = {"name": h.get("name", c), "type": "持仓"} + for s in wl.get("stocks", []): + c = s.get("code", "") + if c and c not in holding_map: + holding_map[c] = {"name": s.get("name", c), "type": "自选"} + + # 对每个暴跌行业,查持仓中是否有股票属于该行业 + for sec_name, sec_data in sorted(crashing_sectors.items(), + key=lambda x: x[1].get("change", 0)): + chg = sec_data.get("change", 0) + for code, sectors in stock_sector_map.items(): + if code in holding_map and sec_name in sectors: + info = holding_map[code] + alerts.append({ + "code": code, + "name": info["name"], + "sector": sec_name, + "sector_change": chg, + "type": info["type"], + "action": f"行业{sec_name}跌{chg:+.1f}%,{info['type']}需关注", + }) + + alerts.sort(key=lambda a: a["sector_change"]) + return alerts + + +def regenerate_all(stdout=True): + """全量重评所有持仓+自选策略""" + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_holdings, query_watchlist + conn = get_conn() + holdings = query_holdings(conn) + wl_stocks = query_watchlist(conn) + conn.close() + pf = {"holdings": holdings} + wl = {"stocks": wl_stocks} + except Exception: + try: + pf = read_portfolio() + wl = read_watchlist() + except Exception: + pf = {} + wl = {} + + all_stocks = {} + for item in pf.get("holdings", []): + code = item.get("code", "") + if code: + all_stocks[code] = {"source": "portfolio", "data": item} + for item in wl.get("stocks", []): + code = item.get("code", "") + if code and code not in all_stocks: + all_stocks[code] = {"source": "watchlist", "data": item} + + total = len(all_stocks) + ok = 0 + errors = 0 + results = [] + decisions = [] + + # 加载现有 decisions.json 以便追踪变更 + decisions_path = "/home/hmo/web-dashboard/data/decisions.json" + try: + existing_decisions = {d["code"]: d for d in read_decisions().get("decisions", []) if d.get("code")} + except: + existing_decisions = {} + + # 加载宏观上下文(影响策略参数调整) + macro_bias, macro_desc = load_macro_context() + if stdout: + print(f" 宏观参考: {macro_desc} (bias={macro_bias})") + + # 加载市场上下文 — 行业板块表现 + 大盘宽度(策略参数调整用) + market_ctx = load_market_context() + stock_sector_map = load_stock_sector_map() + market_breadth = market_ctx.get("breadth", 50) + market_mood = market_ctx.get("mood", "neutral") + if stdout: + sectors_found = sum(1 for c in all_stocks if stock_sector_map.get(c)) + print(f" 市场参考: {market_mood} 上涨比{market_breadth}% 已匹配{sectors_found}/{total}只个股行业") + + # 批量预取所有价格(一次API调用 vs 之前N次) + prices_map = batch_fetch_prices(list(all_stocks.keys())) + if stdout: + print(f" 批量获取价格: {len(prices_map)}/{total} 成功") + + for code, info in sorted(all_stocks.items()): + stock = info["data"] + name = stock.get("name", code) + cost = stock.get("cost", 0) or 0 + shares = stock.get("shares", 0) or 0 + source = info["source"] + + q = prices_map.get(code) + if not q or not q.get("price"): + results.append({"code": code, "name": name, "error": "腾讯API无数据"}) + errors += 1 + if stdout: + print(f" ❌ {name}({code}): 腾讯API无数据") + continue + + price = q["price"] + profit_pct = (price - cost) / cost * 100 if cost else 0 + current_action = stock.get("analysis", {}).get("action", "") + close_yest = q.get("close", 0) + sentiment = "neutral" + if close_yest and price > close_yest * 1.02: + sentiment = "bullish" + elif close_yest and price < close_yest * 0.98: + sentiment = "bearish" + + try: + is_wl = (source == "watchlist") + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal="中性", sentiment=sentiment, + is_watchlist=(source == "watchlist"), + ) + + # --- Manual param preservation: 用户手动策略永不覆盖 --- + old_entry = existing_decisions.get(code, {}) + if old_entry.get("source") == "manual": + # 仅覆盖策略参数,技术分析/信号/价格照常保留 + for key in ["entry_low", "entry_high", "stop_loss", "take_profit"]: + if key in old_entry and old_entry[key] is not None: + result[key] = old_entry[key] + # 重算盈亏比(基于手动参数) + manual_stop = result.get("stop_loss", 0) or 0 + manual_target = result.get("take_profit", 0) or 0 + risk = max(price - manual_stop, price * 0.01) if manual_stop > 0 else price * 0.01 + reward = max(manual_target - price, 0) if manual_target > 0 else 0 + result["rr_ratio"] = round(reward / risk, 2) if risk > 0 else 0 + # 重建 action 文本(引用手动参数,不引用自动计算的) + profit_pct = (price - cost) / cost * 100 if cost else 0 + manual_action_parts = [] + if profit_pct < -20: + manual_action_parts.append("深套持有") + elif profit_pct < -10: + manual_action_parts.append("持有观察") + elif profit_pct < 0: + manual_action_parts.append("持有观察") + elif profit_pct < 5: + manual_action_parts.append("盈利持有") + else: + manual_action_parts.append("盈利良好") + if result.get("action_note"): + manual_action_parts.append(result["action_note"]) + if is_wl: + if manual_stop > 0: + manual_action_parts.append(f"止损参考{manual_stop}") + manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") + else: + if manual_stop > 0: + manual_action_parts.append(f"止损{manual_stop}") + if manual_target > 0: + manual_action_parts.append(f"目标{manual_target}") + manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") + ts = result.get("timing_signal", "") + if ts and ts != "neutral": + manual_action_parts.append(f"信号:{ts}") + result["action"] = " | ".join(manual_action_parts) + result["status"] = "manual" # 标记为手动管理,变更追踪不受影响 + if stdout: + print(f" [手动保留] {name}({code}) 策略参数未覆盖") + + # 宏观偏差调整:收盘后重评时根据宏观方向微调止损/止盈 + # 自选股不做止盈宏观调整(无持仓) + # 手动策略不做宏观偏差调整(尊重用户设定) + if macro_bias != 1.0 and not is_wl and old_entry.get("source") != "manual": + old_stop = result.get("stop_loss", 0) + old_target = result.get("take_profit", 0) + if macro_bias < 1.0 and old_stop > 0: # 宏观偏弱 → 收紧止损 + # 止损上移(但保留最小3%间距) + adjusted_stop = round(old_stop * (1 + (1 - macro_bias) * 0.3), 2) + min_stop = round(price * 0.97, 2) + result["stop_loss"] = min(adjusted_stop, min_stop) + if old_target > 0: + result["take_profit"] = round(old_target * (1 - (1 - macro_bias) * 0.2), 2) + elif macro_bias > 1.0 and old_target > 0: # 宏观偏强 → 止盈上调让利润跑 + result["take_profit"] = round(old_target * (1 + (macro_bias - 1) * 0.3), 2) + + # 行业偏差调整:根据个股所在行业的市场表现微调止损/止盈 + # 手动策略不做行业调整(尊重用户设定) + sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) + sector_note = sector_adj.get("note", "") + if sector_note and old_entry.get("source") != "manual": + old_stop = result.get("stop_loss", 0) + old_target = result.get("take_profit", 0) + stop_bias = sector_adj.get("stop_bias", 1.0) + target_bias = sector_adj.get("target_bias", 1.0) + if stop_bias != 1.0 and old_stop > 0: + # 行业偏差调整(在宏观调整之后叠加) + adjusted = round(old_stop * stop_bias, 2) + # 保留最小3%间距 + min_stop = round(price * 0.97, 2) + result["stop_loss"] = min(adjusted, min_stop) + if target_bias != 1.0 and old_target > 0 and not is_wl: + result["take_profit"] = round(old_target * target_bias, 2) + + # 加载消息面+基本面(逐个股) + news_sentiment = load_stock_news_sentiment(code) + fund = load_fundamentals(code) + + # 多因子合成 timing_signal:大盘+行业+消息+基本面+技术 + if old_entry.get("source") != "manual": + enriched, _ = enrich_timing_signal( + base_signal=result.get("timing_signal", ""), + macro_desc=macro_desc, + sector_note=sector_note, + profit_pct=profit_pct, + stock_category=result.get("stock_category", ""), + is_new_entry=(source == "watchlist"), + fundamentals=fund, + news_sentiment=news_sentiment, + rr_ratio=result.get("rr_ratio", 0), + ) + result["timing_signal"] = enriched + + # 在宏观/行业/多因子调整后重建 action 文本(同步调整后的止损/止盈数字) + if new_action_needs_refresh(result, old_entry, price): + _refresh_action_text(result, price, name) + + extra = { + "rr_ratio": result.get("rr_ratio"), + "action_note": result.get("action_note", ""), + "timing_signal": result.get("timing_signal", ""), + } + analysis = { + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "entry_low": result["entry_low"], + "entry_high": result["entry_high"], + "action": result["action"], + "tech_snapshot": result.get("tech_snapshot", ""), + "multi_tf_context": result.get("multi_tf_context", ""), + "reassessed_at": result["reassessed_at"], + "status": result["status"], + **extra, + } + stock["analysis"] = analysis + # 同步 top-level 字段 → zone_breach/price_monitor 依赖这些字段 + # (2026-06-24 bugfix: analysis 子对象有但顶层没有,导致新持仓的止损检测盲区) + stock["stop_loss"] = result.get("stop_loss", 0) + stock["take_profit"] = result.get("take_profit", 0) + stock["entry_low"] = result.get("entry_low", 0) + stock["entry_high"] = result.get("entry_high", 0) + # 同步 trigger 字段 -> price_monitor 依赖 + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + el = result.get("entry_low", 0) + eh = result.get("entry_high", 0) + trig = {} + if sl and float(sl) > 0: + trig["stop_loss"] = float(sl) + if el and eh and float(el) > 0 and float(eh) > 0: + trig["entry_zone"] = f"{float(el)}~{float(eh)}" + if tp and float(tp) > 0: + trig["take_profit_zone"] = f"0~{float(tp)}" + stock["trigger"] = trig + results.append({ + "code": code, "name": name, + "price": price, "cost": cost, + "action": result["action"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "rr_ratio": result["rr_ratio"], + }) + ok += 1 + if stdout: + rr_str = f" RR={result['rr_ratio']}" if "rr_ratio" in result else "" + print(f" ✅ {name}({code}) {price} {result['action']}{rr_str}") + + # 记录所有股票的决策日志(含变更追踪) + status_display = result.get("status", "active") + # 构建行业上下文 + sector_ctx_str = "" + sec_name = sector_adj.get("sector_name", "") + sec_chg = sector_adj.get("sector_change", 0) + if sec_name: + sector_ctx_str = f"行业{sec_name}{sec_chg:+.1f}%" + if sector_adj.get("note"): + # note 已包含大盘宽度信息 + sector_ctx_str = sector_adj["note"] + elif market_breadth < 40: + # 无行业映射时至少记录大盘宽度 + sector_ctx_str = f"大盘上涨比{market_breadth}%" + new_entry = { + "code": code, "name": name, "price": price, + "cost": old_entry.get("cost", cost) if old_entry else cost, # 优先保留旧成本(holding.xls权威) + "shares": shares, # 当前实际持仓股数(不继承旧决策的可能为0的值) + "avg_price": old_entry.get("avg_price", 0), # 保留持仓均价 + "currency": "HKD" if is_hk_stock(str(code)) else "CNY", + "action": result["action"], + "stop_loss": result.get("stop_loss"), + "entry_low": result["entry_low"], + "entry_high": result["entry_high"], + "tech_snapshot": result.get("tech_snapshot", ""), + "timing_signal": result.get("timing_signal", ""), + "rr_ratio": result.get("rr_ratio", 0), + "status": status_display, + "note": result.get("action_note", ""), + "timestamp": result["reassessed_at"], + "updated_at": result["reassessed_at"], + "type": "自选策略" if is_wl else "持仓策略", + "source": old_entry.get("source", "auto"), # manual/auto,继承旧标记 + "sector_context": sector_ctx_str, # 市场上下文:行业表现+大盘宽度 + "stock_category": result.get("stock_category", "中短线"), # 组合监测用 + "position_advice": result.get("position_advice", "中等仓位"), + "time_horizon": result.get("time_horizon", "2周~3月"), + } + new_entry["trigger"] = trig + # created_at: 首次创建时设置,后续 preserve + old_entry = existing_decisions.get(code, {}) + if old_entry.get("created_at"): + new_entry["created_at"] = old_entry["created_at"] + else: + new_entry["created_at"] = result["reassessed_at"] + # 保留 last_reassessed_price(per_stock_reassess 维护的防抖字段) + if old_entry.get("last_reassessed_price"): + new_entry["last_reassessed_price"] = old_entry["last_reassessed_price"] + # 自选股也写止盈位(用于RR校验),但标签用"目标参考"非"止盈" + new_entry["take_profit"] = result.get("take_profit") + + # --- 变更追踪 --- + old_action = old_entry.get("action", "") + old_stop = old_entry.get("stop_loss") + old_target = old_entry.get("take_profit") + + # 构建旧策略摘要和变更理由 + update_reason = "" + changelog_entry = None + + if old_action and old_action != result["action"]: + # 策略有变化 → 记录变更 + old_summary = old_action + new_summary = result["action"] + + # 判断触发原因 + if abs(price - old_entry.get("price", price)) / max(price, 0.01) > 0.03: + trigger = f"价格变动({old_entry.get('price','?')}→{price})" + elif result.get("timing_signal") and result["timing_signal"] != old_entry.get("timing_signal", ""): + trigger = f"技术信号变化: {result['timing_signal']}" + else: + trigger = "技术面重评" + + # 格式化的变更理由(自选股只看止损,不看止盈) + diff_parts = [] + if old_stop and result["stop_loss"] != old_stop: + diff_parts.append(f"止损{old_stop}→{result['stop_loss']}") + if not is_wl and old_target and result.get("take_profit") and result["take_profit"] != old_target: + diff_parts.append(f"止盈{old_target}→{result['take_profit']}") + if diff_parts: + update_reason = f"{trigger}: {', '.join(diff_parts)} | {result.get('tech_snapshot','')[:60]}" + else: + update_reason = f"{trigger}: 策略文字调整" + + changelog_entry = { + "date": result["reassessed_at"], + "old_action": old_action, + "new_action": result["action"], + "reason": update_reason, + "trigger": trigger, + } + new_entry["updated_reason"] = update_reason + + elif not old_action: + # 首次创建策略 + update_reason = f"初始策略创建 | {result.get('tech_snapshot','')[:60]}" + changelog_entry = { + "date": result["reassessed_at"], + "old_action": "", + "new_action": result["action"], + "reason": update_reason, + "trigger": "初始创建", + } + + # 合并changelog + old_changelog = old_entry.get("changelog", []) if old_entry else [] + if changelog_entry: + new_entry["changelog"] = old_changelog + [changelog_entry] + else: + new_entry["changelog"] = old_changelog + + # 保留执行记录 + if old_entry and old_entry.get("execution"): + new_entry["execution"] = old_entry["execution"] + elif stock.get("analysis", {}).get("status") == "executing": + new_entry["execution"] = { + "status": "executing", + "entry_price": cost if cost else 0, + "shares": shares, + "notes": "", + } + + # --- 自动标记 current_recommend --- + # 只在真正执行中的持仓才自动推荐:execution.status 为 executing 或 partial_exit + exec_status = old_entry.get("execution", {}).get("status", "") if old_entry else "" + is_active = exec_status in ("executing", "partial_exit") + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_deep_loss_stock = profit_pct < -20 + rr = result.get("rr_ratio", 0) + ts = result.get("timing_signal", "") + note = result.get("action_note", "") + + # 计算是否在/接近买入区 + entry_low_val = result.get("entry_low", 0) + entry_high_val = result.get("entry_high", 0) + in_buy_zone = (entry_low_val > 0 and entry_high_val > 0 and + entry_low_val <= price <= entry_high_val) + near_buy_zone_low = (entry_low_val > 0 and + price >= entry_low_val * 0.98 and + price <= entry_high_val) + + # 推荐条件:必须是执行中的持仓 + 基本面条件达标 + is_recommendable = ( + is_active + and not is_deep_loss_stock + and rr >= 1.5 + and ts != "neutral" + and "不建议" not in note + ) + if is_recommendable: + new_entry["tag"] = "current_recommend" + else: + # 不清除 active_manual(用户手动标记),只清除自动推荐的 + old_tag = old_entry.get("tag", "") if old_entry else "" + if old_tag != "active_manual": + new_entry.pop("tag", None) + + decisions.append(new_entry) + + except Exception as e: + results.append({"code": code, "name": name, "error": str(e)}) + errors += 1 + if stdout: + print(f" ❌ {name}({code}): {e}") + + # 写回数据文件 — 保留现有字段(现金、总资产等)不丢 + try: + existing_pf = read_portfolio() + except Exception: + existing_pf = {} + # 保留 price/change_pct — price_monitor 维护的实时价,regenerate_all 不应清除 + _existing_holdings_map = {} + for _h in existing_pf.get('holdings', []): + if _h.get('code'): + _existing_holdings_map[_h['code']] = _h + _new_holdings = pf.get("holdings", []) + for _h in _new_holdings: + _code = _h.get('code') + if _code and _code in _existing_holdings_map: + _old = _existing_holdings_map[_code] + _h['price'] = _old.get('price', 0) + _h['change_pct'] = _old.get('change_pct', 0) + existing_pf["holdings"] = _new_holdings + existing_pf["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M') + + # ── Watchlist ↔ Holdings 双向自动迁移(2026-06-27 Dad要求)── + # ① 持仓已有 → 从自选移除(买入自动清除) + wl_codes = {s.get("code") for s in wl.get("stocks", []) if s.get("code")} + pf_codes = {h.get("code") for h in _new_holdings if h.get("code") and h.get("shares", 0) > 0} + removed_from_wl = [] + for h_code in wl_codes & pf_codes: + # 持仓>0且量够 → 自选移除 + wl["stocks"] = [s for s in wl.get("stocks", []) if s.get("code") != h_code] + removed_from_wl.append(h_code) + if removed_from_wl and stdout: + print(f" 自选→持仓自动移除: {', '.join(removed_from_wl)}") + + # ② 清仓/卖光 → 加回自选(只要仍有关注价值) + added_to_wl = [] + old_pf_codes = {_h.get("code") for _h in existing_pf.get("holdings", []) if _h.get("code")} + sold_codes = old_pf_codes - pf_codes # 曾持仓但现在没有(或不在了) + for sc in sold_codes: + # 已有自选就不重复加 + if sc in wl_codes: + continue + # 从现有decisions看是否有关注价值 + for d in decisions: + if d.get("code") == sc and d.get("entry_low") and d.get("entry_high"): + wl["stocks"].append({ + "code": sc, "name": d.get("name", sc), + "entry_low": d.get("entry_low"), "entry_high": d.get("entry_high"), + "stop_loss": d.get("stop_loss", 0), + "analysis": {"action": d.get("action", ""), "tech_snapshot": d.get("tech_snapshot", "")} + }) + added_to_wl.append(sc) + break + if added_to_wl and stdout: + print(f" 清仓→自选自动加入: {', '.join(added_to_wl)}") + + # 重新计算 portfolio 汇总(保留已存在的 cash,用最新价格算市值) + try: + total_mv = 0.0 + total_cost = 0.0 + for h in existing_pf.get('holdings', []): + p = h.get('price') or 0 + s = h.get('shares') or 0 + c = h.get('cost') or 0 + total_mv += p * s + total_cost += c * s + if p and s and total_mv > 0: + h['market_value'] = round(p * s, 2) + old_cash = existing_pf.get('cash') or 80476 # fallback 6/23 backup + frozen_cash = existing_pf.get('frozen_cash') or 0 + existing_pf['cash'] = old_cash + existing_pf['total_mv'] = round(total_mv, 2) + existing_pf['total_assets'] = round(total_mv + old_cash + frozen_cash, 2) + existing_pf['total_pnl'] = round(total_mv - total_cost, 2) + existing_pf['position_pct'] = round(total_mv / (total_mv + old_cash + frozen_cash) * 100, 2) if (total_mv + old_cash + frozen_cash) > 0 else 0 + except Exception as e: + print(f" [汇总计算失败] {e}", flush=True) + + # DB 写入(替代 JSON dump — 强制币种约束) + try: + from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy + conn = get_conn() + write_holdings_batch(conn, existing_pf.get('holdings', [])) + write_portfolio_summary(conn, existing_pf) + for s in wl.get('stocks', []): + s.setdefault('currency', 'CNY') + write_watchlist_stock(conn, s) + for d in decisions: + # ── 策略质量门禁 ── + code = d.get('code', '') + name = d.get('name', '') + enforce_strategy_quality(code, name, d) + write_holding_strategy(conn, code, name, d) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}", flush=True) + + # 记录策略→提示词版本关联 + if HAS_PROMPT_TRACKING: + try: + for d in decisions: + if d.get("code") and d.get("action"): + record_strategy_generation( + d["code"], d.get("name", ""), d.get("action", "") + ) + except Exception as e: + if stdout: + print(f" ⚠️ 提示词版本追踪失败: {e}", file=sys.stderr) + + # 刷新多周期缓存到磁盘 + try: + import multi_timeframe as _mtf + _mtf.flush_mtf_cache() + except Exception: + pass + + summary = {"total": total, "ok": ok, "errors": errors} + if stdout: + print(f"\n✅ 全量重评完成: {ok}/{total}成功, {errors}错误") + return summary + + +if __name__ == "__main__": + regenerate_all() diff --git a/technical_analysis.py b/technical_analysis.py index b5128578..27426959 100644 --- a/technical_analysis.py +++ b/technical_analysis.py @@ -116,6 +116,8 @@ def get_quote(code): h = get(33) # high l = get(34) # low c = get(3) # price / close + v = get(6) # volume(手) + amt = get(37) # 成交额 if h and l and c: history = _load_history() if raw not in history: @@ -126,8 +128,13 @@ def get_quote(code): days[-1]["high"] = max(days[-1]["high"], h) days[-1]["low"] = min(days[-1]["low"], l) days[-1]["close"] = c # 盘中用最新价,收盘后是收盘价 + if v: days[-1]["volume"] = v + if amt: days[-1]["amount"] = amt else: - days.append({"date": today_str, "high": h, "low": l, "close": c}) + entry = {"date": today_str, "high": h, "low": l, "close": c} + if v: entry["volume"] = v + if amt: entry["amount"] = amt + days.append(entry) # 只保留最近 HISTORY_DAYS 天 history[raw] = days[-HISTORY_DAYS:] _save_history(history) @@ -348,6 +355,240 @@ def analyze_volume(q): return result +def analyze_volume_trend(code): + """量价趋势分析:对比历史N日平均成交量,检测量价背离模式 + + 从 price_history.json 读取历史数据,比较今日量价关系。 + """ + result = {} + try: + history = _load_history() + days = history.get(code, []) + if len(days) < 3: + result["trend"] = "数据不足" + return result + + today = days[-1] + prev = days[-2] if len(days) >= 2 else None + today_vol = today.get("volume", 0) + today_close = today.get("close", 0) + + if not today_vol or not today_close: + result["trend"] = "数据不足" + return result + + # 计算N日均量 + vols_5 = [d.get("volume", 0) for d in days[-6:-1] if d.get("volume")] + vols_20 = [d.get("volume", 0) for d in days[-21:-1] if d.get("volume")] + avg_5 = sum(vols_5) / len(vols_5) if vols_5 else 0 + avg_20 = sum(vols_20) / len(vols_20) if vols_20 else 0 + + vol_ratio_vs_5 = today_vol / avg_5 if avg_5 > 0 else 0 + vol_ratio_vs_20 = today_vol / avg_20 if avg_20 > 0 else 0 + + result["avg_volume_5d"] = round(avg_5, 0) + result["avg_volume_20d"] = round(avg_20, 0) + result["today_volume"] = int(today_vol) + result["volume_ratio_vs_5d"] = round(vol_ratio_vs_5, 2) + result["volume_ratio_vs_20d"] = round(vol_ratio_vs_20, 2) + + # 最近3日的收盘价和成交量趋势 + if len(days) >= 3: + recent_close = [d.get("close", 0) for d in days[-4:-1]] + recent_vol = [d.get("volume", 0) for d in days[-4:-1]] + if all(recent_close) and all(recent_vol): + price_up = today_close > recent_close[-1] + vol_up = today_vol > recent_vol[-1] + + # 量价模式判定 + if vol_ratio_vs_5 >= 1.8: + # 明显放量 + if price_up: + result["trend"] = "放量上攻" + result["action"] = "buy_conformation" + else: + # 价格下跌但大幅放量 = 恐慌?还是承接收筹? + # 看今日K线:如果是阳线(低开高走)= 承接 + # 简单判断:如果close > open = 有承接 + result["trend"] = "放量下跌" + result["action"] = "watch" + elif vol_ratio_vs_5 <= 0.6: + # 明显缩量 + if price_up: + result["trend"] = "缩量上涨" + result["action"] = "divergence" + else: + result["trend"] = "缩量回调" + result["action"] = "healthy_pullback" + elif vol_ratio_vs_5 >= 1.3: + # 温和放量 + if price_up: + result["trend"] = "温和放量上涨" + result["action"] = "bullish" + else: + result["trend"] = "温和放量下跌" + result["action"] = "bearish" + else: + # 正常量 + if price_up: + result["trend"] = "正常量上涨" + result["action"] = "neutral_bullish" + else: + result["trend"] = "正常量下跌" + result["action"] = "neutral_bearish" + + # 量价背离检测:价格走高但成交量逐日递减 + if len(days) >= 5: + close_5 = [d.get("close", 0) for d in days[-5:]] + vol_5 = [d.get("volume", 0) for d in days[-5:]] + if all(close_5) and all(vol_5): + close_trend = close_5[-1] - close_5[0] + vol_trend = vol_5[-1] - vol_5[0] + # 价格涨但量跌 = 顶背离 + if close_trend > 0 and vol_trend < 0 and abs(vol_trend) > sum(vol_5) * 0.3: + result["divergence"] = "顶背离(价涨量缩)" + # 价格跌但量涨 = 底背离 + elif close_trend < 0 and vol_trend > 0 and abs(vol_trend) > sum(vol_5) * 0.3: + result["divergence"] = "底背离(价跌量增)" + + except Exception as e: + result["trend_error"] = str(e) + + return result + + +def analyze_volume_deep(code): + """深度量价分析:从日K线分析量价配合/背离/建仓/出货 + + 使用 mtf_cache 表的日K线数据做历史量价分析。 + """ + import sqlite3 + from pathlib import Path + + DATA_DIR = Path(__file__).parent / "data" + try: + conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) + row = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + conn.close() + if not row: + return {"volume_signal": "数据不足"} + data = json.loads(row[0]) + except Exception: + return {"volume_signal": "数据不足"} + + daily = data.get("daily", []) + if len(daily) < 5: + return {"volume_signal": "数据不足"} + + closes = [d["close"] for d in daily] + volume = [d["volume"] for d in daily] + n = len(daily) + + # 基准:最近20日均量(不足20日则用全部) + lookback = min(20, n - 1) + avg_vol_20d = sum(volume[-lookback-1:-1]) / lookback if lookback > 0 else volume[-1] + + # 最近N日的量比 + recent = min(5, n) + recent_vol_ratios = [] + for i in range(recent): + vol = volume[-i-1] if i+1 <= n else volume[0] + recent_vol_ratios.append(round(vol / avg_vol_20d, 2) if avg_vol_20d > 0 else 1) + + today_ratio = recent_vol_ratios[0] if recent_vol_ratios else 1 + recent_max_ratio = max(recent_vol_ratios) if recent_vol_ratios else 1 + + # 量价配合度 + signals = [] + patterns = {} + + # 1. 放量检测(量比 > 2x) + if today_ratio > 2.0: + signals.append(f"量比{today_ratio:.1f}倍放量") + patterns["volume_surge"] = True + # 放量方向 + if len(closes) >= 2 and closes[-1] > closes[-2]: + patterns["surge_direction"] = "放量上涨" + if today_ratio > 2.5 and closes[-1] > closes[-2] * 1.03: + signals[-1] += "↑主力买入" + else: + signals[-1] += "↑" + elif len(closes) >= 2 and closes[-1] < closes[-2]: + patterns["surge_direction"] = "放量下跌" + if today_ratio > 2.5 and closes[-1] < closes[-2] * 0.97: + signals[-1] += "↓主力出货" + else: + signals[-1] += "↓" + else: + patterns["surge_direction"] = "放量平盘" + elif today_ratio < 0.5: + signals.append(f"量比{today_ratio:.1f}倍缩量") + patterns["volume_shrink"] = True + else: + signals.append(f"量比{today_ratio:.1f}倍正常") + patterns["volume_normal"] = True + + # 2. 量价趋势分析(近5日 vs 前5日) + if len(daily) >= 10: + recent5_vol = sum(volume[-5:]) / 5 + prev5_vol = sum(volume[-10:-5]) / 5 + vol_trend = "增" if recent5_vol > prev5_vol * 1.3 else ("减" if recent5_vol < prev5_vol * 0.7 else "稳") + recent5_price = closes[-5:] + price_trend = "涨" if recent5_price[-1] > recent5_price[0] else ("跌" if recent5_price[-1] < recent5_price[0] * 0.95 else "平") + + if vol_trend == "增" and price_trend == "涨": + patterns["accumulation"] = True # 量价齐升=建仓 + signals.append(f"近5日{vol_trend}量{price_trend}价=建仓型") + elif vol_trend == "增" and price_trend == "跌": + patterns["distribution"] = True # 放量下跌=出货 + signals.append(f"近5日{vol_trend}量{price_trend}价=⚠️出货型") + elif vol_trend == "减" and price_trend == "涨": + patterns["divergence"] = True # 量缩价涨=背离 + signals.append(f"近5日{vol_trend}量{price_trend}价=⬆量价背离") + elif vol_trend == "减" and price_trend == "跌": + patterns["washout"] = True # 缩量下跌=洗盘末端 + signals.append(f"近5日{vol_trend}量{price_trend}价=洗盘特征") + else: + signals.append(f"近5日{vol_trend}量{price_trend}价") + else: + vol_trend = price_trend = "?" + + # 3. 寻找历史放量区间(主力活动痕迹) + surge_days = [] + for i in range(max(0, n - 60), n): + vol_ratio = volume[i] / avg_vol_20d if avg_vol_20d > 0 else 0 + if vol_ratio > 2.0: + surge_days.append({ + "date": daily[i].get("date", ""), + "ratio": round(vol_ratio, 1), + "close": closes[i], + "direction": "涨" if (i > 0 and closes[i] > closes[i-1]) else "跌" + }) + + # 汇总描述 + vol_level = "放量" if today_ratio > 2.0 else ("缩量" if today_ratio < 0.5 else "正常") + price_vol = f"{vol_level}" + if patterns.get("accumulation"): + price_vol = f"量价齐升(建仓特征) | {signals[-1]}" + elif patterns.get("distribution"): + price_vol = f"放量下跌⚠️ | {signals[-1]}" + elif patterns.get("washout"): + price_vol = f"缩量回踩(洗盘末端) | {signals[-1]}" + elif patterns.get("divergence"): + price_vol = f"量价背离 | {signals[-1]}" + + return { + "volume_signal": " ; ".join(signals) if signals else "正常", + "volume_ratio": today_ratio, + "avg_volume_20d": int(avg_vol_20d), + "recent_ratios": recent_vol_ratios, + "surge_count_60d": len(surge_days), + "price_vol_description": price_vol, + "patterns": patterns, + "surge_days": surge_days[-5:] if surge_days else [], + } + + def full_analysis(code): """完整技术分析(带30秒缓存,避免分钟级波动)""" import time @@ -364,6 +605,12 @@ def full_analysis(code): sr = calc_support_resistance(q) candle = analyze_candlestick(q) vol = analyze_volume(q) + # 深度量价分析(使用日K线历史数据) + vol_deep = {} + try: + vol_deep = analyze_volume_deep(code) + except Exception: + pass # graceful degradation # 多周期+均线分析(整合 multi_timeframe) mtf = {} @@ -403,6 +650,7 @@ def full_analysis(code): "support_resistance": sr, "candlestick": candle, "volume": vol, + "volume_deep": vol_deep, "multi_tf": mtf, "analyzed_at": datetime.now().strftime("%H:%M"), }