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
+1464 -150

No files matched your search

+5
View File
@@ -27,3 +27,8 @@ dependencies = [
[build-system]
requires = ["uv_build>=0.12.23,<0.13.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
]
+2
View File
@@ -31,6 +31,8 @@ MAX_RETRIES = 2
# 这些都由这里再重试,且只在还没有输出内容时重试
STREAM_RETRIES = 3
STREAM_RETRY_DELAY_SECONDS = 2
# 输出被截断时回给模型的工具结果:参数不完整,不能执行
TRUNCATED_RESULT = json.dumps({"error": "输出达到最大输出上限被截断,工具参数不完整,未执行。请精简内容后重新调用"}, ensure_ascii=False)
logger = logging.getLogger("alphapulse.ai.llm")
+7 -3
View File
@@ -50,14 +50,18 @@ async def data_overview(session: AsyncSession) -> str:
return "\n".join(lines)
async def build_system_prompt(session: AsyncSession) -> str:
parts = [BASE_INSTRUCTIONS, "## 数据概况", await data_overview(session)]
async def skills_section(session: AsyncSession) -> str:
skills = await published_skills(session)
parts.append("## Skills(按优先级从高到低)")
parts = ["## Skills(按优先级从高到低)"]
if skills:
parts += [f"### 【优先级 {info.node.priority}】{info.path}\n\n{content}" for info, content in skills]
else:
parts.append("(暂无已发布的 skill)")
return "\n\n".join(parts)
async def build_system_prompt(session: AsyncSession) -> str:
parts = [BASE_INSTRUCTIONS, "## 数据概况", await data_overview(session), await skills_section(session)]
context = await current_context(session)
parts += ["## CONTEXT.md", context.content if context and context.content.strip() else "(空)"]
return "\n\n".join(parts)
+1 -2
View File
@@ -27,7 +27,6 @@ MAX_TOOL_ROUNDS = 8
# 发给前端展示的工具结果长度
TOOL_PREVIEW_CHARS = 2000
STOPPED_RESULT = json.dumps({"error": "用户已停止生成,工具未执行"}, ensure_ascii=False)
TRUNCATED_RESULT = json.dumps({"error": "输出达到最大输出上限被截断,工具参数不完整,未执行。请精简内容后重新调用"}, ensure_ascii=False)
# api 只有一个进程:同一时间只允许一轮生成,Reset 也要等生成结束
generating = asyncio.Lock()
@@ -195,7 +194,7 @@ async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
call = pending[0]
yield event("tool_call", {"id": call["id"], "name": call["name"], "arguments": call["arguments"]})
# 截断时不执行,把原因告诉模型,让它精简后重试
outcome = ToolOutcome(TRUNCATED_RESULT) if completion.truncated else await run_tool(call["name"], call["arguments"])
outcome = ToolOutcome(llm.TRUNCATED_RESULT) if completion.truncated else await run_tool(call["name"], call["arguments"])
result = {"tool_call_id": call["id"], "name": call["name"], "result": outcome.result}
await save_messages(ChatMessage(session_id=current.id, role="tool", content=result))
pending.pop(0)
+69 -7
View File
@@ -18,8 +18,9 @@ from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
from alphapulse.trading.client import ExchangeError, public_get
from alphapulse.trading.engine import DAILY_ANALYSIS_LIMIT, OWN_CLIENT_ID, STATUS_KEY
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
from alphapulse.trading.snapshot import read_snapshot
router = APIRouter(prefix="/api/trading", tags=["trading"], dependencies=[Depends(require_admin)])
@@ -82,6 +83,36 @@ class SymbolPatch(BaseModel):
angle: Angle
class AnalysisOut(BaseModel):
id: int
symbol: str
angle: str
reasons: list[str]
dry_run: bool
status: str
action: str | None
decision: dict | None
sizing: dict | None
error: str | None
model: str | None
reasoning_effort: str | None
usage: dict | None
started_at: datetime
finished_at: datetime | None
plan_status: str | None
valid_until: datetime | None
first_fill_at: datetime | None
close_deadline: datetime | None
closed_at: datetime | None
close_reason: str | None
result: dict | None
class AnalysisDetail(AnalysisOut):
text: str | None
transcript: list | None
class EventOut(BaseModel):
id: int
created_at: datetime
@@ -97,11 +128,6 @@ async def get_account(session: AsyncSession) -> TradingAccount:
return await session.get(TradingAccount, 1) or TradingAccount(id=1, running=False)
async def read_snapshot() -> dict | None:
raw = await redis.get(STATUS_KEY)
return json.loads(raw) if raw else None
@router.get("/status")
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
account = await get_account(session)
@@ -292,3 +318,39 @@ async def list_events(
if before:
query = query.where(TradingEvent.id < before)
return [EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)]
@router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""排队一次手动分析:引擎停止时为试运行,计划只记录、不下单"""
s = await session.get(TradingSymbol, symbol)
if s is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
if (await get_account(session)).capital_usdt is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有设置交易资金")
running = await session.scalar(select(func.count()).where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "running"))
if running or s.analysis_requested_at is not None:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在分析或排队中")
s.analysis_requested_at = datetime.now(LOCAL_TZ)
s.analysis_reasons = ["手动分析"]
await session.commit()
@router.get("/analyses")
async def list_analyses(
session: Annotated[AsyncSession, Depends(get_session)],
symbol: str | None = None,
limit: Annotated[int, Query(ge=1, le=50)] = 10,
) -> list[AnalysisOut]:
query = select(TradingAnalysis).order_by(TradingAnalysis.id.desc()).limit(limit)
if symbol:
query = query.where(TradingAnalysis.symbol == symbol)
return [AnalysisOut.model_validate(a, from_attributes=True) for a in await session.scalars(query)]
@router.get("/analyses/{analysis_id}")
async def get_analysis(analysis_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> AnalysisDetail:
analysis = await session.get(TradingAnalysis, analysis_id)
if analysis is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "分析不存在")
return AnalysisDetail.model_validate(analysis, from_attributes=True)
+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})
+3
View File
@@ -5,6 +5,7 @@
import hashlib
import hmac
import re
import time
from typing import Any
from urllib.parse import urlencode
@@ -14,6 +15,8 @@ import httpx
BASE_URL = "https://demo-fapi.binance.com"
RECV_WINDOW_MS = 5000
TIMEOUT_SECONDS = 10
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
OWN_CLIENT_ID = re.compile(r"^ap\d+$")
class ExchangeError(Exception):
+46 -127
View File
@@ -1,156 +1,75 @@
"""模拟盘交易进程:定时把账户、持仓、挂单快照写入 Redis,供页面读取。
"""模拟盘交易进程:同步账户快照、调度分析。
用法:python -m alphapulse.trading.engine
"""
import asyncio
import json
import logging
import re
import time
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy import select, update
from alphapulse.core.crypto import decrypt
from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import TradingAccount, TradingSymbol
from alphapulse.trading.client import DemoClient, ExchangeError
from alphapulse.models import TradingAnalysis, TradingSymbol
from alphapulse.trading.analyst import run_analysis
from alphapulse.trading.snapshot import snapshot_loop
logger = logging.getLogger("alphapulse.trading.engine")
# 进程挂掉后快照自动过期,页面据此显示「引擎离线」
STATUS_KEY = f"{KEY_PREFIX}trader:status"
STATUS_TTL_SECONDS = 15
SNAPSHOT_SECONDS = 3
# 杠杆和保证金模式很少变,按币缓存
SYMBOL_CONFIG_SECONDS = 60
# 所有币合计每天(北京时间)最多自动分析的次数
DAILY_ANALYSIS_LIMIT = 60
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
OWN_CLIENT_ID = re.compile(r"^ap\d+$")
ANALYSIS_POLL_SECONDS = 5
# 模型调用耗时长,并发过多会撞上游限流
MAX_CONCURRENT_ANALYSES = 2
class Snapshot:
def __init__(self) -> None:
self.client: DemoClient | None = None
self.key_ciphertext: bytes | None = None
# 最近一次成功同步的数据;同步失败时保留并标注错误,避免页面闪空
self.data: dict | None = None
self.synced_at: datetime | None = None
self.symbol_config: dict[str, tuple[float, dict]] = {}
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
ciphertext = account.api_key_encrypted if account else None
if ciphertext != self.key_ciphertext:
# key 被修改或删除:旧连接作废,旧数据属于另一个账户,一并清掉
if self.client is not None:
await self.client.http.aclose()
self.client = None
self.data = self.synced_at = None
self.symbol_config.clear()
self.key_ciphertext = ciphertext
if account is not None and ciphertext is not None:
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
return self.client
async def leverage_and_margin(self, client: DemoClient, symbol: str) -> dict:
cached = self.symbol_config.get(symbol)
if cached is None or time.monotonic() - cached[0] > SYMBOL_CONFIG_SECONDS:
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
cached = (time.monotonic(), {"leverage": config["leverage"], "margin_type": config["marginType"]})
self.symbol_config[symbol] = cached
return cached[1]
async def fetch(self, client: DemoClient, symbols: list[str]) -> dict:
account = await client.request("GET", "/fapi/v3/account")
positions = [p for p in await client.request("GET", "/fapi/v3/positionRisk") if float(p["positionAmt"]) != 0]
# 挂单只查交易列表里的币和有持仓的币:不带 symbol 查全部的权重是 40,3 秒一次太贵
watched = sorted(set(symbols) | {p["symbol"] for p in positions})
orders, algo_orders = [], []
for symbol in watched:
orders += await client.request("GET", "/fapi/v1/openOrders", {"symbol": symbol})
algo_orders += await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol})
return {
"account": {
"wallet_balance": account["totalWalletBalance"],
"margin_balance": account["totalMarginBalance"],
"available_balance": account["availableBalance"],
"unrealized_pnl": account["totalUnrealizedProfit"],
},
"positions": [
{
"symbol": p["symbol"],
"side": "LONG" if float(p["positionAmt"]) > 0 else "SHORT",
"amount": p["positionAmt"].lstrip("-"),
"entry_price": p["entryPrice"],
"break_even_price": p["breakEvenPrice"],
"mark_price": p["markPrice"],
"unrealized_pnl": p["unRealizedProfit"],
"liquidation_price": p["liquidationPrice"],
"notional": p["notional"].lstrip("-"),
"margin": p["isolatedMargin"] if float(p["isolatedMargin"]) else p["initialMargin"],
**await self.leverage_and_margin(client, p["symbol"]),
}
for p in positions
],
"orders": [
{
"symbol": o["symbol"], "client_id": o["clientOrderId"], "side": o["side"], "type": o["type"],
"price": o["price"], "quantity": o["origQty"], "filled": o["executedQty"], "reduce_only": o["reduceOnly"],
"time": o["time"],
}
for o in orders
],
"algo_orders": [
{
"symbol": o["symbol"], "client_id": o["clientAlgoId"], "side": o["side"], "type": o["orderType"],
"trigger_price": o["triggerPrice"], "quantity": o["quantity"], "close_position": o["closePosition"],
"reduce_only": o["reduceOnly"], "time": o["createTime"],
}
for o in algo_orders
],
}
async def publish(self) -> None:
async def fail_interrupted() -> None:
"""上次进程退出时还在进行的分析不会再有结果"""
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
error = None
client = await self.ensure_client(account)
if client is None:
error = "还没有填写模拟盘 key"
else:
try:
self.data = await self.fetch(client, symbols)
self.synced_at = datetime.now(UTC)
except ExchangeError as e:
logger.warning("同步模拟盘失败:%s", e)
error = str(e)
status = {
"updated_at": datetime.now(UTC).isoformat(),
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
"error": error,
**(self.data or {"account": None, "positions": [], "orders": [], "algo_orders": []}),
}
await redis.set(STATUS_KEY, json.dumps(status), ex=STATUS_TTL_SECONDS)
await session.execute(
update(TradingAnalysis).where(TradingAnalysis.status == "running")
.values(status="failed", error="引擎重启,分析中断", finished_at=datetime.now(UTC))
)
await session.commit()
async def snapshot_loop() -> None:
snapshot = Snapshot()
async def claim_requests(limit: int, busy: set[str]) -> list[tuple[str, list[str]]]:
"""取出到期的分析请求并清空,同一请求里的多个触发原因合并为一次分析"""
async with SessionLocal() as session:
symbols = (await session.scalars(
select(TradingSymbol)
.where(TradingSymbol.analysis_requested_at <= datetime.now(UTC), TradingSymbol.symbol.not_in(busy))
.order_by(TradingSymbol.analysis_requested_at).limit(limit)
)).all()
claimed = [(s.symbol, list(s.analysis_reasons)) for s in symbols]
for s in symbols:
s.analysis_requested_at = None
s.analysis_reasons = []
await session.commit()
return claimed
async def analysis_loop() -> None:
await fail_interrupted()
tasks: dict[str, asyncio.Task] = {}
while True:
try:
await snapshot.publish()
for symbol, task in list(tasks.items()):
if task.done():
del tasks[symbol]
if not task.cancelled() and task.exception():
logger.error("%s 分析任务异常退出", symbol, exc_info=task.exception())
free = MAX_CONCURRENT_ANALYSES - len(tasks)
if free > 0:
for symbol, reasons in await claim_requests(free, set(tasks)):
tasks[symbol] = asyncio.create_task(run_analysis(symbol, reasons))
except Exception:
logger.exception("发布快照失败,稍后重试")
await asyncio.sleep(SNAPSHOT_SECONDS)
logger.exception("调度分析出错,稍后重试")
await asyncio.sleep(ANALYSIS_POLL_SECONDS)
async def main() -> None:
# 分析器和执行器在后续里程碑加入同一个 TaskGroup
async with asyncio.TaskGroup() as tg:
tg.create_task(snapshot_loop())
tg.create_task(analysis_loop())
if __name__ == "__main__":
+285
View File
@@ -0,0 +1,285 @@
"""交易计划的纯计算:模型提交的计划参数、校验、价格取整、仓位计算、止盈拆分。
下方常量与 main/README 第 1、2 节的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
"""
from dataclasses import dataclass
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, Field
Angle = Literal["short", "long"]
Direction = Literal["long", "short"]
# 所有币合计每天(北京时间)最多自动分析的次数
DAILY_ANALYSIS_LIMIT = 60
RISK_PCT = {"short": Decimal("0.01"), "long": Decimal("0.02")}
LOW_CONFIDENCE_FACTOR = Decimal("0.5")
MAX_LEVERAGE = 3
MIN_REWARD_RISK = Decimal("1.5")
ENTRY_VALID_HOURS = {"short": 4, "long": 24}
MAX_HOLD_HOURS = {"short": 24, "long": 48}
# 入场档数(最少,最多)
ENTRY_COUNT = {"short": (1, 1), "long": (1, 3)}
# 止盈档数由角度决定:短线止盈 1 平一半、止盈 2 平剩余;长线 50/30/20
TAKE_PROFIT_RATIOS = {"short": (Decimal("0.5"), Decimal("0.5")), "long": (Decimal("0.5"), Decimal("0.3"), Decimal("0.2"))}
# 短线失效立即市价平仓,要用短周期;长线等 4h 收盘后复核
INVALIDATION_TIMEFRAMES = {"short": ("1m", "15m"), "long": ("4h",)}
MAX_REEVALUATE = 4
# 开仓、止损都按吃单手续费估算(C1 实测 taker 0.04%)
TAKER_FEE_RATE = Decimal("0.0004")
# 模拟盘 PERCENT_PRICE:买单价不高于标记价 × 1.05,卖单价不低于标记价 × 0.95
PERCENT_PRICE_LIMIT = Decimal("0.05")
RATIO_TOLERANCE = Decimal("0.001")
Price = Annotated[Decimal, Field(gt=0)]
class Condition(BaseModel):
direction: Annotated[Literal["above", "below"], Field(description="above:价格高于 price 时满足;below:低于时满足")]
price: Price
timeframe: Annotated[
Literal["mark", "1m", "15m", "1h", "4h"],
Field(description="mark:标记价触及即满足;其他:该周期 K 线收盘价满足才算"),
]
basis: Annotated[str, Field(description="依据,如对应的支撑区、压力区")]
class Entry(BaseModel):
price: Price
ratio: Annotated[Decimal, Field(gt=0, le=1, description="占总仓位的比例,所有档位之和为 1")]
basis: str
class TakeProfit(BaseModel):
price: Price
basis: str
class TradePlan(BaseModel):
"""submit_trade_plan 的参数"""
action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=(
"plan:给出新计划(替换当前计划);keep:当前计划不变;"
"exit:结束当前计划,撤销未成交的入场单,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)"
))]
summary: Annotated[str, Field(description="一句话结论,含方向和置信度")]
reasons: Annotated[str, Field(description="依据要点:新闻、结构、衍生品,引用新闻写 [新闻 id]")]
risks: Annotated[str, Field(description="持仓时限内的重大事件及时间、与结论相反的信号")]
reevaluate: Annotated[list[Condition], Field(max_length=MAX_REEVALUATE, description=(
"满足任一条件时程序重新请你分析:用来表达另一侧方向、突破回踩等条件计划、需要复核的位置。提交时不能已经满足"
))] = []
direction: Direction | None = None
confidence: Literal["high", "medium", "low"] | None = None
entries: Annotated[list[Entry] | None, Field(description="入场限价单,每档一张")] = None
stop: Annotated[Price | None, Field(description="统一止损价")] = None
stop_basis: str | None = None
take_profits: Annotated[list[TakeProfit] | None, Field(description="按价格由近到远排列")] = None
entry_valid_hours: Annotated[Decimal | None, Field(gt=0, description="入场单有效小时数,到期未成交的档位撤销")] = None
max_hold_hours: Annotated[Decimal | None, Field(gt=0, description="首档成交起最长持仓小时数,到时市价平仓")] = None
invalidation: Annotated[Condition | None, Field(description="失效条件:做多为 below,做空为 above")] = None
@dataclass
class SymbolRules:
tick_size: Decimal
step_size: Decimal
min_notional: Decimal
@dataclass
class PlanContext:
angle: Angle
mark_price: Decimal
# min(设定的交易资金, 账户权益)
capital: Decimal
# 交易列表中其他币的持仓名义价值合计
other_notional: Decimal
rules: SymbolRules
has_plan: bool
position_side: Direction | None
class PlanError(Exception):
"""计划不通过校验;errors 逐条返回给模型修正"""
def __init__(self, errors: list[str]) -> None:
super().__init__(";".join(errors))
self.errors = errors
def round_to_step(value: Decimal, step: Decimal, rounding: str) -> Decimal:
# quantize 统一小数位,否则可能得到 8.05E+4 这类指数形式,转成字符串下单会被拒
return ((value / step).to_integral_value(rounding) * step).quantize(step)
def split_take_profits(quantity: Decimal, ratios: tuple[Decimal, ...], step: Decimal) -> list[Decimal]:
"""按比例拆分止盈数量:前几档向下取整,最后一档取余数,保证合计等于 quantity。取整后为 0 的档位由调用方跳过"""
parts = [round_to_step(quantity * r, step, ROUND_FLOOR) for r in ratios[:-1]]
return [*parts, quantity - sum(parts, Decimal(0))]
def reward_risk(entry: Decimal, stop: Decimal, targets: list[Decimal], ratios: tuple[Decimal, ...]) -> Decimal:
"""综合盈亏比 = Σ(止盈比例 × |止盈价 − 入场价|) ÷ |入场价 − 止损价|,与 README 第 4 节一致(不含手续费)"""
reward = sum((r * abs(t - entry) for t, r in zip(targets, ratios, strict=True)), Decimal(0))
return reward / abs(entry - stop)
def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
errors = []
if plan.action == "keep" and not ctx.has_plan:
errors.append("当前没有计划,不能用 keep")
if plan.action == "exit" and not ctx.has_plan and ctx.position_side is None:
errors.append("当前没有计划也没有持仓,不能用 exit,观望请用 wait")
if plan.action == "wait" and (ctx.has_plan or ctx.position_side is not None):
errors.append("有当前计划或持仓时不能用 wait:保留用 keep,结束用 exit")
for i, c in enumerate(plan.reevaluate, 1):
satisfied = ctx.mark_price > c.price if c.direction == "above" else ctx.mark_price < c.price
if satisfied:
errors.append(f"reevaluate 第 {i} 个条件({c.direction} {c.price})按现价 {ctx.mark_price} 已经满足,提交后会立即触发")
return errors
def check_plan_fields(plan: TradePlan, ctx: PlanContext) -> list[str]:
"""action=plan 时的字段完整性、档数、比例、有效期"""
required = ("direction", "confidence", "entries", "stop", "take_profits", "entry_valid_hours", "max_hold_hours", "invalidation")
missing = [name for name in required if getattr(plan, name) is None]
if missing:
return [f"action=plan 时缺少:{', '.join(missing)}"]
errors = []
angle = ctx.angle
label = "短线" if angle == "short" else "长线"
low, high = ENTRY_COUNT[angle]
if not low <= len(plan.entries) <= high:
errors.append(f"{label}入场档数须为 {low}–{high},现为 {len(plan.entries)}" if low != high else f"{label}只能 {low} 档入场,现为 {len(plan.entries)}")
if abs(sum((e.ratio for e in plan.entries), Decimal(0)) - 1) > RATIO_TOLERANCE:
errors.append("入场各档比例之和须为 1")
if len(plan.take_profits) != len(TAKE_PROFIT_RATIOS[angle]):
errors.append(f"{label}须给 {len(TAKE_PROFIT_RATIOS[angle])} 个止盈,现为 {len(plan.take_profits)}")
if plan.entry_valid_hours > ENTRY_VALID_HOURS[angle]:
errors.append(f"{label} entry_valid_hours 不能超过 {ENTRY_VALID_HOURS[angle]}")
if plan.max_hold_hours > MAX_HOLD_HOURS[angle]:
errors.append(f"{label} max_hold_hours 不能超过 {MAX_HOLD_HOURS[angle]}")
if plan.invalidation.timeframe not in INVALIDATION_TIMEFRAMES[angle]:
errors.append(f"{label}失效条件的周期只能是 {'/'.join(INVALIDATION_TIMEFRAMES[angle])}")
if ctx.position_side is not None and plan.direction != ctx.position_side:
errors.append(f"已有{'多' if ctx.position_side == 'long' else '空'}头持仓,新计划方向必须一致;要反手请先用 exit")
return errors
def check_prices(direction: Direction, entries: list[Decimal], stop: Decimal, targets: list[Decimal], plan: TradePlan, ctx: PlanContext) -> list[str]:
"""价格顺序:做多 止损 < 失效价 < 入场 < 止盈 1 < 止盈 2 < …,止损低于现价、止盈 1 高于现价;做空镜像"""
errors = []
sign = 1 if direction == "long" else -1
mark = ctx.mark_price
invalidation = plan.invalidation.price
def below(a: Decimal, b: Decimal) -> bool:
# 做多时 a < b;做空时 a > b
return sign * (b - a) > 0
side = "做多" if direction == "long" else "做空"
if plan.invalidation.direction != ("below" if direction == "long" else "above"):
errors.append(f"{side}的失效条件方向应为 {'below' if direction == 'long' else 'above'}")
if not below(stop, invalidation):
errors.append(f"{side}时止损 {stop} 应在失效价 {invalidation} 之外")
nearest_entry = max(entries) if direction == "long" else min(entries)
farthest_entry = min(entries) if direction == "long" else max(entries)
if not below(invalidation, farthest_entry):
errors.append(f"{side}时失效价 {invalidation} 应在所有入场价之外")
if not below(stop, mark):
errors.append(f"{side}时止损 {stop} 应在现价 {mark} 之外,否则挂单会立即触发")
if len(set(entries)) != len(entries):
errors.append("入场档位价格重复")
if targets and not below(nearest_entry, targets[0]):
errors.append(f"{side}时止盈 1 {targets[0]} 应在所有入场价之外的盈利方向")
if targets and not below(mark, targets[0]):
errors.append(f"止盈 1 {targets[0]} 已越过现价 {mark},挂单会立即成交")
if any(not below(a, b) for a, b in zip(targets, targets[1:], strict=False)):
errors.append("止盈价须按由近到远排列且不重复")
limit = mark * (1 + PERCENT_PRICE_LIMIT) if direction == "long" else mark * (1 - PERCENT_PRICE_LIMIT)
if any(below(limit, e) for e in entries):
errors.append(f"入场价超出交易所限价范围({'不高于' if direction == 'long' else '不低于'} {limit:.2f})")
return errors
def size_plan(plan: TradePlan, ctx: PlanContext) -> dict:
"""校验计划并计算仓位;不通过时抛 PlanError。返回值可直接存 JSONB(数值为字符串)"""
errors = check_action(plan, ctx)
if plan.action != "plan":
if errors:
raise PlanError(errors)
return {}
errors += check_plan_fields(plan, ctx)
if errors:
raise PlanError(errors)
angle, direction, rules = ctx.angle, plan.direction, ctx.rules
# 向保守方向取整:做多的入场、止损、止盈都向下(入场更低、止损更远、止盈更近),做空都向上
rounding = ROUND_FLOOR if direction == "long" else ROUND_CEILING
entry_prices = [round_to_step(e.price, rules.tick_size, rounding) for e in plan.entries]
stop = round_to_step(plan.stop, rules.tick_size, rounding)
targets = [round_to_step(t.price, rules.tick_size, rounding) for t in plan.take_profits]
ratios = [e.ratio / sum((e.ratio for e in plan.entries), Decimal(0)) for e in plan.entries]
tp_ratios = TAKE_PROFIT_RATIOS[angle]
errors = check_prices(direction, entry_prices, stop, targets, plan, ctx)
if errors:
raise PlanError(errors)
average = sum((r * p for r, p in zip(ratios, entry_prices, strict=True)), Decimal(0))
rr = reward_risk(average, stop, targets, tp_ratios) if angle == "long" else reward_risk(average, stop, targets[:1], (Decimal(1),))
if rr < MIN_REWARD_RISK:
basis = "50/30/20 加权" if angle == "long" else "止盈 1"
raise PlanError([f"盈亏比 {rr:.2f}(按{basis})低于 {MIN_REWARD_RISK}。止盈必须放在有依据的区,不得为凑盈亏比倒推;凑不够就不给这个方向"])
nearest_entry = max(entry_prices) if direction == "long" else min(entry_prices)
rr_first = reward_risk(nearest_entry, stop, targets, tp_ratios) if angle == "long" else rr
def unit_loss(price: Decimal) -> Decimal:
return abs(price - stop) + (price + stop) * TAKER_FEE_RATE
risk_amount = ctx.capital * RISK_PCT[angle] * (LOW_CONFIDENCE_FACTOR if plan.confidence == "low" else 1)
max_notional = ctx.capital * MAX_LEVERAGE - ctx.other_notional
if max_notional <= 0:
raise PlanError([f"交易列表其他币的名义仓位 {ctx.other_notional:.2f} 已达到 {MAX_LEVERAGE} 倍交易资金,不能开新仓"])
by_risk = risk_amount / unit_loss(average)
by_leverage = max_notional / average
total = min(by_risk, by_leverage)
quantities = [round_to_step(total * r, rules.step_size, ROUND_FLOOR) for r in ratios]
small = [
f"第 {i} 档 {p} × {q} = {p * q:.2f} USDT"
for i, (p, q) in enumerate(zip(entry_prices, quantities, strict=True), 1) if p * q < rules.min_notional
]
if small:
raise PlanError([
f"入场档位名义价值低于最小下单金额 {rules.min_notional} USDT:{'、'.join(small)}。"
f"本次可用风险金额 {risk_amount:.2f} USDT、总数量约 {total:.6f},请合并或删档"
])
quantity = sum(quantities, Decimal(0))
notional = sum((p * q for p, q in zip(entry_prices, quantities, strict=True)), Decimal(0))
risk_at_stop = sum((q * unit_loss(p) for p, q in zip(entry_prices, quantities, strict=True)), Decimal(0))
tp_quantities = split_take_profits(quantity, tp_ratios, rules.step_size)
return {
"direction": direction,
"entries": [
{"price": str(p), "ratio": str(r.quantize(Decimal("0.0001"))), "quantity": str(q), "notional": str((p * q).quantize(Decimal("0.01")))}
for p, r, q in zip(entry_prices, ratios, quantities, strict=True)
],
"stop": str(stop),
"take_profits": [
{"price": str(t), "ratio": str(r), "quantity": str(q)} for t, r, q in zip(targets, tp_ratios, tp_quantities, strict=True)
],
"average_price": str(average.quantize(rules.tick_size)),
"quantity": str(quantity),
"notional": str(notional.quantize(Decimal("0.01"))),
"capital": str(ctx.capital),
"leverage": str((notional / ctx.capital).quantize(Decimal("0.01"))),
"risk_amount": str(risk_amount.quantize(Decimal("0.01"))),
"risk_at_stop": str(risk_at_stop.quantize(Decimal("0.01"))),
"risk_pct": str((risk_at_stop / ctx.capital * 100).quantize(Decimal("0.01"))),
"capped_by_leverage": by_leverage < by_risk,
"reward_risk": str(rr.quantize(Decimal("0.01"))),
"reward_risk_first_only": str(rr_first.quantize(Decimal("0.01"))),
}
+146
View File
@@ -0,0 +1,146 @@
"""账户、持仓、挂单快照:定时从模拟盘同步并写入 Redis,供页面和分析器读取"""
import asyncio
import json
import logging
import time
from datetime import UTC, datetime
from sqlalchemy import select
from alphapulse.core.crypto import decrypt
from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import TradingAccount, TradingSymbol
from alphapulse.trading.client import DemoClient, ExchangeError
logger = logging.getLogger("alphapulse.trading.snapshot")
# 进程挂掉后快照自动过期,页面据此显示「引擎离线」
STATUS_KEY = f"{KEY_PREFIX}trader:status"
STATUS_TTL_SECONDS = 15
SNAPSHOT_SECONDS = 3
# 杠杆和保证金模式很少变,按币缓存
SYMBOL_CONFIG_SECONDS = 60
class Snapshot:
def __init__(self) -> None:
self.client: DemoClient | None = None
self.key_ciphertext: bytes | None = None
# 最近一次成功同步的数据;同步失败时保留并标注错误,避免页面闪空
self.data: dict | None = None
self.synced_at: datetime | None = None
self.symbol_config: dict[str, tuple[float, dict]] = {}
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
ciphertext = account.api_key_encrypted if account else None
if ciphertext != self.key_ciphertext:
# key 被修改或删除:旧连接作废,旧数据属于另一个账户,一并清掉
if self.client is not None:
await self.client.http.aclose()
self.client = None
self.data = self.synced_at = None
self.symbol_config.clear()
self.key_ciphertext = ciphertext
if account is not None and ciphertext is not None:
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
return self.client
async def leverage_and_margin(self, client: DemoClient, symbol: str) -> dict:
cached = self.symbol_config.get(symbol)
if cached is None or time.monotonic() - cached[0] > SYMBOL_CONFIG_SECONDS:
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
cached = (time.monotonic(), {"leverage": config["leverage"], "margin_type": config["marginType"]})
self.symbol_config[symbol] = cached
return cached[1]
async def fetch(self, client: DemoClient, symbols: list[str]) -> dict:
account = await client.request("GET", "/fapi/v3/account")
positions = [p for p in await client.request("GET", "/fapi/v3/positionRisk") if float(p["positionAmt"]) != 0]
# 挂单只查交易列表里的币和有持仓的币:不带 symbol 查全部的权重是 40,3 秒一次太贵
watched = sorted(set(symbols) | {p["symbol"] for p in positions})
orders, algo_orders = [], []
for symbol in watched:
orders += await client.request("GET", "/fapi/v1/openOrders", {"symbol": symbol})
algo_orders += await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol})
return {
"account": {
"wallet_balance": account["totalWalletBalance"],
"margin_balance": account["totalMarginBalance"],
"available_balance": account["availableBalance"],
"unrealized_pnl": account["totalUnrealizedProfit"],
},
"positions": [
{
"symbol": p["symbol"],
"side": "LONG" if float(p["positionAmt"]) > 0 else "SHORT",
"amount": p["positionAmt"].lstrip("-"),
"entry_price": p["entryPrice"],
"break_even_price": p["breakEvenPrice"],
"mark_price": p["markPrice"],
"unrealized_pnl": p["unRealizedProfit"],
"liquidation_price": p["liquidationPrice"],
"notional": p["notional"].lstrip("-"),
"margin": p["isolatedMargin"] if float(p["isolatedMargin"]) else p["initialMargin"],
**await self.leverage_and_margin(client, p["symbol"]),
}
for p in positions
],
"orders": [
{
"symbol": o["symbol"], "client_id": o["clientOrderId"], "side": o["side"], "type": o["type"],
"price": o["price"], "quantity": o["origQty"], "filled": o["executedQty"], "reduce_only": o["reduceOnly"],
"time": o["time"],
}
for o in orders
],
"algo_orders": [
{
"symbol": o["symbol"], "client_id": o["clientAlgoId"], "side": o["side"], "type": o["orderType"],
"trigger_price": o["triggerPrice"], "quantity": o["quantity"], "close_position": o["closePosition"],
"reduce_only": o["reduceOnly"], "time": o["createTime"],
}
for o in algo_orders
],
}
async def publish(self) -> None:
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
error = None
client = await self.ensure_client(account)
if client is None:
error = "还没有填写模拟盘 key"
else:
try:
self.data = await self.fetch(client, symbols)
self.synced_at = datetime.now(UTC)
except ExchangeError as e:
logger.warning("同步模拟盘失败:%s", e)
error = str(e)
status = {
"updated_at": datetime.now(UTC).isoformat(),
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
"error": error,
**(self.data or {"account": None, "positions": [], "orders": [], "algo_orders": []}),
}
await redis.set(STATUS_KEY, json.dumps(status), ex=STATUS_TTL_SECONDS)
async def snapshot_loop() -> None:
snapshot = Snapshot()
while True:
try:
await snapshot.publish()
except Exception:
logger.exception("发布快照失败,稍后重试")
await asyncio.sleep(SNAPSHOT_SECONDS)
async def read_snapshot() -> dict | None:
raw = await redis.get(STATUS_KEY)
return json.loads(raw) if raw else None
+139
View File
@@ -0,0 +1,139 @@
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
import pytest
from alphapulse.trading.plan import PlanContext, PlanError, SymbolRules, TradePlan, round_to_step, size_plan, split_take_profits
BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50"))
def context(**overrides) -> PlanContext:
values = {
"angle": "long", "mark_price": Decimal("82000"), "capital": Decimal("1000"), "other_notional": Decimal("0"),
"rules": BTC, "has_plan": False, "position_side": None,
}
return PlanContext(**{**values, **overrides})
def long_plan(**overrides) -> TradePlan:
values = {
"action": "plan", "summary": "长线做多", "reasons": "支撑区", "risks": "无",
"direction": "long", "confidence": "medium",
"entries": [{"price": "81000", "ratio": "0.4", "basis": "区1"}, {"price": "80000", "ratio": "0.6", "basis": "区2"}],
"stop": "79500", "stop_basis": "区下沿外",
"take_profits": [{"price": "83000", "basis": "压1"}, {"price": "84000", "basis": "压2"}, {"price": "85000", "basis": "压3"}],
"entry_valid_hours": "24", "max_hold_hours": "48",
"invalidation": {"direction": "below", "price": "79800", "timeframe": "4h", "basis": "跌破区"},
}
return TradePlan.model_validate({**values, **overrides})
def short_term_plan(**overrides) -> TradePlan:
values = {
"action": "plan", "summary": "短线做空", "reasons": "压力区", "risks": "无",
"direction": "short", "confidence": "medium",
"entries": [{"price": "82500", "ratio": "1", "basis": "压力区"}],
"stop": "82900", "take_profits": [{"price": "81800", "basis": "支1"}, {"price": "81300", "basis": "支2"}],
"entry_valid_hours": "4", "max_hold_hours": "24",
"invalidation": {"direction": "above", "price": "82800", "timeframe": "15m", "basis": "站上区"},
}
return TradePlan.model_validate({**values, **overrides})
def errors_of(plan: TradePlan, ctx: PlanContext) -> str:
with pytest.raises(PlanError) as e:
size_plan(plan, ctx)
return ";".join(e.value.errors)
def test_round_to_step():
assert round_to_step(Decimal("81000.05"), Decimal("0.1"), ROUND_FLOOR) == Decimal("81000.0")
# 整数输入不能变成指数形式
assert str(round_to_step(Decimal("80500"), Decimal("0.1"), ROUND_FLOOR)) == "80500.0"
assert round_to_step(Decimal("82500.01"), Decimal("0.1"), ROUND_CEILING) == Decimal("82500.1")
assert round_to_step(Decimal("0.00829908"), Decimal("0.0001"), ROUND_FLOOR) == Decimal("0.0082")
def test_split_take_profits_keeps_total():
parts = split_take_profits(Decimal("0.0206"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.0001"))
assert parts == [Decimal("0.0103"), Decimal("0.0061"), Decimal("0.0042")]
assert sum(parts) == Decimal("0.0206")
assert split_take_profits(Decimal("0.001"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.001")) == [0, 0, Decimal("0.001")]
def test_long_plan_sizing_matches_hand_calculation():
sizing = size_plan(long_plan(), context())
# 均价 0.4×81000 + 0.6×80000 = 80400;单位亏损 900 + (80400+79500)×0.0004 = 963.96;20 ÷ 963.96 = 0.020747
assert sizing["average_price"] == "80400.0"
assert [e["quantity"] for e in sizing["entries"]] == ["0.0082", "0.0124"]
assert sizing["quantity"] == "0.0206"
assert sizing["notional"] == "1656.20"
assert sizing["risk_amount"] == "20.00"
assert sizing["risk_at_stop"] == "19.82"
# (0.5×2600 + 0.3×3600 + 0.2×4600) ÷ 900;只成交首档 (0.5×2000 + 0.3×3000 + 0.2×4000) ÷ 1500
assert sizing["reward_risk"] == "3.67"
assert sizing["reward_risk_first_only"] == "1.80"
assert [t["quantity"] for t in sizing["take_profits"]] == ["0.0103", "0.0061", "0.0042"]
assert sizing["capped_by_leverage"] is False
def test_low_confidence_halves_risk():
sizing = size_plan(long_plan(confidence="low"), context())
assert sizing["risk_amount"] == "10.00"
# 10 ÷ 963.96 = 0.010374:0.4 档 0.0041,0.6 档 0.0062
assert sizing["quantity"] == "0.0103"
def test_leverage_cap_counts_other_positions():
sizing = size_plan(long_plan(), context(other_notional=Decimal("2000")))
assert sizing["capped_by_leverage"] is True
assert Decimal(sizing["notional"]) <= Decimal("1000")
def test_short_term_short_plan():
sizing = size_plan(short_term_plan(), context(angle="short"))
# 止盈 1 盈亏比 700 ÷ 400
assert sizing["reward_risk"] == "1.75"
assert sizing["risk_amount"] == "10.00"
assert len(sizing["take_profits"]) == 2
def test_short_prices_round_up():
sizing = size_plan(short_term_plan(entries=[{"price": "82500.01", "ratio": "1", "basis": "x"}]), context(angle="short"))
assert sizing["entries"][0]["price"] == "82500.1"
def test_rejects_wrong_counts_and_timeframe():
message = errors_of(short_term_plan(entries=[{"price": "82500", "ratio": "0.5", "basis": "a"}, {"price": "82600", "ratio": "0.5", "basis": "b"}]), context(angle="short"))
assert "短线只能 1 档入场" in message
assert "长线失效条件的周期只能是 4h" in errors_of(long_plan(invalidation={"direction": "below", "price": "79800", "timeframe": "1h", "basis": "x"}), context())
assert "长线须给 3 个止盈" in errors_of(long_plan(take_profits=[{"price": "83000", "basis": "a"}]), context())
def test_rejects_bad_price_order():
message = errors_of(long_plan(stop="80500"), context())
assert "做多时止损 80500.0 应在失效价 79800 之外" in message
assert "止盈 1 81000.0 已越过现价" in errors_of(long_plan(take_profits=[{"price": "81000", "basis": "a"}, {"price": "84000", "basis": "b"}, {"price": "85000", "basis": "c"}], entries=[{"price": "80500", "ratio": "1", "basis": "a"}]), context())
def test_rejects_low_reward_risk():
# (0.5×400 + 0.3×600 + 0.2×800) ÷ 500 = 1.08
plan = long_plan(take_profits=[{"price": "80400", "basis": "a"}, {"price": "80600", "basis": "b"}, {"price": "80800", "basis": "c"}],
entries=[{"price": "80000", "ratio": "1", "basis": "a"}])
assert "盈亏比 1.08" in errors_of(plan, context(mark_price=Decimal("80300")))
def test_rejects_entries_below_min_notional():
message = errors_of(long_plan(entries=[{"price": "81000", "ratio": "0.02", "basis": "a"}, {"price": "80000", "ratio": "0.98", "basis": "b"}]), context())
assert "最小下单金额 50" in message
assert "第 1 档" in message
def test_action_rules():
assert "不能用 keep" in errors_of(TradePlan(action="keep", summary="s", reasons="r", risks="x"), context())
assert "不能用 wait" in errors_of(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context(position_side="long"))
assert size_plan(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {}
satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x",
reevaluate=[{"direction": "above", "price": "81000", "timeframe": "1h", "basis": "x"}])
assert "已经满足" in errors_of(satisfied, context())
assert "方向必须一致" in errors_of(long_plan(), context(position_side="short"))
+60
View File
@@ -186,6 +186,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.20.0" },
@@ -205,6 +210,9 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
[[package]]
name = "annotated-doc"
version = "0.0.5"
@@ -665,6 +673,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "constantly"
version = "23.10.4"
@@ -1101,6 +1118,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "itemadapter"
version = "0.13.1"
@@ -1592,6 +1618,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724, upload-time = "2026-10-11T02:05:22.776Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.5.4"
@@ -1829,6 +1864,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0e/9ee7bc0b48ec45d93b302fa2d787830dca4dc454d31a237faa5815995988/PyDispatcher-2.0.7-py3-none-any.whl", hash = "sha256:96543bea04115ffde08f851e1d45cacbfd1ee866ac42127d9b476dc5aefa7de0", size = 12040, upload-time = "2023-02-17T20:11:11.991Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyjwt"
version = "2.15.1"
@@ -1856,6 +1900,22 @@ version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/7b/65f55513d3c769fd677f90032d8d8703e3dc17e88a41b6074d2177548bca/PyPyDispatcher-2.1.2.tar.gz", hash = "sha256:b6bec5dfcff9d2535bca2b23c80eae367b1ac250a645106948d315fcfa9130f2", size = 23224, upload-time = "2017-07-03T14:20:51.806Z" }
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
+92
View File
@@ -140,3 +140,95 @@ export function deleteSymbol(symbol: string): Promise<void> {
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
return http.get("/trading/events", { params })
}
export interface PlanCondition {
direction: "above" | "below"
price: string
timeframe: "mark" | "1m" | "15m" | "1h" | "4h"
basis: string
}
/** submit_trade_plan 的参数原样保存;数值为字符串 */
export interface TradeDecision {
action: "plan" | "keep" | "exit" | "wait"
summary: string
reasons: string
risks: string
reevaluate: PlanCondition[]
direction: "long" | "short" | null
confidence: "high" | "medium" | "low" | null
entries: { price: string, ratio: string, basis: string }[] | null
stop: string | null
stop_basis: string | null
take_profits: { price: string, basis: string }[] | null
entry_valid_hours: string | null
max_hold_hours: string | null
invalidation: PlanCondition | null
}
/** 程序按交易资金算出的数量和风险 */
export interface Sizing {
direction: "long" | "short"
entries: { price: string, ratio: string, quantity: string, notional: string }[]
stop: string
take_profits: { price: string, ratio: string, quantity: string }[]
average_price: string
quantity: string
notional: string
capital: string
leverage: string
risk_amount: string
risk_at_stop: string
risk_pct: string
capped_by_leverage: boolean
reward_risk: string
reward_risk_first_only: string
}
export interface Analysis {
id: number
symbol: string
angle: Angle
reasons: string[]
dry_run: boolean
status: "running" | "done" | "failed"
action: TradeDecision["action"] | null
decision: TradeDecision | null
sizing: Sizing | null
error: string | null
model: string | null
reasoning_effort: string | null
usage: { input_tokens: number, output_tokens: number } | null
started_at: string
finished_at: string | null
plan_status: "pending" | "open" | "closed" | "canceled" | "replaced" | null
valid_until: string | null
first_fill_at: string | null
close_deadline: string | null
closed_at: string | null
close_reason: string | null
result: Record<string, unknown> | null
}
/** 与协议无关的消息格式,同 Chat */
export type TranscriptMessage
= | { role: "user", text: string }
| { role: "assistant", text: string, tool_calls: { id: string, name: string, arguments: Record<string, unknown> }[] }
| { role: "tool", tool_call_id: string, name: string, result: string }
export interface AnalysisDetail extends Analysis {
text: string | null
transcript: TranscriptMessage[] | null
}
export function requestAnalysis(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/analyze`)
}
export function listAnalyses(params: { symbol?: string, limit?: number }): Promise<Analysis[]> {
return http.get("/trading/analyses", { params })
}
export function getAnalysis(id: number): Promise<AnalysisDetail> {
return http.get(`/trading/analyses/${id}`)
}
@@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { AnalysisDetail } from "@/apis/trading"
import { getAnalysis } from "@/apis/trading"
import { renderMarkdown } from "@/utils/markdown"
import { formatTime } from "@/utils/text"
const props = defineProps<{ analysisId: number | null }>()
const visible = defineModel<boolean>({ required: true })
const detail = ref<AnalysisDetail>()
watch(() => [visible.value, props.analysisId] as const, async ([open, id]) => {
detail.value = undefined
if (open && id)
detail.value = await getAnalysis(id)
})
const toolResults = computed(() => {
const results = new Map<string, string>()
for (const m of detail.value?.transcript ?? []) {
if (m.role === "tool")
results.set(m.tool_call_id, m.result)
}
return results
})
function pretty(raw: string | undefined) {
if (raw === undefined)
return "无结果"
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
</script>
<template>
<el-drawer v-model="visible" :title="detail ? `分析 #${detail.id} · ${detail.symbol}` : '分析'" size="55%">
<div v-if="detail" class="flex flex-col gap-3 text-sm">
<div class="text-xs text-[var(--el-text-color-secondary)]">
{{ formatTime(detail.started_at) }} – {{ formatTime(detail.finished_at) }} · {{ detail.reasons.join(";") }}
· {{ detail.model }}{{ detail.reasoning_effort ? ` / ${detail.reasoning_effort}` : "" }}
· {{ detail.usage?.input_tokens.toLocaleString() }} / {{ detail.usage?.output_tokens.toLocaleString() }} token
{{ detail.dry_run ? "· 试运行" : "" }}
</div>
<el-alert v-if="detail.error" type="error" :closable="false" :title="detail.error" />
<!-- markdown-it 已关闭 html,模型输出中的标签按文本显示 -->
<div v-if="detail.text" class="markdown-body" v-html="renderMarkdown(detail.text)" />
<el-collapse>
<template v-for="(m, i) in detail.transcript ?? []" :key="i">
<el-collapse-item v-if="m.role === 'user'" :name="`u${i}`" :title="i === 0 ? '发给模型的数据(账户、持仓、预取行情)' : '程序提示'">
<pre class="m-0 max-h-120 overflow-auto whitespace-pre-wrap break-all text-xs">{{ m.text }}</pre>
</el-collapse-item>
<template v-else-if="m.role === 'assistant'">
<el-collapse-item v-for="call in m.tool_calls" :key="call.id" :name="call.id">
<template #title>
<span class="inline-block max-w-[90%] truncate text-xs font-mono">{{ call.name }} {{ JSON.stringify(call.arguments) }}</span>
</template>
<pre class="m-0 max-h-120 overflow-auto whitespace-pre-wrap break-all text-xs">{{ pretty(toolResults.get(call.id)) }}</pre>
</el-collapse-item>
</template>
</template>
</el-collapse>
</div>
<div v-else v-loading="true" class="h-40" />
</el-drawer>
</template>
@@ -0,0 +1,148 @@
<script lang="ts" setup>
import type { Analysis, PlanCondition, TradingSymbol } from "@/apis/trading"
import { ANGLE_LABELS } from "@/apis/trading"
import { formatTime } from "@/utils/text"
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, requesting: boolean }>()
const emit = defineEmits<{ analyze: [], detail: [id: number] }>()
const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const
const CONFIDENCE_LABELS = { high: "高", medium: "中", low: "低" } as const
const TIMEFRAME_LABELS = { "mark": "标记价触及", "1m": "1m 收盘", "15m": "15m 收盘", "1h": "1h 收盘", "4h": "4h 收盘" } as const
const now = ref(Date.now())
const ticker = setInterval(() => now.value = Date.now(), 1000)
onBeforeUnmount(() => clearInterval(ticker))
const decision = computed(() => props.analysis?.decision ?? null)
const sizing = computed(() => props.analysis?.sizing ?? null)
const busy = computed(() => props.requesting || props.symbol.analyzing || !!props.symbol.analysis_requested_at)
const elapsed = computed(() => props.analysis ? Math.max(0, Math.round((now.value - new Date(props.analysis.started_at).getTime()) / 1000)) : 0)
function condition(c: PlanCondition) {
return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}`
}
/** 失效后的应急动作:短线立即市价平仓,长线等 4h 收盘后请模型复核 */
const invalidationAction = computed(() => props.analysis?.angle === "short" ? "立即市价平仓" : "请模型复核")
</script>
<template>
<div class="border border-[var(--el-border-color)] rounded border-solid p-3">
<div class="mb-2 flex items-center gap-2">
<span class="font-bold">{{ symbol.symbol }}</span>
<span class="text-xs text-[var(--el-text-color-secondary)]">{{ ANGLE_LABELS[symbol.angle] }}</span>
<div class="flex-1" />
<el-button v-if="analysis && analysis.status !== 'running'" link type="primary" @click="emit('detail', analysis.id)">
查看完整分析
</el-button>
<el-button size="small" type="primary" :loading="busy" @click="emit('analyze')">
{{ busy ? "分析中" : "立即分析" }}
</el-button>
</div>
<div v-if="!analysis" class="text-sm text-[var(--el-text-color-secondary)]">
还没有分析。引擎停止时点「立即分析」为试运行:计划只记录,不下单。
</div>
<div v-else-if="analysis.status === 'running'" class="text-sm text-[var(--el-text-color-secondary)]">
模型分析中,已用 {{ elapsed }} 秒(通常 1–3 分钟)…
</div>
<el-alert v-else-if="analysis.status === 'failed'" type="error" :closable="false" show-icon>
{{ formatTime(analysis.started_at) }} 分析失败:{{ analysis.error }}
</el-alert>
<div v-else-if="decision" class="flex flex-col gap-2 text-sm">
<div class="flex flex-wrap items-center gap-2">
<el-tag v-if="analysis.dry_run" type="info" size="small">
试运行
</el-tag>
<el-tag size="small">
{{ ACTION_LABELS[decision.action] }}
</el-tag>
<el-tag v-if="decision.direction" :type="decision.direction === 'long' ? 'success' : 'danger'" size="small">
{{ decision.direction === "long" ? "做多" : "做空" }}
</el-tag>
<el-tag v-if="decision.confidence" type="warning" size="small">
置信度{{ CONFIDENCE_LABELS[decision.confidence] }}
</el-tag>
<span class="text-xs text-[var(--el-text-color-secondary)]">
{{ formatTime(analysis.started_at) }} · {{ analysis.reasons.join(";") }} · {{ analysis.model }}{{ analysis.reasoning_effort ? ` / ${analysis.reasoning_effort}` : "" }}
· {{ analysis.usage?.input_tokens.toLocaleString() }} / {{ analysis.usage?.output_tokens.toLocaleString() }} token
</span>
</div>
<div class="font-bold">
{{ decision.summary }}
</div>
<div><span class="label">为什么</span>{{ decision.reasons }}</div>
<template v-if="decision.action === 'plan' && sizing">
<el-table :data="sizing.entries" border size="small">
<el-table-column label="入场档位" min-width="110">
<template #default="{ $index }">
第 {{ $index + 1 }} 档
</template>
</el-table-column>
<el-table-column prop="price" label="挂单价" min-width="100" />
<el-table-column label="比例" width="80">
<template #default="{ row }">
{{ Math.round(Number(row.ratio) * 100) }}%
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" min-width="90" />
<el-table-column prop="notional" label="名义 USDT" min-width="100" />
<el-table-column label="依据" min-width="200">
<template #default="{ $index }">
{{ decision.entries?.[$index]?.basis }}
</template>
</el-table-column>
</el-table>
<el-table :data="sizing.take_profits" border size="small">
<el-table-column label="分批止盈" min-width="110">
<template #default="{ $index }">
止盈 {{ $index + 1 }}
</template>
</el-table-column>
<el-table-column prop="price" label="价格" min-width="100" />
<el-table-column label="平仓比例" width="80">
<template #default="{ row }">
{{ Math.round(Number(row.ratio) * 100) }}%
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" min-width="90" />
<el-table-column label="依据" min-width="300">
<template #default="{ $index }">
{{ decision.take_profits?.[$index]?.basis }}
</template>
</el-table-column>
</el-table>
<div class="grid grid-cols-2 gap-x-6 gap-y-1">
<div><span class="label">止损</span>{{ sizing.stop }}(交易所挂单,标记价触发)· {{ decision.stop_basis }}</div>
<div>
<span class="label">失效条件</span>{{ decision.invalidation ? condition(decision.invalidation) : "-" }}
→ {{ invalidationAction }} · {{ decision.invalidation?.basis }}
</div>
<div><span class="label">均价 / 数量</span>{{ sizing.average_price }} / {{ sizing.quantity }}(名义 {{ sizing.notional }} USDT,{{ sizing.leverage }} 倍交易资金)</div>
<div><span class="label">止损亏损</span>{{ sizing.risk_at_stop }} USDT(交易资金的 {{ sizing.risk_pct }}%){{ sizing.capped_by_leverage ? ",受 3 倍上限限制" : "" }}</div>
<div><span class="label">盈亏比</span>全部成交 {{ sizing.reward_risk }},仅首档 {{ sizing.reward_risk_first_only }}</div>
<div><span class="label">时效</span>挂单 {{ decision.entry_valid_hours }} 小时内有效,首档成交后最长持仓 {{ decision.max_hold_hours }} 小时</div>
</div>
</template>
<div v-if="decision.reevaluate.length">
<span class="label">重新判断</span>
<div v-for="(c, i) in decision.reevaluate" :key="i" class="ml-2">
· {{ condition(c) }}:{{ c.basis }}
</div>
</div>
<div><span class="label">风险</span>{{ decision.risks }}</div>
</div>
</div>
</template>
<style scoped>
.label {
margin-right: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
+42 -11
View File
@@ -1,11 +1,13 @@
<script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings"
import type { Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import type { Analysis, Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import { EFFORTS, listProviders } from "@/apis/settings"
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listEvents, listSymbols, patchConfig, patchSymbol, startTrading, stopTrading } from "@/apis/trading"
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text"
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
import AnalysisDrawer from "./components/AnalysisDrawer.vue"
import PlanCard from "./components/PlanCard.vue"
const STATUS_REFRESH_MS = 3000
const EVENTS_PAGE = 50
@@ -20,6 +22,10 @@ const events = ref<TradingEvent[]>([])
const eventsExhausted = ref(false)
const addVisible = ref(false)
const toggling = ref(false)
const latestAnalysis = ref<Analysis | null>(null)
const requesting = ref(false)
const drawerVisible = ref(false)
const drawerAnalysisId = ref<number | null>(null)
const snapshot = computed(() => status.value?.snapshot ?? null)
const account = computed(() => snapshot.value?.account ?? null)
@@ -86,6 +92,30 @@ async function refresh() {
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
if (!current.value && symbols.value.length)
selected.value = symbols.value[0].symbol
await refreshAnalysis()
}
async function refreshAnalysis() {
const symbol = selected.value
const latest = symbol ? (await listAnalyses({ symbol, limit: 1 }))[0] ?? null : null
if (symbol === selected.value)
latestAnalysis.value = latest
}
async function analyze() {
requesting.value = true
try {
await requestAnalysis(selected.value)
ElMessage.success(status.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单")
await refresh()
} finally {
requesting.value = false
}
}
function openAnalysis(id: number) {
drawerAnalysisId.value = id
drawerVisible.value = true
}
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
@@ -113,7 +143,9 @@ async function loadMoreEvents() {
watch(selected, () => {
events.value = []
latestAnalysis.value = null
refreshEvents()
refreshAnalysis()
})
async function toggleRunning() {
@@ -355,15 +387,10 @@ onBeforeUnmount(() => clearInterval(timer))
<!-- 主区域:当前计划、挂单、时间线 -->
<el-scrollbar class="flex-1">
<div class="flex flex-col gap-3 pr-2">
<div v-if="current" class="border border-[var(--el-border-color)] rounded border-solid p-3">
<div class="mb-2 font-bold">
{{ current.symbol }} 当前计划
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)] font-normal">{{ ANGLE_LABELS[current.angle] }}</span>
</div>
<div class="text-sm text-[var(--el-text-color-secondary)]">
暂无计划
</div>
</div>
<PlanCard
v-if="current" :symbol="current" :analysis="latestAnalysis" :requesting="requesting"
@analyze="analyze" @detail="openAnalysis"
/>
<div v-if="current">
<div class="mb-2 font-bold">
@@ -415,6 +442,9 @@ onBeforeUnmount(() => clearInterval(timer))
{{ e.symbol ?? "账户" }}
</el-tag>
<span :class="e.level === 'error' ? 'text-[var(--el-color-danger)]' : ''">{{ e.message }}</span>
<el-link v-if="e.analysis_id && e.kind !== 'analysis_started'" type="primary" underline="never" class="ml-2" @click="openAnalysis(e.analysis_id)">
查看分析
</el-link>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="还没有事件" :image-size="60" />
@@ -427,6 +457,7 @@ onBeforeUnmount(() => clearInterval(timer))
</div>
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
<AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" />
</div>
</template>