Add demo trading analyst with plan validation and sizing

The analyst prompts the model with trading rules, published skills,
account sizing inputs and prefetched market data, exposes read-only
tools plus submit_trade_plan, and validates and sizes the submitted plan
in pure functions covered by pytest. Manual analyses run as dry runs
while trading is stopped; the trading page shows the latest plan and an
analysis detail drawer.
This commit is contained in:
eezd committed 2026-10-15 10:40:29 +08:00
1 parent 7536c9c5de
commit 78ca4422af
16 files changed
+1465 -151

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
"""模拟盘分析器:为一个币的一个角度调用模型,产出经程序校验、可自动执行的交易计划。
只开放只读工具和 submit_trade_plan;行情预先取好放进用户消息,减少工具往返。
"""
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from pydantic import ValidationError
from sqlalchemy import select
from alphapulse.ai import llm
from alphapulse.ai.prompt import data_overview, estimate_tokens, skills_section
from alphapulse.ai.tools import TOOLS, dump, local, run_tool
from alphapulse.core.db import SessionLocal
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
from alphapulse.trading import plan as rules
from alphapulse.trading.client import public_get
from alphapulse.trading.plan import PlanContext, PlanError, SymbolRules, TradePlan, size_plan
from alphapulse.trading.snapshot import read_snapshot
logger = logging.getLogger("alphapulse.trading.analyst")
READ_TOOLS = ("search_news", "get_news", "search_symbols", "get_klines", "get_ticker", "get_derivatives")
# 最后一轮只开放 submit_trade_plan,逼模型给出结论
MAX_ROUNDS = 6
RECENT_DECISIONS = 2
EXCHANGE_INFO_SECONDS = 3600
ANGLE_LABELS = {"short": "短线(24 小时内平仓)", "long": "长线(12–48 小时内平仓)"}
DIRECTION_LABELS = {"long": "做多", "short": "做空"}
CONFIDENCE_LABELS = {"high": "高", "medium": "中", "low": "低"}
ACTION_LABELS = {"plan": "新计划", "keep": "维持当前计划", "exit": "结束当前计划", "wait": "观望"}
# 与 README 第 1 节一致:定方向和找区的周期、衍生品参数
KLINE_INTERVALS = {"short": ("4h", "1h", "15m"), "long": ("1d", "4h", "1h")}
DERIVATIVES_PARAMS = {"short": ("1h", 24), "long": ("4h", 18)}
def _pct(value: Decimal) -> str:
return f"{(value * 100).normalize()}%"
INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币种的一个角度给出由程序自动执行的交易计划。
## 任务
- 只分析用户消息指定的币种和角度(短线或长线),不给另一个角度。
- 下方 Skills 是交易规范。以下几处按本节执行,与 Skills 冲突时以本节为准:
- main/README 第 1 节「每次同时给出两个角度」不适用,只给指定角度。
- main/README 第 8 节的输出表格不适用:用简短中文写出结论、用到的支撑区与压力区、依据(新闻、结构、衍生品)和风险;计划的数字只通过 submit_trade_plan 提交。
- main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,按 README 第 6 节做实时复核,不直接沿用旧点位。
- main/README 5.4 的条件计划(突破回踩、事件落地后再入场等)不能直接挂单,改写成 reevaluate 条件:价格到达后程序会重新请你分析。
- 数量由程序按交易资金计算,你只给价格、档位比例和置信度。
- 用户消息里已预先取好实盘行情(ticker、K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。
- 新闻没有预取,必须按 main/README 第 3 节第 3 步用 search_news 检索相关新闻,并查清持仓时限内的重大数据及公布时间。
- 最后必须调用一次 submit_trade_plan。校验不通过会返回原因,按原因修正后重新提交。
## 程序如何执行计划
- 入场:每档一张限价单;到 entry_valid_hours 仍未成交的档位撤销。买单价高于现价(卖单价低于现价)会立即成交。
- 止损:与入场单同时在交易所挂只减仓止损单(按标记价触发),首档成交即受保护。
- 止盈:短线止盈 1 平一半、止盈 2 平剩余;长线 3 个止盈依次平 50% / 30% / 20%。止盈 1 成交后撤销剩余入场单,止损移到保本价。
- invalidation(失效条件):短线用 1m 或 15m 收盘,触发后程序立即市价平仓;长线只能用 4h 收盘,触发后程序请你复核。
- reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个)。用来表达另一侧方向、条件计划和需要复核的位置。
- max_hold_hours 从首档成交起算,到时程序市价平仓。
## 程序的硬性校验
- 短线 1 档入场、2 个止盈;长线 1–3 档入场、3 个止盈;入场比例之和为 1。
- 做多:止损 < 失效价 < 所有入场价 < 止盈 1 < 止盈 2 < 止盈 3,且止损低于现价、止盈 1 高于现价;做空反过来。
- 盈亏比 ≥ {rules.MIN_REWARD_RISK}:短线按止盈 1,长线按 50/30/20 加权(全部档位成交时)。
- entry_valid_hours:短线 ≤ {rules.ENTRY_VALID_HOURS['short']},长线 ≤ {rules.ENTRY_VALID_HOURS['long']};max_hold_hours:短线 ≤ {rules.MAX_HOLD_HOURS['short']},长线 ≤ {rules.MAX_HOLD_HOURS['long']}。
- 每档入场的名义价值不低于交易所最小下单金额,不足时合并或删档。
- 单笔风险为交易资金的 {_pct(rules.RISK_PCT['short'])}(短线)或 {_pct(rules.RISK_PCT['long'])}(长线),置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。这两条由程序计算数量来保证。
- 有持仓时新计划的方向必须与持仓一致;要反手先用 exit。
"""
SUBMIT_SPEC = llm.ToolSpec(
"submit_trade_plan",
"提交本次分析的结论和计划。程序会校验并计算仓位;不通过时返回原因,修正后重新提交。",
TradePlan.model_json_schema(),
)
class AnalysisError(Exception):
"""分析无法完成;message 写入分析记录和时间线"""
@dataclass
class Prepared:
provider: AiProvider
effort: str | None
dry_run: bool
angle: str
system: str
user_text: str
context: PlanContext
_exchange_info: tuple[float, dict[str, SymbolRules]] | None = None
async def symbol_rules(symbol: str) -> SymbolRules:
"""模拟盘的价格精度、数量精度、最小下单金额,按小时缓存"""
global _exchange_info
if _exchange_info is None or time.monotonic() - _exchange_info[0] > EXCHANGE_INFO_SECONDS:
info = await public_get("/fapi/v1/exchangeInfo")
parsed = {}
for s in info["symbols"]:
filters = {f["filterType"]: f for f in s["filters"]}
parsed[s["symbol"]] = SymbolRules(
tick_size=Decimal(filters["PRICE_FILTER"]["tickSize"]).normalize(),
step_size=Decimal(filters["LOT_SIZE"]["stepSize"]).normalize(),
min_notional=Decimal(filters["MIN_NOTIONAL"]["notional"]),
)
_exchange_info = (time.monotonic(), parsed)
if symbol not in _exchange_info[1]:
raise AnalysisError(f"模拟盘没有交易对 {symbol}")
return _exchange_info[1][symbol]
async def mark_price(symbol: str) -> Decimal:
return Decimal((await public_get("/fapi/v1/premiumIndex", {"symbol": symbol}))["markPrice"])
def plan_brief(decision: dict, sizing: dict | None) -> dict:
"""给模型看的计划摘要:只留价格和状态,不带长段文字"""
brief = {k: decision.get(k) for k in ("action", "summary", "direction", "confidence", "stop", "entry_valid_hours", "max_hold_hours")}
brief["entries"] = [{"price": e["price"], "ratio": e["ratio"]} for e in decision.get("entries") or []]
brief["take_profits"] = [t["price"] for t in decision.get("take_profits") or []]
brief["invalidation"] = decision.get("invalidation")
brief["reevaluate"] = [{k: c[k] for k in ("direction", "price", "timeframe")} for c in decision.get("reevaluate") or []]
if sizing:
brief["sizing"] = {k: sizing.get(k) for k in ("quantity", "notional", "risk_at_stop", "reward_risk")}
return brief
async def prefetch(symbol: str, angle: str) -> str:
"""预取行情:工具出错时把错误原样放进消息,不中断分析"""
period, limit = DERIVATIVES_PARAMS[angle]
calls = [("get_ticker", {"symbol": symbol})]
calls += [("get_klines", {"symbol": symbol, "interval": i, "limit": 200}) for i in KLINE_INTERVALS[angle]]
calls.append(("get_derivatives", {"symbol": symbol, "period": period, "limit": limit}))
outcomes = await asyncio.gather(*(run_tool(name, args) for name, args in calls))
return "\n\n".join(f"### {name} {json.dumps(args, ensure_ascii=False)}\n{o.result}" for (name, args), o in zip(calls, outcomes, strict=True))
async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared:
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
target = await session.get(TradingSymbol, symbol)
if target is None:
raise AnalysisError(f"{symbol} 已不在交易列表中")
if account is None or account.capital_usdt is None:
raise AnalysisError("还没有设置交易资金")
provider = await session.get(AiProvider, account.provider_id) if account.provider_id else await session.scalar(
select(AiProvider).where(AiProvider.is_default)
)
if provider is None:
raise AnalysisError("没有可用的模型配置,请先到「环境变量」页面添加")
session.expunge(provider)
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
recent = (await session.scalars(
select(TradingAnalysis)
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id)
.order_by(TradingAnalysis.id.desc()).limit(RECENT_DECISIONS)
)).all()
current = await session.scalar(
select(TradingAnalysis)
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open")), TradingAnalysis.dry_run.is_(False))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
system = "\n\n".join([INSTRUCTIONS, "## 数据概况", await data_overview(session), await skills_section(session)])
angle, dry_run = target.angle, not account.running
effort = account.reasoning_effort or provider.reasoning_effort
snapshot = await read_snapshot()
if not snapshot or not snapshot["account"]:
raise AnalysisError("取不到模拟盘账户快照(引擎未同步到币安),无法计算仓位")
equity = Decimal(snapshot["account"]["margin_balance"])
capital = min(account.capital_usdt, equity)
position = next((p for p in snapshot["positions"] if p["symbol"] == symbol), None)
other_notional = sum((Decimal(p["notional"]) for p in snapshot["positions"] if p["symbol"] != symbol and p["symbol"] in symbols), Decimal(0))
symbol_rules_ = await symbol_rules(symbol)
mark = await mark_price(symbol)
context = PlanContext(
angle=angle, mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_,
has_plan=current is not None, position_side=("long" if position["side"] == "LONG" else "short") if position else None,
)
orders = [o for o in snapshot["orders"] + snapshot["algo_orders"] if o["symbol"] == symbol]
risk = capital * rules.RISK_PCT[angle]
lines = [
"## 任务",
f"- 币种:{symbol}(币安 U 本位永续)",
f"- 角度:{ANGLE_LABELS[angle]}",
f"- 触发原因:{';'.join(reasons)}",
f"- 运行模式:{'试运行(引擎已停止,计划只记录、不下单)' if dry_run else '交易中(计划会在模拟盘执行)'}",
"",
"## 账户(程序据此计算数量)",
f"- 交易资金:{capital:.2f} USDT(设定 {account.capital_usdt} USDT,账户权益 {equity:.2f} USDT,取较小者)",
f"- 本次单笔风险金额:{risk:.2f} USDT({_pct(rules.RISK_PCT[angle])};置信度低时 {risk * rules.LOW_CONFIDENCE_FACTOR:.2f})",
f"- 可用名义仓位:{capital * rules.MAX_LEVERAGE - other_notional:.2f} USDT({rules.MAX_LEVERAGE} 倍交易资金减去交易列表其他币的持仓 {other_notional:.2f})",
f"- 模拟盘交易规则:价格精度 {symbol_rules_.tick_size},数量精度 {symbol_rules_.step_size},最小下单金额 {symbol_rules_.min_notional} USDT",
f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)",
"",
"## 当前持仓与挂单(模拟盘)",
f"- 持仓:{dump(position) if position else '无'}",
f"- 挂单:{dump(orders) if orders else '无'}",
"",
"## 当前计划",
dump({"id": current.id, "plan_status": current.plan_status, **plan_brief(current.decision, current.sizing)}) if current else "无",
"",
"## 最近决策(新的在前)",
*([f"- {local(a.started_at)}{'(试运行)' if a.dry_run else ''}:{dump(plan_brief(a.decision, a.sizing))}" for a in recent] or ["无"]),
"",
"## 预取的行情(实盘数据,已取,不要用相同参数重复调用)",
await prefetch(symbol, angle),
]
return Prepared(provider=provider, effort=effort, dry_run=dry_run, angle=angle, system=system, user_text="\n".join(lines), context=context)
async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.ToolSpec]) -> llm.Completion:
async for item in llm.stream(prepared.provider, prepared.system, messages, tools, prepared.effort):
if isinstance(item, llm.Completion):
return item
raise AssertionError("llm.stream 没有产出 Completion")
async def add_event(symbol: str, analysis_id: int, kind: str, message: str, level: str = "info", data: dict | None = None) -> None:
async with SessionLocal() as session:
session.add(TradingEvent(symbol=symbol, analysis_id=analysis_id, kind=kind, level=level, message=message, data=data))
await session.commit()
def describe(plan: TradePlan, sizing: dict, dry_run: bool) -> str:
prefix = "试运行" if dry_run else ""
if plan.action != "plan":
return f"{prefix}{ACTION_LABELS[plan.action]}:{plan.summary}"
entries = "、".join(f"{e['price']}×{e['quantity']}" for e in sizing["entries"])
targets = " / ".join(t["price"] for t in sizing["take_profits"])
return (
f"{prefix}计划:{DIRECTION_LABELS[plan.direction]},置信度{CONFIDENCE_LABELS[plan.confidence]};入场 {entries},"
f"止损 {sizing['stop']},止盈 {targets};止损亏损 {sizing['risk_at_stop']} USDT(交易资金的 {sizing['risk_pct']}%),"
f"盈亏比 {sizing['reward_risk']}(仅首档 {sizing['reward_risk_first_only']})。{plan.summary}"
)
async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: dict) -> tuple[str, tuple[TradePlan, dict] | None]:
try:
plan = TradePlan.model_validate(arguments)
except ValidationError as e:
return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None
# 分析可能持续几分钟,按提交时的标记价校验
prepared.context.mark_price = await mark_price(symbol)
try:
sizing = size_plan(plan, prepared.context)
except PlanError as e:
await add_event(symbol, analysis_id, "plan_rejected", f"计划未通过校验,已退回模型修正:{';'.join(e.errors)}", "warn")
return dump({"error": "计划未通过校验", "details": e.errors, "mark_price": str(prepared.context.mark_price)}), None
return dump({"status": "已接受", "sizing": sizing}), (plan, sizing)
async def run_analysis(symbol: str, reasons: list[str]) -> None:
"""一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出"""
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
target = await session.get(TradingSymbol, symbol)
analysis = TradingAnalysis(
symbol=symbol, angle=target.angle if target else "long", reasons=reasons,
dry_run=not (account and account.running), status="running",
)
session.add(analysis)
await session.commit()
analysis_id, dry_run = analysis.id, analysis.dry_run
await add_event(symbol, analysis_id, "analysis_started", f"开始{'试运行' if dry_run else ''}分析:{';'.join(reasons)}")
messages: list[dict] = []
texts: list[str] = []
usage = {"input_tokens": 0, "output_tokens": 0}
accepted: tuple[TradePlan, dict] | None = None
prepared: Prepared | None = None
try:
prepared = await prepare(symbol, reasons, analysis_id)
estimated = estimate_tokens(prepared.system) + estimate_tokens(prepared.user_text)
if estimated + prepared.provider.max_tokens > prepared.provider.context_window:
raise AnalysisError(f"提示词约 {estimated} token,加上最大输出超过模型上下文上限 {prepared.provider.context_window}")
messages.append({"role": "user", "text": prepared.user_text})
specs = [llm.ToolSpec(name, TOOLS[name].description, TOOLS[name].args_model.model_json_schema()) for name in READ_TOOLS]
for round_no in range(MAX_ROUNDS):
last = round_no == MAX_ROUNDS - 1
completion = await complete(prepared, messages, [SUBMIT_SPEC] if last else [*specs, SUBMIT_SPEC])
for key in usage:
usage[key] += completion.usage.get(key, 0)
if completion.text.strip():
texts.append(completion.text.strip())
assistant = {"role": "assistant", "text": completion.text, "tool_calls": completion.tool_calls}
if completion.thinking_blocks:
assistant["thinking_blocks"] = completion.thinking_blocks
messages.append(assistant)
if not completion.tool_calls:
if completion.truncated:
raise AnalysisError(f"模型输出达到最大输出上限({prepared.provider.max_tokens} token)被截断")
messages.append({"role": "user", "text": "请调用 submit_trade_plan 提交结论。"})
continue
for call in completion.tool_calls:
if completion.truncated:
result = llm.TRUNCATED_RESULT
elif call["name"] == "submit_trade_plan":
result, outcome = await submit(prepared, symbol, analysis_id, call["arguments"])
accepted = accepted or outcome
elif call["name"] in READ_TOOLS:
result = (await run_tool(call["name"], call["arguments"])).result
else:
result = dump({"error": f"分析时不能使用工具 {call['name']}"})
messages.append({"role": "tool", "tool_call_id": call["id"], "name": call["name"], "result": result})
if accepted:
break
if accepted is None:
raise AnalysisError(f"模型在 {MAX_ROUNDS} 轮内没有提交通过校验的结论")
except (AnalysisError, llm.LlmError, PlanError) as e:
error = str(e)
except Exception as e:
logger.exception("%s 分析出错", symbol)
error = f"程序出错:{e!r}"
else:
error = None
async with SessionLocal() as session:
analysis = await session.get(TradingAnalysis, analysis_id)
analysis.status = "failed" if error else "done"
analysis.error = error
analysis.text = "\n\n".join(texts) or None
# thinking 块只在同一轮工具循环内需要回传,存档时去掉,减小体积
analysis.transcript = [{k: v for k, v in m.items() if k != "thinking_blocks"} for m in messages] or None
analysis.usage = usage
analysis.finished_at = datetime.now(UTC)
if prepared:
analysis.provider_id, analysis.model, analysis.reasoning_effort = prepared.provider.id, prepared.provider.model, prepared.effort
if accepted:
plan, sizing = accepted
analysis.action = plan.action
analysis.decision = plan.model_dump(mode="json")
analysis.sizing = sizing or None
await session.commit()
if error:
await add_event(symbol, analysis_id, "analysis_failed", f"分析失败:{error}", "error")
else:
plan, sizing = accepted
await add_event(symbol, analysis_id, "analysis_done", describe(plan, sizing, dry_run), data={"action": plan.action})