diff --git a/json_validator.py b/json_validator.py
new file mode 100644
index 00000000..9bd6d1cf
--- /dev/null
+++ b/json_validator.py
@@ -0,0 +1,778 @@
+#!/usr/bin/env python3
+"""
+json_validator.py — 知微股票分析 Pipeline JSON 格式校验器
+
+为「打分-重试 + 格式校验」双重循环提供基础设施。
+
+核心功能:
+1. validate_json_syntax(text) → (score, errors, parsed)
+ json.loads() + 详细错误分类(语法错误/缺失字段/截断)
+
+2. validate_with_schema(text, schema_fn) → (score, errors, parsed)
+ 在语法校验上叠Schema校验:字段存在性、类型正确性、非空约束
+
+3. score_and_retry(llm_generate_fn, max_retries=3, threshold=90) → result
+ 通用重试包装器:不合格自动重试 + 错误反馈注入
+
+4. FormatErrorLibrary — 格式错误持久化档案库
+ 记录每次错误 → 按类型统计 → 触发自动改进建议
+"""
+
+import json
+import os
+import re
+import sys
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from collections import defaultdict, Counter
+
+
+# ── 错误类型分类 ──────────────────────────────────────────────
+
+ERROR_SYNTAX = "syntax_error" # JSON 语法解析失败
+ERROR_MISSING_FIELD = "missing_field" # Schema 必填字段缺失
+ERROR_WRONG_TYPE = "wrong_type" # 字段类型不符
+ERROR_EMPTY_FIELD = "empty_field" # 关键字段为空/null
+ERROR_TRUNCATION = "truncation" # 输出截断(在 JSON 外检测)
+ERROR_UNEXPECTED = "unexpected_error" # 其他
+
+
+# ── 错误档案库路径 ────────────────────────────────────────────
+
+FORMAT_ERROR_LIBRARY_PATH = Path(
+ os.environ.get(
+ "FORMAT_ERROR_LIBRARY_PATH",
+ "/home/hmo/MoFin/data/format_error_library.json"
+ )
+)
+
+
+# ═══════════════════════════════════════════════════════════════
+# 核心校验器
+# ═══════════════════════════════════════════════════════════════
+
+def validate_json_syntax(text):
+ """JSON 语法校验 + 评分。
+
+ Args:
+ text: 要校验的字符串(可能包含 ```json ... ``` 或纯 JSON)
+
+ Returns:
+ (score, errors, parsed)
+ score: 0-100,100=完美JSON,<90=需要重试
+ errors: [{'type': str, 'detail': str, 'line': int}, ...]
+ parsed: 解析成功时返回 dict/list,失败返回 None
+ """
+ errors = []
+ parsed = None
+
+ if not text or not text.strip():
+ errors.append({
+ "type": ERROR_SYNTAX,
+ "detail": "空输出",
+ "line": 0,
+ })
+ return 0, errors, None
+
+ # 尝试提取 JSON 块(支持 ```json ... ``` 包裹)
+ raw = text.strip()
+ json_block = raw
+
+ # 检测 markdown 代码块
+ code_match = re.search(
+ r'```(?:json)?\s*\n?(.*?)```', raw, re.DOTALL
+ )
+ if code_match:
+ json_block = code_match.group(1).strip()
+
+ # 检测 ... 包裹
+ struct_match = re.search(
+ r'\s*(.*?)\s*', raw, re.DOTALL
+ )
+ if struct_match:
+ json_block = struct_match.group(1).strip()
+
+ # 尝试括号定位:从第一个 { 到最后一个 }
+ brace_start = json_block.find("{")
+ brace_end = json_block.rfind("}")
+ if brace_start >= 0 and brace_end > brace_start:
+ json_block = json_block[brace_start:brace_end + 1]
+
+ # 预处理修复常见问题
+ fixed = _auto_fix_json(json_block)
+
+ # 尝试解析
+ try:
+ parsed = json.loads(fixed)
+ except json.JSONDecodeError as e:
+ line_no = e.lineno if hasattr(e, 'lineno') else 0
+ col_no = e.colno if hasattr(e, 'colno') else 0
+ msg = str(e)
+
+ # 分类错误类型
+ err_type = _classify_syntax_error(msg, json_block)
+
+ errors.append({
+ "type": err_type,
+ "detail": f"L{line_no}:{col_no} {msg[:200]}",
+ "line": line_no,
+ })
+
+ # 尝试 best-effort 修复后再次解析
+ repaired = _deep_fix_json(json_block)
+ if repaired:
+ try:
+ parsed = json.loads(repaired)
+ errors.append({
+ "type": "repaired",
+ "detail": f"自动修复后解析成功 (原始: {msg[:100]})",
+ "line": 0,
+ })
+ except json.JSONDecodeError:
+ pass
+
+ # 评分
+ score = _compute_score(errors, parsed, text)
+
+ return score, errors, parsed
+
+
+def _auto_fix_json(text):
+ """浅层自动修复常见 JSON 问题。"""
+ if not text or not text.strip():
+ return text
+
+ fixed = text
+
+ # 修复1: 尾随逗号在数组/对象末尾
+ fixed = re.sub(r',\s*}', '}', fixed)
+ fixed = re.sub(r',\s*\]', ']', fixed)
+
+ # 修复2: 单引号代替双引号(只在键名位置)
+ # 匹配 'key': 或 'key' :
+ fixed = re.sub(r"'([^']+)'(\s*:)", r'"\1"\2', fixed)
+
+ # 修复3: 字符串内未转义的换行符
+ result = []
+ in_str = False
+ escape = False
+ for ch in fixed:
+ if escape:
+ result.append(ch)
+ escape = False
+ continue
+ if ch == '\\':
+ result.append(ch)
+ escape = True
+ continue
+ if ch == '"' and not escape:
+ in_str = not in_str
+ result.append(ch)
+ continue
+ if in_str and ch in '\n\r':
+ result.append('\\n')
+ else:
+ result.append(ch)
+
+ fixed = ''.join(result)
+
+ # 修复4: 多余尾部括号
+ fixed = fixed.rstrip('}') + '}'
+
+ return fixed
+
+
+def _deep_fix_json(text):
+ """深度修复:尝试补全截断的 JSON。"""
+ if not text:
+ return None
+
+ fixed = text.strip()
+
+ # 如果是截断的(以不完整的状态结尾)
+ # 尝试补全括号
+ open_braces = fixed.count('{') - fixed.count('}')
+ open_brackets = fixed.count('[') - fixed.count(']')
+
+ if open_braces > 0:
+ fixed += '}' * open_braces
+ if open_brackets > 0:
+ fixed += ']' * open_brackets
+
+ # 如果缺少闭合引号
+ in_str = False
+ for ch in fixed:
+ if ch == '"':
+ in_str = not in_str
+ if in_str:
+ fixed += '"'
+
+ # 尝试补全末尾逗号导致的问题
+ fixed = re.sub(r',$', '', fixed.rstrip())
+
+ # 补齐尾部的 }]
+ if fixed.rstrip().endswith(','):
+ fixed = fixed.rstrip()[:-1]
+
+ # 确保对象闭合
+ if '{' in fixed and '}' not in fixed[fixed.rfind('{'):]:
+ fixed += '}'
+
+ try:
+ json.loads(fixed)
+ return fixed
+ except json.JSONDecodeError:
+ return None
+
+
+def _classify_syntax_error(msg, text):
+ """对 JSONDecodeError 消息分类。"""
+ msg_lower = msg.lower()
+
+ if 'unexpected EOF' in msg_lower or 'unterminated' in msg_lower:
+ return ERROR_TRUNCATION
+ if 'expect' in msg_lower and 'property name' in msg_lower:
+ return ERROR_MISSING_FIELD
+ if 'trailing' in msg_lower:
+ return ERROR_SYNTAX
+ if 'invalid' in msg_lower and 'control character' in msg_lower:
+ return ERROR_SYNTAX
+ return ERROR_SYNTAX
+
+
+def _compute_score(errors, parsed, raw_text):
+ """计算格式合规性评分 (0-100)。
+
+ 扣分规则:
+ - 语法错误: -40(致命,必须重试)
+ - 截断: -50(致命,输出不完整)
+ - 自动修复后成功: -20(能修复但说明输出质量不高)
+ - 空输出: -100(完全无用)
+ """
+ if parsed is not None and not errors:
+ return 100
+
+ if not errors:
+ return 100
+
+ score = 100
+ for err in errors:
+ t = err["type"]
+ if t == ERROR_SYNTAX:
+ score -= 40
+ elif t == ERROR_TRUNCATION:
+ score -= 50
+ elif t == ERROR_MISSING_FIELD:
+ score -= 30
+ elif t == "repaired":
+ score -= 20
+ elif t == ERROR_EMPTY_FIELD:
+ score -= 15
+
+ return max(0, score)
+
+
+def validate_with_schema(text, schema=None):
+ """JSON 语法 + Schema 双层校验。
+
+ schema: 可选的自定义校验函数 schema(parsed) -> [(field, type, detail)]
+ 或内置 check 列表 dict 格式:
+ [{"field": "...", "check": callable, "desc": "..."}]
+ 或 None(只做语法校验)
+ """
+ score, errors, parsed = validate_json_syntax(text)
+
+ if parsed is None:
+ return score, errors, parsed
+
+ if schema is None:
+ return score, errors, parsed
+
+ # Schema 校验
+ import collections
+ schema_errors = []
+ if callable(schema):
+ raw_errors = schema(parsed)
+ if isinstance(raw_errors, collections.abc.Iterable):
+ for se in raw_errors:
+ field, err_type, detail = se[:3]
+ schema_errors.append({
+ "type": err_type or ERROR_MISSING_FIELD,
+ "detail": detail,
+ "line": 0,
+ })
+ elif isinstance(schema, (list, tuple)):
+ for rule in schema:
+ field = rule.get("field", "?")
+ check = rule.get("check")
+ desc = rule.get("desc", "")
+ if check and not check(parsed):
+ errors.append({
+ "type": rule.get("severity", ERROR_MISSING_FIELD),
+ "detail": f"[{field}] {desc}",
+ "line": 0,
+ })
+
+ # 重算分数
+ score = _compute_score(errors, parsed, text)
+ return score, errors, parsed
+
+
+# ═══════════════════════════════════════════════════════════════
+# 通用于 decisions.json 的 Schema 校验规则
+# ═══════════════════════════════════════════════════════════════
+
+# 股票分析结果的 Schema 定义
+STOCK_ANALYSIS_SCHEMA = [
+ {
+ "field": "code",
+ "check": lambda d: bool(d.get("code")),
+ "desc": "股票代码必须存在",
+ "severity": "missing_field",
+ },
+ {
+ "field": "action",
+ "check": lambda d: bool(d.get("action")),
+ "desc": "操作建议(action)必须存在",
+ "severity": "missing_field",
+ },
+ {
+ "field": "stop_loss",
+ "check": lambda d: (d.get("stop_loss") or 0) > 0,
+ "desc": "止损(stop_loss)必须大于0",
+ "severity": "missing_field",
+ },
+ {
+ "field": "take_profit",
+ "check": lambda d: (d.get("take_profit") or 0) > 0,
+ "desc": "止盈(take_profit)必须存在且大于0",
+ "severity": "missing_field",
+ },
+ {
+ "field": "entry_low",
+ "check": lambda d: (d.get("entry_low") or 0) > 0,
+ "desc": "买入区下沿(entry_low)必须大于0",
+ "severity": "missing_field",
+ },
+ {
+ "field": "entry_high",
+ "check": lambda d: (d.get("entry_high") or 0) > 0,
+ "desc": "买入区上沿(entry_high)必须大于0",
+ "severity": "missing_field",
+ },
+ {
+ "field": "entry_range",
+ "check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0),
+ "desc": "买入区下沿 < 上沿",
+ "severity": "syntax_error",
+ },
+ {
+ "field": "rr_ratio",
+ "check": lambda d: (d.get("rr_ratio") or 0) >= 0,
+ "desc": "盈亏比(rr_ratio)必须非负",
+ "severity": "missing_field",
+ },
+ {
+ "field": "timing_signal",
+ "check": lambda d: bool(d.get("timing_signal")),
+ "desc": "时机信号(timing_signal)必须存在",
+ "severity": "missing_field",
+ },
+]
+
+# 用于 的 Schema
+STRUCTURED_DATA_SCHEMA = [
+ {
+ "field": "holdings",
+ "check": lambda d: isinstance(d.get("holdings"), list),
+ "desc": "holdings 必须是数组",
+ "severity": "missing_field",
+ },
+ {
+ "field": "type",
+ "check": lambda d: bool(d.get("type")),
+ "desc": "type 字段必须存在",
+ "severity": "missing_field",
+ },
+]
+
+
+# ═══════════════════════════════════════════════════════════════
+# Score-and-Retry 循环
+# ═══════════════════════════════════════════════════════════════
+
+def score_and_retry(llm_generate_fn, max_retries=3, threshold=90,
+ schema=None, feedback_prefix="[Format Feedback]",
+ on_retry=None, on_failure=None):
+ """通用打分-重试循环包装器。
+
+ Args:
+ llm_generate_fn: 调用函数,接收 attempt + previous_errors 字典
+ 签名: llm_generate_fn(attempt, context)
+ 返回生成的文本
+ max_retries: 最大重试次数(含首次,即最多调用 max_retries+1 次)
+ threshold: 合格分数线 (0-100)
+ schema: 可选 Schema 校验规则
+ feedback_prefix: 错误反馈前缀
+ on_retry: 可选回调 on_retry(attempt, score, errors)
+ on_failure: 可选回调 on_failure(attempts, best_raw, all_errors)
+
+ Returns:
+ (parsed, score, errors, raw)
+ """
+ best_score = 0
+ best_raw = None
+ best_parsed = None
+ all_errors = []
+
+ for attempt in range(max_retries + 1):
+ context = {
+ "attempt": attempt,
+ "previous_errors": all_errors[-1] if all_errors else [],
+ "feedback": "",
+ }
+
+ # 构建带反馈的调用
+ if attempt > 0 and all_errors:
+ feedback = _build_feedback(all_errors[-1], feedback_prefix)
+ context["feedback"] = feedback
+
+ raw = llm_generate_fn(attempt, context)
+ score, errors, parsed = validate_with_schema(raw, schema=schema)
+
+ all_errors.append(errors)
+
+ if on_retry:
+ on_retry(attempt, score, errors)
+
+ # 保留最佳结果
+ if score > best_score:
+ best_score = score
+ best_raw = raw
+ best_parsed = parsed
+
+ if score >= threshold and parsed is not None:
+ # 验收通过
+ _record_format_success(attempt, score)
+ return parsed, score, errors, raw
+
+ if attempt < max_retries:
+ # 记录失败(用于重试)
+ _record_format_error(raw, errors, attempt)
+
+ # 所有重试用尽
+ if on_failure:
+ on_failure(max_retries, best_raw, all_errors)
+
+ _record_format_failure(best_raw, all_errors, max_retries)
+
+ # 返回 best_effort
+ if best_parsed is not None:
+ return best_parsed, best_score, all_errors[-1], best_raw
+
+ return None, best_score, all_errors[-1], best_raw
+
+
+def _build_feedback(errors, prefix):
+ """将错误列表格式化为模型可读的反馈文本。"""
+ parts = [f"{prefix} 上一轮输出存在以下格式问题,请在本次修正:"]
+
+ for err in errors:
+ t = err.get("type", "unknown")
+ d = err.get("detail", "")
+ parts.append(f" - [{t}] {d[:150]}")
+
+ if not errors:
+ parts.append(" - 通用格式要求:输出必须是合法的 JSON")
+
+ return "\n".join(parts)
+
+
+# ═══════════════════════════════════════════════════════════════
+# Format Error Library
+# ═══════════════════════════════════════════════════════════════
+
+def _ensure_library_path():
+ FORMAT_ERROR_LIBRARY_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+
+def _load_library():
+ _ensure_library_path()
+ if FORMAT_ERROR_LIBRARY_PATH.exists():
+ try:
+ return json.loads(FORMAT_ERROR_LIBRARY_PATH.read_text())
+ except (json.JSONDecodeError, Exception):
+ pass
+ return {
+ "records": [],
+ "stats": {},
+ "created_at": datetime.now().isoformat(),
+ "last_updated": datetime.now().isoformat(),
+ }
+
+
+def _save_library(lib):
+ lib["last_updated"] = datetime.now().isoformat()
+ FORMAT_ERROR_LIBRARY_PATH.write_text(
+ json.dumps(lib, ensure_ascii=False, indent=2)
+ )
+
+
+def _record_format_error(raw, errors, attempt):
+ """记录一次格式错误到档案库。"""
+ lib = _load_library()
+
+ record = {
+ "timestamp": datetime.now().isoformat(),
+ "attempt": attempt + 1,
+ "raw_preview": raw[:500] if raw else "",
+ "errors": errors,
+ "error_types": [e.get("type", "unknown") for e in errors],
+ }
+ lib["records"].append(record)
+
+ # 更新统计
+ for err in errors:
+ t = err.get("type", "unknown")
+ # 按天统计
+ today = datetime.now().strftime("%Y-%m-%d")
+ key = f"{today}:{t}"
+ lib.setdefault("stats", {}).setdefault("daily", {}).setdefault(key, 0)
+ lib["stats"]["daily"][key] = lib["stats"]["daily"].get(key, 0) + 1
+
+ # 容量控制:保留最近 1000 条
+ if len(lib["records"]) > 1000:
+ lib["records"] = lib["records"][-1000:]
+
+ _save_library(lib)
+
+
+def _record_format_success(attempt, score):
+ """记录一次成功的格式校验。"""
+ lib = _load_library()
+ today = datetime.now().strftime("%Y-%m-%d")
+ key = f"{today}:success"
+ lib.setdefault("stats", {}).setdefault("daily", {}).setdefault(key, 0)
+ lib["stats"]["daily"][key] = lib["stats"]["daily"].get(key, 0) + 1
+ lib.setdefault("stats", {}).setdefault("total_calls", 0)
+ lib["stats"]["total_calls"] = lib["stats"].get("total_calls", 0) + 1
+ _save_library(lib)
+
+
+def _record_format_failure(raw, all_errors, max_retries):
+ """记录超出最大重试次数的完全失败。"""
+ lib = _load_library()
+ today = datetime.now().strftime("%Y-%m-%d")
+ key = f"{today}:total_failure"
+ lib.setdefault("stats", {}).setdefault("daily", {}).setdefault(key, 0)
+ lib["stats"]["daily"][key] = lib["stats"]["daily"].get(key, 0) + 1
+ lib.setdefault("stats", {}).setdefault("total_calls", 0)
+ lib["stats"]["total_calls"] = lib["stats"].get("total_calls", 0) + 1
+ lib["stats"]["total_failures"] = lib["stats"].get("total_failures", 0) + 1
+ _save_library(lib)
+
+
+# ═══════════════════════════════════════════════════════════════
+# 统计与报告
+# ═══════════════════════════════════════════════════════════════
+
+def get_error_report(days=7):
+ """生成格式错误统计报告。"""
+ lib = _load_library()
+
+ daily = lib.get("stats", {}).get("daily", {})
+ total_calls = lib.get("stats", {}).get("total_calls", 0)
+ total_failures = lib.get("stats", {}).get("total_failures", 0)
+
+ # 按错误类型聚合(近 N 天)
+ type_count = Counter()
+ today = datetime.now()
+ for key, count in daily.items():
+ try:
+ date_str, err_type = key.split(":", 1)
+ date = datetime.strptime(date_str, "%Y-%m-%d")
+ if (today - date).days <= days:
+ type_count[err_type] += count
+ except (ValueError, IndexError):
+ pass
+
+ # 总调用数
+ total_typed = sum(type_count.values())
+ success_count = type_count.get("success", 0)
+
+ report = {
+ "period_days": days,
+ "total_calls": total_calls,
+ "total_failures": total_failures,
+ "period_calls": total_typed,
+ "period_success": success_count,
+ "period_error_rate": (
+ round((1 - success_count / max(total_typed, 1)) * 100, 1)
+ if total_typed > 0 else 0
+ ),
+ "error_breakdown": dict(type_count.most_common()),
+ "recent_records": lib["records"][-20:],
+ }
+
+ return report
+
+
+def get_auto_fix_recommendations():
+ """根据错误统计自动生成修复建议。
+
+ 规则:
+ - missing_field 连续 7 天 > 5 次/天 → 检查 prompt 字段排序
+ - syntax_error 连续 7 天 > 3 次/天 → 建议启用 response_format
+ - truncation > 3 次/周 → 检查 max_tokens
+ """
+ lib = _load_library()
+ daily = lib.get("stats", {}).get("daily", {})
+
+ # 近 7 天统计
+ today = datetime.now()
+ week_errors = defaultdict(int)
+ for key, count in daily.items():
+ try:
+ date_str, err_type = key.split(":", 1)
+ date = datetime.strptime(date_str, "%Y-%m-%d")
+ if (today - date).days <= 7:
+ week_errors[err_type] += count
+ except (ValueError, IndexError):
+ pass
+
+ recommendations = []
+
+ if week_errors.get("missing_field", 0) > 35: # 5次/天 * 7天
+ recommendations.append(
+ "missing_field 频繁(近7天{}次)→ 检查 prompt 中字段是否过于靠后,"
+ "考虑将 schema 定义提前到 prompt 前部".format(
+ week_errors.get("missing_field", 0)
+ )
+ )
+
+ if week_errors.get("syntax_error", 0) > 21: # 3次/天 * 7天
+ recommendations.append(
+ "syntax_error 频繁(近7天{}次)→ 建议启用 response_format=json_object "
+ "或 constrained decoding".format(
+ week_errors.get("syntax_error", 0)
+ )
+ )
+
+ if week_errors.get("truncation", 0) > 3:
+ recommendations.append(
+ "truncation {}次/周 → 检查 max_tokens 是否足够,建议调高 20%".format(
+ week_errors.get("truncation", 0)
+ )
+ )
+
+ total_errors = sum(v for k, v in week_errors.items() if k != "success")
+ total_calls = week_errors.get("success", 0) + total_errors
+ error_rate = total_errors / max(total_calls, 1) * 100
+
+ if error_rate > 5 and total_calls > 20:
+ recommendations.append(
+ "总错误率 {:.1f}%({}次/{}次)→ 循环软约束已达极限,建议接入 "
+ "response_format=json_object 或 outlines JSON Schema generation".format(
+ error_rate, total_errors, total_calls
+ )
+ )
+
+ return recommendations
+
+
+# ═══════════════════════════════════════════════════════════════
+# 便捷包装器 — 用于 update_data.py 等下游
+# ═══════════════════════════════════════════════════════════════
+
+def safe_parse_structured(text, schema=None, log_errors=True):
+ """安全解析 JSON,带评分重试语义。
+
+ 用于 update_data.py 的 parse_report() 替代 json.loads()。
+
+ Args:
+ text: markdown 文本
+ schema: 可选 Schema
+ log_errors: 是否记录到 FormatErrorLibrary
+
+ Returns:
+ (parsed, score, errors)
+ parsed 为 None 时代表完全失败
+ """
+ score, errors, parsed = validate_with_schema(text, schema=schema)
+
+ if log_errors and errors and parsed is None:
+ _record_format_error(text, errors, 0)
+
+ return parsed, score, errors
+
+
+# ═══════════════════════════════════════════════════════════════
+# CLI 入口 — 可直接作为工具使用
+# ═══════════════════════════════════════════════════════════════
+
+def main():
+ """CLI: 校验从 stdin 或文件传入的 JSON。"""
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description="JSON 格式校验器 — 知微股票分析 Pipeline"
+ )
+ parser.add_argument("file", nargs="?", help="要校验的文件路径(默认从 stdin 读)")
+ parser.add_argument("--schema", choices=["decisions", "structured", "none"],
+ default="none", help="Schema 校验模式")
+ parser.add_argument("--report", action="store_true",
+ help="生成格式错误统计报告")
+ parser.add_argument("--recommend", action="store_true",
+ help="生成自动修复建议")
+ parser.add_argument("--days", type=int, default=7,
+ help="统计报告的天数范围(默认 7)")
+
+ args = parser.parse_args()
+
+ if args.report:
+ report = get_error_report(days=args.days)
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ return
+
+ if args.recommend:
+ recs = get_auto_fix_recommendations()
+ if recs:
+ print("## 自动修复建议\n")
+ for r in recs:
+ print(f"- {r}")
+ else:
+ print("无待修复问题。格式错误率在可接受范围内。")
+ return
+
+ # 读取输入
+ if args.file:
+ with open(args.file, "r", encoding="utf-8") as f:
+ text = f.read()
+ else:
+ text = sys.stdin.read()
+
+ # 选择 Schema
+ schema = None
+ if args.schema == "decisions":
+ schema = STOCK_ANALYSIS_SCHEMA
+ elif args.schema == "structured":
+ schema = STRUCTURED_DATA_SCHEMA
+
+ score, errors, parsed = validate_with_schema(text, schema=schema)
+
+ result = {
+ "score": score,
+ "passed": score >= 90,
+ "errors": errors,
+ "parsed_preview": str(parsed)[:300] if parsed else None,
+ }
+
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+ if errors:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/strategy_lifecycle.py b/strategy_lifecycle.py
index c1cbd7ee..49963bd2 100644
--- a/strategy_lifecycle.py
+++ b/strategy_lifecycle.py
@@ -1,2577 +1,2590 @@
-#!/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:
- # 尝试修复:替换字符串内未转义的换行符,去多余括号
- 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:
- 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:
+ # 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()
diff --git a/update_data.py b/update_data.py
index 97eb910a..f8fa4eee 100644
--- a/update_data.py
+++ b/update_data.py
@@ -55,20 +55,32 @@ def parse_report(markdown_text, source_file=None):
report["type"] = "盘前"
# ★ 优先提取结构化JSON(如果知微输出了的话)
- struct_match = re.search(r'\s*(\{.*?\})\s*', markdown_text, re.DOTALL)
+ struct_match = re.search(r'\s*(.*?)\s*', markdown_text, re.DOTALL)
if struct_match:
try:
- parsed = json.loads(struct_match.group(1))
- report["structured"] = parsed
- # 从结构化数据中直接取stock codes
- codes = set()
- for h in parsed.get("holdings", []):
- c = h.get("code", "")
- if c:
- codes.add(c)
- report["stocks_mentioned"] = sorted(codes)
- except (json.JSONDecodeError, Exception) as e:
- pass # JSON解析失败→走NLP兜底
+ import sys as _sys
+ _sys.path.insert(0, "/home/hmo/MoFin")
+ from json_validator import safe_parse_structured
+
+ parsed_sd, sd_score, sd_errors = safe_parse_structured(
+ struct_match.group(0),
+ log_errors=True,
+ )
+ if parsed_sd:
+ report["structured"] = parsed_sd
+ report["_json_score"] = sd_score
+ # 从结构化数据中直接取stock codes
+ codes = set()
+ for h in parsed_sd.get("holdings", []):
+ c = h.get("code", "")
+ if c:
+ codes.add(c)
+ report["stocks_mentioned"] = sorted(codes)
+ elif sd_errors:
+ # 记录错误但继续,不阻断流程
+ pass
+ except Exception:
+ pass # 兜底,不阻断 pipeline
# 摘要(前3非空行)
body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")]