288 lines
12 KiB
Python
288 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
小唯多账户模拟交易 — 按行业分配独立 paper 账户
|
||
============================================
|
||
2026-08-01 行业扫描后新增:强势行业单独账户,弱势行业拦截
|
||
|
||
账户结构:
|
||
强势行业账户: 新能源 / 科技 / 煤炭 / 半导体 / 证券(每个独立资金池)
|
||
弱势行业账户: 白酒 / 医药 / 通信 / 汽车 / 地产(硬拦截,只记录不交易)
|
||
|
||
用法:
|
||
python3 stock_multi_account.py status # 所有账户状态
|
||
python3 stock_multi_account.py status <行业> # 指定行业账户
|
||
python3 stock_multi_account.py execute <sig_json> # 信号执行(自动路由)
|
||
python3 stock_multi_account.py report # 汇总报告
|
||
"""
|
||
import json, os, sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
OUTPUT = Path.home() / ".hermes" / "stock_backtest" / "multi_account"
|
||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 行业 → 账户配置
|
||
# 强势行业: 独立资金池,全仓交易
|
||
# 弱势行业: 独立资金池,半仓交易(2026-08-01 回测优化:全拦截损失 α,半仓最优)
|
||
# 2026-08-02 扩充:新增有色(紫金矿业动量+47.4% Sharpe1.41 最强标的)
|
||
ACCOUNTS = {
|
||
"新能源": {"code": "300750", "name": "宁德时代", "capital": 100000, "blocked": False},
|
||
"科技": {"code": "002415", "name": "海康威视", "capital": 100000, "blocked": False},
|
||
"煤炭": {"code": "601088", "name": "中国神华", "capital": 100000, "blocked": False},
|
||
"半导体": {"code": "688981", "name": "中芯国际", "capital": 100000, "blocked": False},
|
||
"证券": {"code": "600030", "name": "中信证券", "capital": 100000, "blocked": False},
|
||
"有色": {"code": "601899", "name": "紫金矿业", "capital": 100000, "blocked": False},
|
||
"石油": {"code": "601857", "name": "中国石油", "capital": 100000, "blocked": False},
|
||
"白酒": {"code": "000858", "name": "五粮液", "capital": 100000, "blocked": False},
|
||
"医药": {"code": "600276", "name": "恒瑞医药", "capital": 100000, "blocked": False},
|
||
"地产": {"code": "000002", "name": "万科A", "capital": 100000, "blocked": False},
|
||
}
|
||
|
||
WEAK_INDUSTRIES = {"白酒", "医药", "通信", "汽车", "地产"}
|
||
|
||
|
||
def account_file(industry):
|
||
return OUTPUT / f"account_{industry}.json"
|
||
|
||
|
||
def load_account(industry):
|
||
f = account_file(industry)
|
||
if f.exists():
|
||
with open(f) as fp:
|
||
return json.load(fp)
|
||
cfg = ACCOUNTS.get(industry, {})
|
||
return {
|
||
"industry": industry,
|
||
"stock": cfg.get("name", ""),
|
||
"code": cfg.get("code", ""),
|
||
"blocked": cfg.get("blocked", industry in WEAK_INDUSTRIES),
|
||
"initial_capital": cfg.get("capital", 0),
|
||
"current_capital": cfg.get("capital", 0),
|
||
"positions": [],
|
||
"closed_trades": [],
|
||
"last_signal": "空仓",
|
||
"last_signal_date": "",
|
||
"stats": {"total_trades": 0, "winning_trades": 0, "losing_trades": 0},
|
||
"created": datetime.now().strftime("%Y-%m-%d"),
|
||
}
|
||
|
||
|
||
def save_account(acct):
|
||
with open(account_file(acct["industry"]), "w") as f:
|
||
json.dump(acct, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
def get_dynamic_alloc(industry):
|
||
"""
|
||
动态仓位(2026-08-02 增强:按行业动量强度调整)
|
||
从 industry_scan.json 读取行业动量:
|
||
动量 > 0 → 1.0(全仓,强势行业)
|
||
动量 -20% ~ 0 → 0.6(偏弱,降仓)
|
||
动量 -40% ~ -20%→ 0.5(弱势,半仓)
|
||
动量 < -40% → 0.3(深度弱势,轻仓)
|
||
无数据回退:弱势行业 0.5,其他 1.0
|
||
"""
|
||
f = OUTPUT.parent / "industry_scan.json"
|
||
mom = None
|
||
if f.exists():
|
||
try:
|
||
scan = json.load(open(f))
|
||
mom_map = scan.get("industries", {}).get("avg_mom", {})
|
||
mom = mom_map.get(industry)
|
||
except Exception:
|
||
mom = None
|
||
|
||
if mom is None:
|
||
return 0.5 if industry in WEAK_INDUSTRIES else 1.0
|
||
|
||
if mom > 0:
|
||
return 1.0
|
||
if mom > -0.20:
|
||
return 0.6
|
||
if mom > -0.40:
|
||
return 0.5
|
||
return 0.3
|
||
|
||
|
||
def execute_signal(sig, dry_run=False):
|
||
"""
|
||
信号执行(多账户路由版 v4 — 2026-08-02 止损保险丝)
|
||
sig: {"industry": "新能源", "signal": "BUY"/"SELL"/"STOP_LOSS"/..., "close": 价格, ...}
|
||
|
||
v4 变更:新增 STOP_LOSS 信号(熔断保险丝)
|
||
回测证据(stock_stop_loss_validate.py):
|
||
- MA20 死叉本身就是动态止损(比 8% 硬止损更早触发)
|
||
- 8%/10% 硬止损触发 0 次,收益不变
|
||
- 5% 止损触发 1-2 次,收益仍不变
|
||
- 结论:硬止损是"保险丝"——常规行情用不到,极端跳空/跌停时兜底
|
||
"""
|
||
if not sig:
|
||
return ("SKIP", "无信号")
|
||
|
||
industry = sig.get("industry", "")
|
||
signal = sig.get("signal", "")
|
||
price = sig.get("close") or sig.get("price") or 0
|
||
code = sig.get("code") or sig.get("stock_code") or ""
|
||
|
||
if industry not in ACCOUNTS:
|
||
return ("SKIP", f"未配置行业账户: {industry}")
|
||
|
||
# v5 修复(2026-08-28):行业路由张冠李戴防护
|
||
# 同一行业多只股票(如新能源=宁德时代+比亚迪、煤炭=中国神华+兖矿能源),
|
||
# 金叉信号若只按行业路由,会把触发股票的价格记到账户绑定股票名下(假盈亏)。
|
||
# 信号必须携带触发股票的 code,且必须等于账户绑定的 code 才执行。
|
||
acct_cfg = ACCOUNTS.get(industry, {})
|
||
bound_code = str(acct_cfg.get("code", "") or "")
|
||
if code and bound_code and str(code).strip() != bound_code.strip():
|
||
return ("SKIP", f"信号股票{code}≠账户标的{bound_code},跳过(防张冠李戴)")
|
||
|
||
acct = load_account(industry)
|
||
has_position = bool(acct["positions"])
|
||
|
||
if signal == "BUY" and not has_position:
|
||
if acct.get("blocked"):
|
||
return ("BLOCK", f"行业{industry}账户已锁定")
|
||
# 动态仓位(v3:按行业动量强度调整,2026-08-02)
|
||
alloc = get_dynamic_alloc(industry)
|
||
is_weak = industry in WEAK_INDUSTRIES
|
||
shares = int(acct["current_capital"] * alloc // price) if price > 0 else 0
|
||
if shares <= 0:
|
||
return ("SKIP", "资金不足")
|
||
cost = shares * price
|
||
if dry_run:
|
||
return ("BUY", f"{industry}{'半仓' if is_weak else '全仓'}买入@{price:.2f} {shares}股(仓位{alloc:.0%})")
|
||
acct["positions"].append({"code": bound_code, "shares": shares, "avg_cost": price})
|
||
acct["current_capital"] -= cost
|
||
acct["last_signal"] = "买入"
|
||
acct["last_signal_date"] = datetime.now().strftime("%Y-%m-%d")
|
||
save_account(acct)
|
||
mode = f"{alloc:.0%}仓(弱势行业)" if is_weak else "全仓"
|
||
return ("BUY", f"{industry}{mode}买入{shares}股@{price:.2f} 剩余{acct['current_capital']:.0f}")
|
||
|
||
elif signal == "SELL" and has_position:
|
||
shares = sum(p["shares"] for p in acct["positions"])
|
||
if dry_run:
|
||
return ("SELL", f"{industry}死叉卖出@{price:.2f} {shares}股")
|
||
avg_cost = sum(p["shares"] * p["avg_cost"] for p in acct["positions"]) / shares
|
||
pnl = (price - avg_cost) * shares
|
||
acct["current_capital"] += price * shares
|
||
acct["closed_trades"].append({
|
||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||
"buy_price": avg_cost, "sell_price": price,
|
||
"shares": shares, "pnl": pnl,
|
||
"pnl_pct": pnl / (avg_cost * shares) * 100 if avg_cost * shares else 0,
|
||
})
|
||
acct["stats"]["total_trades"] += 1
|
||
acct["stats"]["winning_trades"] += 1 if pnl > 0 else 0
|
||
acct["stats"]["losing_trades"] += 1 if pnl < 0 else 0
|
||
acct["positions"] = []
|
||
acct["last_signal"] = "卖出"
|
||
acct["last_signal_date"] = datetime.now().strftime("%Y-%m-%d")
|
||
save_account(acct)
|
||
return ("SELL", f"{industry}卖出{shares}股@{price:.2f} 盈亏{pnl:+.0f}")
|
||
|
||
elif signal == "BUY" and has_position:
|
||
return ("HOLD", f"{industry}已持仓,忽略重复买入")
|
||
|
||
elif signal == "SELL" and not has_position:
|
||
return ("HOLD", f"{industry}已空仓,忽略重复卖出")
|
||
|
||
elif signal == "STOP_LOSS":
|
||
if not has_position:
|
||
return ("HOLD", f"{industry}已空仓,无止损需要")
|
||
# 止损保险丝:极端行情兜底(正常由死叉触发,这里兜底)
|
||
shares = sum(p["shares"] for p in acct["positions"])
|
||
if dry_run:
|
||
return ("STOP_LOSS", f"{industry}止损卖出@{price:.2f} {shares}股")
|
||
avg_cost = sum(p["shares"] * p["avg_cost"] for p in acct["positions"]) / shares
|
||
pnl = (price - avg_cost) * shares
|
||
acct["current_capital"] += price * shares
|
||
acct["closed_trades"].append({
|
||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||
"buy_price": avg_cost, "sell_price": price,
|
||
"shares": shares, "pnl": pnl,
|
||
"pnl_pct": pnl / (avg_cost * shares) * 100 if avg_cost * shares else 0,
|
||
})
|
||
acct["stats"]["total_trades"] += 1
|
||
acct["stats"]["winning_trades"] += 1 if pnl > 0 else 0
|
||
acct["stats"]["losing_trades"] += 1 if pnl < 0 else 0
|
||
acct["positions"] = []
|
||
acct["last_signal"] = "止损"
|
||
acct["last_signal_date"] = datetime.now().strftime("%Y-%m-%d")
|
||
save_account(acct)
|
||
return ("STOP_LOSS", f"{industry}止损卖出{shares}股@{price:.2f} 盈亏{pnl:+.0f}")
|
||
|
||
elif signal == "HOLD_LONG":
|
||
return ("HOLD", f"{industry}持仓中" if has_position else f"{industry}空仓(非金叉不买)")
|
||
|
||
return ("HOLD", f"{industry}空仓观望")
|
||
|
||
|
||
def cmd_status(industry=None):
|
||
print("=" * 60)
|
||
print(f"小唯多账户模拟 — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||
print("=" * 60)
|
||
|
||
industries = [industry] if industry else list(ACCOUNTS.keys())
|
||
total_assets = 0
|
||
for ind in industries:
|
||
acct = load_account(ind)
|
||
shares = sum(p["shares"] for p in acct["positions"])
|
||
total_cost = sum(p["shares"] * p["avg_cost"] for p in acct["positions"])
|
||
market_val = total_cost # 简化:成本计市值(真实市值需行情)
|
||
total = acct["current_capital"] + (market_val if shares else 0)
|
||
total_assets += total
|
||
|
||
tag = "⛔拦截" if acct.get("blocked") else "🟢可交易"
|
||
pos = f"持仓{shares}股" if shares else "空仓"
|
||
pnl = total - acct["initial_capital"]
|
||
print(f"\n【{ind}】{tag} 标的:{acct['stock']}({acct['code']})")
|
||
print(f" 资金: {acct['current_capital']:.0f} | {pos} | 盈亏: {pnl:+.0f}")
|
||
if acct["closed_trades"]:
|
||
wins = acct["stats"]["winning_trades"]
|
||
tot = acct["stats"]["total_trades"]
|
||
print(f" 已平仓: {tot}笔 | 胜率: {wins}/{tot}")
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"总资产: {total_assets:.0f}")
|
||
|
||
|
||
def cmd_report():
|
||
"""汇总报告(飞书推送用)"""
|
||
lines = [f"📊 多账户日报 {datetime.now().strftime('%Y-%m-%d')}"]
|
||
total = 0
|
||
for ind, cfg in ACCOUNTS.items():
|
||
acct = load_account(ind)
|
||
shares = sum(p["shares"] for p in acct["positions"])
|
||
total_cost = sum(p["shares"] * p["avg_cost"] for p in acct["positions"])
|
||
market_val = total_cost if shares else 0
|
||
t = acct["current_capital"] + market_val
|
||
total += t
|
||
pnl = t - acct["initial_capital"]
|
||
emoji = "⛔" if acct.get("blocked") else "🟢"
|
||
lines.append(f"{emoji} {ind}: {acct['stock']} 资产{t:.0f} ({pnl:+.0f})")
|
||
lines.append(f"💰 总资产: {total:.0f}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||
|
||
if cmd == "status":
|
||
ind = sys.argv[2] if len(sys.argv) > 2 else None
|
||
cmd_status(ind)
|
||
elif cmd == "execute":
|
||
# 从 stdin 或文件读取信号 JSON
|
||
if len(sys.argv) > 2 and os.path.exists(sys.argv[2]):
|
||
sig = json.load(open(sys.argv[2]))
|
||
elif len(sys.argv) > 2:
|
||
sig = json.loads(sys.argv[2])
|
||
else:
|
||
sig = json.load(sys.stdin)
|
||
action, detail = execute_signal(sig)
|
||
print(f"[{action}] {detail}")
|
||
elif cmd == "report":
|
||
print(cmd_report())
|
||
else:
|
||
print(__doc__)
|