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:
1 parent
7536c9c5de
commit
78ca4422af
16 files changed
+1464
-150
No files matched your search
@@ -27,3 +27,8 @@ dependencies = [
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.12.23,<0.13.0"]
|
requires = ["uv_build>=0.12.23,<0.13.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
]
|
||||||
@@ -31,6 +31,8 @@ MAX_RETRIES = 2
|
|||||||
# 这些都由这里再重试,且只在还没有输出内容时重试
|
# 这些都由这里再重试,且只在还没有输出内容时重试
|
||||||
STREAM_RETRIES = 3
|
STREAM_RETRIES = 3
|
||||||
STREAM_RETRY_DELAY_SECONDS = 2
|
STREAM_RETRY_DELAY_SECONDS = 2
|
||||||
|
# 输出被截断时回给模型的工具结果:参数不完整,不能执行
|
||||||
|
TRUNCATED_RESULT = json.dumps({"error": "输出达到最大输出上限被截断,工具参数不完整,未执行。请精简内容后重新调用"}, ensure_ascii=False)
|
||||||
|
|
||||||
logger = logging.getLogger("alphapulse.ai.llm")
|
logger = logging.getLogger("alphapulse.ai.llm")
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,18 @@ async def data_overview(session: AsyncSession) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def build_system_prompt(session: AsyncSession) -> str:
|
async def skills_section(session: AsyncSession) -> str:
|
||||||
parts = [BASE_INSTRUCTIONS, "## 数据概况", await data_overview(session)]
|
|
||||||
skills = await published_skills(session)
|
skills = await published_skills(session)
|
||||||
parts.append("## Skills(按优先级从高到低)")
|
parts = ["## Skills(按优先级从高到低)"]
|
||||||
if skills:
|
if skills:
|
||||||
parts += [f"### 【优先级 {info.node.priority}】{info.path}\n\n{content}" for info, content in skills]
|
parts += [f"### 【优先级 {info.node.priority}】{info.path}\n\n{content}" for info, content in skills]
|
||||||
else:
|
else:
|
||||||
parts.append("(暂无已发布的 skill)")
|
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)
|
context = await current_context(session)
|
||||||
parts += ["## CONTEXT.md", context.content if context and context.content.strip() else "(空)"]
|
parts += ["## CONTEXT.md", context.content if context and context.content.strip() else "(空)"]
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
@@ -27,7 +27,6 @@ MAX_TOOL_ROUNDS = 8
|
|||||||
# 发给前端展示的工具结果长度
|
# 发给前端展示的工具结果长度
|
||||||
TOOL_PREVIEW_CHARS = 2000
|
TOOL_PREVIEW_CHARS = 2000
|
||||||
STOPPED_RESULT = json.dumps({"error": "用户已停止生成,工具未执行"}, ensure_ascii=False)
|
STOPPED_RESULT = json.dumps({"error": "用户已停止生成,工具未执行"}, ensure_ascii=False)
|
||||||
TRUNCATED_RESULT = json.dumps({"error": "输出达到最大输出上限被截断,工具参数不完整,未执行。请精简内容后重新调用"}, ensure_ascii=False)
|
|
||||||
|
|
||||||
# api 只有一个进程:同一时间只允许一轮生成,Reset 也要等生成结束
|
# api 只有一个进程:同一时间只允许一轮生成,Reset 也要等生成结束
|
||||||
generating = asyncio.Lock()
|
generating = asyncio.Lock()
|
||||||
@@ -195,7 +194,7 @@ async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
|
|||||||
call = pending[0]
|
call = pending[0]
|
||||||
yield event("tool_call", {"id": call["id"], "name": call["name"], "arguments": call["arguments"]})
|
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}
|
result = {"tool_call_id": call["id"], "name": call["name"], "result": outcome.result}
|
||||||
await save_messages(ChatMessage(session_id=current.id, role="tool", content=result))
|
await save_messages(ChatMessage(session_id=current.id, role="tool", content=result))
|
||||||
pending.pop(0)
|
pending.pop(0)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from alphapulse.core.auth import require_admin
|
|||||||
from alphapulse.core.db import get_session
|
from alphapulse.core.db import get_session
|
||||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||||
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
|
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
|
||||||
from alphapulse.trading.client import ExchangeError, public_get
|
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get
|
||||||
from alphapulse.trading.engine import DAILY_ANALYSIS_LIMIT, OWN_CLIENT_ID, STATUS_KEY
|
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)])
|
router = APIRouter(prefix="/api/trading", tags=["trading"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
@@ -82,6 +83,36 @@ class SymbolPatch(BaseModel):
|
|||||||
angle: Angle
|
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):
|
class EventOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
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)
|
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")
|
@router.get("/status")
|
||||||
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
|
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
|
||||||
account = await get_account(session)
|
account = await get_account(session)
|
||||||
@@ -292,3 +318,39 @@ async def list_events(
|
|||||||
if before:
|
if before:
|
||||||
query = query.where(TradingEvent.id < before)
|
query = query.where(TradingEvent.id < before)
|
||||||
return [EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)]
|
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)
|
||||||
@@ -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})
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@@ -14,6 +15,8 @@ import httpx
|
|||||||
BASE_URL = "https://demo-fapi.binance.com"
|
BASE_URL = "https://demo-fapi.binance.com"
|
||||||
RECV_WINDOW_MS = 5000
|
RECV_WINDOW_MS = 5000
|
||||||
TIMEOUT_SECONDS = 10
|
TIMEOUT_SECONDS = 10
|
||||||
|
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
|
||||||
|
OWN_CLIENT_ID = re.compile(r"^ap\d+$")
|
||||||
|
|
||||||
|
|
||||||
class ExchangeError(Exception):
|
class ExchangeError(Exception):
|
||||||
|
|||||||
@@ -1,156 +1,75 @@
|
|||||||
"""模拟盘交易进程:定时把账户、持仓、挂单快照写入 Redis,供页面读取。
|
"""模拟盘交易进程:同步账户快照、调度分析。
|
||||||
|
|
||||||
用法:python -m alphapulse.trading.engine
|
用法:python -m alphapulse.trading.engine
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from datetime import UTC, datetime
|
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.db import SessionLocal
|
||||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
from alphapulse.models import TradingAnalysis, TradingSymbol
|
||||||
from alphapulse.models import TradingAccount, TradingSymbol
|
from alphapulse.trading.analyst import run_analysis
|
||||||
from alphapulse.trading.client import DemoClient, ExchangeError
|
from alphapulse.trading.snapshot import snapshot_loop
|
||||||
|
|
||||||
logger = logging.getLogger("alphapulse.trading.engine")
|
logger = logging.getLogger("alphapulse.trading.engine")
|
||||||
|
|
||||||
# 进程挂掉后快照自动过期,页面据此显示「引擎离线」
|
ANALYSIS_POLL_SECONDS = 5
|
||||||
STATUS_KEY = f"{KEY_PREFIX}trader:status"
|
# 模型调用耗时长,并发过多会撞上游限流
|
||||||
STATUS_TTL_SECONDS = 15
|
MAX_CONCURRENT_ANALYSES = 2
|
||||||
SNAPSHOT_SECONDS = 3
|
|
||||||
# 杠杆和保证金模式很少变,按币缓存
|
|
||||||
SYMBOL_CONFIG_SECONDS = 60
|
|
||||||
# 所有币合计每天(北京时间)最多自动分析的次数
|
|
||||||
DAILY_ANALYSIS_LIMIT = 60
|
|
||||||
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
|
|
||||||
OWN_CLIENT_ID = re.compile(r"^ap\d+$")
|
|
||||||
|
|
||||||
|
|
||||||
class Snapshot:
|
async def fail_interrupted() -> None:
|
||||||
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:
|
async with SessionLocal() as session:
|
||||||
account = await session.get(TradingAccount, 1)
|
await session.execute(
|
||||||
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
|
update(TradingAnalysis).where(TradingAnalysis.status == "running")
|
||||||
error = None
|
.values(status="failed", error="引擎重启,分析中断", finished_at=datetime.now(UTC))
|
||||||
client = await self.ensure_client(account)
|
)
|
||||||
if client is None:
|
await session.commit()
|
||||||
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:
|
async def claim_requests(limit: int, busy: set[str]) -> list[tuple[str, list[str]]]:
|
||||||
snapshot = Snapshot()
|
"""取出到期的分析请求并清空,同一请求里的多个触发原因合并为一次分析"""
|
||||||
|
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:
|
while True:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
logger.exception("发布快照失败,稍后重试")
|
logger.exception("调度分析出错,稍后重试")
|
||||||
await asyncio.sleep(SNAPSHOT_SECONDS)
|
await asyncio.sleep(ANALYSIS_POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
# 分析器和执行器在后续里程碑加入同一个 TaskGroup
|
|
||||||
async with asyncio.TaskGroup() as tg:
|
async with asyncio.TaskGroup() as tg:
|
||||||
tg.create_task(snapshot_loop())
|
tg.create_task(snapshot_loop())
|
||||||
|
tg.create_task(analysis_loop())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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"))),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
Generated
+60
@@ -186,6 +186,11 @@ dependencies = [
|
|||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.20.0" },
|
{ name = "alembic", specifier = ">=1.20.0" },
|
||||||
@@ -205,6 +210,9 @@ requires-dist = [
|
|||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
version = "0.0.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "constantly"
|
name = "constantly"
|
||||||
version = "23.10.4"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "itemadapter"
|
name = "itemadapter"
|
||||||
version = "0.13.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.5.4"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.15.1"
|
version = "2.15.1"
|
||||||
@@ -1856,6 +1900,22 @@ version = "2.1.2"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.4"
|
version = "1.2.4"
|
||||||
|
|||||||
@@ -140,3 +140,95 @@ export function deleteSymbol(symbol: string): Promise<void> {
|
|||||||
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
|
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
|
||||||
return http.get("/trading/events", { params })
|
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>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Effort, Provider } from "@/apis/settings"
|
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 { 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 { usePersisted } from "@/utils/storage"
|
||||||
import { formatTime } from "@/utils/text"
|
import { formatTime } from "@/utils/text"
|
||||||
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
||||||
|
import AnalysisDrawer from "./components/AnalysisDrawer.vue"
|
||||||
|
import PlanCard from "./components/PlanCard.vue"
|
||||||
|
|
||||||
const STATUS_REFRESH_MS = 3000
|
const STATUS_REFRESH_MS = 3000
|
||||||
const EVENTS_PAGE = 50
|
const EVENTS_PAGE = 50
|
||||||
@@ -20,6 +22,10 @@ const events = ref<TradingEvent[]>([])
|
|||||||
const eventsExhausted = ref(false)
|
const eventsExhausted = ref(false)
|
||||||
const addVisible = ref(false)
|
const addVisible = ref(false)
|
||||||
const toggling = 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 snapshot = computed(() => status.value?.snapshot ?? null)
|
||||||
const account = computed(() => snapshot.value?.account ?? null)
|
const account = computed(() => snapshot.value?.account ?? null)
|
||||||
@@ -86,6 +92,30 @@ async function refresh() {
|
|||||||
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
|
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
|
||||||
if (!current.value && symbols.value.length)
|
if (!current.value && symbols.value.length)
|
||||||
selected.value = symbols.value[0].symbol
|
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, () => {
|
watch(selected, () => {
|
||||||
events.value = []
|
events.value = []
|
||||||
|
latestAnalysis.value = null
|
||||||
refreshEvents()
|
refreshEvents()
|
||||||
|
refreshAnalysis()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function toggleRunning() {
|
async function toggleRunning() {
|
||||||
@@ -355,15 +387,10 @@ onBeforeUnmount(() => clearInterval(timer))
|
|||||||
<!-- 主区域:当前计划、挂单、时间线 -->
|
<!-- 主区域:当前计划、挂单、时间线 -->
|
||||||
<el-scrollbar class="flex-1">
|
<el-scrollbar class="flex-1">
|
||||||
<div class="flex flex-col gap-3 pr-2">
|
<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">
|
<PlanCard
|
||||||
<div class="mb-2 font-bold">
|
v-if="current" :symbol="current" :analysis="latestAnalysis" :requesting="requesting"
|
||||||
{{ current.symbol }} 当前计划
|
@analyze="analyze" @detail="openAnalysis"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="current">
|
<div v-if="current">
|
||||||
<div class="mb-2 font-bold">
|
<div class="mb-2 font-bold">
|
||||||
@@ -415,6 +442,9 @@ onBeforeUnmount(() => clearInterval(timer))
|
|||||||
{{ e.symbol ?? "账户" }}
|
{{ e.symbol ?? "账户" }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span :class="e.level === 'error' ? 'text-[var(--el-color-danger)]' : ''">{{ e.message }}</span>
|
<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-item>
|
||||||
</el-timeline>
|
</el-timeline>
|
||||||
<el-empty v-else description="还没有事件" :image-size="60" />
|
<el-empty v-else description="还没有事件" :image-size="60" />
|
||||||
@@ -427,6 +457,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
|
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
|
||||||
|
<AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user