236 lines
9.4 KiB
Python
236 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
股票模拟盘业绩汇报 — 自动收集/分析/汇总/建议
|
||
============================================
|
||
2026-08-02 牧尘批评:"项目运行一个月了,你从没有把数据收集、分析、汇总,给我建议。
|
||
每次都是我问你进度,你才开始看情况"——本脚本修复这个缺口:
|
||
不再等人问,定期自动把模拟盘业绩算清楚推给牧尘。
|
||
|
||
数据源:
|
||
1. paper 账户(~/.hermes/stock_backtest/paper_trades_*.json)— 早期单股模拟
|
||
2. 多行业账户(multi_account/account_*.json)— 当前主力模拟盘
|
||
3. 实时行情(腾讯 qt.gtimg.cn)— 持仓浮盈计算
|
||
4. 回测汇总(ma20_summary.json)— 策略历史有效性
|
||
|
||
用法:
|
||
python3 stock_performance.py # 完整报告(默认)
|
||
python3 stock_performance.py --weekly # 周报模式(周五收盘后)
|
||
python3 stock_performance.py --monthly # 月报模式(月末)
|
||
python3 stock_performance.py --json # JSON 输出
|
||
|
||
输出: 报告文本 + 推飞书(--push)
|
||
"""
|
||
import json, os, subprocess, sys
|
||
from datetime import datetime, date
|
||
from pathlib import Path
|
||
|
||
HOME = Path.home()
|
||
BACKTEST = HOME / ".hermes" / "stock_backtest"
|
||
SCRIPTS = HOME / ".hermes" / "scripts"
|
||
|
||
# 腾讯行情接口(curl subprocess 模式,铁律:urllib 在此目录挂起)
|
||
def get_url(url, timeout=8, enc="utf-8"):
|
||
env = dict(os.environ)
|
||
for k in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"]:
|
||
env.pop(k, None)
|
||
r = subprocess.run(["curl", "-s", "--max-time", str(timeout), "--compressed", url],
|
||
capture_output=True, timeout=timeout+2, env=env)
|
||
return r.stdout.decode(enc, errors="ignore")
|
||
|
||
def get_quote(code):
|
||
"""腾讯实时行情 → {price, name};code 如 000001 → sz000001"""
|
||
mc = ("sh" if code.startswith(("6", "5")) else "sz") + code
|
||
raw = get_url(f"https://qt.gtimg.cn/q={mc}", enc="gbk")
|
||
if "~" not in raw:
|
||
return None
|
||
parts = raw.split("~")
|
||
return {"price": float(parts[3]), "name": parts[1], "code": code}
|
||
|
||
def load_json(path):
|
||
if Path(path).exists():
|
||
try:
|
||
return json.load(open(path))
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
def calc_position_pnl(code, shares, avg_cost):
|
||
"""按实时价算浮盈"""
|
||
q = get_quote(code)
|
||
if not q:
|
||
return None, None, None
|
||
market_value = shares * q["price"]
|
||
pnl = market_value - shares * avg_cost
|
||
pnl_pct = pnl / (shares * avg_cost) * 100
|
||
return q["price"], pnl, pnl_pct
|
||
|
||
def collect_paper_accounts():
|
||
"""早期 paper 账户"""
|
||
result = []
|
||
for f in sorted(BACKTEST.glob("paper_trades_*.json")):
|
||
d = load_json(f)
|
||
if not d:
|
||
continue
|
||
for pos in d.get("positions", []):
|
||
code = f.stem.replace("paper_trades_", "")
|
||
result.append({
|
||
"type": "paper",
|
||
"stock": d.get("stock", code),
|
||
"code": code,
|
||
"shares": pos["shares"],
|
||
"avg_cost": pos["avg_cost"],
|
||
"start": d.get("start_date", ""),
|
||
})
|
||
return result
|
||
|
||
def collect_multi_accounts():
|
||
"""多行业账户 + 做空账户 + 全球账户"""
|
||
result = []
|
||
# 多行业账户
|
||
adir = BACKTEST / "multi_account"
|
||
if adir.exists():
|
||
for f in sorted(adir.glob("account_*.json")):
|
||
a = load_json(f)
|
||
if not a:
|
||
continue
|
||
for pos in a.get("positions", []):
|
||
result.append({
|
||
"type": "multi",
|
||
"stock": a.get("stock", ""),
|
||
"code": a.get("code", ""),
|
||
"industry": a.get("industry", ""),
|
||
"shares": pos.get("shares", 0),
|
||
"avg_cost": pos.get("avg_cost", 0),
|
||
"start": a.get("created", ""),
|
||
})
|
||
# 做空账户(空头持仓:盈亏 = (开仓价 - 现价) * 股数)
|
||
sdir = BACKTEST / "short_account"
|
||
if sdir.exists():
|
||
for f in sorted(sdir.glob("short_*.json")):
|
||
a = load_json(f)
|
||
if not a:
|
||
continue
|
||
for pos in a.get("short_positions", []):
|
||
result.append({
|
||
"type": "short",
|
||
"stock": a.get("stock", ""),
|
||
"code": a.get("code", ""),
|
||
"industry": a.get("industry", ""),
|
||
"shares": pos.get("shares", 0),
|
||
"avg_cost": pos.get("open_price", 0),
|
||
"start": pos.get("open_date", ""),
|
||
})
|
||
# 全球账户
|
||
gdir = BACKTEST / "global_account"
|
||
if gdir.exists():
|
||
for f in sorted(gdir.glob("global_*.json")):
|
||
a = load_json(f)
|
||
if not a:
|
||
continue
|
||
for pos in a.get("positions", []):
|
||
result.append({
|
||
"type": "global",
|
||
"stock": a.get("stock", ""),
|
||
"code": a.get("code", ""),
|
||
"market": a.get("market", ""),
|
||
"shares": pos.get("shares", 0),
|
||
"avg_cost": pos.get("avg_cost", 0),
|
||
"start": a.get("created", ""),
|
||
})
|
||
return result
|
||
|
||
def build_report():
|
||
today = date.today().isoformat()
|
||
lines = []
|
||
lines.append(f"📊 模拟盘业绩报告 {today}")
|
||
lines.append("=" * 42)
|
||
|
||
# 1. 当前持仓 + 浮盈
|
||
positions = collect_paper_accounts() + collect_multi_accounts()
|
||
lines.append("📈 当前持仓")
|
||
total_pnl = 0
|
||
total_cost = 0
|
||
if positions:
|
||
for p in positions:
|
||
price, pnl, pnl_pct = calc_position_pnl(p["code"], p["shares"], p["avg_cost"])
|
||
if price is None:
|
||
lines.append(f" {p['stock']}({p['code']}) 行情获取失败")
|
||
continue
|
||
# 做空盈亏 = (开仓价 - 现价) * 股数,与做多方向相反
|
||
if p["type"] == "short":
|
||
pnl = (p["avg_cost"] - price) * p["shares"]
|
||
pnl_pct = pnl / (p["avg_cost"] * p["shares"]) * 100
|
||
if p["type"] == "paper":
|
||
tag = "paper"
|
||
elif p["type"] == "short":
|
||
tag = f"做空[{p.get('industry','')}]"
|
||
elif p["type"] == "global":
|
||
tag = f"{p.get('market','')}[{p['stock']}]"
|
||
else:
|
||
tag = f"多账户[{p.get('industry','')}]"
|
||
arrow = "🟢" if pnl >= 0 else "🔴"
|
||
lines.append(f" {arrow} {p['stock']} {p['shares']}股 @{p['avg_cost']:.2f} → {price:.2f} "
|
||
f"({pnl:+,.0f}元 / {pnl_pct:+.2f}%) [{tag}]")
|
||
total_pnl += pnl
|
||
total_cost += p["shares"] * p["avg_cost"]
|
||
if total_cost > 0:
|
||
lines.append(f" 合计持仓成本 {total_cost:,.0f}元 | 浮盈 {total_pnl:+,.0f}元 ({total_pnl/total_cost*100:+.2f}%)")
|
||
else:
|
||
lines.append(" 空仓(无持仓)")
|
||
|
||
# 2. 多账户总资产
|
||
adir = BACKTEST / "multi_account"
|
||
total_cap = 0
|
||
acct_count = 0
|
||
if adir.exists():
|
||
for f in sorted(adir.glob("account_*.json")):
|
||
a = load_json(f)
|
||
if a:
|
||
total_cap += a.get("current_capital", 0)
|
||
acct_count += 1
|
||
lines.append(f"\n💰 多账户资产: {acct_count} 个账户 | 总资产 {total_cap:,.0f}元")
|
||
# 有平仓交易的账户显示胜率
|
||
win_t = sum(load_json(f).get("stats", {}).get("winning_trades", 0)
|
||
for f in adir.glob("account_*.json") if load_json(f))
|
||
lose_t = sum(load_json(f).get("stats", {}).get("losing_trades", 0)
|
||
for f in adir.glob("account_*.json") if load_json(f))
|
||
if win_t + lose_t > 0:
|
||
lines.append(f" 已平仓交易: 胜{win_t} 负{lose_t} 胜率 {win_t/(win_t+lose_t)*100:.0f}%")
|
||
|
||
# 2b. 做空 + 全球账户资产
|
||
for dname, label in [("short_account", "做空"), ("global_account", "全球")]:
|
||
d = BACKTEST / dname
|
||
if d.exists():
|
||
cap = sum(load_json(f).get("current_capital", 0) for f in d.glob("*.json") if load_json(f))
|
||
cnt = len(list(d.glob("*.json")))
|
||
lines.append(f"💰 {label}账户资产: {cnt} 个账户 | 总资产 {cap:,.0f}元")
|
||
|
||
# 3. 策略回测有效性(ma20_summary)
|
||
ms = load_json(BACKTEST / "ma20_summary.json")
|
||
if ms:
|
||
lines.append("\n📚 策略回测参考 (2024-06~2026 区间)")
|
||
for s in ms.get("stocks", [])[:5]:
|
||
lines.append(f" {s['name']}: α {s['alpha']:+.1f}% 胜率{s['win_rate']:.0f}% 交易{s['total_trades']}笔")
|
||
lines.append(f" 有效板块: {', '.join(ms.get('effective', []))} | 低效: {', '.join(ms.get('ineffective', []))}")
|
||
|
||
# 4. 建议
|
||
lines.append("\n💡 建议")
|
||
if total_pnl > 0 and total_cost > 0:
|
||
lines.append(f" ✅ 当前持仓浮盈 {total_pnl:+,.0f}元 — 持有策略有效,继续按 MA20 纪律(死叉卖出)")
|
||
else:
|
||
lines.append(" ⚠️ 当前空仓 — 等待强势行业金叉信号自动开仓")
|
||
if positions:
|
||
for p in positions:
|
||
lines.append(f" • {p['stock']}: 关注 MA20 死叉信号({p['code']})")
|
||
|
||
return "\n".join(lines)
|
||
|
||
if __name__ == "__main__":
|
||
args = sys.argv[1:]
|
||
mode = "monthly" if "--monthly" in args else ("weekly" if "--weekly" in args else "daily")
|
||
report = build_report()
|
||
print(report)
|
||
if "--push" in args:
|
||
# 通过 send_message 推飞书(由外层 wrapper 处理)
|
||
pass
|