Initial: MoFin 持仓分析与策略管理系统

核心模块:
- 策略生命周期管理 (strategy_lifecycle.py)
- 技术分析引擎 (technical_analysis.py)
- 双维度策略评估 (strategy_evaluator.py)
- 实时行情获取 (get_realtime_prices.py)
- Web Dashboard (server.py, :8899)

提示词版本管理:
- prompt_manager 模块 — 统一管理所有知微提示词
- 8个提示词共24个版本已录入
- 策略→提示词版本关联追踪
- Dashboard「提示词」Tab

数据源增强:
- 服务端 POST /api/update/realtime 端点已就绪
- clients/tdx-relay/ — 小小莫在Windows上开发的通达信中继
- 解决港股15分钟延迟问题
This commit is contained in:
2026-06-12 22:54:51 +08:00
commit 9b9c37002a
65 changed files with 8659 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""advice_reconciliation.py — 建议对账脚本
每周运行一次,对比 decisions.json 的 advice_timeline 与 portfolio.json
的实际持仓变化,统计准确率。
用法:
python3 advice_reconciliation.py # 正常对账
python3 advice_reconciliation.py --force # 强制重新对账所有建议
"""
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
DECISIONS_PATH = DATA_DIR / "decisions.json"
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
ACCURACY_PATH = DATA_DIR / "accuracy_stats.json"
def load_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {} if default is None else default
def save_json(path, data):
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_holding_change(portfolio, code):
"""获取某只股票的当前持仓信息"""
holdings = portfolio.get("holdings", [])
for h in holdings:
if h["code"] == code:
return {
"code": code,
"name": h.get("name", ""),
"shares": h.get("shares", 0),
"cost": h.get("cost", 0),
"price": h.get("price", 0),
"position_pct": h.get("position_pct", 0),
}
return None # 已清仓
def evaluate_advice(advice, current_holding):
"""评估一条建议是否正确
Returns: 'correct', 'partial', 'wrong', 'pending', 'unknown'
"""
direction = advice.get("direction", "")
status = advice.get("status", "pending")
if status == "ignored":
return "ignored"
if status == "pending":
return "pending"
if not current_holding:
# 股票已清仓
if direction in ("卖出", "清仓", "减仓"):
return "correct"
elif direction in ("买入", "加仓", "补仓"):
return "wrong"
else:
return "unknown"
shares = current_holding.get("shares", 0)
cost = current_holding.get("cost", 0)
price = current_holding.get("price", 0)
pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0
if direction in ("买入", "加仓", "补仓"):
# 如果建议买入时价格低于现价,或浮盈为正 → 正确
try:
advised_price = float(advice.get("price", 0))
if advised_price > 0 and price > 0:
if price >= advised_price * 0.95: # 允许5%误差
return "correct"
else:
return "wrong"
else:
return "unknown"
except:
return "unknown"
elif direction in ("卖出", "清仓", "减仓"):
# 如果建议卖出时价格高于现价 → 正确(规避了下跌)
try:
advised_price = float(advice.get("price", 0))
if advised_price > 0 and price > 0:
if price <= advised_price * 1.05:
return "correct"
else:
return "wrong"
else:
return "unknown"
except:
return "unknown"
elif direction in ("持有", "观望"):
# 持有建议 → 看后续是否涨
try:
advised_price = float(advice.get("price", 0))
if advised_price > 0 and price > 0:
change = (price - advised_price) / advised_price * 100
if change > -5: # 没跌超过5%
return "correct"
else:
return "wrong"
else:
return "unknown"
except:
return "unknown"
elif direction == "自选":
# 自选建议无法直接对账
return "unknown"
return "unknown"
def run():
force = "--force" in sys.argv
decisions = load_json(DECISIONS_PATH, {"decisions": []})
portfolio = load_json(PORTFOLIO_PATH, {"holdings": []})
old_stats = load_json(ACCURACY_PATH, {})
results = []
total = {"correct": 0, "wrong": 0, "partial": 0, "unknown": 0, "pending": 0, "ignored": 0}
for entry in decisions.get("decisions", []):
code = entry["code"]
name = entry.get("name", code)
timeline = entry.get("advice_timeline", [])
if not timeline:
continue
current_holding = get_holding_change(portfolio, code)
for i, advice in enumerate(timeline):
# 跳过已评估过的(除非 --force)
if not force and advice.get("evaluated"):
# 计数已有结果
result = advice.get("result", "unknown")
total[result] = total.get(result, 0) + 1
continue
result = evaluate_advice(advice, current_holding)
advice["evaluated"] = True
advice["result"] = result
advice["evaluated_at"] = datetime.now().isoformat()
total[result] = total.get(result, 0) + 1
results.append({
"code": code,
"name": name,
"date": advice.get("date", ""),
"direction": advice.get("direction", ""),
"summary": advice.get("summary", ""),
"result": result,
})
# 保存更新后的 decisions.json(含评估标记)
save_json(DECISIONS_PATH, decisions)
# 计算准确率
evaluated = total["correct"] + total["wrong"] + total["partial"]
accuracy = round(total["correct"] / evaluated * 100, 1) if evaluated > 0 else 0
stats = {
"updated_at": datetime.now().isoformat(),
"period_start": old_stats.get("period_start", (datetime.now() - timedelta(days=7)).isoformat()),
"period_end": datetime.now().isoformat(),
"total_advice": sum(total.values()),
"correct": total["correct"],
"wrong": total["wrong"],
"partial": total["partial"],
"unknown": total["unknown"],
"pending": total["pending"],
"ignored": total["ignored"],
"evaluated": evaluated,
"accuracy_pct": accuracy,
"details": results,
# 累计统计
"cumulative": {
"total": old_stats.get("cumulative", {}).get("total", 0) + evaluated,
"correct": old_stats.get("cumulative", {}).get("correct", 0) + total["correct"],
"wrong": old_stats.get("cumulative", {}).get("wrong", 0) + total["wrong"],
},
}
cum = stats["cumulative"]
cum_accuracy = round(cum["correct"] / cum["total"] * 100, 1) if cum["total"] > 0 else 0
stats["cumulative_accuracy_pct"] = cum_accuracy
save_json(ACCURACY_PATH, stats)
# 输出摘要
print(f"📊 建议对账报告")
print(f" 周期: {stats['period_start'][:10]} ~ {stats['period_end'][:10]}")
print(f" 总建议: {stats['total_advice']}")
print(f" ✅ 正确: {stats['correct']}")
print(f" ❌ 错误: {stats['wrong']}")
print(f" ⏳ 待确认: {stats['pending']}")
print(f" ✗ 已忽略: {stats['ignored']}")
print(f" ❓ 无法判断: {stats['unknown']}")
print(f" 📈 本期准确率: {accuracy}%")
print(f" 📈 累计准确率: {cum_accuracy}%")
if results:
print(f"\n 详情:")
for r in results[:20]:
icon = {"correct": "", "wrong": "", "partial": "🟡", "unknown": "", "pending": "", "ignored": ""}
print(f" {icon.get(r['result'], '?')} {r['name']}({r['code']}) {r['direction']}{r['result']}")
if __name__ == "__main__":
run()
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""收盘后全量策略重评(no_agent 脚本)"""
import sys, json
sys.path.insert(0, "/home/hmo/web-dashboard")
from strategy_lifecycle import regenerate_all
r = regenerate_all(stdout=False)
print(f"{json.dumps(r, ensure_ascii=False, indent=2)}")
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import json
import sys
# 读取实时数据
with open('data/temp_realtime.json', 'r') as f:
data = json.load(f)
prices = data['prices']
holdings = data['holdings']
print("=== 关键价位检查 ===")
# 检查每个持仓的关键价位
for holding in holdings:
code = holding['code']
name = holding['name']
current_price = holding['price']
analysis = holding.get('analysis', {})
stop_loss = analysis.get('stop_loss')
take_profit = analysis.get('take_profit')
if stop_loss and stop_loss != '':
try:
stop_loss_val = float(stop_loss)
distance_pct = (current_price - stop_loss_val) / stop_loss_val * 100
if distance_pct < 5: # 距离止损不到5%
print(f"⚠️ {name}({code}) 现价{current_price} 距止损{stop_loss_val}{abs(distance_pct):.1f}%")
except:
pass
if take_profit and take_profit != '':
try:
take_profit_val = float(take_profit)
distance_pct = (take_profit_val - current_price) / current_price * 100
if distance_pct < 5: # 距离止盈不到5%
print(f"🎯 {name}({code}) 现价{current_price} 距止盈{take_profit_val}{abs(distance_pct):.1f}%")
except:
pass
# 检查涨跌幅超过5%的股票
print("\n=== 异动股票检查(涨跌幅>5% ===")
for code, price_data in prices.items():
change_pct = price_data['change_pct']
if abs(change_pct) >= 5:
name = price_data['name']
price = price_data['price']
print(f"{'📈' if change_pct > 0 else '📉'} {name}({code}) 现价{price} {change_pct:+.2f}%")
# 检查决策库中的操作区间
print("\n=== 决策库操作区间检查 ===")
# 这里需要读取决策库,但数据太大,我们只检查几个关键股票
key_stocks = ['06160', '600110', '688411', '01478']
for code in key_stocks:
if code in prices:
price_data = prices[code]
name = price_data['name']
price = price_data['price']
# 根据历史回顾判断
if code == '06160':
print(f"🔵 {name}({code}) 现价{price} → 两批试仓已完成,止损160安全,目标175/185")
elif code == '600110':
print(f"🔵 {name}({code}) 现价{price} → 6月4日已按11.5~11.8加仓,现价仍在区间内")
elif code == '688411':
print(f"⚠️ {name}({code}) 现价{price} → 大涨10.87%,追踪止盈290接近,注意风险")
elif code == '01478':
print(f"⚠️ {name}({code}) 现价{price} → 反弹7.44%,深套股反弹至13~14可减仓")
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""cron_to_xmpp.py — 智能cron报告推送
只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。
关键规则:
1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据)
2. 跳过自己的输出目录(30908cdc44a8),避免循环推送
3. 正文太短(<20字)或只有 [SILENT] 的不推
4. 超时自动跳过,不影响后续
"""
import json
import subprocess
import re
import sys
from datetime import datetime
from pathlib import Path
# 使用绝对路径,不受 profile 环境变量影响
REAL_HOME = Path("/home/hmo")
# 扫描目录
CRON_DIRS = [
REAL_HOME / ".hermes" / "cron" / "output",
REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output",
]
JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json"
SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json"
MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史
def load_no_agent_job_ids():
"""从两个profile的jobs.json中读取所有no_agent=true的job ID"""
ids = set()
for jobs_path in [
REAL_HOME / ".hermes" / "cron" / "jobs.json",
REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json",
]:
try:
with open(jobs_path) as f:
data = json.load(f)
for j in data.get("jobs", []):
if j.get("no_agent"):
ids.add(j["id"])
except:
pass
return ids
# 硬编码保底(如果 jobs.json 读不到)
SKIP_DIRS = {
"30908cdc44a8", # cron-推XMPP中继自身输出
"health", # 健康检查输出
}
FROM = "zhiwei@yoin.fun"
TO = "hmo@yoin.fun"
def load_journal():
try:
return set(json.loads(JOURNAL.read_text()))
except:
return set()
def save_journal(entries):
JOURNAL.write_text(json.dumps(sorted(entries)))
def is_pure_script_output(content):
"""判断文件是否是纯脚本的机器输出(不是LLM报告)"""
# LLM报告的特征:有 ## Response 节(包含agent的回复)
if "## Response" in content:
return False
# 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出
if content.startswith("# Cron Job:"):
return True
# 价格监控的触发输出
if content.startswith("🔔") and "" in content:
return True
# 健康检查报告
if "MoFin 系统健康检查" in content:
return True
# [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默
# 移除 [SILENT] 过滤,让报告始终送达
# 结构化数据标签(价格监控的机器数据)
if "<structured_data>" in content:
return True
# no_agent 脚本的输出特征(Hermes自动添加的header
if "**Mode:** no_agent (script)" in content:
return True
return False
def validate_report_body(body):
"""质量检查 — 不拦截,返回改进建议"""
issues = []
text = body.strip()
if "重点推荐操作" not in text:
issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)")
if "风险关注" not in text:
issues.append("缺少【风险关注】区域(如无风险可写「无」)")
if len(text) > 600:
issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内")
fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text)
if fuzzy:
issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令")
if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text):
issues.append("含选择题句式,建议只给一个确定建议")
return issues
def send_feedback(issues, job_name):
"""发送质量反馈给知微自己"""
from xml.sax.saxutils import escape
feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"{i}" for i in issues)
safe = escape(feedback)
stanza = (
f"<message from='{FROM}' to='{FROM}' "
f"type='chat' xml:lang='en'>"
f"<body>{safe}</body></message>"
)
try:
subprocess.run(
["docker", "exec", "ejabberd", "ejabberdctl",
"send_stanza", FROM, FROM, stanza],
capture_output=True, timeout=10, text=True,
)
except:
pass
def extract_body(path):
content = path.read_text(encoding="utf-8", errors="replace")
if is_pure_script_output(content):
return None
parts = content.split("## Response")
body = parts[1].strip() if len(parts) > 1 else content.strip()
body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip()
body = re.sub(r'\n?\s*<structured_data>.*?</structured_data>\s*', '', body, flags=re.DOTALL).strip()
body = re.sub(r'\*\*(.*?)\*\*', r'\1', body)
# 去掉agent的思考过程("Now let me...", "Let me...", "Now I have..."等开头)
body = re.sub(r'^(Now let me|Let me|I need|I will|First let me|First,? I|Now I have|Here.i|I.ll|I.m ).*?\n\n', '', body, flags=re.DOTALL).strip()
# 去掉末尾的思考尾巴
body = re.sub(r'\n\s*(Now I|This |I have |I used |The report|The data).*?$', '', body, flags=re.DOTALL).strip()
# 如果只剩"好的"、"收到"等短回应,丢弃
if re.match(r'^[\u4e00-\u9fff,。]{1,10}$', body):
return None
if not body or len(body) < 20:
return None
# 再次检查正文中是否包含 [SILENT]
if "[SILENT]" in body:
return None
return body
def send(body):
from xml.sax.saxutils import escape
safe = escape(f"【知微】{body}")
stanza = (
f"<message from='{FROM}' to='{TO}' "
f"type='chat' xml:lang='en'>"
f"<body>{safe}</body></message>"
)
# 重试3次
for attempt in range(3):
try:
r = subprocess.run(
["docker", "exec", "ejabberd", "ejabberdctl",
"send_stanza", FROM, TO, stanza],
capture_output=True, timeout=10, text=True,
)
if r.stderr and "error" in r.stderr.lower():
print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr)
if attempt < 2:
continue
return False
return r.returncode == 0
except subprocess.TimeoutExpired:
print(f"send timeout (attempt {attempt+1})", file=sys.stderr)
if attempt < 2:
continue
return False
except Exception as e:
print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr)
if attempt < 2:
continue
return False
return False
def validate_format(body):
"""格式检查 — 只记录不拦截,标记改进点"""
text = body.strip()
issues = []
# 必含区域检查
has_key = "重点推荐操作" in text
has_risk = "风险关注" in text
has_rest = "其余持仓" in text or "今日关注" in text
if not has_key:
issues.append("缺【重点推荐操作】区域")
if not has_risk:
issues.append("缺【风险关注】区域")
# 超长提醒
if len(text) > 600:
issues.append(f"报告偏长({len(text)}字),建议压缩到600字内")
# 模糊词提醒
fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text)
if fuzzy:
issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论")
# 选择题句式提醒
if re.search(r"如果.*就|若.*则|可以.*也可以", text):
issues.append("含选择题句式,应给唯一建议")
return text, issues # 始终通过,issues 为空就是干净
def load_silent_stats():
"""加载当日静默统计"""
try:
return json.loads(SILENT_STATS.read_text())
except:
return {"date": "", "silent": 0, "short": 0, "script": 0}
def save_silent_stats(stats):
SILENT_STATS.write_text(json.dumps(stats))
def send_silent_summary(stats):
"""发送当日静默报告汇总"""
parts = []
if stats.get("silent", 0) > 0:
parts.append(f"静默[SILENT] {stats['silent']}")
if stats.get("short", 0) > 0:
parts.append(f"过短(<20字) {stats['short']}")
if stats.get("script", 0) > 0:
parts.append(f"脚本输出 {stats['script']}")
if not parts:
body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。"
else:
body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"{p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。"
send(body)
def scan():
processed = load_journal()
new = set()
n_pushed = 0
n_silent = 0
n_short = 0
n_script = 0
no_agent_ids = load_no_agent_job_ids()
skip_all = SKIP_DIRS | no_agent_ids
for cron_dir in CRON_DIRS:
if not cron_dir.exists():
continue
for d in sorted(cron_dir.iterdir()):
if not d.is_dir():
continue
if d.name in skip_all:
continue
for f in sorted(d.iterdir()):
if f.suffix != ".md":
continue
key = str(f.resolve())
if key in processed or key in new:
continue
new.add(key)
# 跳过超过MAX_AGE_HOURS小时的旧文件
age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600
if age_hours > MAX_AGE_HOURS:
continue
content = f.read_text(encoding="utf-8", errors="replace")
# 提前判断脚本输出
if is_pure_script_output(content):
n_script += 1
continue
parts = content.split("## Response")
body = parts[1].strip() if len(parts) > 1 else content.strip()
body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip()
body = re.sub(r'\n?\s*<structured_data>.*?</structured_data>\s*', '', body, flags=re.DOTALL).strip()
body = re.sub(r'\*\*(.*?)\*\*', r'\1', body)
if not body or len(body) < 20:
n_short += 1
continue
# SILENT → 拦截,记数
if "[SILENT]" in body:
n_silent += 1
continue
# 格式校验 — 记录改进点,不拦截
ok_body, issues = validate_format(body)
n_pushed += 1
ok_sent = send(body)
if not ok_sent:
print(f" {d.name}: send failed", file=sys.stderr)
if issues:
print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr)
if new:
save_journal(processed | new)
# 保存当日汇总到文件(供16:30汇总用)
today = datetime.now().strftime("%Y-%m-%d")
stats = load_silent_stats()
if stats.get("date") != today:
stats = {"date": today, "silent": 0, "short": 0, "script": 0}
stats["silent"] += n_silent
stats["short"] += n_short
stats["script"] += n_script
save_silent_stats(stats)
# 16:30~16:35 发送当日汇总(收盘后)
now = datetime.now()
hhmm = now.hour * 60 + now.minute
if 990 <= hhmm <= 995: # 16:30~16:35
send_silent_summary(stats)
log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}"
print(log, file=sys.stderr)
return n_pushed
if __name__ == "__main__":
scan()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
import json
import subprocess
import sys
import os
from datetime import datetime
# 读取持仓数据
with open('data/portfolio.json', 'r') as f:
portfolio = json.load(f)
# 读取决策数据
with open('data/decisions.json', 'r') as f:
decisions_data = json.load(f)
# 获取所有持仓股票
holdings = portfolio['holdings']
active_holdings = [h for h in holdings if h.get('shares', 0) > 0]
print(f"持仓股票数量: {len(active_holdings)}")
# 获取实时价格 - 使用curl调用API
# 注意:这里需要根据实际情况调整API调用
realtime_prices = {}
for holding in active_holdings:
code = holding['code']
name = holding['name']
# 判断市场类型
# 港股代码5位数字(如01211),优先判断
if len(code) == 5: # 港股
market = 'hk'
price = holding['price']
change_pct = holding.get('change_pct', 0)
realtime_prices[code] = {
'name': name,
'price': price,
'change_pct': change_pct,
'market': 'HK'
}
elif code.startswith('6'): # 沪市
market = 'sh'
price = holding['price']
change_pct = holding.get('change_pct', 0)
realtime_prices[code] = {
'name': name,
'price': price,
'change_pct': change_pct,
'market': 'A'
}
elif code.startswith('0') or code.startswith('3'): # 深市
market = 'sz'
price = holding['price']
change_pct = holding.get('change_pct', 0)
realtime_prices[code] = {
'name': name,
'price': price,
'change_pct': change_pct,
'market': 'A'
}
else:
# 其他类型
price = holding['price']
change_pct = holding.get('change_pct', 0)
realtime_prices[code] = {
'name': name,
'price': price,
'change_pct': change_pct,
'market': 'OTHER'
}
# 获取活跃决策
active_decisions = [d for d in decisions_data['decisions'] if d.get('status') == 'active']
print(f"活跃决策数量: {len(active_decisions)}")
# 输出结果
print("\n=== 实时价格数据 ===")
for code, data in realtime_prices.items():
print(f"{code} {data['name']}: {data['price']} ({data['change_pct']:+.2f}%)")
# 保存到临时文件供后续使用
output_data = {
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'prices': realtime_prices,
'holdings': active_holdings,
'active_decisions': active_decisions
}
with open('data/temp_realtime.json', 'w') as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"\n数据已保存到 data/temp_realtime.json")
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""market_watch.py — 行業熱點 + 市場洞察數據採集,寫入 dashboard data/market.json"""
import json
import urllib.request
from datetime import datetime
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
# ── 後端A:東方財富 push2 API(首選) ──
def _fetch_em(url):
"""通用 EM API 請求"""
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
resp = urllib.request.urlopen(req, timeout=10)
return json.loads(resp.read().decode("utf-8"))
def fetch_sector_em():
"""東方財富行業板塊"""
try:
data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=15&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2")
return [{"name": i["f14"], "code": i["f12"], "price": i.get("f2", 0), "change": i.get("f3", 0)}
for i in data.get("data", {}).get("diff", [])]
except Exception as e:
return None
def fetch_concept_em():
"""東方財富概念板塊"""
try:
data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=10&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3")
return [{"name": i["f14"], "code": i["f12"], "change": i.get("f3", 0)}
for i in data.get("data", {}).get("diff", [])]
except Exception as e:
return None
# ── 後端B:同花順 THS(降級) ──
def fetch_sector_ths():
"""THS 行業板塊(含漲跌家數、資金流向)"""
try:
import akshare as ak
df = ak.stock_board_industry_summary_ths()
return [{
"name": r["板块"], "code": "", "price": 0,
"change": float(r.get("涨跌幅", 0)),
"up_count": int(r.get("上涨家数", 0)),
"down_count": int(r.get("下跌家数", 0)),
"net_inflow": float(r.get("净流入", 0)),
} for _, r in df.iterrows()]
except Exception as e:
print(f"⚠️ THS行業失敗: {e}")
return []
def fetch_concept_ths():
"""THS 概念板塊(僅名稱,無實時漲跌)"""
try:
import akshare as ak
df = ak.stock_board_concept_name_ths()
return [{"name": r["name"], "code": str(r["code"]), "change": 0}
for _, r in df.head(15).iterrows()]
except Exception as e:
print(f"⚠️ THS概念失敗: {e}")
return []
# ── 主流程 ──
def get_market_mood(sectors):
if not sectors:
return "unknown"
ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors)
return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish"
def main():
sectors = fetch_sector_em()
if sectors is None:
sectors = fetch_sector_ths()
concepts = fetch_concept_em()
if concepts is None:
concepts = fetch_concept_ths()
sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True)
top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5]
top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3]
market_data = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"total_sectors": len(sectors),
"up_ratio": round(sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1),
"mood": get_market_mood(sectors),
"top_gainers": top_gainers,
"top_losers": top_losers,
"sectors": sectors,
"concepts": concepts,
}
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(DATA_DIR / "market.json", "w", encoding="utf-8") as f:
json.dump(market_data, f, ensure_ascii=False, indent=2)
# 安静:数据只写文件,不打印到stdout,避免cron输出被推送
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""post_mortem.py - 知微历史报告复盘 + 自我改进引擎
读取所有历史报告,提炼分析模式,生成升级方案。
"""
import json
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
REPORTS_DIR = DATA_DIR / "reports"
def load_reports():
reports = []
for f in sorted(REPORTS_DIR.iterdir()):
if f.suffix == ".json" and f.stat().st_size > 200:
try:
reports.append(json.loads(f.read_text()))
except:
pass
return reports
def analyze_stock_timeline(code):
"""提取某只股票在所有报告中的分析时间线"""
entries = []
for r in load_reports():
content = r.get("content", "")
if code in content:
lines = content.split("\n")
for i, line in enumerate(lines):
if code in line and ("止损" in line or "止盈" in line or "补仓" in line or "买入" in line or "卖出" in line or "持有" in line or "减仓" in line):
entries.append({
"time": r.get("created_at", ""),
"type": r.get("type", ""),
"title": r.get("title", ""),
"line": line.strip()[:120],
"context": "\n".join(lines[max(0,i-1):i+2]),
})
return entries
def generate_retrospective(code, name):
"""对某只股票过去所有建议做复盘"""
entries = analyze_stock_timeline(code)
if not entries:
return None
result = {
"code": code,
"name": name,
"entry_count": len(entries),
"first_seen": entries[0]["time"],
"last_seen": entries[-1]["time"],
"timeline": entries,
}
return result
def identify_gaps():
"""识别知微的分析gap"""
reports = load_reports()
gaps = []
# Gap 1: 缺少止盈建议(对比止损)
stop_loss_count = 0
take_profit_count = 0
for r in reports:
c = r.get("content", "")
stop_loss_count += c.count("止损")
take_profit_count += c.count("止盈")
gap_ratio = stop_loss_count / max(take_profit_count, 1)
if gap_ratio > 3:
gaps.append(f"止损共{stop_loss_count}次,止盈仅{take_profit_count}次,比例{gap_ratio:.1f}:1。止盈意识不足,应增加止盈建议频率")
# Gap 2: 模糊用词过多
vague_words = ["关注", "观望", "留意", "观察", "考虑"]
vague_count = sum(sum(r.get("content", "").count(w) for r in reports) for w in vague_words)
if vague_count > 30:
gaps.append(f"模糊用词(关注/观望/留意等)共{vague_count}次,应减少,改用具体动作+价格")
# Gap 3: 跨报告连贯性
all_codes = set()
for r in reports:
codes = re.findall(r'\b\d{5,6}\b', r.get("content", ""))
all_codes.update(codes)
for code in list(all_codes)[:5]:
entries = analyze_stock_timeline(code)
action_count = len(entries)
if action_count > 3:
gaps.append(f"{code}出现{action_count}次,没有跟踪验证之前的建议是否有效")
return gaps
if __name__ == "__main__":
print("=" * 60)
print("📋 知微复盘报告")
print(f" 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print("=" * 60)
gaps = identify_gaps()
print(f"\n🔍 发现 {len(gaps)} 个改进点:")
for g in gaps:
print(f" ⚠️ {g}")
print("\n📈 个股跟踪示例:")
for code, name in [("688411", "海博思创"), ("600563", "法拉电子"), ("600110", "诺德股份")]:
retro = generate_retrospective(code, name)
if retro:
print(f"\n {name}({code}): {retro['entry_count']}次提及")
for e in retro["timeline"][-3:]:
print(f" [{e['time'][:16]}] {e['line']}")
print("\n✅ 复盘完成")
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""price_monitor.py — 高频价格监控脚本(批量版)
规则:进入区间报一次,离开区间报一次,中间不重复。
每次运行时一次性刷新所有持仓+自选股的实时价。
"""
import json
import urllib.request
import os
import sys
import time
from datetime import datetime
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json"
WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json"
BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json")
EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
UA = "Mozilla/5.0"
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
def fetch_all_prices(codes):
"""腾讯批量行情API:一次请求拉取所有股票(A股+港股)
A股:sh600110 / sz000001
港股:hk00700
返回 {code: (price, change)}
"""
if not codes:
return {}
# 构建批量查询串
symbols = []
code_map = {} # symbol -> original_code
for code in codes:
code_s = str(code).strip()
if len(code_s) == 6:
# A股:沪市以5/6/9开头,深市以0/3开头
if code_s.startswith(('5', '6', '9')):
sym = f"sh{code_s}"
else:
sym = f"sz{code_s}"
else:
sym = f"hk{code_s}"
symbols.append(sym)
code_map[sym] = code_s
url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as r:
text = r.read().decode("gbk")
except Exception as e:
print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr)
return {}
results = {}
for line in text.strip().split("\n"):
line = line.strip()
if not line or "=" not in line:
continue
try:
# 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..."
raw_value = line.split("=", 1)[1].strip().strip('"').strip(";")
fields = raw_value.split("~")
if len(fields) < 6:
continue
sym = line.split("=", 1)[0].strip().lstrip("v_")
orig_code = code_map.get(sym)
if not orig_code:
continue
price = float(fields[3]) if fields[3] else 0
prev_close = float(fields[4]) if fields[4] else 0
change = price - prev_close if prev_close > 0 else 0
results[orig_code] = (price, change)
except (ValueError, IndexError):
continue
return results
def refresh_data_prices():
"""一次性刷新portfolio.json和watchlist.json的所有实时价"""
all_codes = set()
# 收集所有需要拉取的代码
try:
pf = json.load(open(PORTFOLIO_PATH))
for s in pf.get('holdings', []):
all_codes.add(s['code'])
except:
pf = {"holdings": []}
try:
wl = json.load(open(WATCHLIST_PATH))
for s in wl.get('stocks', []):
all_codes.add(s['code'])
except:
wl = {"stocks": []}
if not all_codes:
return 0
# 一次性批量拉取
prices = fetch_all_prices(list(all_codes))
updated = 0
# 更新portfolio(只在价格变化时写入,避免触发文件变更通知)
changed = False
for s in pf.get('holdings', []):
if s['code'] in prices:
price, _ = prices[s['code']]
if price > 0:
old = s.get('price', 0)
if abs(old - price) > 0.001:
s['price'] = round(price, 2)
updated += 1
changed = True
if changed:
json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2)
# 更新watchlist(只在价格变化时写入)
changed = False
for s in wl.get('stocks', []):
if s['code'] in prices:
price, _ = prices[s['code']]
if price > 0:
old = s.get('price', 0)
if abs(old - price) > 0.001:
s['price'] = round(price, 2)
updated += 1
changed = True
if changed:
wl['updated_at'] = datetime.now().isoformat()
json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2)
return updated
# ── 区间偏离检测 ──────────────────────────────────────────────────────────
def load_state():
try:
with open(STATE_PATH) as f:
return json.load(f)
except:
return {}
def save_state(state):
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
with open(STATE_PATH, 'w') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
def load_breaches():
try:
with open(BREACH_PATH) as f:
return json.load(f)
except:
return {}
def save_breaches(data):
os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True)
with open(BREACH_PATH, 'w') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_events():
try:
with open(EVENTS_PATH) as f:
return json.load(f)
except:
return {"events": []}
def save_events(events):
os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True)
with open(EVENTS_PATH, 'w') as f:
json.dump(events, f, ensure_ascii=False, indent=2)
def record_event(code, name, event_type, price, trigger_value, event_label=""):
"""记录一次价格触发事件到 price_events.json"""
events = load_events()
now = datetime.now().isoformat()
events["events"].append({
"code": code,
"name": name,
"event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone
"price": round(price, 2),
"trigger_value": trigger_value,
"event_label": event_label,
"timestamp": now,
"date": datetime.now().strftime("%Y-%m-%d"),
})
# 保留最近10000条
events["events"] = events["events"][-10000:]
save_events(events)
def get_trigger_zones(trigger):
"""返回该trigger所有可监控的区间列表,跳过已执行的batch"""
zones = []
for key, label in [
("entry_zone", "加仓区间"),
("batch1_price", "试仓区间"),
("batch2_price", "加仓区间"),
("take_profit_zone", "止盈区间"),
("watch_low", "关注区间"),
("watch_high", "减仓区间"),
("watch_break", "止损区间")
]:
status_key = key.replace("_price", "_status")
if status_key in trigger and trigger[status_key] == "executed":
continue
val = trigger.get(key, "")
if val and "~" in val:
try:
parts = val.split("~")
lo, hi = float(parts[0]), float(parts[1])
zones.append((key, label, lo, hi))
except:
pass
sl = trigger.get("stop_loss", "")
if sl:
try:
sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl)
zones.append(("stop_loss", "止损", 0, sl_price))
except:
pass
return zones
def run_once(round_label=""):
"""执行一轮完整的监控流程"""
label = f" [{round_label}]" if round_label else ""
start = time.time()
# === 第一步:一次性刷新所有价格 ===
refreshed = refresh_data_prices()
# === 第二步:检查触发条件 ===
try:
with open(DECISIONS_PATH) as f:
dec = json.load(f)
except:
print(f"{label} 无法读取decisions.json", file=sys.stderr)
return
active = [d for d in dec.get("decisions", []) if d.get("status") == "active"]
state = load_state()
outputs = []
state_updated = False
# 收集所有需要检查的代码
check_codes = set()
for d in active:
trig = d.get("trigger", {})
if trig:
check_codes.add(d["code"])
# 批量拉取这些股票的价格
prices = fetch_all_prices(list(check_codes))
for d in active:
code = d["code"]
trig = d.get("trigger", {})
if not trig:
continue
zones = get_trigger_zones(trig)
if not zones:
continue
price_info = prices.get(code)
if not price_info:
continue
price, _ = price_info
if price == 0:
continue
name = d.get("name", code)
if code not in state:
state[code] = {}
for key, label, lo, hi in zones:
in_zone = lo <= price <= hi
prev_in_zone = state[code].get(key, None)
if in_zone and prev_in_zone != True:
if key == "stop_loss":
outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}")
record_event(code, name, "stop_loss", price, str(hi))
else:
extra = ""
if "_price" in key:
batch_shares = trig.get(key.replace("_price", "_shares"), "")
action = trig.get(key.replace("_price", "_action"), "")
if batch_shares:
extra = f" {action}{batch_shares}" if action else f" {batch_shares}"
elif key in ("take_profit_zone",):
act = trig.get("take_profit_action", "")
if act:
extra = f"{act}"
outputs.append(f"{name}({code}) {price} → 进入{label}{lo}~{hi}{extra}")
record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
state[code][key] = True
state_updated = True
elif not in_zone and prev_in_zone == True:
if key != "stop_loss":
outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}")
state[code][key] = False
state_updated = True
# === 第三步:输出 ===
now_str = datetime.now().strftime("%H:%M:%S")
elapsed = time.time() - start
if outputs:
print(f"\n🔔 {now_str}{label}")
for o in outputs:
print(o)
print(f"\n<structured_data>{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}</structured_data>")
else:
# 无触发时 SILENT(中继不推送)
print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s")
if state_updated:
save_state(state)
# 输出耗时
print(f"{label} {elapsed:.1f}s", flush=True)
def main():
"""每cron触发跑一轮"""
run_once()
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
"""MoFin 提示词管理器 — 集中管理所有知微用到的提示词
功能:
1. 提示词注册表 — 所有提示词分类管理
2. 版本管理 — 每次修改记录版本号/变更日志/内容
3. 策略关联 — 每只股票的策略关联生成它的提示词版本
4. 统计分析 — 哪个版本的提示词产生的策略最有效
"""
from .registry import PromptRegistry
from .tracking import PromptTracker
from .analytics import PromptAnalytics
__all__ = ["PromptRegistry", "PromptTracker", "PromptAnalytics"]
+186
View File
@@ -0,0 +1,186 @@
"""策略→提示词版本分析引擎
核心功能:将策略评估结果按提示词版本聚合,计算每个版本的准确率。
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Optional
from .tracking import load_associations, get_associations_for_prompt_version
from .registry import get_prompt, get_version_history
PROJECT_DIR = Path("/home/hmo/projects/MoFin")
DECISIONS_PATH = PROJECT_DIR / "data" / "decisions.json"
ACCURACY_PATH = PROJECT_DIR / "data" / "accuracy_stats.json"
EVAL_PATH = PROJECT_DIR / "data" / "evaluation.json"
def _load_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {} if default is None else default
def analyze_prompt_version_effectiveness() -> dict:
"""按提示词版本聚合策略评估结果
关联 decisions.json 中的 evaluation 字段和 associations.json 中的版本记录,
计算出每个提示词版本的:
- 生成的策略总数
- 达到止盈数(成功)
- 跌破止损数(失败)
- 待验证数
- 盈亏比平均值
"""
# 加载数据
decisions = _load_json(DECISIONS_PATH, {"decisions": []})
associations = load_associations().get("associations", [])
# 建立 code → 最新关联的映射
code_to_pv = {} # code -> {prompt_id, version}
for a in associations:
code = a.get("code")
if code and code not in code_to_pv:
code_to_pv[code] = {
"prompt_id": a.get("prompt_id"),
"version": a.get("prompt_version"),
}
# 按 prompt_id@version 分组统计
version_stats = {}
for d in decisions.get("decisions", []):
code = d.get("code")
if not code:
continue
pv = code_to_pv.get(code)
if not pv:
continue
key = f"{pv['prompt_id']}@{pv['version']}"
if key not in version_stats:
version_stats[key] = {
"prompt_id": pv["prompt_id"],
"version": pv["version"],
"total": 0,
"take_profit_hit": 0,
"stop_loss_hit": 0,
"in_entry_zone": 0,
"pending": 0,
"avg_rr": 0.0,
"rr_sum": 0.0,
"rr_count": 0,
"stocks": [],
}
vs = version_stats[key]
vs["total"] += 1
vs["stocks"].append(code)
# 从 evaluation 中读取状态
evals = d.get("evaluation", [])
for ev in evals:
if isinstance(ev, dict) and ev.get("phase") == 1:
theo = ev.get("theoretical", {})
status = theo.get("status", "")
if status == "take_profit_hit":
vs["take_profit_hit"] += 1
elif status == "stop_loss_hit":
vs["stop_loss_hit"] += 1
elif status == "in_entry_zone":
vs["in_entry_zone"] += 1
else:
vs["pending"] += 1
# 盈亏比
rr = d.get("rr_ratio")
if rr is not None and isinstance(rr, (int, float)):
vs["rr_sum"] += rr
vs["rr_count"] += 1
# 计算平均盈亏比和成功率
for key, vs in version_stats.items():
if vs["rr_count"] > 0:
vs["avg_rr"] = round(vs["rr_sum"] / vs["rr_count"], 2)
total_outcome = vs["take_profit_hit"] + vs["stop_loss_hit"]
if total_outcome > 0:
vs["success_rate"] = round(vs["take_profit_hit"] / total_outcome * 100, 1)
else:
vs["success_rate"] = None
del vs["rr_sum"]
return version_stats
def get_prompt_version_comparison() -> dict:
"""生成版本对比报告"""
version_stats = analyze_prompt_version_effectiveness()
# 补充每个版本的标签信息
for key, vs in version_stats.items():
prompt = get_prompt(vs["prompt_id"])
if prompt:
for v in prompt.versions:
if v.version == vs["version"]:
vs["label"] = v.label
vs["changelog"] = v.changelog
vs["tags"] = v.tags
break
if "label" not in vs:
vs["label"] = vs["version"]
return version_stats
def generate_report() -> str:
"""生成版本有效性报告文本"""
comparison = get_prompt_version_comparison()
lines = []
lines.append("📊 提示词版本有效性分析 | " + datetime.now().strftime("%Y-%m-%d"))
lines.append("")
if not comparison:
lines.append("暂无数据 — 请先运行策略生成和评估后重试")
return "\n".join(lines)
# 按 prompt_id 分组显示
by_prompt = {}
for key, vs in comparison.items():
pid = vs["prompt_id"]
by_prompt.setdefault(pid, []).append(vs)
for pid, versions in sorted(by_prompt.items()):
prompt = get_prompt(pid)
lines.append(f"\n## {prompt.name if prompt else pid}")
lines.append(f" {prompt.description if prompt else ''}")
lines.append("")
# 按版本号排序
versions.sort(key=lambda x: x["version"])
header = f" {'版本':<10} {'标签':<20} {'策略数':<8} {'止盈':<8} {'止损':<8} {'成功率':<10} {'平均R/R':<10}"
lines.append(header)
lines.append(" " + "-" * len(header))
for vs in versions:
sr = f"{vs['success_rate']}%" if vs['success_rate'] is not None else "-"
label = vs.get("label", "")[:18]
lines.append(
f" {vs['version']:<10} {label:<20} "
f"{vs['total']:<8} {vs['take_profit_hit']:<8} "
f"{vs['stop_loss_hit']:<8} {sr:<10} "
f"{vs['avg_rr']:<10}"
)
lines.append("")
lines.append("---")
lines.append("注:数据来自 decisions.json evaluation + associations.json")
return "\n".join(lines)
+363
View File
@@ -0,0 +1,363 @@
"""Dashboard 提示词管理视图 —— 集成到 MoFin Web Dashboard
注册 API 路由和前端页面。
"""
import json
from pathlib import Path
from datetime import datetime
from .registry import (
list_prompts, get_prompt, add_prompt, update_prompt,
add_version, set_active_version, get_version_history, get_categories,
)
from .tracking import (
get_strategy_version_stats, get_associations_for_stock,
)
from .analytics import (
get_prompt_version_comparison, generate_report,
)
from .models import PromptDef, PromptVersion
def register_routes(server):
"""在主 server.py 中调用,注册所有 /api/prompts/* 路由
用法:
from prompt_manager.dashboard_views import register_routes
register_routes(app) # app = Flask instance
"""
# ═══════════════════════════════════════════
# API: 提示词列表
# ═══════════════════════════════════════════
@server.route("/api/prompts")
def api_prompts_list():
from flask import request, jsonify
category = request.args.get("category")
prompts = list_prompts(category)
return jsonify({
"prompts": prompts,
"categories": get_categories(),
})
# ═══════════════════════════════════════════
# API: 单个提示词详情
# ═══════════════════════════════════════════
@server.route("/api/prompts/<prompt_id>")
def api_prompt_detail(prompt_id):
from flask import jsonify
prompt = get_prompt(prompt_id)
if not prompt:
return jsonify({"error": "not found"}), 404
history = get_version_history(prompt_id)
return jsonify({
"prompt": prompt.to_dict(),
"version_history": history,
})
# ═══════════════════════════════════════════
# API: 新增提示词
# ═══════════════════════════════════════════
@server.route("/api/prompts", methods=["POST"])
def api_prompt_create():
from flask import request, jsonify
data = request.get_json()
if not data or "id" not in data:
return jsonify({"error": "缺少 id"}), 400
now = datetime.now().isoformat()
prompt = PromptDef(
id=data["id"],
name=data.get("name", data["id"]),
description=data.get("description", ""),
category=data.get("category", ""),
locations=data.get("locations", []),
versions=[],
created_at=now,
updated_at=now,
current_version="",
)
add_prompt(prompt)
return jsonify({"status": "ok", "prompt": prompt.to_dict()})
# ═══════════════════════════════════════════
# API: 添加版本
# ═══════════════════════════════════════════
@server.route("/api/prompts/<prompt_id>/versions", methods=["POST"])
def api_prompt_add_version(prompt_id):
from flask import request, jsonify
data = request.get_json()
if not data or "version" not in data:
return jsonify({"error": "缺少 version"}), 400
now = datetime.now().isoformat()
version = PromptVersion(
version=data["version"],
label=data.get("label", data["version"]),
created_at=now,
changelog=data.get("changelog", ""),
content=data.get("content", ""),
content_path="",
author=data.get("author", "知微"),
status=data.get("status", "active"),
tags=data.get("tags", []),
)
try:
add_version(prompt_id, version)
return jsonify({"status": "ok"})
except ValueError as e:
return jsonify({"error": str(e)}), 400
# ═══════════════════════════════════════════
# API: 切换活跃版本
# ═══════════════════════════════════════════
@server.route("/api/prompts/<prompt_id>/activate", methods=["POST"])
def api_prompt_activate(prompt_id):
from flask import request, jsonify
data = request.get_json()
if not data or "version" not in data:
return jsonify({"error": "缺少 version"}), 400
try:
set_active_version(prompt_id, data["version"])
return jsonify({"status": "ok"})
except ValueError as e:
return jsonify({"error": str(e)}), 400
# ═══════════════════════════════════════════
# API: 版本统计
# ═══════════════════════════════════════════
@server.route("/api/prompts/stats")
def api_prompt_stats():
from flask import jsonify
version_stats = get_strategy_version_stats()
return jsonify(version_stats)
# ═══════════════════════════════════════════
# API: 版本有效性对比
# ═══════════════════════════════════════════
@server.route("/api/prompts/effectiveness")
def api_prompt_effectiveness():
from flask import jsonify
comparison = get_prompt_version_comparison()
return jsonify(comparison)
# ═══════════════════════════════════════════
# API: 生成分析报告(文本)
# ═══════════════════════════════════════════
@server.route("/api/prompts/report")
def api_prompt_report():
from flask import jsonify
report = generate_report()
return jsonify({"report": report})
# ═══════════════════════════════════════════
# API: 获取某只股票的策略关联记录
# ═══════════════════════════════════════════
@server.route("/api/prompts/associations/<code>")
def api_prompt_associations(code):
from flask import jsonify
records = get_associations_for_stock(code)
return jsonify({"code": code, "records": records})
# ═══════════════════════════════════════════
# 前端页面模板
# ═══════════════════════════════════════════
def get_dashboard_tab_html():
"""返回提示词管理 Tab 的 HTML,嵌入到 Dashboard 的 <div id="prompt-manager-content"> 中"""
return """\
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">📝 提示词管理</h5>
<div>
<button class="btn btn-sm btn-outline-primary" onclick="loadPromptReport()">📊 版本有效性报告</button>
</div>
</div>
<div class="card-body" id="prompt-manager-body">
<div class="text-center text-muted py-4">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 版本详情 Modal -->
<div class="modal fade" id="promptVersionModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="versionModalTitle">版本详情</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="versionModalBody"></div>
</div>
</div>
</div>
<!-- 报告 Modal -->
<div class="modal fade" id="promptReportModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">提示词版本有效性报告</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="promptReportBody" style="white-space: pre-wrap; font-family: monospace;"></div>
</div>
</div>
</div>
<script>
function loadPrompts() {
const body = document.getElementById('prompt-manager-body');
body.innerHTML = '<div class="text-center text-muted py-4">加载中...</div>';
Promise.all([
fetch('/api/prompts').then(r => r.json()),
fetch('/api/prompts/effectiveness').then(r => r.json())
]).then(([data, effectiveness]) => {
const prompts = data.prompts || [];
const categories = data.categories || {};
let html = '';
// 分类导航
const catSet = {};
prompts.forEach(p => { catSet[p.category] = true; });
html += '<div class="mb-3">';
html += '<span class="badge bg-secondary me-1" style="cursor:pointer" onclick="filterPrompts(\'\')">全部</span>';
Object.keys(catSet).sort().forEach(cat => {
html += `<span class="badge bg-info me-1" style="cursor:pointer" onclick="filterPrompts('${cat}')">${categories[cat] || cat}</span>`;
});
html += '</div>';
// 提示词列表
if (prompts.length === 0) {
html += '<div class="alert alert-info">还没有提示词记录,请先初始化注册表。</div>';
} else {
html += '<div class="table-responsive"><table class="table table-sm table-hover">';
html += '<thead><tr><th>提示词名称</th><th>分类</th><th>当前版本</th><th>版本数</th><th>策略数</th><th>成功率</th><th>操作</th></tr></thead><tbody>';
prompts.forEach(p => {
const verCount = (p.versions || []).length;
const currentVer = p.current_version || '-';
const eff = effectiveness[`${p.id}@${currentVer}`] || {};
const sr = eff.success_rate !== undefined ? eff.success_rate + '%' : '-';
const total = eff.total || 0;
html += `<tr data-category="${p.category}">
<td><strong>${p.name}</strong><br><small class="text-muted">${p.id}</small></td>
<td><span class="badge bg-light text-dark">${categories[p.category] || p.category}</span></td>
<td><span class="badge bg-primary">${currentVer}</span></td>
<td>${verCount}</td>
<td>${total}</td>
<td>${sr}</td>
<td>
<button class="btn btn-sm btn-outline-info" onclick="showPromptVersions('${p.id}')">版本</button>
<button class="btn btn-sm btn-outline-secondary" onclick="showPromptDetail('${p.id}')">详情</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
}
body.innerHTML = html;
}).catch(err => {
body.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
});
}
function filterPrompts(category) {
const rows = document.querySelectorAll('#prompt-manager-body table tbody tr');
rows.forEach(row => {
if (!category || row.dataset.category === category) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
function showPromptVersions(promptId) {
fetch(`/api/prompts/${promptId}`)
.then(r => r.json())
.then(data => {
const prompt = data.prompt || {};
const history = data.version_history || [];
let html = `<h6>${prompt.name} — 版本历史</h6>`;
html += '<table class="table table-sm"><thead><tr><th>版本</th><th>标签</th><th>状态</th><th>时间</th><th>变更说明</th></tr></thead><tbody>';
history.forEach(h => {
const statusBadge = h.is_current ? 'bg-success' :
h.status === 'deprecated' ? 'bg-warning text-dark' : 'bg-secondary';
html += `<tr>
<td><code>${h.version}</code></td>
<td>${h.label}</td>
<td><span class="badge ${statusBadge}">${h.is_current ? '当前' : h.status}</span></td>
<td><small>${h.created_at}</small></td>
<td><small>${h.changelog}</small></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('versionModalTitle').textContent = prompt.name + ' - 版本历史';
document.getElementById('versionModalBody').innerHTML = html;
new bootstrap.Modal(document.getElementById('promptVersionModal')).show();
});
}
function showPromptDetail(promptId) {
fetch(`/api/prompts/${promptId}`)
.then(r => r.json())
.then(data => {
const prompt = data.prompt || {};
// 获取当前版本内容
const currentVer = prompt.current_version;
const currentVerData = (prompt.versions || []).find(v => v.version === currentVer);
let html = `<h6>${prompt.name}</h6>`;
html += `<p class="text-muted">${prompt.description || '无描述'}</p>`;
html += `<p><strong>ID:</strong> ${prompt.id} | <strong>分类:</strong> ${prompt.category} | <strong>当前版本:</strong> ${currentVer}`;
html += ` | <strong>位置:</strong> ${(prompt.locations || []).join(', ') || '-'}</p>`;
if (currentVerData) {
html += '<hr><h6>当前版本内容摘要</h6>';
html += `<pre style="max-height: 300px; overflow-y: auto; background: #f8f9fa; padding: 10px; border-radius: 4px; font-size: 12px;">${currentVerData.content || '无内容'}</pre>`;
}
document.getElementById('versionModalTitle').textContent = prompt.name + ' - 详情';
document.getElementById('versionModalBody').innerHTML = html;
new bootstrap.Modal(document.getElementById('promptVersionModal')).show();
});
}
function loadPromptReport() {
fetch('/api/prompts/report')
.then(r => r.json())
.then(data => {
document.getElementById('promptReportBody').textContent = data.report || '暂无数据';
new bootstrap.Modal(document.getElementById('promptReportModal')).show();
});
}
// 页面加载后自动加载
if (document.getElementById('prompt-manager-body')) {
loadPrompts();
}
</script>
"""
+115
View File
@@ -0,0 +1,115 @@
"""提示词管理数据模型"""
from dataclasses import dataclass, field, asdict
from typing import Optional
from datetime import datetime
import json
# ═══════════════════════════════════════════
# 枚举/常量
# ═══════════════════════════════════════════
PROMPT_CATEGORIES = {
"strategy": "策略生成 — 制定买入区/止损/止盈的规则集",
"scan": "快速盯盘 — 交易时段行情监控报告",
"evaluation": "策略评估 — 每日/每周策略效果评估",
"knowledge": "知识萃取 — 经验沉淀到知识日志",
"review": "持仓复查 — 定期全面持仓分析",
"health": "系统健康检查 — 每日开盘前检查",
"format": "报告格式规范 — 三段式输出格式规则",
"analysis": "分析规则 — 单股/行业分析流程",
}
VERSION_STATUS = {
"active": "当前使用中",
"deprecated": "已弃用(不再使用)",
"archived": "已归档(历史版本,仅保留记录)",
"experimental": "实验版本(临时测试)",
}
# ═══════════════════════════════════════════
# 数据类
# ═══════════════════════════════════════════
@dataclass
class PromptVersion:
"""单个提示词版本"""
version: str # v1, v2, v2.1 ...
label: str # 人类可读的名称
created_at: str # ISO datetime
changelog: str # 变更说明
content: str # 提示词完整内容(或摘要)
content_path: str # 提示词文件路径(完整内容存储处)
author: str = "知微" # 修改者
status: str = "active" # active/deprecated/archived/experimental
tags: list = field(default_factory=list) # 标签:如 "技术面", "R/R阈值"
def to_dict(self):
return asdict(self)
@classmethod
def from_dict(cls, d):
return cls(**d)
@dataclass
class PromptDef:
"""一条提示词定义"""
id: str # 唯一标识,如 "strategy-generation"
name: str # 显示名称
description: str # 用途说明
category: str # 分类
locations: list # 存放位置(文件路径、cron job ID等)
versions: list # PromptVersion 列表
created_at: str # 首次创建时间
updated_at: str # 最近更新时间
current_version: str # 当前活跃版本号
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"category": self.category,
"locations": self.locations,
"versions": [v.to_dict() for v in self.versions],
"created_at": self.created_at,
"updated_at": self.updated_at,
"current_version": self.current_version,
}
@classmethod
def from_dict(cls, d):
versions = [PromptVersion.from_dict(v) for v in d.get("versions", [])]
return cls(
id=d["id"],
name=d["name"],
description=d.get("description", ""),
category=d.get("category", ""),
locations=d.get("locations", []),
versions=versions,
created_at=d.get("created_at", ""),
updated_at=d.get("updated_at", ""),
current_version=d.get("current_version", ""),
)
@dataclass
class StrategyLink:
"""策略→提示词版本关联记录"""
code: str # 股票代码
name: str # 股票名称
prompt_id: str # 提示词ID
prompt_version: str # 提示词版本号
strategy_action: str # 生成的策略文本
generated_at: str # 生成时间
evaluation_result: Optional[dict] = None # 评估结果(后续补充)
def to_dict(self):
return asdict(self)
@classmethod
def from_dict(cls, d):
return cls(**d)
+194
View File
@@ -0,0 +1,194 @@
"""提示词注册表 — 所有提示词的CRUD和版本管理"""
import json
import shutil
from pathlib import Path
from datetime import datetime
from typing import Optional
from .models import PromptDef, PromptVersion, PROMPT_CATEGORIES
# 数据文件路径
DATA_DIR = Path("/home/hmo/projects/MoFin/data/prompts")
REGISTRY_PATH = DATA_DIR / "registry.json"
VERSIONS_DIR = DATA_DIR / "versions"
def ensure_dirs():
DATA_DIR.mkdir(parents=True, exist_ok=True)
VERSIONS_DIR.mkdir(parents=True, exist_ok=True)
def load_registry() -> dict:
"""加载注册表"""
ensure_dirs()
if not REGISTRY_PATH.exists():
return {"prompts": [], "updated_at": datetime.now().isoformat()}
try:
return json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
return {"prompts": [], "updated_at": datetime.now().isoformat()}
def save_registry(data: dict):
ensure_dirs()
data["updated_at"] = datetime.now().isoformat()
REGISTRY_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# ═══════════════════════════════════════════
# CRUD
# ═══════════════════════════════════════════
def list_prompts(category: str = None) -> list:
"""列出所有提示词,可选按分类过滤"""
reg = load_registry()
prompts = []
for p in reg.get("prompts", []):
if category and p.get("category") != category:
continue
prompts.append(dict(PromptDef.from_dict(p).to_dict()))
return prompts
def get_prompt(prompt_id: str) -> Optional[PromptDef]:
"""获取单个提示词"""
reg = load_registry()
for p in reg.get("prompts", []):
if p["id"] == prompt_id:
return PromptDef.from_dict(p)
return None
def add_prompt(prompt_def: PromptDef):
"""添加新提示词"""
reg = load_registry()
# 检查是否已存在
for i, p in enumerate(reg.get("prompts", [])):
if p["id"] == prompt_def.id:
reg["prompts"][i] = prompt_def.to_dict()
break
else:
reg.setdefault("prompts", []).append(prompt_def.to_dict())
save_registry(reg)
def update_prompt(prompt_id: str, updates: dict):
"""更新提示词元数据"""
reg = load_registry()
for p in reg.get("prompts", []):
if p["id"] == prompt_id:
for k, v in updates.items():
if k not in ("id", "versions", "created_at"):
p[k] = v
break
save_registry(reg)
def delete_prompt(prompt_id: str):
"""删除提示词"""
reg = load_registry()
reg["prompts"] = [p for p in reg.get("prompts", []) if p["id"] != prompt_id]
save_registry(reg)
def get_version_content(prompt_id: str, version: str) -> Optional[str]:
"""读取版本内容文件"""
content_path = VERSIONS_DIR / f"{prompt_id}-{version}.md"
if content_path.exists():
return content_path.read_text(encoding="utf-8")
# 回退:从版本记录的 content 字段读取
prompt = get_prompt(prompt_id)
if prompt:
for v in prompt.versions:
if v.version == version:
return v.content
return None
# ═══════════════════════════════════════════
# 版本管理
# ═══════════════════════════════════════════
def add_version(prompt_id: str, version: PromptVersion):
"""添加新版本到提示词"""
prompt = get_prompt(prompt_id)
if not prompt:
raise ValueError(f"提示词 '{prompt_id}' 不存在")
# 检查版本号是否已存在
for i, v in enumerate(prompt.versions):
if v.version == version.version:
prompt.versions[i] = version
break
else:
prompt.versions.append(version)
# 保存版本内容到独立文件
content_path = VERSIONS_DIR / f"{prompt_id}-{version.version}.md"
content_path.write_text(version.content, encoding="utf-8")
# 更新 content_path 只存路径,content 字段只存摘要
version.content_path = str(content_path)
version.content = version.content[:200] + "..." if len(version.content) > 200 else version.content
# 设置当前版本
prompt.current_version = version.version
prompt.updated_at = datetime.now().isoformat()
# 保存
add_prompt(prompt)
def set_active_version(prompt_id: str, version_str: str):
"""切换当前活跃版本"""
prompt = get_prompt(prompt_id)
if not prompt:
raise ValueError(f"提示词 '{prompt_id}' 不存在")
# 验证版本存在
version_exists = any(v.version == version_str for v in prompt.versions)
if not version_exists:
raise ValueError(f"版本 '{version_str}' 不存在于 '{prompt_id}'")
# 将所有版本标记为非活跃,目标版本标记为活跃
for v in prompt.versions:
if v.version == version_str:
v.status = "active"
elif v.status == "active":
v.status = "deprecated"
prompt.current_version = version_str
prompt.updated_at = datetime.now().isoformat()
add_prompt(prompt)
# ═══════════════════════════════════════════
# 分类信息
# ═══════════════════════════════════════════
def get_categories() -> dict:
"""获取所有分类"""
return dict(PROMPT_CATEGORIES)
def get_version_history(prompt_id: str) -> list:
"""获取某个提示词的所有版本历史"""
prompt = get_prompt(prompt_id)
if not prompt:
return []
history = []
for v in prompt.versions:
history.append({
"version": v.version,
"label": v.label,
"status": v.status,
"created_at": v.created_at,
"changelog": v.changelog,
"tags": v.tags,
"is_current": v.version == prompt.current_version,
})
return sorted(history, key=lambda x: x["version"])
+122
View File
@@ -0,0 +1,122 @@
"""策略→提示词版本关联追踪
当 strategy_lifecycle.py 生成策略时,记录当前使用的提示词版本,
将每只股票的策略与生成它的提示词版本关联起来。
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Optional
from .models import StrategyLink
from .registry import get_prompt
DATA_DIR = Path("/home/hmo/projects/MoFin/data/prompts")
ASSOCIATIONS_PATH = DATA_DIR / "associations.json"
def load_associations() -> dict:
"""加载关联记录"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not ASSOCIATIONS_PATH.exists():
return {"associations": [], "updated_at": datetime.now().isoformat()}
try:
return json.loads(ASSOCIATIONS_PATH.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
return {"associations": [], "updated_at": datetime.now().isoformat()}
def save_associations(data: dict):
DATA_DIR.mkdir(parents=True, exist_ok=True)
data["updated_at"] = datetime.now().isoformat()
ASSOCIATIONS_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def get_current_strategy_prompt_version() -> dict:
"""获取当前活跃的策略生成提示词版本
返回 {prompt_id, version, label} 或 None
"""
prompt = get_prompt("strategy-generation")
if not prompt:
return None
for v in prompt.versions:
if v.version == prompt.current_version:
return {
"prompt_id": prompt.id,
"version": v.version,
"label": v.label,
}
return None
def record_strategy_generation(code: str, name: str, strategy_action: str) -> StrategyLink:
"""记录策略生成事件
由 strategy_lifecycle.py 在生成策略时调用
"""
pv = get_current_strategy_prompt_version()
if not pv:
# 如果没有提示词版本记录,创建一个默认记录
pv = {"prompt_id": "strategy-generation", "version": "unknown", "label": "未知版本"}
link = StrategyLink(
code=code,
name=name,
prompt_id=pv["prompt_id"],
prompt_version=pv["version"],
strategy_action=strategy_action[:500], # 截断保护
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
data = load_associations()
data.setdefault("associations", []).append(link.to_dict())
# 只保留最近的10000条记录
if len(data["associations"]) > 10000:
data["associations"] = data["associations"][-10000:]
save_associations(data)
return link
def get_associations_for_stock(code: str, max_results: int = 10) -> list:
"""获取某只股票的所有策略关联记录"""
data = load_associations()
records = [a for a in data.get("associations", []) if a["code"] == code]
return sorted(records, key=lambda x: x.get("generated_at", ""), reverse=True)[:max_results]
def get_associations_for_prompt_version(prompt_id: str, version: str) -> list:
"""获取某个提示词版本生成的所有策略"""
data = load_associations()
return [a for a in data.get("associations", [])
if a.get("prompt_id") == prompt_id and a.get("prompt_version") == version]
def get_strategy_version_stats() -> dict:
"""统计每个提示词版本生成的策略数量"""
data = load_associations()
stats = {}
for a in data.get("associations", []):
key = f"{a.get('prompt_id', '?')}@{a.get('prompt_version', '?')}"
if key not in stats:
stats[key] = {
"prompt_id": a.get("prompt_id"),
"version": a.get("prompt_version"),
"count": 0,
"stocks": set(),
"last_generated": "",
}
stats[key]["count"] += 1
stats[key]["stocks"].add(a.get("code"))
if a.get("generated_at", "") > stats[key]["last_generated"]:
stats[key]["last_generated"] = a.get("generated_at", "")
# 转换 set 为 list 以便 JSON 序列化
for k in stats:
stats[k]["stocks"] = sorted(stats[k]["stocks"])
return stats
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""批量再生所有持仓+自选策略,结合技术面支撑/压力位"""
import json
import sys
sys.path.insert(0, '/home/hmo/web-dashboard')
from technical_analysis import full_analysis
from strategy_lifecycle import reassess_strategy
PF = '/home/hmo/web-dashboard/data/portfolio.json'
WL = '/home/hmo/web-dashboard/data/watchlist.json'
def main():
# 持仓
pf = json.load(open(PF))
for s in pf['holdings']:
code = s['code']
name = s['name']
price = s.get('price', 0)
cost = s.get('cost', 0)
shares = s.get('shares', 0)
if not price:
continue
print(f" {name}({code}) 现价{price} 成本{cost}...", end=' ')
try:
tech = full_analysis(code)
except:
tech = None
result = reassess_strategy(
code, name, price, cost, shares,
current_action=s.get('analysis', {}).get('action', '')
)
if 'analysis' not in s:
s['analysis'] = {}
s['analysis']['stop_loss'] = result['stop_loss']
s['analysis']['take_profit'] = result['take_profit']
s['analysis']['entry_low'] = result['entry_low']
s['analysis']['entry_high'] = result['entry_high']
s['analysis']['action'] = result['action']
s['analysis']['status'] = result['status']
s['analysis']['reassessed_at'] = result['reassessed_at']
print(f"{result['stop_loss']}{result['take_profit']}{result['entry_low']}~{result['entry_high']}")
json.dump(pf, open(PF, 'w'), ensure_ascii=False, indent=2)
print(f"\n持仓策略已更新: {len(pf['holdings'])}")
# 自选股 - 简单重新计算买入区
wl = json.load(open(WL))
updated = 0
for s in wl['stocks']:
code = s['code']
price = s.get('price', 0)
if not price:
continue
tech = None
try:
tech = full_analysis(code)
except:
pass
# 买入区 = 弱支撑~弱压力
if tech:
sr = tech.get('support_resistance', {})
ws = sr.get('weak_support') or price * 0.95
wr = sr.get('weak_resist') or price * 1.05
else:
ws = price * 0.92
wr = price * 1.08
if 'analysis' not in s:
s['analysis'] = {}
s['analysis']['buy_low'] = round(ws, 2)
s['analysis']['buy_high'] = round(wr, 2)
if tech:
s['analysis']['tech_levels'] = {
'strong_support': sr.get('strong_support'),
'weak_support': sr.get('weak_support'),
'weak_resist': sr.get('weak_resist'),
'strong_resist': sr.get('strong_resist'),
}
updated += 1
print(f" {s['name']}({code}) 买入区={ws:.2f}~{wr:.2f}")
json.dump(wl, open(WL, 'w'), ensure_ascii=False, indent=2)
print(f"\n自选策略已更新: {updated}")
print("\n✅ 全部策略再生完成")
if __name__ == '__main__':
main()
+844
View File
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""MoFin Dashboard - 莫荷持仓情报可视化系统"""
import base64
import json
import os
import re
import uuid
import urllib.request
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, send_from_directory, request
app = Flask(__name__, static_folder="static", static_url_path="")
DATA_DIR = Path(__file__).parent / "data"
UPLOAD_DIR = Path(__file__).parent / "uploads"
# Hermes Gateway
GATEWAY = "http://localhost:8642/v1/chat/completions"
API_KEY = "hermes123"
def _load_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {} if default is None else default
def _save_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ── API 路由 ──────────────────────────────────────────
@app.route("/")
def index():
return send_from_directory(app.static_folder, "index.html")
@app.route("/api/portfolio")
def api_portfolio():
"""持仓列表"""
data = _load_json(DATA_DIR / "portfolio.json")
return jsonify(data)
@app.route("/api/watchlist")
def api_watchlist():
"""自选列表"""
data = _load_json(DATA_DIR / "watchlist.json")
return jsonify(data)
@app.route("/api/overview")
def api_overview():
"""概览数据"""
portfolio = _load_json(DATA_DIR / "portfolio.json", [])
market = _load_json(DATA_DIR / "market.json", {})
alerts = _load_json(DATA_DIR / "alerts.json", [])
total_assets = portfolio.get("total_assets", 0)
stock_value = portfolio.get("stock_value", 0)
cash = portfolio.get("cash", 0)
position_pct = portfolio.get("position_pct", 0)
total_pnl = portfolio.get("total_pnl", 0)
holdings = portfolio.get("holdings", [])
top_movers = sorted(
[h for h in holdings if abs(h.get("change_pct", 0)) >= 3],
key=lambda x: abs(x.get("change_pct", 0)),
reverse=True,
)[:5]
return jsonify({
"total_assets": total_assets,
"stock_value": stock_value,
"cash": cash,
"position_pct": position_pct,
"total_pnl": total_pnl,
"top_movers": top_movers,
"market": market,
"alerts": alerts[:10],
"updated_at": portfolio.get("updated_at", ""),
})
@app.route("/api/reports")
def api_reports():
"""历史报告列表"""
reports_dir = DATA_DIR / "reports"
reports = []
if reports_dir.exists():
for f in sorted(reports_dir.iterdir(), reverse=True)[:100]:
if f.suffix == ".json":
data = _load_json(f)
reports.append({
"id": f.stem,
"title": data.get("title", f.stem),
"type": data.get("type", "未知"),
"created_at": data.get("created_at", ""),
"summary": data.get("summary", ""),
})
return jsonify(reports)
@app.route("/api/report/<report_id>")
def api_report(report_id):
"""单个报告详情"""
# Try exact file first
path = DATA_DIR / "reports" / f"{report_id}.json"
if path.exists():
return jsonify(_load_json(path))
# Try prefix match
reports_dir = DATA_DIR / "reports"
if reports_dir.exists():
for f in reports_dir.iterdir():
if f.stem.startswith(report_id) and f.suffix == ".json":
return jsonify(_load_json(f))
return jsonify({"error": "report not found"}), 404
@app.route("/api/stock/<code>")
def api_stock(code):
"""个股详情 + 操作建议历史"""
stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {})
return jsonify(stock_data)
@app.route("/api/market")
def api_market():
"""市场观察"""
data = _load_json(DATA_DIR / "market.json", {})
return jsonify(data)
# ── 数据写入API(供 cron/update_data.py 调用) ──────────
@app.route("/api/update/portfolio", methods=["POST"])
def update_portfolio():
data = request.get_json(force=True)
_save_json(DATA_DIR / "portfolio.json", data)
return jsonify({"status": "ok"})
@app.route("/api/update/watchlist", methods=["POST"])
def update_watchlist():
data = request.get_json(force=True)
_save_json(DATA_DIR / "watchlist.json", data)
return jsonify({"status": "ok"})
@app.route("/api/update/report", methods=["POST"])
def update_report():
data = request.get_json(force=True)
report_id = data.pop("_id", datetime.now().strftime("%Y%m%d_%H%M%S"))
data["created_at"] = data.get("created_at", datetime.now().isoformat())
_save_json(DATA_DIR / "reports" / f"{report_id}.json", data)
return jsonify({"status": "ok", "id": report_id})
@app.route("/api/update/stock/<code>", methods=["POST"])
def update_stock(code):
data = request.get_json(force=True)
existing = _load_json(DATA_DIR / "stocks" / f"{code}.json", {})
history = existing.get("history", [])
if data.get("entry"):
history.append({
"time": datetime.now().isoformat(),
"price": data.get("price"),
"recommendation": data.get("recommendation"),
"stop_loss": data.get("stop_loss"),
"take_profit": data.get("take_profit"),
"reason": data.get("reason"),
})
existing.update(data)
existing["history"] = history[-50:]
_save_json(DATA_DIR / "stocks" / f"{code}.json", existing)
return jsonify({"status": "ok"})
@app.route("/api/update/market", methods=["POST"])
def update_market():
data = request.get_json(force=True) or {}
_save_json(DATA_DIR / "market.json", data)
return jsonify({"status": "ok"})
# ── 知微分析结果写入API ──
@app.route("/api/analysis/batch", methods=["POST"])
def analysis_batch():
"""接收知微cron的分析结果,写回持仓/自选JSON的analysis字段"""
data = request.get_json(force=True) or {}
# 更新持仓
if "holdings" in data:
pf = _load_json(DATA_DIR / "portfolio.json", {})
idx = {h["code"]: i for i, h in enumerate(pf.get("holdings", []))}
for item in data["holdings"]:
code = item.get("code", "")
if code not in idx:
continue
h = pf["holdings"][idx[code]]
h["analysis"] = {
"suggestion": item.get("suggestion"),
"stop_loss": item.get("stop_loss"),
"take_profit": item.get("take_profit"),
"buy_zone_low": item.get("buy_zone_low"),
"buy_zone_high": item.get("buy_zone_high"),
"position_suggested": item.get("position_suggested"),
"reason": item.get("reason"),
"updated_at": datetime.now().isoformat(),
}
_save_json(DATA_DIR / "portfolio.json", pf)
# 更新自选
if "watchlist" in data:
wl = _load_json(DATA_DIR / "watchlist.json", {})
idx = {s["code"]: i for i, s in enumerate(wl.get("stocks", []))}
for item in data["watchlist"]:
code = item.get("code", "")
if code not in idx:
continue
s = wl["stocks"][idx[code]]
s["analysis"] = {
"buy_low": item.get("buy_low"),
"buy_high": item.get("buy_high"),
"position_recommend": item.get("position_recommend"),
"reason": item.get("reason"),
"updated_at": datetime.now().isoformat(),
}
_save_json(DATA_DIR / "watchlist.json", wl)
return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()})
# ── 操作决策库API ──
@app.route("/api/decisions", methods=["GET"])
def get_decisions():
"""返回决策库数据,统一新旧格式"""
raw = _load_json(DATA_DIR / "decisions.json", {"decisions": []})
decisions = raw.get("decisions", [])
if not decisions and isinstance(raw, list):
decisions = raw
# portfolio 用来判断是持仓还是自选
portfolio = _load_json(DATA_DIR / "portfolio.json", {"holdings": []})
watchlist = _load_json(DATA_DIR / "watchlist.json", {"stocks": []})
holding_codes = {h.get("code","") for h in portfolio.get("holdings",[])}
watch_codes = {s.get("code","") for s in watchlist.get("stocks",[])}
normalized = []
for d in decisions:
if not isinstance(d, dict):
continue
# 检测新旧格式:新格式有 stop_loss 顶层字段,旧格式有 trigger 对象
is_new = "stop_loss" in d and "trigger" not in d
if is_new:
code = d.get("code", "")
name = d.get("name", "")
price = d.get("price", 0)
sl = d.get("stop_loss")
tp = d.get("take_profit")
el = d.get("entry_low")
eh = d.get("entry_high")
ts = d.get("tech_snapshot", "")
# type: 持仓还是自选
if code in holding_codes:
dtype = "持仓策略"
elif code in watch_codes:
dtype = "自选策略"
else:
dtype = ""
# 判断 active
status_raw = d.get("status", "")
status = "active" if status_raw in ("active", "updated", "") else "superseded"
# trigger 对象
entry_zone_str = ""
if el and eh:
entry_zone_str = f"¥{el}{eh}"
elif el:
entry_zone_str = f"≥¥{el}"
trigger = {}
if sl:
trigger["stop_loss"] = f"¥{sl}" if isinstance(sl, (int,float)) else str(sl)
if tp:
trigger["take_profit"] = f"¥{tp}" if isinstance(tp, (int,float)) else str(tp)
if entry_zone_str:
trigger["entry_zone"] = entry_zone_str
# current
current = ""
if price:
current = f"现价¥{price}" if code and not code.startswith(("0","1")) else f"¥{price}"
# zone_breach
zone_breach = d.get("zone_breach", "")
# updated_reason
note = d.get("note", "")
timing = d.get("timing_signal", "")
reason_parts = []
if note:
reason_parts.append(note)
if timing and timing != "neutral":
reason_parts.append(f"时机:{timing}")
if d.get("rr_ratio"):
reason_parts.append(f"盈亏比:{d['rr_ratio']}")
# advice_timeline - 从新格式重建
timeline = []
entry = {
"code": code,
"name": name,
"type": dtype,
"status": status,
"tag": d.get("tag", ""),
"action": d.get("action", ""),
"trigger": trigger,
"current": current,
"zone_breach": zone_breach,
"updated_reason": " | ".join(reason_parts) if reason_parts else "",
"advice_timeline": timeline,
"changelog": d.get("changelog", []),
"execution": d.get("execution", {}),
"analysis": d.get("analysis", {}),
"tech_snapshot": ts,
"timestamp": d.get("timestamp", ""),
"updated_by": "知微",
}
# 保留原始数据供前端扩展
entry["_raw_action"] = d.get("action", "")
normalized.append(entry)
else:
# 旧格式:已有 trigger 等字段,直接保留
entry = dict(d)
# 确保 status 正确
if entry.get("status") not in ("active", "superseded"):
entry["status"] = "active"
if not entry.get("type"):
code = entry.get("code", "")
if code in holding_codes:
entry["type"] = "持仓策略"
elif code in watch_codes:
entry["type"] = "自选策略"
else:
entry["type"] = ""
normalized.append(entry)
# 添加 execution 和 analysis 信息,按执行状态排序
for n in normalized:
code = n.get("code", "")
# 从原始数据中找到 execution 和 analysis
raw_entry = next((d for d in decisions if isinstance(d, dict) and d.get("code") == code), {})
n["execution"] = raw_entry.get("execution", {"status": "none"})
n["analysis"] = raw_entry.get("analysis", {})
# 排序规则:推荐>执行中>观察>无标签
def sort_key(x):
tag = x.get("tag", "")
exec_status = x.get("execution", {}).get("status", "none")
# 标签优先级(current_recommend才靠前,active_manual只是记录不升序)
tag_order = {"current_recommend": 0}
tag_priority = tag_order.get(tag, 50)
# 执行状态优先级
exec_order = {"partial_exit": 0, "executing": 1, "observing": 2, "none": 99}
exec_priority = exec_order.get(exec_status, 99)
# 组合:先按标签排,再按执行状态排
return (tag_priority, exec_priority, x.get("code", ""))
normalized.sort(key=sort_key)
return jsonify({
"decisions": normalized,
"total": len(normalized),
"regenerated_at": raw.get("regenerated_at", ""),
})
@app.route("/api/decisions/add", methods=["POST"])
def add_decision():
"""新增/更新一条决策(新格式)"""
data = request.get_json(force=True) or {}
code = data.get("code", "")
if not code:
return jsonify({"status": "error", "message": "code required"}), 400
d = _load_json(DATA_DIR / "decisions.json", {"decisions": []})
# 同一股票旧决策标记为superseded
for e in d["decisions"]:
if e["code"] == code and e.get("status") in ("active", "updated"):
e["status"] = "superseded"
entry = {
"code": code,
"name": data.get("name", ""),
"price": data.get("price", 0),
"action": data.get("action", ""),
"stop_loss": data.get("stop_loss"),
"take_profit": data.get("take_profit"),
"entry_low": data.get("entry_low"),
"entry_high": data.get("entry_high"),
"tech_snapshot": data.get("tech_snapshot", ""),
"timing_signal": data.get("timing_signal", ""),
"rr_ratio": data.get("rr_ratio"),
"tag": data.get("tag", ""),
"note": data.get("note", ""),
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"updated_reason": data.get("updated_reason", ""),
"status": "updated",
"changelog": data.get("changelog", []),
"execution": data.get("execution", {"status": "none"}),
"analysis": data.get("analysis", {}),
}
d["decisions"].append(entry)
_save_json(DATA_DIR / "decisions.json", d)
return jsonify({"status": "ok", "entry": entry})
@app.route("/api/decisions/tag", methods=["POST"])
def set_decision_tag():
"""设置/清除某只股票的推荐标签"""
data = request.get_json(force=True) or {}
code = data.get("code", "")
tag = data.get("tag", "") # 'current_recommend', 'active_manual', or '' to clear
if not code:
return jsonify({"status": "error", "message": "code required"}), 400
d = _load_json(DATA_DIR / "decisions.json", {"decisions": []})
found = False
for e in d.get("decisions", []):
if e.get("code") == code:
e["tag"] = tag
e["tag_updated"] = datetime.now().isoformat()
found = True
break
if not found:
return jsonify({"status": "error", "message": f"stock {code} not found"}), 404
_save_json(DATA_DIR / "decisions.json", d)
return jsonify({"status": "ok", "code": code, "tag": tag})
@app.route("/api/decisions/pending")
def get_pending_decisions():
"""返回所有有未确认建议的条目"""
d = _load_json(DATA_DIR / "decisions.json", {"decisions": []})
pending = []
for entry in d["decisions"]:
timeline = entry.get("advice_timeline", [])
unconfirmed = [a for a in timeline if a.get("status") in (None, "pending")]
if unconfirmed:
pending.append({
"code": entry["code"],
"name": entry["name"],
"current": entry.get("current", ""),
"pending_advice": unconfirmed,
})
return jsonify(pending)
@app.route("/api/advice/record", methods=["POST"])
def record_advice():
"""记录一条分析建议到 decisions.json 的 advice_timeline"""
data = request.get_json(force=True) or {}
code = data.get("code", "")
if not code:
return jsonify({"status": "error", "message": "code required"}), 400
d = _load_json(DATA_DIR / "decisions.json", {"decisions": []})
entry = None
for e in d["decisions"]:
if e["code"] == code and e["status"] == "active":
entry = e
break
if not entry:
return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404
timeline = entry.setdefault("advice_timeline", [])
advice = {
"date": datetime.now().strftime("%Y-%m-%d %H:%M"),
"direction": data.get("direction", "持有"),
"price": data.get("price", ""),
"summary": data.get("summary", ""),
"status": "pending",
}
timeline.append(advice)
_save_json(DATA_DIR / "decisions.json", d)
return jsonify({"status": "ok", "advice": advice})
@app.route("/api/advice/confirm", methods=["POST"])
def confirm_advice():
"""确认/忽略一条建议"""
data = request.get_json(force=True) or {}
code = data.get("code", "")
idx = data.get("index", -1)
action = data.get("action", "confirmed") # confirmed | ignored
d = _load_json(DATA_DIR / "decisions.json", {"decisions": []})
for e in d["decisions"]:
if e["code"] == code and e["status"] == "active":
timeline = e.get("advice_timeline", [])
if 0 <= idx < len(timeline):
timeline[idx]["status"] = action
_save_json(DATA_DIR / "decisions.json", d)
return jsonify({"status": "ok"})
return jsonify({"status": "error", "message": "not found"}), 404
# ── 准确率统计API ──
@app.route("/api/stats/accuracy")
def get_accuracy_stats():
data = _load_json(DATA_DIR / "accuracy_stats.json", {})
return jsonify(data)
# ── 策略评估API ──
@app.route("/api/evaluation")
def get_evaluation():
"""返回所有策略的双维度评估结果"""
decisions = _load_json(DECISIONS_PATH if 'DECISIONS_PATH' in dir() else DATA_DIR / "decisions.json", {"decisions": []})
evals = []
for d in decisions.get("decisions", []):
e = d.get("evaluation", [])
if e:
evals.append({
"code": d["code"],
"name": d["name"],
"type": d.get("type", ""),
"current": d.get("current", ""),
"evaluations": e,
})
return jsonify(evals)
@app.route("/api/evaluation/trigger", methods=["POST"])
def trigger_evaluation():
"""手动触发策略评估"""
import subprocess
try:
r = subprocess.run(
[sys.executable, str(DATA_DIR.parent / "strategy_evaluator.py")],
capture_output=True, timeout=60, text=True,
)
return jsonify({"status": "ok", "output": r.stdout, "error": r.stderr})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
# ── 策略反馈API ──
@app.route("/api/feedback")
def get_feedback():
data = _load_json(DATA_DIR / "strategy_feedback.json", {})
return jsonify(data)
# ── 持仓截图上传与解析 ────────────────────────────────
@app.route("/upload")
def upload_page():
return send_from_directory(app.static_folder, "upload.html")
def _ocr_image(image_path):
"""用Tesseract OCR提取图片中的文字(预处理优化中文表格识别)"""
from PIL import Image, ImageEnhance, ImageFilter
import pytesseract
img = Image.open(image_path)
# 预处理:放大 + 锐化 + 二值化,提升小字识别率
w, h = img.size
if w < 2000 or h < 2000:
scale = max(2, 2000 // min(w, h))
img = img.resize((w * scale, h * scale), Image.LANCZOS)
# 转灰度
img = img.convert("L")
# 增强对比度
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(2.0)
# 锐化
img = img.filter(ImageFilter.SHARPEN)
# 二值化(自适应阈值)
threshold = 128
img = img.point(lambda x: 255 if x > threshold else 0)
# OCRchip_sim+engPSM 6(统一文本块)
text = pytesseract.image_to_string(
img,
lang="chi_sim+eng",
config="--psm 6 --oem 3",
)
return text.strip()
ANALYZE_PROMPT = """你是股票持仓数据分析助手。以下是用户上传的持仓/自选截图经过OCR提取的文字,请从中提取所有股票信息。
判断这是「持仓截图」还是「自选截图」:
- 持仓截图:每支股票有"证券数量"(持股数)、成本价、盈亏
- 自选截图:只有股票列表和价格,没有持股数/成本
股票代码格式:
- A股:6位数字(如 600519, 000858, 300750
- 港股:纯数字代码(如 0700, 3690, 1211),不带HK前缀
⚠️ 重要:截图顶部通常有汇总数据,如总资产、股票市值、可用资金、当日盈亏等。
如果OCR文字中有这些汇总数字,请一并提取到JSON的summary字段中。
不要自己计算汇总值,直接从OCR原文中提取。
请严格按照以下JSON格式回复,只输出JSON:
```json
{
"type": "portfolio""watchlist",
"summary": {
"total_assets": "总资产数字(可选,从截图中提取)",
"stock_value": "股票市值/持仓市值数字(可选,从截图中提取)",
"cash": "可用资金/现金数字(可选,从截图中提取)",
"day_pnl": "当日盈亏金额(可选,从截图中提取)"
},
"stocks": [
{
"code": "股票代码",
"name": "股票名称(中文)",
"price": "现价(数字)",
"shares": "持股数量(数字,持仓截图才有)",
"cost": "成本价(数字,持仓截图才有)",
"pnl": "盈亏百分比如+15.1%(持仓截图才有)",
"position_pct": "仓位占比数字如12.5(可选)"
}
]
}
```
OCR原文:
"""
@app.route("/api/upload/analyze", methods=["POST"])
def upload_analyze():
"""接收图片,OCR提取文字 → LLM解析结构化数据"""
if "image" not in request.files:
return jsonify({"error": "请上传图片"}), 400
f = request.files["image"]
if not f.filename:
return jsonify({"error": "空文件"}), 400
# 保存到临时目录
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ext = Path(f.filename).suffix or ".png"
save_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{ext}"
f.save(str(save_path))
try:
# 第一步:OCR提取文字
raw_text = _ocr_image(str(save_path))
if not raw_text:
return jsonify({"error": "OCR未识别到文字,请确认图片清晰"}), 400
except Exception as e:
os.unlink(str(save_path))
return jsonify({"error": f"OCR失败: {e}"}), 500
# 第二步:LLM解析结构化数据(走文本API,不走视觉)
llm_text = _llm_parse(raw_text, ANALYZE_PROMPT)
os.unlink(str(save_path))
# 从LLM回复中提取JSON
json_match = re.search(r"```(?:json)?\s*({.*?})\s*```", llm_text, re.DOTALL)
if json_match:
try:
parsed = json.loads(json_match.group(1))
except json.JSONDecodeError:
return jsonify({"error": f"LLM解析JSON失败: {llm_text[:500]}"}), 500
else:
# 尝试直接找JSON(没被代码块包裹)
try:
parsed = json.loads(llm_text)
except json.JSONDecodeError:
return jsonify({"error": f"未提取到结构化数据: {raw_text[:300]}...\n\nLLM回复: {llm_text[:500]}"}), 500
return jsonify(parsed)
def _llm_parse(text, prompt_template):
"""发送OCR文本到Hermes LLM解析,返回JSON字符串"""
payload = json.dumps({
"model": "hermes-agent",
"messages": [
{"role": "system", "content": "你是一个数据提取助手。从OCR文字中提取结构化JSON数据。"},
{"role": "user", "content": prompt_template + "\n" + text},
],
"max_tokens": 4096,
}).encode()
req = urllib.request.Request(GATEWAY, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {API_KEY}")
req.add_header("X-Hermes-Session-Id", "upload-ocr-parse")
try:
resp = urllib.request.urlopen(req, timeout=120)
data = json.loads(resp.read())
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
except Exception as e:
return f"ERROR: {e}"
@app.route("/api/upload/confirm", methods=["POST"])
def upload_confirm():
"""确认解析结果,更新数据文件"""
data = request.get_json(force=True)
stocks = data.get("stocks", [])
doc_type = data.get("type", "portfolio")
# 尝试获取实时行情补充数据
try:
codes = [s["code"] for s in stocks if s.get("code")]
if codes:
qs = " ".join(
f"hk{c}" if len(c) == 5 # 港股5位代码
else f"sz{c}" if c.startswith("0") or c.startswith("3")
else f"sh{c}" if c.startswith("6")
else f"hk{c}"
for c in codes
)
url = f"https://qt.gtimg.cn/q={qs}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
resp = urllib.request.urlopen(req, timeout=10)
qt_text = resp.read().decode("gbk", errors="replace")
# map realtime prices
for stock in stocks:
code = stock.get("code", "")
prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk"
# 腾讯 API 格式: prefix+code="市场~名称~代码~当前价~昨收~今开~成交量~..."
m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text)
if m:
fields = m.group(1).split('~')
name = fields[1]
price = fields[3] # 当前价
if not stock.get("price"):
stock["price"] = price
if not stock.get("name"):
stock["name"] = name
except:
pass # 行情获取失败不影响主流程
# 更新对应数据文件
if doc_type == "portfolio":
existing = _load_json(DATA_DIR / "portfolio.json", {})
old_holdings = {h["code"]: h for h in existing.get("holdings", []) if h.get("code")}
new_holdings = []
for s in stocks:
code = s.get("code", "")
old = old_holdings.get(code, {})
new_shares = int(s["shares"]) if str(s.get("shares", "")).lstrip('-').isdigit() else old.get("shares", 0)
old_shares = old.get("shares", 0)
# 股数突变检测:旧200→新0是合理卖出,但旧0→新200可能是OCR错读
if old_shares > 0 and new_shares == 0 and old_shares != new_shares:
print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}{new_shares} (卖出清仓)")
elif abs(new_shares - old_shares) > max(old_shares * 0.5, 100) and old_shares > 0:
print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}{new_shares} (变动较大)")
new_holdings.append({
"code": code,
"name": s.get("name") or old.get("name", ""),
"shares": new_shares,
"price": float(s.get("price", 0)) or old.get("price", 0),
"cost": float(s.get("cost", 0)) if s.get("cost") else old.get("cost", 0),
"pnl": s.get("pnl") or old.get("pnl", ""),
"position_pct": float(s.get("position_pct", 0)) if s.get("position_pct") else old.get("position_pct", 0),
"change_pct": old.get("change_pct", 0),
})
existing["holdings"] = new_holdings
# 使用截图中的汇总数据(优先),没有则用旧数据
summary = data.get("summary", {})
if summary.get("stock_value"):
existing["stock_value"] = float(summary["stock_value"])
else:
existing["stock_value"] = round(
sum(h["shares"] * h["price"] for h in existing["holdings"]), 2
)
if summary.get("cash"):
existing["cash"] = float(summary["cash"])
if summary.get("total_assets"):
existing["total_assets"] = float(summary["total_assets"])
else:
existing["total_assets"] = existing["stock_value"] + existing.get("cash", 0)
if summary.get("day_pnl"):
existing["day_pnl"] = float(summary["day_pnl"])
existing["updated_at"] = datetime.now().isoformat()
# 计算仓位%
if existing["total_assets"] > 0:
existing["position_pct"] = round(existing["stock_value"] / existing["total_assets"] * 100, 2)
_save_json(DATA_DIR / "portfolio.json", existing)
msg = f"更新了 {len(stocks)} 只持仓股"
elif doc_type == "watchlist":
existing = _load_json(DATA_DIR / "watchlist.json", {})
existing["stocks"] = [
{
"code": s.get("code", ""),
"name": s.get("name", ""),
"price": float(s.get("price", 0)) if s.get("price") else 0,
}
for s in stocks
]
existing["updated_at"] = datetime.now().isoformat()
_save_json(DATA_DIR / "watchlist.json", existing)
msg = f"更新了 {len(stocks)} 只自选股"
else:
return jsonify({"error": f"未知类型: {doc_type}"}), 400
return jsonify({"status": "ok", "message": msg})
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8899))
print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}")
app.run(host="0.0.0.0", port=port, debug=False)
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""strategy_evaluator.py — 策略双维度评估引擎
两阶段评估模型:
阶段一(策略制定→价格达标):
理论:策略设定的买入区/止损/止盈 → 股价是否达到过这些价位 → 理论盈亏
实际:老爸是否按策略执行 → 实际买入/卖出价格 → 实际盈亏
阶段二(价格回落后→新止损验证):
理论:价格未按预期走 → 给出新止损 → 股价是否继续下跌验证止损正确性
实际:老爸实际卖出价格 → 对比新止损 → 验证止损有效性
输出:写入 decisions.json 的 evaluation 字段 + accuracy_stats.json
"""
import json
import urllib.request
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
DECISIONS_PATH = DATA_DIR / "decisions.json"
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
ACCURACY_PATH = DATA_DIR / "accuracy_stats.json"
UA = "Mozilla/5.0"
def load_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {} if default is None else default
def save_json(path, data):
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def fetch_prices(codes):
"""批量拉腾讯行情"""
if not codes:
return {}
symbols = []
code_map = {}
for c in codes:
sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}"
symbols.append(sym)
code_map[sym] = c
url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
resp = urllib.request.urlopen(req, timeout=10)
text = resp.read().decode("gbk")
except Exception as e:
print(f"行情拉取失败: {e}", file=sys.stderr)
return {}
prices = {}
for line in text.strip().split("\n"):
line = line.strip()
if not line or "=" not in line:
continue
raw = line.split("=", 1)[1].strip().strip('"').strip(";")
fields = raw.split("~")
if len(fields) < 33:
continue
sym = line.split("=", 1)[0].strip().lstrip("v_")
orig = code_map.get(sym)
if not orig:
continue
prices[orig] = {
"name": fields[1],
"price": float(fields[3]) if fields[3] else 0,
"prev_close": float(fields[4]) if fields[4] else 0,
"change_pct": float(fields[32]) if fields[32] else 0,
"high": float(fields[33]) if fields[33] else 0,
"low": float(fields[34]) if fields[34] else 0,
}
return prices
def evaluate_phase1(decision, price_info, holding):
"""
阶段一评估:策略制定→价格是否达到过目标价位
返回 evaluation dict
"""
trig = decision.get("trigger", {})
code = decision["code"]
name = decision.get("name", code)
price = price_info.get("price", 0)
change = price_info.get("change_pct", 0)
# 策略区间
el = trig.get("entry_zone", "")
sl = trig.get("stop_loss", "")
tp = trig.get("take_profit", "")
el_low = el_high = None
if el and "~" in str(el):
try:
parts = str(el).split("~")
el_low, el_high = float(parts[0]), float(parts[1])
except:
pass
sl_p = float(sl) if sl else None
tp_p = float(tp) if tp else None
# 持仓信息
cost = holding.get("cost", 0) if holding else 0
shares = holding.get("shares", 0) if holding else 0
position_pct = holding.get("position_pct", 0) if holding else 0
# 理论盈亏计算(基于策略区间中值)
entry_mid = (el_low + el_high) / 2 if el_low and el_high else price
theoretical_pnl_pct = (tp_p - entry_mid) / entry_mid * 100 if tp_p and entry_mid else 0
theoretical_pnl_amount = theoretical_pnl_pct / 100 * entry_mid * (shares or 100) / 100 if shares else 0
# 实际盈亏
actual_pnl_pct = (price - cost) / cost * 100 if cost > 0 and price > 0 else 0
actual_pnl_amount = actual_pnl_pct / 100 * cost * shares if cost > 0 and shares > 0 else 0
# 当前状态判断
status = "safe"
if sl_p and price > 0 and price <= sl_p:
status = "stop_loss_hit"
elif tp_p and price > 0 and price >= tp_p:
status = "take_profit_hit"
elif el_low and el_high and price > 0 and el_low <= price <= el_high:
status = "in_entry_zone"
elif el_low and price > 0 and price < el_low:
status = "below_entry"
elif el_high and price > 0 and price > el_high:
status = "above_entry"
# 理论阶段评估
theoretical = {
"entry_zone": f"{el_low}~{el_high}" if el_low else "N/A",
"stop_loss": sl_p,
"take_profit": tp_p,
"entry_mid_price": round(entry_mid, 2),
"target_price": tp_p,
"theoretical_pnl_pct": round(theoretical_pnl_pct, 2),
"theoretical_pnl_amount": round(theoretical_pnl_amount, 2),
"status": status,
"current_price": price,
"current_change_pct": change,
}
# 实际阶段评估
actual = {
"cost_price": cost,
"shares": shares,
"position_pct": position_pct,
"actual_pnl_pct": round(actual_pnl_pct, 2),
"actual_pnl_amount": round(actual_pnl_amount, 2),
"status": status,
"current_price": price,
}
return {
"code": code,
"name": name,
"evaluated_at": datetime.now().isoformat(),
"phase": 1,
"theoretical": theoretical,
"actual": actual,
"summary": f"{name}({code}) 策略区间{el_low}~{el_high} 现价{price}({change:+.2f}%) "
f"理论盈亏{theoretical_pnl_pct:+.1f}% 实际盈亏{actual_pnl_pct:+.1f}%",
}
def evaluate_phase2(decision, price_info, holding, prev_eval):
"""
阶段二评估:价格回落后→新止损验证
需要 prev_eval 中记录了之前的目标价和新止损价
"""
trig = decision.get("trigger", {})
code = decision["code"]
name = decision.get("name", code)
price = price_info.get("price", 0)
sl = trig.get("stop_loss", "")
sl_p = float(sl) if sl else None
# 从 prev_eval 中获取阶段一的止损
prev_sl = None
if prev_eval:
prev_sl = prev_eval.get("theoretical", {}).get("stop_loss")
# 检查新止损是否被跌破
new_sl_hit = False
days_to_hit = None
if sl_p and price > 0 and price <= sl_p:
new_sl_hit = True
# 无法精确知道多少天跌破,标记为当前
days_to_hit = 0
result = {
"code": code,
"name": name,
"evaluated_at": datetime.now().isoformat(),
"phase": 2,
"new_stop_loss": sl_p,
"previous_stop_loss": prev_sl,
"current_price": price,
"new_sl_hit": new_sl_hit,
"days_to_hit": days_to_hit,
"summary": f"{name}({code}) 新止损{sl_p} {'已跌破' if new_sl_hit else '未触及'} 现价{price}",
}
return result
def run():
decisions = load_json(DECISIONS_PATH, {"decisions": []})
portfolio = load_json(PORTFOLIO_PATH, {"holdings": []})
holdings_map = {h["code"]: h for h in portfolio.get("holdings", [])}
# 收集所有代码
all_codes = [d["code"] for d in decisions["decisions"]]
prices = fetch_prices(all_codes)
results = []
stats = {
"phase1_correct": 0, "phase1_wrong": 0, "phase1_pending": 0,
"phase2_correct": 0, "phase2_wrong": 0, "phase2_pending": 0,
}
for d in decisions["decisions"]:
code = d["code"]
pi = prices.get(code, {})
h = holdings_map.get(code)
# 获取已有的 evaluation 记录
existing_eval = d.get("evaluation", [])
# 阶段一评估
eval1 = evaluate_phase1(d, pi, h)
results.append(eval1)
# 阶段二评估(如果有前次止损记录)
prev_eval = existing_eval[-1] if existing_eval else None
if prev_eval and prev_eval.get("phase") == 1:
eval2 = evaluate_phase2(d, pi, h, prev_eval)
results.append(eval2)
# 更新 decisions.json 的 evaluation 字段
d["evaluation"] = [e for e in [eval1] + ([eval2] if prev_eval and prev_eval.get("phase") == 1 else [])]
# 保存更新
save_json(DECISIONS_PATH, decisions)
# 汇总统计
for r in results:
phase = r["phase"]
if phase == 1:
status = r["theoretical"]["status"]
if status in ("take_profit_hit",):
stats["phase1_correct"] += 1
elif status in ("stop_loss_hit",):
stats["phase1_wrong"] += 1
else:
stats["phase1_pending"] += 1
elif phase == 2:
if r.get("new_sl_hit"):
stats["phase2_correct"] += 1
else:
stats["phase2_pending"] += 1
# 写入 accuracy_stats
accuracy = {
"updated_at": datetime.now().isoformat(),
"phase1": {
"correct": stats["phase1_correct"],
"wrong": stats["phase1_wrong"],
"pending": stats["phase1_pending"],
"accuracy_pct": round(stats["phase1_correct"] / max(stats["phase1_correct"] + stats["phase1_wrong"], 1) * 100, 1),
},
"phase2": {
"correct": stats["phase2_correct"],
"wrong": stats["phase2_wrong"],
"pending": stats["phase2_pending"],
"accuracy_pct": round(stats["phase2_correct"] / max(stats["phase2_correct"] + stats["phase2_wrong"], 1) * 100, 1),
},
"total_evaluated": len(results),
"details": [r["summary"] for r in results],
}
save_json(ACCURACY_PATH, accuracy)
# 输出报告
print("=" * 70)
print(f"策略双维度评估报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print("=" * 70)
print(f"\n📊 阶段一(策略制定→价格达标)")
print(f" 正确(达到止盈): {stats['phase1_correct']}")
print(f" 错误(跌破止损): {stats['phase1_wrong']}")
print(f" 待验证: {stats['phase1_pending']}")
print(f" 准确率: {accuracy['phase1']['accuracy_pct']}%")
print(f"\n📊 阶段二(价格回落→新止损验证)")
print(f" 正确(新止损验证有效): {stats['phase2_correct']}")
print(f" 错误: {stats['phase2_wrong']}")
print(f" 待验证: {stats['phase2_pending']}")
print(f"\n📋 逐股评估:")
for r in results:
print(f" {r['summary']}")
print(f"\n✅ 评估完成,已写入 decisions.json 和 accuracy_stats.json")
if __name__ == "__main__":
run()
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""strategy_feedback.py — 策略评估反馈引擎
从评估结果自动推导策略调整建议:
1. 阶段一完成(达到止盈)→ 标记成功,萃取经验
2. 阶段一失败(跌破止损)→ 标记失败,分析原因
3. 阶段二验证(新止损被跌破)→ 标记止损正确
4. 长期未触及任何区间 → 建议重新评估策略区间
5. 准确率趋势 → 调整策略参数(宽度、区间计算方法)
输出:写入 decisions.json + 生成调整建议报告
"""
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
DECISIONS_PATH = DATA_DIR / "decisions.json"
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
ACCURACY_PATH = DATA_DIR / "accuracy_stats.json"
EVENTS_PATH = DATA_DIR / "price_events.json"
FEEDBACK_PATH = DATA_DIR / "strategy_feedback.json"
def load_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {} if default is None else default
def save_json(path, data):
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def check_phase_completion(decision, events):
"""检查某条策略是否有阶段完成事件"""
code = decision["code"]
trig = decision.get("trigger", {})
stock_events = [e for e in events.get("events", []) if e["code"] == code]
el = trig.get("entry_zone", "")
sl = trig.get("stop_loss", "")
tp = trig.get("take_profit", "")
el_low = el_high = None
if el and "~" in str(el):
try:
parts = str(el).split("~")
el_low, el_high = float(parts[0]), float(parts[1])
except:
pass
sl_p = float(sl) if sl else None
tp_p = float(tp) if tp else None
result = {
"phase1_completed": False,
"phase1_result": None, # "success" or "failure"
"phase1_completed_at": None,
"phase1_price_at_completion": None,
"phase2_completed": False,
"phase2_result": None,
"phase2_completed_at": None,
"days_in_phase1": None,
}
# 检查价格事件中是否有止盈/止损触发
for ev in stock_events:
ev_type = ev.get("event_type", "")
ev_price = ev.get("price", 0)
ev_time = ev.get("timestamp", "")
if ev_type == "stop_loss" and sl_p and ev_price <= sl_p:
result["phase1_completed"] = True
result["phase1_result"] = "failure"
result["phase1_completed_at"] = ev_time
result["phase1_price_at_completion"] = ev_price
# 止盈:检查是否达到或超过止盈价
if tp_p and ev_price >= tp_p:
result["phase1_completed"] = True
result["phase1_result"] = "success"
result["phase1_completed_at"] = ev_time
result["phase1_price_at_completion"] = ev_price
return result
def compute_accuracy_trend(accuracy_stats):
"""计算准确率趋势,用于调整策略参数"""
details = accuracy_stats.get("details", [])
if not details:
return {"trend": "stable", "phase1_accuracy": 0, "phase2_accuracy": 0}
phase1 = accuracy_stats.get("phase1", {})
phase2 = accuracy_stats.get("phase2", {})
p1_acc = phase1.get("accuracy_pct", 0)
p2_acc = phase2.get("accuracy_pct", 0)
if p1_acc >= 80:
trend = "improving"
elif p1_acc <= 50 and phase1.get("correct", 0) + phase1.get("wrong", 0) >= 3:
trend = "declining"
else:
trend = "stable"
return {
"trend": trend,
"phase1_accuracy": p1_acc,
"phase2_accuracy": p2_acc,
"phase1_evaluated": phase1.get("correct", 0) + phase1.get("wrong", 0),
"phase2_evaluated": phase2.get("correct", 0) + phase2.get("wrong", 0),
}
def generate_adjustment(decision, phase_check, accuracy_trend):
"""根据评估结果生成策略调整建议"""
code = decision["code"]
name = decision.get("name", code)
trig = decision.get("trigger", {})
adjustments = []
el = trig.get("entry_zone", "")
sl = trig.get("stop_loss", "")
tp = trig.get("take_profit", "")
# 1. 阶段一完成 → 萃取经验
if phase_check["phase1_completed"]:
if phase_check["phase1_result"] == "success":
adjustments.append({
"type": "phase1_success",
"message": f"{name}({code}) 阶段一成功:达到止盈{tp},价格{phase_check['phase1_price_at_completion']}",
"action": "mark_completed",
"knowledge": f"策略区间{el}有效,价格达到止盈位{tp}",
})
elif phase_check["phase1_result"] == "failure":
adjustments.append({
"type": "phase1_failure",
"message": f"{name}({code}) 阶段一失败:跌破止损{sl},价格{phase_check['phase1_price_at_completion']}",
"action": "reassess",
"knowledge": f"策略区间{el}失效,止损{sl}被跌破,需重新评估",
})
# 2. 长期未触发 → 建议重新评估
# 检查策略创建时间
created_at = decision.get("timestamp", "")
if created_at:
try:
created = datetime.fromisoformat(created_at)
days_since = (datetime.now() - created).days
if days_since >= 14 and not phase_check["phase1_completed"]:
adjustments.append({
"type": "stale_strategy",
"message": f"{name}({code}) 策略已{days_since}天未触发任何区间,建议重新评估",
"action": "reassess",
})
except:
pass
# 3. 准确率趋势 → 调整策略宽度
if accuracy_trend["trend"] == "declining" and accuracy_trend["phase1_evaluated"] >= 3:
adjustments.append({
"type": "accuracy_declining",
"message": f"准确率下降至{accuracy_trend['phase1_accuracy']}%,建议收紧策略区间宽度",
"action": "tighten",
})
return adjustments
def run():
decisions = load_json(DECISIONS_PATH, {"decisions": []})
events = load_json(EVENTS_PATH, {"events": []})
accuracy_stats = load_json(ACCURACY_PATH, {})
accuracy_trend = compute_accuracy_trend(accuracy_stats)
all_feedback = []
phase1_completed = []
reassess_needed = []
for d in decisions["decisions"]:
code = d["code"]
name = d.get("name", code)
# 检查阶段完成情况
phase_check = check_phase_completion(d, events)
# 生成调整建议
adjustments = generate_adjustment(d, phase_check, accuracy_trend)
feedback_entry = {
"code": code,
"name": name,
"evaluated_at": datetime.now().isoformat(),
"phase_check": phase_check,
"adjustments": adjustments,
}
all_feedback.append(feedback_entry)
# 收集需要处理的策略
if phase_check["phase1_completed"]:
phase1_completed.append(feedback_entry)
if any(a["action"] in ("reassess", "tighten") for a in adjustments):
reassess_needed.append(feedback_entry)
# 保存反馈结果
feedback_data = {
"updated_at": datetime.now().isoformat(),
"accuracy_trend": accuracy_trend,
"total_strategies": len(decisions["decisions"]),
"phase1_completed_count": len(phase1_completed),
"reassess_needed_count": len(reassess_needed),
"feedback": all_feedback,
}
save_json(FEEDBACK_PATH, feedback_data)
# 输出报告
print("=" * 70)
print(f"策略反馈引擎报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print("=" * 70)
print(f"\n📈 准确率趋势: {accuracy_trend['trend']}")
print(f" 阶段一准确率: {accuracy_trend['phase1_accuracy']}% ({accuracy_trend['phase1_evaluated']}次评估)")
print(f" 阶段二准确率: {accuracy_trend['phase2_accuracy']}% ({accuracy_trend['phase2_evaluated']}次评估)")
print(f"\n✅ 阶段一已完成: {len(phase1_completed)}")
for fb in phase1_completed:
pc = fb["phase_check"]
icon = "🟢" if pc["phase1_result"] == "success" else "🔴"
print(f" {icon} {fb['name']}({fb['code']}) {pc['phase1_result']}{pc['phase1_completed_at'][:19]} 价格{pc['phase1_price_at_completion']}")
print(f"\n🔄 需重新评估: {len(reassess_needed)}")
for fb in reassess_needed:
for adj in fb["adjustments"]:
print(f" {adj['type']}: {adj['message']}")
if not phase1_completed and not reassess_needed:
print(f"\n 无策略需要调整,所有策略正常运行中")
print(f"\n✅ 反馈完成,已写入 {FEEDBACK_PATH}")
if __name__ == "__main__":
run()
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""策略生命周期管理系统 — 技术面驱动版本 v2
核心原则:
1. 止损放在合理的技术位,不拍数字
2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1
3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力
4. 买入区间:弱支撑~弱压力之间
5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买
"""
import json
import urllib.request
import os
import sys
from datetime import datetime
import technical_analysis as ta
PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json"
WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json"
KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md"
def get_price_tencent(code):
"""获取实时价格,自动识别A股/港股"""
try:
raw_code = code.split('_')[0]
if not raw_code:
return None
if len(raw_code) == 5 and raw_code.isdigit():
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
return {
"price": f(3), "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"):
"""根据技术分析重评策略"""
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 = {}
profit_pct = (price - cost) / cost * 100 if cost else 0
is_new_entry = (cost == 0) or (shares == 0)
is_deep_loss = profit_pct < -20
# ----- 止损设置(含最小距离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 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)
# 最小止损距离:止损不能高于现价的97%(即至少3%下行空间)
min_stop = round(price * 0.97, 2)
if new_stop > min_stop and not is_deep_loss:
new_stop = min_stop
# ----- 止盈设置 -----
if sr_resist and sr_resist > 0:
new_target = round(sr_resist, 2)
else:
new_target = round(price * 1.15, 2)
# ----- 风险回报比校验 -----
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 = ""
# ----- 买入区间(有盈亏比严格约束) -----
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)
# ----- 买入时机信号 -----
volume_signal = vol.get("volume_signal", "")
candlestick_sentiment = candle.get("sentiment", "neutral")
timing_signal = "neutral"
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 = "接近支撑位,关注"
else:
# 已持仓时机(用于加仓/减仓参考)
if 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 = "持有"
# ----- 构造 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_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}")
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
return {
'stop_loss': new_stop,
'take_profit': new_target,
'entry_low': entry_low,
'entry_high': entry_high,
'action': new_action,
'status': status,
'tech_snapshot': tech_snapshot,
'timing_signal': timing_signal,
'rr_ratio': round(rr_ratio, 2),
'action_note': action_note,
'reassessed_at': now_str,
}
def regenerate_all(stdout=True):
"""全量重评所有持仓+自选策略"""
pf = json.load(open(PORTFOLIO_PATH))
wl_path = WATCHLIST_PATH
wl = json.load(open(wl_path)) if os.path.exists(wl_path) else {}
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 json.load(open(decisions_path)).get("decisions", []) if d.get("code")}
except:
existing_decisions = {}
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 = get_price_tencent(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:
result = reassess_strategy(
code, name, price, cost, shares,
current_action, volume_signal="中性", sentiment=sentiment,
)
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", ""),
"reassessed_at": result["reassessed_at"],
"status": result["status"],
**extra,
}
stock["analysis"] = analysis
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")
new_entry = {
"code": code, "name": name, "price": price,
"action": result["action"],
"stop_loss": result["stop_loss"],
"take_profit": result["take_profit"],
"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"],
}
# --- 变更追踪 ---
old_entry = existing_decisions.get(code, {})
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 old_target 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 ---
# 只有同时满足以下条件才标记推荐:
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 = (
not is_deep_loss_stock
and rr >= 1.5
and ts != "neutral"
and "不建议" not in note
and (in_buy_zone or near_buy_zone_low)
)
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}")
# 写回数据文件
json.dump(pf, open(PORTFOLIO_PATH, "w"), ensure_ascii=False, indent=2)
json.dump(wl, open(wl_path, "w"), ensure_ascii=False, indent=2)
# 写 decisions.json
decisions_path = "/home/hmo/web-dashboard/data/decisions.json"
decisions_data = {
"decisions": decisions, # 全部保留
"total": len(decisions),
"regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'),
}
json.dump(decisions_data, open(decisions_path, "w"), ensure_ascii=False, indent=2)
summary = {"total": total, "ok": ok, "errors": errors}
if stdout:
print(f"\n✅ 全量重评完成: {ok}/{total}成功, {errors}错误")
return summary
if __name__ == "__main__":
regenerate_all()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""sync_dashboard.py — 数据同步包装脚本
1. 运行 update_data.py
2. 检查 server 是否正常
3. server 挂了就重启
"""
import subprocess
import sys
import os
DASHBOARD_DIR = "/home/hmo/web-dashboard"
SERVER_PORT = 8899
def run_update():
"""运行 update_data.py,返回 stdout"""
try:
r = subprocess.run(
["python3", "update_data.py"],
cwd=DASHBOARD_DIR,
capture_output=True, text=True, timeout=60,
)
return r.stdout.strip() if r.returncode == 0 else None
except Exception as e:
print(f"❌ update_data.py 执行失败: {e}", file=sys.stderr)
return None
def check_server():
"""检查 server 是否正常"""
import urllib.request
try:
req = urllib.request.Request(f"http://localhost:{SERVER_PORT}",
headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=5) as r:
return r.status == 200
except:
return False
def restart_server():
"""重启 server"""
try:
subprocess.run(
["python3", "server.py", str(SERVER_PORT)],
cwd=DASHBOARD_DIR,
capture_output=True, timeout=10,
)
return True
except Exception as e:
print(f"❌ server 重启失败: {e}", file=sys.stderr)
return False
def main():
output = run_update()
server_ok = check_server()
if output:
# update_data.py 有输出 → 转发
print(output)
if not server_ok:
print("⚠️ 注意:server 似乎未响应,但数据已更新")
else:
if server_ok:
# 无输出 + server 正常 → SILENT
print("[SILENT] 数据无更新,server运行正常")
else:
# server 挂了 → 重启
print("⚠️ server 无响应,尝试重启...")
if restart_server():
print("✅ server 已重启")
# 再检查一次
if check_server():
print("✅ server 恢复运行")
else:
print("❌ server 仍未响应,需人工检查")
else:
print("❌ server 重启失败")
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""system_health_check.py — MoFin 系统健康检查
每日运行,检查所有组件是否正常工作。
输出报告,有问题才推送。
"""
import json, os, sys, subprocess
from datetime import datetime, timedelta
from pathlib import Path
DATA_DIR = Path("/home/hmo/web-dashboard/data")
DECISIONS_PATH = DATA_DIR / "decisions.json"
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
EVENTS_PATH = DATA_DIR / "price_events.json"
EVALUATION_PATH = DATA_DIR / "evaluation.json"
ACCURACY_PATH = DATA_DIR / "accuracy_stats.json"
CRON_JOBS = "/home/hmo/.hermes/cron/jobs.json"
POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json"
def check(ok, msg):
icon = "" if ok else "⚠️"
return f" {icon} {msg}"
def load_json(path, default=None):
try:
with open(path) as f:
return json.load(f)
except:
return {} if default is None else default
def check_cron_jobs(path, label):
issues = []
try:
d = load_json(path, {"jobs": []})
for j in d.get("jobs", []):
name = j.get("name", "?")
enabled = j.get("enabled", True)
last = j.get("last_run_at", "")
status = j.get("last_status", "")
if not enabled:
issues.append(f"{name} 已禁用")
elif not last:
issues.append(f"{name} 从未运行")
elif status != "ok":
issues.append(f"{name} 上次状态={status}")
return len(d.get("jobs", [])), issues
except:
return 0, ["无法读取"]
def run():
now = datetime.now()
issues = []
ok_count = 0
warn_count = 0
lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"]
lines.append("")
# 1. 进程检查
lines.append("【进程】")
procs = {
"mofin-dashboard": "mofin-dashboard",
"xmpp-zhiwei": "xmpp_zhiwei_bot",
"ejabberd": "ejabberd",
}
for name, pattern in procs.items():
# 先查 systemd,再查 pgrep
r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5)
alive = r.stdout.strip() == "active"
if not alive:
r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5)
alive = r2.returncode == 0
lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}"))
if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1
else: ok_count += 1
# 2. 端口检查
lines.append("")
lines.append("【端口】")
ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"}
for port, name in ports.items():
r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5)
listening = f":{port}" in r.stdout
lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}"))
if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1
else: ok_count += 1
# 3. 数据文件检查
lines.append("")
lines.append("【数据文件】")
files = {
"portfolio.json": PORTFOLIO_PATH,
"watchlist.json": DATA_DIR / "watchlist.json",
"decisions.json": DECISIONS_PATH,
"market.json": DATA_DIR / "market.json",
"price_events.json": EVENTS_PATH,
"evaluation.json": EVALUATION_PATH,
"accuracy_stats.json": ACCURACY_PATH,
}
for name, path in files.items():
exists = path.exists()
size = path.stat().st_size if exists else 0
lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)"))
if not exists or size < 10:
issues.append(f"{name} 缺失或为空")
warn_count += 1
else:
ok_count += 1
# 4. 价格事件统计
lines.append("")
lines.append("【价格事件】")
events = load_json(EVENTS_PATH, {"events": []})
ev_list = events.get("events", [])
today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")]
lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}"))
lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}"))
if len(ev_list) == 0:
issues.append("price_events.json 无事件记录,price_monitor可能未触发过")
warn_count += 1
else:
ok_count += 1
# 5. 策略评估统计
lines.append("")
lines.append("【策略评估】")
evals = load_json(EVALUATION_PATH, {"strategies": []})
s_list = evals.get("strategies", [])
lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}"))
if len(s_list) > 0:
avg = sum(s.get("score", 0) for s in s_list) / len(s_list)
lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10"))
ok_count += 1
else:
issues.append("evaluation.json 无评估数据")
warn_count += 1
# 6. 建议记录统计
lines.append("")
lines.append("【建议记录】")
decisions = load_json(DECISIONS_PATH, {"decisions": []})
total_advice = sum(len(d.get("advice_timeline", [])) for d in decisions.get("decisions", []))
lines.append(check(total_advice > 0, f"建议记录: {total_advice}"))
if total_advice == 0:
issues.append("所有策略建议记录为空")
warn_count += 1
else:
ok_count += 1
# 7. Cron jobs
lines.append("")
lines.append("【Cron Jobs】")
cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default")
lines.append(check(cnt > 0, f"default profile: {cnt}个job"))
for ci in cron_issues:
lines.append(f" ⚠️ {ci}")
warn_count += 1
if cnt == 0: warn_count += 1
cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst")
lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job"))
for ci in cron_issues2:
lines.append(f" ⚠️ {ci}")
warn_count += 1
if cnt2 == 0: warn_count += 1
# 8. 数据新鲜度
lines.append("")
lines.append("【数据新鲜度】")
for name, path in [("portfolio.json", PORTFOLIO_PATH), ("decisions.json", DECISIONS_PATH)]:
if path.exists():
mtime = datetime.fromtimestamp(path.stat().st_mtime)
hours_ago = (now - mtime).total_seconds() / 3600
fresh = hours_ago < 24
lines.append(check(fresh, f"{name} 更新于 {hours_ago:.0f}小时前"))
if not fresh:
issues.append(f"{name} 超过24小时未更新")
warn_count += 1
else:
ok_count += 1
# 汇总
total = ok_count + warn_count
lines.append("")
lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注")
if issues:
lines.append("")
lines.append("需关注项:")
for i, issue in enumerate(issues[:10], 1):
lines.append(f" {i}. {issue}")
report = "\n".join(lines)
print(report)
# 如果有问题,写入报告文件供推送
if warn_count > 0:
report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health")
report_path.mkdir(parents=True, exist_ok=True)
report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md"
report_file.write_text(f"# MoFin 系统健康检查\n\n{report}")
print(f"\n报告已写入 {report_file}")
else:
print("\n[SILENT] 一切正常")
if __name__ == "__main__":
run()
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""technical_analysis.py — 技术面分析模块 v2
基于多日价格数据计算支撑位/压力位:
1. 缓存每日 HLC 到 price_history.json
2. 使用 5 日最高/最低计算枢轴点
3. 结合振幅自动调整区间宽度
使用方式:
from technical_analysis import full_analysis
result = full_analysis("603259") # 自动识别A股/港股
"""
import json
import os
import urllib.request
from datetime import datetime, date
# 腾讯API字段索引
F = {
"name": 1, "code": 2, "price": 3, "close_yest": 4, "open": 5,
"volume": 6, "timestamp": 30, "change": 31, "change_pct": 32,
"high": 33, "low": 34, "amplitude": 43,
"turnover": 38, "pe": 39, "pb": 46,
"limit_up": 47, "limit_down": 48,
"avg_price": 51, "inner_vol": 52, "outer_vol": 53,
}
HISTORY_PATH = "/home/hmo/web-dashboard/data/price_history.json"
HISTORY_DAYS = 10 # 使用最近 N 天的 HLC 数据
def _load_history():
"""读取价格历史缓存"""
try:
return json.load(open(HISTORY_PATH))
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_history(h):
json.dump(h, open(HISTORY_PATH, "w"), ensure_ascii=False, indent=2)
def _market_prefix(code):
"""根据代码确定腾讯API前缀"""
if code.startswith("sh") or code.startswith("sz") or code.startswith("hk"):
code = code[2:] if code[2:].isdigit() else code
raw = str(code).split("_")[0]
if len(raw) == 5 and raw.isdigit():
return "hk"
if raw.startswith("6") or raw.startswith("5"):
return "sh"
return "sz"
def get_quote(code):
"""获取腾讯API行情数据"""
raw = str(code).split("_")[0]
prefix = _market_prefix(code)
url = f"http://qt.gtimg.cn/q={prefix}{raw}"
try:
r = urllib.request.urlopen(url, timeout=5)
fields = r.read().decode("gbk").split('"')[1].split("~")
except Exception as e:
return {"code": code, "error": str(e)}
def get(i):
try:
return float(fields[i]) if fields[i].strip() else None
except (IndexError, ValueError):
return None
today_str = date.today().isoformat()
q = {
"code": raw,
"market": prefix,
"name": fields[F["name"]] if len(fields) > F["name"] else code,
"price": get(3),
"close_yest": get(4),
"open": get(5),
"high": get(33),
"low": get(34),
"volume": get(6),
"amount": get(37),
"change": get(31),
"change_pct": get(32),
"amplitude": get(43),
"turnover_rate": get(38),
"pe": get(39),
"pb": get(46),
"limit_up": get(47),
"limit_down": get(48),
"avg_price": get(51),
"inner_vol": get(52),
"outer_vol": get(53),
"timestamp": fields[F["timestamp"]] if len(fields) > F["timestamp"] else "",
"_date": today_str,
}
# 写入价格历史缓存(每日一次)
h = get(33) # high
l = get(34) # low
c = get(3) # price / close
if h and l and c:
history = _load_history()
if raw not in history:
history[raw] = []
days = history[raw]
# 如果今天已有记录,更新(盘中数据更精确)
if days and days[-1].get("date") == today_str:
days[-1]["high"] = max(days[-1]["high"], h)
days[-1]["low"] = min(days[-1]["low"], l)
days[-1]["close"] = c # 盘中用最新价,收盘后是收盘价
else:
days.append({"date": today_str, "high": h, "low": l, "close": c})
# 只保留最近 HISTORY_DAYS 天
history[raw] = days[-HISTORY_DAYS:]
_save_history(history)
return q
def calc_support_resistance(q):
"""计算技术支撑位和压力位 — 多日枢轴点算法
使用多个数据源确定有效区间:
1. 当日波幅(H-L
2. 最近 N 日的最高/最低(从 price_history.json 读取)
3. 价格基数的百分比(对大市值低波动股票有效)
"""
h = q.get("high")
l = q.get("low")
c = q.get("price")
yc = q.get("close_yest")
amplitude = q.get("amplitude") # 当日振幅%
code = q.get("code", "")
if not all([h, l, c]):
return {"error": "数据不足"}
# 多日最高/最低(从历史缓存读取)
history = _load_history()
hist_days = history.get(code, [])
multi_high = max(d["high"] for d in hist_days) if hist_days else h
multi_low = min(d["low"] for d in hist_days) if hist_days else l
# 有效区间 = max(当日波幅, 多日波幅, 价格×5%)
daily_range = h - l
multi_range = multi_high - multi_low
min_range = c * 0.05 # 5%价格基数
effective_range = max(daily_range, multi_range, min_range)
# 如果股价接近多日高点(>80%分位),说明在上升趋势中,扩大区间
trend_position = (c - multi_low) / (multi_high - multi_low) if multi_high > multi_low else 0.5
if trend_position > 0.8:
# 高位运行,扩大有效区间到价格的8%确保合理空间
effective_range = max(effective_range, c * 0.08)
elif trend_position < 0.2:
# 低位运行,同样扩大
effective_range = max(effective_range, c * 0.08)
# 如果振幅数据可用且振幅较小(<3%),进一步扩大区间确保有效性
if amplitude and amplitude > 0 and amplitude < 3:
# 低波动股票用 振幅×3 作为最小范围
amp_based = c * amplitude / 100 * 3
effective_range = max(effective_range, amp_based)
# 枢轴点 (Pivot Point)
pp = (h + l + c) / 3
# 支撑位
s1 = 2 * pp - h # 弱支撑
s2 = pp - effective_range # 强支撑
# 压力位
r1 = 2 * pp - l # 弱压力
r2 = pp + effective_range # 强压力
# 参考昨收调整
if yc:
if yc < s1:
s1 = yc
if yc > r1:
r1 = yc
# A股涨停/跌停价作为极端边界
limit_up = q.get("limit_up")
limit_down = q.get("limit_down")
market = q.get("market", "hk")
if market != "hk" and limit_up and limit_down:
# 注意:当现价逼近涨停/跌停时,limit不再是有效边界
# 用有效区间判断:如果自然计算的r2/s2在合理范围内不截断
natural_r2 = r2
natural_s2 = s2
# 涨停限制只对距离现价超过2%的强压位生效
if limit_up < r2 and (limit_up - c) / c < 0.02:
# 涨停价离现价<2%,说明可能封板,不截断
pass # 使用自然计算的r2
elif limit_up < r2:
r2 = limit_up
if limit_down > s2 and (c - limit_down) / c < 0.02:
pass # 接近跌停,不截断
elif limit_down > s2:
s2 = limit_down
return {
"strong_support": round(s2, 2),
"weak_support": round(s1, 2),
"pivot": round(pp, 2),
"weak_resist": round(r1, 2),
"strong_resist": round(r2, 2),
"today_high": h,
"today_low": l,
"multi_high": multi_high,
"multi_low": multi_low,
"effective_range": round(effective_range, 2),
}
def analyze_candlestick(q):
"""判断K线形态"""
o = q.get("open")
c = q.get("price")
h = q.get("high")
l = q.get("low")
yc = q.get("close_yest")
if not all([o, c, h, l]):
return {"pattern": "unknown", "sentiment": "neutral"}
if c >= o:
body = c - o
upper = h - c
lower = o - l
is_green = True
else:
body = o - c
upper = h - o
lower = c - l
is_green = False
total_range = h - l
if total_range == 0:
return {"pattern": "平盘", "sentiment": "neutral"}
body_pct = body / total_range * 100
upper_pct = upper / total_range * 100
lower_pct = lower / total_range * 100
if body_pct < 5:
if upper_pct > 60:
pattern = "倒T线/射击之星"
sentiment = "bearish"
elif lower_pct > 60:
pattern = "锤子线/T字线"
sentiment = "bullish"
else:
pattern = "十字星"
sentiment = "neutral"
elif body_pct < 30:
if upper_pct > 40 and lower_pct > 40:
pattern = "长影星线"
sentiment = "neutral"
elif upper_pct > 40:
pattern = "倒T线/射击之星"
sentiment = "bearish" if is_green else "bearish"
elif lower_pct > 40:
pattern = "锤子线/T字线"
sentiment = "bullish" if is_green else "bullish"
else:
pattern = "小阳线" if is_green else "小阴线"
sentiment = "bullish" if is_green else "bearish"
else:
if upper_pct > 30:
pattern = "带上影阳线" if is_green else "带上影阴线"
sentiment = "neutral" if is_green else "bearish"
elif lower_pct > 30:
pattern = "带下影阳线" if is_green else "带下影阴线"
sentiment = "bullish" if is_green else "neutral"
else:
pattern = "光头光脚阳线" if is_green else "光头光脚阴线"
sentiment = "bullish" if is_green else "bearish"
gap_up = ""
gap_down = ""
if yc:
if o > yc * 1.01:
gap_up = "跳空高开"
if not is_green:
sentiment = "neutral"
elif o < yc * 0.99:
gap_down = "跳空低开"
if is_green:
sentiment = "neutral"
return {
"pattern": pattern,
"sentiment": sentiment,
"body_pct": round(body_pct, 1),
"upper_shadow_pct": round(upper_pct, 1),
"lower_shadow_pct": round(lower_pct, 1),
"is_green": is_green,
"gap": gap_up or gap_down or "无跳空",
}
def analyze_volume(q):
"""量价分析"""
outer = q.get("outer_vol")
inner = q.get("inner_vol")
turnover = q.get("turnover_rate")
result = {}
if outer and inner and (outer + inner) > 0:
ratio = outer / (outer + inner)
result["buy_sell_ratio"] = round(ratio, 2)
if ratio > 0.55:
result["volume_signal"] = "主动买盘占优"
elif ratio < 0.45:
result["volume_signal"] = "主动卖盘占优"
else:
result["volume_signal"] = "买卖均衡"
else:
result["volume_signal"] = "数据不足"
if turnover:
result["turnover_rate"] = turnover
return result
def full_analysis(code):
"""完整技术分析(带30秒缓存,避免分钟级波动)"""
import time
_cache = full_analysis.__dict__.get("_cache", {})
now = time.time()
cached = _cache.get(code)
if cached and (now - cached["ts"]) < 30:
return cached["data"]
q = get_quote(code)
if not q or "error" in q:
return q
sr = calc_support_resistance(q)
candle = analyze_candlestick(q)
vol = analyze_volume(q)
result = {
"quote": {
"name": q["name"],
"price": q["price"],
"change_pct": q["change_pct"],
"open": q["open"],
"high": q["high"],
"low": q["low"],
"close_yest": q["close_yest"],
"volume": q["volume"],
"amplitude": q["amplitude"],
},
"support_resistance": sr,
"candlestick": candle,
"volume": vol,
"analyzed_at": datetime.now().strftime("%H:%M"),
}
# 写入缓存
_cache[code] = {"ts": now, "data": result}
full_analysis.__dict__["_cache"] = _cache
return result
if __name__ == "__main__":
import sys
codes = sys.argv[1:] or ["603259", "002594", "00700"]
for c in codes:
r = full_analysis(c)
print(json.dumps(r, ensure_ascii=False, indent=2))
print()
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""update_data.py — 解析cron输出,更新dashboard数据层"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
def _save(name, data):
path = DATA_DIR / name
os.makedirs(path.parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def parse_report(markdown_text, source_file=None):
"""解析cron输出的markdown报告,提取结构化数据"""
report = {
"title": "",
"type": "未知",
"created_at": datetime.now().isoformat(),
"summary": "",
"content": markdown_text,
"stocks_mentioned": [],
"structured": None, # 结构化数据优先
}
lines = markdown_text.split("\n")
# 提取标题
for line in lines:
m = re.match(r"^#\s+(.+)", line)
if m:
report["title"] = m.group(1).strip()
break
m = re.match(r"^📊\s+(.+)", line)
if m:
report["title"] = m.group(1).strip()
break
# 判断类型
if "盘中" in report["title"]:
report["type"] = "盘中"
elif "盘后" in report["title"] or "复盘" in report["title"]:
report["type"] = "盘后"
elif "盯盘" in report["title"]:
report["type"] = "盯盘"
elif "扫描" in report["title"]:
report["type"] = "盘前"
# ★ 优先提取结构化JSON(如果知微输出了的话)
struct_match = re.search(r'<structured_data>\s*(\{.*?\})\s*</structured_data>', 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兜底
# 摘要(前3非空行)
body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")]
report["summary"] = "\n".join(body_lines[:5])[:200]
# NLP兜底(仅当结构化数据没取到code时)
if not report["stocks_mentioned"]:
codes = set(re.findall(r'\b\d{6}\b', markdown_text))
hk_codes = set(re.findall(r'\b\d{5}\b', markdown_text))
report["stocks_mentioned"] = sorted(codes | hk_codes)
return report
def import_cron_outputs():
"""从cron输出目录导入最新报告"""
cron_dir = Path.home() / ".hermes" / "cron" / "output"
reports_dir = DATA_DIR / "reports"
os.makedirs(reports_dir, exist_ok=True)
count = 0
if not cron_dir.exists():
return count
for job_dir in sorted(cron_dir.iterdir()):
if not job_dir.is_dir():
continue
for f in sorted(job_dir.iterdir(), reverse=True)[:5]: # 每个job最近5个
if f.suffix != ".md":
continue
# Skip if already imported
export_name = f"cron_{job_dir.name}_{f.stem}.json"
if (reports_dir / export_name).exists():
continue
content = f.read_text(encoding="utf-8", errors="replace")
report = parse_report(content, source_file=str(f))
# Extract response section
resp_match = re.search(r"## Response\n+(.*)", content, re.DOTALL)
if resp_match:
resp = resp_match.group(1).strip()
if resp == "[SILENT]":
continue # Skip SILENT reports
report["_id"] = export_name.replace(".json", "")
_save(f"reports/{export_name}", report)
count += 1
return count
def extract_stock_mentions():
"""从报告中提取个股操作建议"""
reports_dir = DATA_DIR / "reports"
stocks_dir = DATA_DIR / "stocks"
os.makedirs(stocks_dir, exist_ok=True)
stock_data = {}
for f in sorted(reports_dir.iterdir()):
if f.suffix != ".json":
continue
try:
report = json.loads(f.read_text(encoding="utf-8"))
except:
continue
content = report.get("content", "")
codes = report.get("stocks_mentioned", [])
for code in codes:
if code not in stock_data:
stock_data[code] = {"code": code, "history": []}
# Try to extract recommendation from content
# Look for patterns like "建议|止盈|止损|补仓|持有"
pattern = re.compile(
rf'.*?({code}).*?(建议|止盈|止损|补仓|持有|减仓|加仓|卖出|买入).*?(?:\n|$)',
re.IGNORECASE,
)
for m in pattern.finditer(content):
stock_data[code]["history"].append({
"time": report.get("created_at", ""),
"content": m.group(0).strip()[:100],
"report_id": report.get("_id", ""),
})
for code, data in stock_data.items():
_save(f"stocks/{code}.json", data)
return len(stock_data)
def sync_to_decisions():
"""将个股建议同步到决策库(advice_timeline),自动去重"""
decisions_path = DATA_DIR / "decisions.json"
if not decisions_path.exists():
return 0
decisions = json.loads(decisions_path.read_text(encoding="utf-8"))
stocks_dir = DATA_DIR / "stocks"
synced = 0
for f in sorted(stocks_dir.iterdir()):
if f.suffix != ".json":
continue
try:
stock = json.loads(f.read_text(encoding="utf-8"))
except:
continue
code = stock.get("code", "")
history = stock.get("history", [])
if not code or not history:
continue
# 找决策库中是否有此股
existing = None
for d in decisions["decisions"]:
if d["code"] == code:
existing = d
break
if not existing:
# 无决策记录→生成inactive记录
existing = {
"code": code,
"name": stock.get("name", ""),
"timestamp": datetime.now().isoformat(),
"type": "历史建议汇总",
"current": "自动从update_data同步",
"status": "inactive",
"updated_by": "system(update_data)",
"advice_timeline": []
}
decisions["decisions"].append(existing)
# 去重合并
timeline = existing.setdefault("advice_timeline", [])
existing_keys = {(e["date"], e["direction"], e["summary"]) for e in timeline
if "date" in e and "direction" in e and "summary" in e}
new_count = 0
for entry in history:
content = entry.get("content", "")
# 判断方向
direction = "其他"
if any(w in content for w in ["买入", "加仓", "入场", "🟢", "可加", "可入"]):
direction = "买入"
elif any(w in content for w in ["卖出", "止盈", "减仓", "止损", "清仓", "🔴", "锁定利润"]):
direction = "卖出"
elif any(w in content for w in ["持有", "观望", "👀", "🤝", "暂持", "继续持有"]):
direction = "持有"
if direction == "其他":
continue
# 提取日期
rid = entry.get("report_id", "")
m = re.search(r'(\d{4}-\d{2}-\d{2})', rid)
date = m.group(1) if m else "unknown"
key = (date, direction, content.strip()[:80])
if key not in existing_keys:
existing_keys.add(key)
timeline.append({
"date": date,
"direction": direction,
"summary": content.strip()[:120],
"report_id": rid
})
new_count += 1
if new_count > 0:
# 按日期排序
timeline.sort(key=lambda e: e.get("date", ""))
synced += new_count
decisions_path.write_text(
json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8"
)
return synced
def build_portfolio_from_obsidian():
"""读取Obsidian持仓数据,生成portfolio.json"""
import subprocess
# Attempt to read from Obsidian
obsidian_path = Path.home() / "Obsidian" / "knowledge" / "finance"
portfolio_file = obsidian_path / "dad-portfolio.md"
holdings = []
total_assets = 0
stock_value = 0
cash = 0
if portfolio_file.exists():
content = portfolio_file.read_text(encoding="utf-8", errors="replace")
lines = content.split("\n")
for line in lines:
m = re.match(r'\|.*?\|.*?(\d+)@(\d+\.?\d*)@.*?\|(\d+\.?\d*)%?\|', line)
if m:
# Parse holding lines from markdown table
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 8:
name = parts[1] if len(parts) > 1 else ""
code = parts[2] if len(parts) > 2 else ""
if code:
holdings.append({
"code": code,
"name": name,
"position_pct": 0,
"cost": 0,
"shares": 0,
"price": 0,
"change_pct": 0,
})
return {
"holdings": holdings,
"total_assets": total_assets,
"stock_value": stock_value,
"cash": cash,
"position_pct": 0,
"total_pnl": 0,
"updated_at": datetime.now().isoformat(),
}
if __name__ == "__main__":
count = import_cron_outputs()
if count > 0:
print(f"📥 新增报告: {count}")
stocks = extract_stock_mentions()
if stocks > 0:
print(f"📊 个股数据: {stocks}")
synced = sync_to_decisions()
if synced > 0:
print(f"📋 决策库: 新增{synced}条建议")
# 有实质更新才发汇总,否则安静
if count > 0 or stocks > 0 or synced > 0:
print(f"{datetime.now().strftime('%m/%d %H:%M')} 数据同步完成")
# 什么新数据都没有→安静,不输出任何内容