Add demo trading executor and analysis scheduling (plan 3 C4)

- Executor reconciles each symbol to its desired orders: journaled ap{id}
  client ids, reduce-only quantity stop placed before entries, make-before-
  break stop moves, breakeven after TP1, orphan cancel, -2021 market close
- Plan lifecycle: proposed plans are adopted only if the position is
  unchanged; holding-mode plans may only tighten the stop
- Program rules: time stop, short-term invalidation close, long-term 4h
  review, reevaluate conditions, takeover of unmanaged positions
- Trigger and routine analysis scheduling with a 10 min gap, daily cap and
  bounded retries
- Plan endpoint and plan status, fills and countdowns on the trading page
This commit is contained in:
eezd committed 2026-10-15 11:07:00 +08:00
1 parent 78ca4422af
commit 1fcf41952c
12 files changed
+1192 -67

No files matched your search

+50 -9
View File
@@ -17,7 +17,7 @@ from alphapulse.api.market import call_binance, client as market_client
from alphapulse.core.auth import require_admin 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, TradingOrder, TradingSymbol
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
from alphapulse.trading.snapshot import read_snapshot from alphapulse.trading.snapshot import read_snapshot
@@ -30,6 +30,8 @@ SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols"
SYMBOLS_CACHE_SECONDS = 3600 SYMBOLS_CACHE_SECONDS = 3600
# 删除或切换角度前要求快照足够新,否则无法确认没有仓位和挂单 # 删除或切换角度前要求快照足够新,否则无法确认没有仓位和挂单
SNAPSHOT_FRESH_SECONDS = 30 SNAPSHOT_FRESH_SECONDS = 30
# 执行器正在管理的计划状态
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
class TodayUsage(BaseModel): class TodayUsage(BaseModel):
@@ -70,8 +72,10 @@ class SymbolOut(BaseModel):
added_at: datetime added_at: datetime
analysis_requested_at: datetime | None analysis_requested_at: datetime | None
analyzing: bool analyzing: bool
# 当前计划的状态:pending / open,没有计划为空 # 当前计划:pending / open / closing,没有计划为空
plan_id: int | None
plan_status: str | None plan_status: str | None
close_deadline: datetime | None
class SymbolIn(BaseModel): class SymbolIn(BaseModel):
@@ -113,6 +117,28 @@ class AnalysisDetail(AnalysisOut):
transcript: list | None transcript: list | None
class OrderOut(BaseModel):
id: int
purpose: str
leg: int | None
side: str
type: str
price: Decimal | None
trigger_price: Decimal | None
quantity: Decimal
status: str
filled_qty: Decimal
avg_price: Decimal | None
error: str | None
created_at: datetime
class SymbolPlanOut(BaseModel):
# 执行器正在管理的计划;没有时为最近一次待采纳的提议
plan: AnalysisOut | None
orders: list[OrderOut]
class EventOut(BaseModel): class EventOut(BaseModel):
id: int id: int
created_at: datetime created_at: datetime
@@ -236,14 +262,15 @@ async def list_symbol_options() -> list[SymbolOption]:
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]: async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
symbols = (await session.scalars(select(TradingSymbol).order_by(TradingSymbol.added_at))).all() symbols = (await session.scalars(select(TradingSymbol).order_by(TradingSymbol.added_at))).all()
analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.status == "running"))).all()) analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.status == "running"))).all())
plans = dict((await session.execute( plans = {p.symbol: p for p in await session.scalars(
select(TradingAnalysis.symbol, TradingAnalysis.plan_status) select(TradingAnalysis).where(TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES)).order_by(TradingAnalysis.id)
.where(TradingAnalysis.plan_status.in_(("pending", "open")), TradingAnalysis.dry_run.is_(False)) )}
)).all())
return [ return [
SymbolOut( SymbolOut(
symbol=s.symbol, angle=s.angle, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at, symbol=s.symbol, angle=s.angle, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at,
analyzing=s.symbol in analyzing, plan_status=plans.get(s.symbol), analyzing=s.symbol in analyzing, plan_id=plans[s.symbol].id if s.symbol in plans else None,
plan_status=plans[s.symbol].plan_status if s.symbol in plans else None,
close_deadline=plans[s.symbol].close_deadline if s.symbol in plans else None,
) )
for s in symbols for s in symbols
] ]
@@ -269,8 +296,7 @@ async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> Tradin
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
busy = await session.scalar(select(func.count()).where( busy = await session.scalar(select(func.count()).where(
TradingAnalysis.symbol == symbol, TradingAnalysis.symbol == symbol,
or_(TradingAnalysis.status == "running", or_(TradingAnalysis.status == "running", TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES))),
TradingAnalysis.plan_status.in_(("pending", "open")) & TradingAnalysis.dry_run.is_(False)),
)) ))
if busy: if busy:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}") raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}")
@@ -354,3 +380,18 @@ async def get_analysis(analysis_id: int, session: Annotated[AsyncSession, Depend
if analysis is None: if analysis is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "分析不存在") raise HTTPException(status.HTTP_404_NOT_FOUND, "分析不存在")
return AnalysisDetail.model_validate(analysis, from_attributes=True) return AnalysisDetail.model_validate(analysis, from_attributes=True)
@router.get("/symbols/{symbol}/plan")
async def get_symbol_plan(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> SymbolPlanOut:
plan = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES)))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
if plan is None:
return SymbolPlanOut(plan=None, orders=[])
orders = await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id).order_by(TradingOrder.id))
return SymbolPlanOut(
plan=AnalysisOut.model_validate(plan, from_attributes=True),
orders=[OrderOut.model_validate(o, from_attributes=True) for o in orders],
)
+2 -1
View File
@@ -69,7 +69,8 @@ class TradingAnalysis(Base):
error: Mapped[str | None] = mapped_column(Text) error: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# pending(挂单中)/ open(持仓中)/ closed / canceled(未成交即结束)/ replaced(被新计划替换) # proposed(待执行器采纳)/ pending(挂单中)/ open(持仓中)/ closing(平仓中)/ closed / canceled(未成交即结束)
# / replaced(被新计划替换)/ stale(采纳前持仓已变,作废)/ applied(exit 决定已执行)
plan_status: Mapped[str | None] = mapped_column(String(16)) plan_status: Mapped[str | None] = mapped_column(String(16))
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
first_fill_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) first_fill_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+59 -38
View File
@@ -6,7 +6,6 @@
import asyncio import asyncio
import json import json
import logging import logging
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
@@ -18,10 +17,10 @@ from alphapulse.ai import llm
from alphapulse.ai.prompt import data_overview, estimate_tokens, skills_section from alphapulse.ai.prompt import data_overview, estimate_tokens, skills_section
from alphapulse.ai.tools import TOOLS, dump, local, run_tool from alphapulse.ai.tools import TOOLS, dump, local, run_tool
from alphapulse.core.db import SessionLocal from alphapulse.core.db import SessionLocal
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
from alphapulse.trading import plan as rules from alphapulse.trading import plan as rules
from alphapulse.trading.client import public_get from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, mark_price, symbol_rules
from alphapulse.trading.plan import PlanContext, PlanError, SymbolRules, TradePlan, size_plan from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, size_plan
from alphapulse.trading.snapshot import read_snapshot from alphapulse.trading.snapshot import read_snapshot
logger = logging.getLogger("alphapulse.trading.analyst") logger = logging.getLogger("alphapulse.trading.analyst")
@@ -30,7 +29,6 @@ READ_TOOLS = ("search_news", "get_news", "search_symbols", "get_klines", "get_ti
# 最后一轮只开放 submit_trade_plan,逼模型给出结论 # 最后一轮只开放 submit_trade_plan,逼模型给出结论
MAX_ROUNDS = 6 MAX_ROUNDS = 6
RECENT_DECISIONS = 2 RECENT_DECISIONS = 2
EXCHANGE_INFO_SECONDS = 3600
ANGLE_LABELS = {"short": "短线(24 小时内平仓)", "long": "长线(12–48 小时内平仓)"} ANGLE_LABELS = {"short": "短线(24 小时内平仓)", "long": "长线(12–48 小时内平仓)"}
DIRECTION_LABELS = {"long": "做多", "short": "做空"} DIRECTION_LABELS = {"long": "做多", "short": "做空"}
CONFIDENCE_LABELS = {"high": "高", "medium": "中", "low": "低"} CONFIDENCE_LABELS = {"high": "高", "medium": "中", "low": "低"}
@@ -74,6 +72,11 @@ INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币
- 每档入场的名义价值不低于交易所最小下单金额,不足时合并或删档。 - 每档入场的名义价值不低于交易所最小下单金额,不足时合并或删档。
- 单笔风险为交易资金的 {_pct(rules.RISK_PCT['short'])}(短线)或 {_pct(rules.RISK_PCT['long'])}(长线),置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。这两条由程序计算数量来保证。 - 单笔风险为交易资金的 {_pct(rules.RISK_PCT['short'])}(短线)或 {_pct(rules.RISK_PCT['long'])}(长线),置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。这两条由程序计算数量来保证。
- 有持仓时新计划的方向必须与持仓一致;要反手先用 exit。 - 有持仓时新计划的方向必须与持仓一致;要反手先用 exit。
## 有持仓时
- action=plan 只用来调整止损(只能收紧)、止盈、失效条件和 reevaluate:entries 留空,不加仓(README 第 10 节禁止计划外加仓)。止盈按当前持仓数量拆分;平仓截止时间沿用首档成交时确定的,不延长。
- 计划仍然合适就用 keep;要提前结束用 exit(撤销未成交的入场单并市价平仓)。
- 计划提交后由执行器在下一轮(约 2 秒内)按最新持仓核对后生效;期间持仓变了,计划作废并重新请你分析。
""" """
SUBMIT_SPEC = llm.ToolSpec( SUBMIT_SPEC = llm.ToolSpec(
@@ -96,32 +99,8 @@ class Prepared:
system: str system: str
user_text: str user_text: str
context: PlanContext context: PlanContext
# 当前计划的仓位计算结果,提交时重算当前止损用
current_sizing: dict | None
_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: def plan_brief(decision: dict, sizing: dict | None) -> dict:
@@ -168,9 +147,15 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
)).all() )).all()
current = await session.scalar( current = await session.scalar(
select(TradingAnalysis) select(TradingAnalysis)
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open")), TradingAnalysis.dry_run.is_(False)) .where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open", "closing")), TradingAnalysis.dry_run.is_(False))
.order_by(TradingAnalysis.id.desc()).limit(1) .order_by(TradingAnalysis.id.desc()).limit(1)
) )
fills = [
{"purpose": o.purpose, "leg": o.leg, "filled": str(o.filled_qty), "avg_price": str(o.avg_price), "status": o.status}
for o in (await session.scalars(
select(TradingOrder).where(TradingOrder.analysis_id == current.id, TradingOrder.filled_qty > 0).order_by(TradingOrder.id)
)).all()
] if current else []
system = "\n\n".join([INSTRUCTIONS, "## 数据概况", await data_overview(session), await skills_section(session)]) system = "\n\n".join([INSTRUCTIONS, "## 数据概况", await data_overview(session), await skills_section(session)])
angle, dry_run = target.angle, not account.running angle, dry_run = target.angle, not account.running
effort = account.reasoning_effort or provider.reasoning_effort effort = account.reasoning_effort or provider.reasoning_effort
@@ -185,9 +170,10 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
symbol_rules_ = await symbol_rules(symbol) symbol_rules_ = await symbol_rules(symbol)
mark = await mark_price(symbol) mark = await mark_price(symbol)
context = PlanContext( context = PlanContext(
angle=angle, mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, angle=angle, mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, has_plan=current is not None,
has_plan=current is not None, position_side=("long" if position["side"] == "LONG" else "short") if position else None, position_side=None,
) )
apply_position(context, snapshot, symbol, current.sizing if current else None)
orders = [o for o in snapshot["orders"] + snapshot["algo_orders"] if o["symbol"] == symbol] orders = [o for o in snapshot["orders"] + snapshot["algo_orders"] if o["symbol"] == symbol]
risk = capital * rules.RISK_PCT[angle] risk = capital * rules.RISK_PCT[angle]
@@ -210,7 +196,15 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
f"- 挂单:{dump(orders) if orders 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 "无", dump({
"id": current.id, "plan_status": current.plan_status,
"valid_until": current.valid_until and local(current.valid_until),
"first_fill_at": current.first_fill_at and local(current.first_fill_at),
"close_deadline": current.close_deadline and local(current.close_deadline),
"current_stop": str(context.current_stop) if context.current_stop else None,
"fills": fills,
**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 ["无"]), *([f"- {local(a.started_at)}{'(试运行)' if a.dry_run else ''}:{dump(plan_brief(a.decision, a.sizing))}" for a in recent] or ["无"]),
@@ -218,7 +212,28 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
"## 预取的行情(实盘数据,已取,不要用相同参数重复调用)", "## 预取的行情(实盘数据,已取,不要用相同参数重复调用)",
await prefetch(symbol, angle), 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) return Prepared(provider=provider, effort=effort, dry_run=dry_run, angle=angle, system=system, user_text="\n".join(lines),
context=context, current_sizing=current.sizing if current else None)
def apply_position(context: PlanContext, snapshot: dict, symbol: str, current_sizing: dict | None) -> None:
"""按快照填入持仓与当前止损。当前止损取交易所上本程序止损单里最紧的一张(可能已移到保本价),没有则取当前计划的止损"""
position = next((p for p in snapshot["positions"] if p["symbol"] == symbol), None)
if position is None:
context.position_side, context.position_qty, context.position_entry, context.current_stop = None, Decimal(0), None, None
return
side = "long" if position["side"] == "LONG" else "short"
close_side = "SELL" if side == "long" else "BUY"
stops = [
Decimal(o["trigger_price"]) for o in snapshot["algo_orders"]
if o["symbol"] == symbol and o["side"] == close_side and o["type"].startswith("STOP") and OWN_CLIENT_ID.match(o["client_id"])
]
if stops:
current_stop = max(stops) if side == "long" else min(stops)
else:
current_stop = Decimal(current_sizing["stop"]) if current_sizing else None
context.position_side, context.position_qty = side, Decimal(position["amount"])
context.position_entry, context.current_stop = Decimal(position["entry_price"]), current_stop
async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.ToolSpec]) -> llm.Completion: async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.ToolSpec]) -> llm.Completion:
@@ -252,8 +267,11 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
plan = TradePlan.model_validate(arguments) plan = TradePlan.model_validate(arguments)
except ValidationError as e: except ValidationError as e:
return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None
# 分析可能持续几分钟,按提交时的标记价校验 # 分析可能持续几分钟,按提交时的标记价和持仓校验
prepared.context.mark_price = await mark_price(symbol) prepared.context.mark_price = await mark_price(symbol)
snapshot = await read_snapshot()
if snapshot:
apply_position(prepared.context, snapshot, symbol, prepared.current_sizing)
try: try:
sizing = size_plan(plan, prepared.context) sizing = size_plan(plan, prepared.context)
except PlanError as e: except PlanError as e:
@@ -319,7 +337,7 @@ async def run_analysis(symbol: str, reasons: list[str]) -> None:
break break
if accepted is None: if accepted is None:
raise AnalysisError(f"模型在 {MAX_ROUNDS} 轮内没有提交通过校验的结论") raise AnalysisError(f"模型在 {MAX_ROUNDS} 轮内没有提交通过校验的结论")
except (AnalysisError, llm.LlmError, PlanError) as e: except (AnalysisError, ExchangeError, llm.LlmError, PlanError) as e:
error = str(e) error = str(e)
except Exception as e: except Exception as e:
logger.exception("%s 分析出错", symbol) logger.exception("%s 分析出错", symbol)
@@ -343,6 +361,9 @@ async def run_analysis(symbol: str, reasons: list[str]) -> None:
analysis.action = plan.action analysis.action = plan.action
analysis.decision = plan.model_dump(mode="json") analysis.decision = plan.model_dump(mode="json")
analysis.sizing = sizing or None analysis.sizing = sizing or None
# 交易中的新计划和结束决定交给执行器按最新持仓核对后采纳
if not dry_run and plan.action in ("plan", "exit"):
analysis.plan_status = "proposed"
await session.commit() await session.commit()
if error: if error:
await add_event(symbol, analysis_id, "analysis_failed", f"分析失败:{error}", "error") await add_event(symbol, analysis_id, "analysis_failed", f"分析失败:{error}", "error")
+36
View File
@@ -7,14 +7,18 @@ import hashlib
import hmac import hmac
import re import re
import time import time
from decimal import Decimal
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
from alphapulse.trading.plan import SymbolRules
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
EXCHANGE_INFO_SECONDS = 3600
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动 # 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
OWN_CLIENT_ID = re.compile(r"^ap\d+$") OWN_CLIENT_ID = re.compile(r"^ap\d+$")
@@ -96,3 +100,35 @@ class DemoClient:
continue continue
raise ExchangeError(resp.status_code, code, body.get("msg", resp.text[:200]) if isinstance(body, dict) else resp.text[:200]) raise ExchangeError(resp.status_code, code, body.get("msg", resp.text[:200]) if isinstance(body, dict) else resp.text[:200])
raise AssertionError("unreachable") raise AssertionError("unreachable")
_exchange_info: tuple[float, dict[str, SymbolRules]] | None = None
def step_decimal(raw: str) -> Decimal:
"""去掉多余的 0("0.10" → 0.1),但整数步长不能变成 1E+2 这类指数形式,否则按它取整的价格和数量也会是指数形式"""
value = Decimal(raw).normalize()
return value.quantize(Decimal(1)) if value.as_tuple().exponent > 0 else value
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=step_decimal(filters["PRICE_FILTER"]["tickSize"]),
step_size=step_decimal(filters["LOT_SIZE"]["stepSize"]),
min_notional=Decimal(filters["MIN_NOTIONAL"]["notional"]),
)
_exchange_info = (time.monotonic(), parsed)
if symbol not in _exchange_info[1]:
raise ExchangeError(None, None, 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"])
+5 -1
View File
@@ -1,4 +1,4 @@
"""模拟盘交易进程:同步账户快照、调度分析。 """模拟盘交易进程:同步账户快照、执行器对账、调度分析。
用法:python -m alphapulse.trading.engine 用法:python -m alphapulse.trading.engine
""" """
@@ -12,6 +12,8 @@ from sqlalchemy import select, update
from alphapulse.core.db import SessionLocal from alphapulse.core.db import SessionLocal
from alphapulse.models import TradingAnalysis, TradingSymbol from alphapulse.models import TradingAnalysis, TradingSymbol
from alphapulse.trading.analyst import run_analysis from alphapulse.trading.analyst import run_analysis
from alphapulse.trading.executor import executor_loop
from alphapulse.trading.schedule import routine_requests
from alphapulse.trading.snapshot import snapshot_loop from alphapulse.trading.snapshot import snapshot_loop
logger = logging.getLogger("alphapulse.trading.engine") logger = logging.getLogger("alphapulse.trading.engine")
@@ -57,6 +59,7 @@ async def analysis_loop() -> None:
del tasks[symbol] del tasks[symbol]
if not task.cancelled() and task.exception(): if not task.cancelled() and task.exception():
logger.error("%s 分析任务异常退出", symbol, exc_info=task.exception()) logger.error("%s 分析任务异常退出", symbol, exc_info=task.exception())
await routine_requests()
free = MAX_CONCURRENT_ANALYSES - len(tasks) free = MAX_CONCURRENT_ANALYSES - len(tasks)
if free > 0: if free > 0:
for symbol, reasons in await claim_requests(free, set(tasks)): for symbol, reasons in await claim_requests(free, set(tasks)):
@@ -69,6 +72,7 @@ async def analysis_loop() -> None:
async def main() -> None: async def main() -> None:
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(executor_loop())
tg.create_task(analysis_loop()) tg.create_task(analysis_loop())
+578
View File
@@ -0,0 +1,578 @@
"""执行器:按「应有状态」对账。唯一会下单、撤单、推进计划状态的代码。
每轮对交易列表里的每个币:
1. 同步订单流水与交易所(成交、撤销、触发)。
2. 采纳分析器提交的新计划(按最新持仓核对,过时则作废重算)。
3. 推进计划状态,执行程序规则:到期、时间止损、失效条件、复核条件、无计划持仓。
4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不挂新入场单)。
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai.tools import LOCAL_TZ
from alphapulse.core.crypto import decrypt
from alphapulse.core.db import SessionLocal
from alphapulse.market.binance import BinanceClient
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules
from alphapulse.trading.plan import MAX_HOLD_HOURS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step
from alphapulse.trading.schedule import request_analysis
logger = logging.getLogger("alphapulse.trading.executor")
EXECUTOR_SECONDS = 2
ACTIVE_ORDER_STATUSES = ("submitting", "new", "partially_filled")
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
# 刚下的单可能短时间查不到(C1 实测约 0.25s)
SUBMIT_GRACE_SECONDS = 10
# 止损挂单连续失败(可重试错误)达到次数后撤单平仓
STOP_FAILURE_LIMIT = 3
# 同一错误的时间线事件间隔,避免刷屏
ERROR_EVENT_SECONDS = 600
ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled",
"EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"}
PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"}
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "invalidation": "短线失效条件触发",
"exit": "模型决定结束", "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败",
"close": "市价平仓"}
@dataclass
class Position:
side: str # long / short
qty: Decimal
entry: Decimal
break_even: Decimal
mark: Decimal
def fmt(value: Decimal) -> str:
return format(value, "f")
def utcnow() -> datetime:
return datetime.now(UTC)
class Executor:
def __init__(self) -> None:
self.client: DemoClient | None = None
self.key_ciphertext: bytes | None = None
self.market = BinanceClient()
# 已设置为逐仓 3 倍的币;单向持仓是账户级,只设一次
self.configured: set[str] = set()
self.one_way_checked = False
self.stop_failures: dict[str, int] = {}
self.error_events: dict[str, float] = {}
# (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价)
self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {}
# 已请模型接管的无计划持仓:symbol -> 持仓均价,同一笔持仓只请求一次
self.takeover_requested: dict[str, Decimal] = {}
# ---------- 基础 ----------
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
ciphertext = account.api_key_encrypted if account else None
if ciphertext != self.key_ciphertext:
if self.client is not None:
await self.client.http.aclose()
self.client = None
self.configured.clear()
self.one_way_checked = False
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 report(self, session: AsyncSession, symbol: str | None, message: str, level: str = "error", analysis_id: int | None = None) -> None:
"""同一条错误十分钟内只记一次"""
key = f"{symbol}:{message}"
if time.monotonic() - self.error_events.get(key, -ERROR_EVENT_SECONDS) < ERROR_EVENT_SECONDS:
return
self.error_events[key] = time.monotonic()
logger.warning("%s %s", symbol, message)
session.add(TradingEvent(symbol=symbol, analysis_id=analysis_id, kind="executor_error", level=level, message=message))
async def last_close(self, symbol: str, interval: str) -> Decimal:
"""实盘该周期最近一根已收盘 K 线的收盘价;到下一根收盘前用缓存"""
now_ms = int(time.time() * 1000)
cached = self.kline_cache.get((symbol, interval))
if cached and now_ms < cached[0]:
return cached[1]
rows = await self.market.get("um", "klines", {"symbol": symbol, "interval": interval, "limit": 2})
closed = rows[-1] if rows[-1][6] < now_ms else rows[-2]
# 下一根收盘时间 = 当前未收盘 K 线的 close_time
next_close = rows[-1][6] + 1 if rows[-1][6] >= now_ms else now_ms
self.kline_cache[(symbol, interval)] = (next_close, Decimal(closed[4]))
return Decimal(closed[4])
async def condition_met(self, symbol: str, condition: dict, mark: Decimal) -> tuple[bool, Decimal]:
price = mark if condition["timeframe"] == "mark" else await self.last_close(symbol, condition["timeframe"])
return condition_met(condition["direction"], Decimal(condition["price"]), price), price
async def ensure_symbol_setup(self, client: DemoClient, symbol: str) -> None:
"""单向持仓、逐仓、3 倍杠杆。先查再改:有挂单时即使不需要修改,写操作也可能被拒(-4067)"""
if not self.one_way_checked:
if (await client.request("GET", "/fapi/v1/positionSide/dual"))["dualSidePosition"]:
await client.request("POST", "/fapi/v1/positionSide/dual", {"dualSidePosition": False})
self.one_way_checked = True
if symbol in self.configured:
return
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
if config["marginType"] != "ISOLATED":
await client.request("POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": "ISOLATED"})
if config["leverage"] != 3:
await client.request("POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": 3})
self.configured.add(symbol)
# ---------- 订单 ----------
async def place(self, session: AsyncSession, client: DemoClient, symbol: str, plan_id: int | None, purpose: str, spec: OrderSpec) -> ExchangeError | None:
"""先写流水并提交,再用 ap{id} 下单。结果不明(网络、5xx)时保持 submitting,下一轮按 id 查询,绝不换 id 重发"""
row = TradingOrder(
analysis_id=plan_id, symbol=symbol, purpose=purpose, leg=spec.leg, is_algo=spec.type == "STOP_MARKET",
side=spec.side, type=spec.type, price=spec.price, trigger_price=spec.trigger_price, quantity=spec.quantity,
reduce_only=purpose != "entry", status="submitting",
)
session.add(row)
await session.commit()
client_id = f"ap{row.id}"
try:
if row.is_algo:
result = await client.request("POST", "/fapi/v1/algoOrder", {
"algoType": "CONDITIONAL", "symbol": symbol, "side": spec.side, "type": "STOP_MARKET",
"triggerPrice": fmt(spec.trigger_price), "quantity": fmt(spec.quantity), "reduceOnly": True,
"workingType": "MARK_PRICE", "clientAlgoId": client_id,
})
row.exchange_id, row.status = str(result["algoId"]), "new"
else:
params = {"symbol": symbol, "side": spec.side, "type": spec.type, "quantity": fmt(spec.quantity),
"reduceOnly": row.reduce_only or None, "newClientOrderId": client_id}
if spec.type == "LIMIT":
params |= {"price": fmt(spec.price), "timeInForce": "GTC"}
result = await client.request("POST", "/fapi/v1/order", params)
row.exchange_id = str(result["orderId"])
row.status = ORDER_STATUSES.get(result["status"], result["status"].lower())
except ExchangeError as e:
row.error = str(e)
if not (e.retryable or e.status is None):
row.status = "rejected"
await session.commit()
return e
await session.commit()
return None
async def cancel(self, client: DemoClient, row: TradingOrder) -> None:
await self.cancel_client_id(client, row.symbol, f"ap{row.id}", row.is_algo)
async def cancel_client_id(self, client: DemoClient, symbol: str, client_id: str, is_algo: bool) -> None:
"""撤单后不直接改状态:可能恰好已成交,交给下一轮同步确认"""
try:
if is_algo:
await client.request("DELETE", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
else:
await client.request("DELETE", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
except ExchangeError as e:
# -2011 / -2013:单已不在,等同步
if e.code not in (-2011, -2013):
raise
async def sync_order(self, session: AsyncSession, client: DemoClient, row: TradingOrder, open_orders: dict, open_algos: dict) -> None:
client_id = f"ap{row.id}"
before = row.filled_qty
try:
if row.is_algo:
if client_id in open_algos:
row.exchange_id, row.status = str(open_algos[client_id]["algoId"]), "new"
return
algo = await client.request("GET", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
if algo["algoStatus"] in ("NEW", "TRIGGERING"):
row.status = "new"
return
row.exchange_id = str(algo["algoId"])
if algo["algoStatus"] == "FINISHED" and algo.get("actualOrderId"):
actual = await client.request("GET", "/fapi/v1/order", {"symbol": row.symbol, "orderId": algo["actualOrderId"]})
row.filled_qty = Decimal(actual["executedQty"])
row.avg_price = Decimal(actual["avgPrice"]) or None
row.status = "filled" if actual["status"] == "FILLED" else ORDER_STATUSES.get(actual["status"], "finished")
if row.status == "new":
# 触发后的市价单还没成交完,下一轮再查
return
else:
row.status = algo["algoStatus"].lower()
else:
order = open_orders.get(client_id) or await client.request("GET", "/fapi/v1/order", {"symbol": row.symbol, "origClientOrderId": client_id})
row.exchange_id = str(order["orderId"])
row.status = ORDER_STATUSES.get(order["status"], order["status"].lower())
row.filled_qty = Decimal(order["executedQty"])
row.avg_price = Decimal(order["avgPrice"]) or None
except ExchangeError as e:
if e.code != -2013:
raise
if row.status == "submitting" and utcnow() - row.created_at < timedelta(seconds=SUBMIT_GRACE_SECONDS):
return
row.status, row.error = "failed", "交易所查无此单"
return
if row.filled_qty > before:
delta = row.filled_qty - before
leg = f"第 {row.leg + 1} 档" if row.purpose == "entry" else (f" {row.leg + 1} " if row.purpose == "tp" else "")
session.add(TradingEvent(
symbol=row.symbol, analysis_id=row.analysis_id, kind=f"{row.purpose}_filled",
level="warn" if row.purpose == "stop" else "info",
message=f"{PURPOSE_LABELS[row.purpose]}{leg}成交 {fmt(delta)} @ {row.avg_price}",
data={"order_id": row.id, "quantity": fmt(delta)},
))
if row.purpose == "tp":
await request_analysis(session, row.symbol, f"止盈 {row.leg + 1} 成交")
# ---------- 计划 ----------
async def adopt(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, current: TradingAnalysis | None,
position: Position | None) -> TradingAnalysis | None:
"""采纳最新的提议;较早的提议作废。返回采纳后的当前计划"""
proposals = (await session.scalars(
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol.symbol, TradingAnalysis.plan_status == "proposed")
.order_by(TradingAnalysis.id.desc())
)).all()
if not proposals:
return current
proposal, older = proposals[0], proposals[1:]
for p in older:
p.plan_status = "stale"
now = utcnow()
if proposal.action == "exit":
proposal.plan_status = "applied"
if current is not None:
current.plan_status, current.close_reason = "closing", "exit"
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=current.id, kind="plan_exit", level="warn",
message=f"按分析 #{proposal.id} 结束计划:撤销入场单,有持仓则市价平仓"))
elif position is not None:
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_exit", level="warn",
message=f"按分析 #{proposal.id} 市价平掉无计划的持仓 {fmt(position.qty)}"))
close_side = "SELL" if position.side == "long" else "BUY"
await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
return current
qty = position.qty if position else Decimal(0)
expected = Decimal(proposal.sizing["position_qty"])
if qty != expected or (current is not None and current.plan_status == "closing"):
proposal.plan_status = "stale"
reason = "计划正在平仓" if current is not None and current.plan_status == "closing" else f"持仓已从 {fmt(expected)} 变为 {fmt(qty)}"
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
message=f"计划未采纳:{reason}"))
await request_analysis(session, symbol.symbol, "持仓变化,计划作废重算")
return current
if current is not None:
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
if qty > 0:
proposal.plan_status = "open"
proposal.first_fill_at = current.first_fill_at if current and current.first_fill_at else now
# 持仓时限沿用原计划,不延长;接管无计划持仓时从现在起算
proposal.close_deadline = current.close_deadline if current and current.close_deadline else now + timedelta(hours=MAX_HOLD_HOURS[symbol.angle])
else:
proposal.plan_status = "pending"
proposal.valid_until = now + timedelta(hours=float(proposal.decision["entry_valid_hours"]))
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")))
return proposal
async def realized(self, client: DemoClient, symbol: str, since: datetime | None) -> dict | None:
if since is None:
return None
trades = await client.request("GET", "/fapi/v1/userTrades", {"symbol": symbol, "startTime": int(since.timestamp() * 1000) - 1000, "limit": 1000})
pnl = sum((Decimal(t["realizedPnl"]) for t in trades), Decimal(0))
fee = sum((Decimal(t["commission"]) for t in trades if t["commissionAsset"] == "USDT"), Decimal(0))
return {"realized_pnl": fmt(pnl), "commission": fmt(fee), "net": fmt(pnl - fee)}
async def finish(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rows: list[TradingOrder]) -> None:
"""持仓归零:计划结束,记录原因和盈亏,排队分析"""
if plan.close_reason is None or plan.plan_status == "open":
filled = sorted((r for r in rows if r.filled_qty > 0 and r.purpose in ("stop", "tp", "close")), key=lambda r: r.updated_at)
last = filled[-1].purpose if filled else None
plan.close_reason = {"stop": "stop", "tp": "take_profit", "close": plan.close_reason or "close"}.get(last, "external")
plan.plan_status = "closed" if plan.first_fill_at else "canceled"
plan.closed_at = utcnow()
plan.result = await self.realized(client, plan.symbol, plan.first_fill_at)
net = f",净盈亏 {plan.result['net']} USDT(含手续费 {plan.result['commission']})" if plan.result else ""
label = CLOSE_REASON_LABELS.get(plan.close_reason, plan.close_reason)
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closed", level="warn" if plan.close_reason in ("stop", "stop_failed", "external") else "info",
message=f"计划 #{plan.id} 结束:{label}{net}", data=plan.result))
await request_analysis(session, plan.symbol, f"平仓({label})")
async def start_closing(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str = "") -> None:
plan.plan_status, plan.close_reason = "closing", reason
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closing", level="warn",
message=f"{CLOSE_REASON_LABELS[reason]}{detail}:撤销入场单并市价平仓"))
async def review_once(self, session: AsyncSession, plan_id: int, symbol: str, kind: str, index: int, message: str, reason: str) -> None:
"""失效复核、reevaluate 每个条件只触发一次:以时间线事件去重"""
exists = await session.scalar(select(func.count()).where(
TradingEvent.analysis_id == plan_id, TradingEvent.kind == kind, TradingEvent.data["index"].as_integer() == index,
))
if exists:
return
session.add(TradingEvent(symbol=symbol, analysis_id=plan_id, kind=kind, level="warn", message=message, data={"index": index}))
await request_analysis(session, symbol, reason)
# ---------- 对账 ----------
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None) -> None:
name = symbol.symbol
rules = await symbol_rules(name)
open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})}
open_algos = {o["clientAlgoId"]: o for o in await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": name})}
rows = list((await session.scalars(
select(TradingOrder).where(TradingOrder.symbol == name, TradingOrder.status.in_(ACTIVE_ORDER_STATUSES))
)).all())
for row in rows:
await self.sync_order(session, client, row, open_orders, open_algos)
await session.commit()
# 交易所上本程序的单,但流水里不是活动状态:孤儿单,撤掉(用户手动下的单不动)
active_ids = {f"ap{r.id}" for r in rows if r.status in ACTIVE_ORDER_STATUSES}
for client_id, order in [*open_orders.items(), *open_algos.items()]:
if OWN_CLIENT_ID.match(client_id) and client_id not in active_ids:
await self.cancel_client_id(client, name, client_id, client_id in open_algos)
session.add(TradingEvent(symbol=name, kind="orphan_canceled", level="warn", message=f"撤销孤儿单 {client_id}({order.get('type') or order.get('orderType')})"))
plan = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.symbol == name, TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
plan = await self.adopt(session, client, symbol, plan, position)
await session.commit()
# 不属于当前计划的活动订单(被替换、已结束的计划):入场和止盈先撤;止损等新止损挂上后再撤,不留无保护窗口
stale = [r for r in rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose != "close" and (plan is None or r.analysis_id != plan.id)]
for row in stale:
if row.purpose != "stop":
await self.cancel(client, row)
stale_entry_canceled = any(r.purpose == "entry" for r in stale)
if plan is None:
for row in stale:
if row.purpose == "stop":
await self.cancel(client, row)
if position is None:
self.takeover_requested.pop(name, None)
elif running:
await self.unmanaged(session, client, name, position, open_algos)
if running:
await self.check_reevaluate(session, name, position.mark if position else None)
await session.commit()
return
direction = plan.sizing["direction"]
if position is not None and position.side != direction:
await self.report(session, name, f"持仓方向({position.side})与计划 #{plan.id} 方向({direction})不一致,暂停该币的下单", analysis_id=plan.id)
await session.commit()
return
qty = position.qty if position else Decimal(0)
now = utcnow()
plan_rows = list((await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id))).all())
if plan.plan_status == "pending":
if qty > 0:
plan.plan_status, plan.first_fill_at = "open", now
plan.close_deadline = now + timedelta(hours=float(plan.decision["max_hold_hours"]))
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open",
message=f"首档成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
await request_analysis(session, name, "首档成交")
elif now >= plan.valid_until or not running:
plan.plan_status, plan.closed_at = "canceled", now
plan.close_reason = "expired" if running else "stopped"
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_canceled",
message=f"计划 #{plan.id} 撤销:{'入场单到期未成交' if running else '已停止交易,撤销未成交的入场单'}"))
if running:
await request_analysis(session, name, "计划到期未成交")
elif plan.plan_status == "open":
if qty == 0:
await self.finish(session, client, plan, plan_rows)
elif now >= plan.close_deadline:
await self.start_closing(session, plan, "time")
else:
await self.check_invalidation(session, plan, position)
elif plan.plan_status == "closing" and qty == 0:
await self.finish(session, client, plan, plan_rows)
await session.commit()
if plan.plan_status in LIVE_PLAN_STATUSES:
await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
else:
# 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位
for row in plan_rows:
if row.status in ACTIVE_ORDER_STATUSES and row.purpose != "close":
await self.cancel(client, row)
for row in stale:
if row.purpose == "stop":
await self.cancel(client, row)
if running and plan.plan_status != "closing":
await self.check_reevaluate(session, name, position.mark if position else None)
await session.commit()
async def check_invalidation(self, session: AsyncSession, plan: TradingAnalysis, position: Position) -> None:
condition = plan.decision["invalidation"]
met, price = await self.condition_met(plan.symbol, condition, position.mark)
if not met:
return
detail = f"({condition['timeframe']} 收盘 {fmt(price)} {'高于' if condition['direction'] == 'above' else '低于'} {condition['price']})"
if plan.angle == "short":
await self.start_closing(session, plan, "invalidation", detail)
else:
await self.review_once(session, plan.id, plan.symbol, "invalidation_review", 0,
f"长线失效条件触发{detail},请模型复核", f"失效条件触发{detail}")
async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None:
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
latest = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.dry_run.is_(False))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
if latest is None or not latest.decision["reevaluate"]:
return
if mark is None and any(c["timeframe"] == "mark" for c in latest.decision["reevaluate"]):
mark = await mark_price(symbol)
for i, condition in enumerate(latest.decision["reevaluate"]):
met, price = await self.condition_met(symbol, condition, mark)
if met:
text = f"{condition['timeframe']} {'高于' if condition['direction'] == 'above' else '低于'} {condition['price']}(现 {fmt(price)})"
await self.review_once(session, latest.id, symbol, "reevaluate_triggered", i, f"复核条件满足:{text}", f"复核条件满足:{text}")
async def unmanaged(self, session: AsyncSession, client: DemoClient, symbol: str, position: Position, open_algos: dict) -> None:
"""有持仓但没有计划:有止损就请模型接管,没有止损就市价平仓"""
close_side = "SELL" if position.side == "long" else "BUY"
has_stop = any(o["side"] == close_side and o["orderType"].startswith("STOP") for o in open_algos.values())
if has_stop:
if self.takeover_requested.get(symbol) != position.entry:
self.takeover_requested[symbol] = position.entry
session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn",
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划,请模型接管"))
await request_analysis(session, symbol, "有持仓但没有计划,请接管")
return
closing = await session.scalar(select(func.count()).where(
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
))
if closing:
return
session.add(TradingEvent(symbol=symbol, kind="unmanaged_closed", level="error",
message=f"{symbol} 有持仓 {fmt(position.qty)} 但没有计划也没有止损,市价平仓"))
await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
async def sync_orders(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, plan_rows: list[TradingOrder],
position: Position | None, running: bool, rules: SymbolRules, block_entries: bool) -> None:
sizing = plan.sizing
qty = position.qty if position else Decimal(0)
n_entries, n_tps = len(sizing["entries"]), len(sizing["take_profits"])
entry_filled = [sum((r.filled_qty for r in plan_rows if r.purpose == "entry" and r.leg == i), Decimal(0)) for i in range(n_entries)]
tp_filled = [sum((r.filled_qty for r in plan_rows if r.purpose == "tp" and r.leg == j), Decimal(0)) for j in range(n_tps)]
tp1_hit = tp_filled[0] > 0
stop = Decimal(sizing["stop"])
if tp1_hit and position is not None:
# 止盈 1 后止损移到保本价:做多向上取整、做空向下取整,保证不亏;只收紧不放宽
long = sizing["direction"] == "long"
break_even = round_to_step(position.break_even, rules.tick_size, ROUND_CEILING if long else ROUND_FLOOR)
stop = max(stop, break_even) if long else min(stop, break_even)
active = [r for r in plan_rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose in ("entry", "stop", "tp")]
long = sizing["direction"] == "long"
if plan.plan_status == "closing":
# 平仓中:撤入场和止盈,保留已有止损直到市价平仓成交(止损可能正因越过现价挂不上,不再补挂)
for row in active:
if row.purpose != "stop":
await self.cancel(client, row)
if qty > 0 and not any(r.purpose == "close" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows):
close_side = "SELL" if long else "BUY"
await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty))
return
entries_open = running and utcnow() < (plan.valid_until or utcnow()) and not tp1_hit
desired = desired_orders(sizing, entries_open=entries_open, entry_filled=entry_filled, tp_filled=tp_filled,
position_qty=qty, stop_price=stop, step_size=rules.step_size)
actual = [(r, OrderSpec(r.purpose, r.leg, r.side, r.type, r.price, r.trigger_price, r.quantity - r.filled_qty)) for r in active]
to_cancel, to_place = diff_orders(desired, actual)
entries = [d for d in to_place if d.purpose == "entry"]
if entries:
# 保证金模式和杠杆在有挂单时不能改,必须在挂任何单之前设好
await self.ensure_symbol_setup(client, plan.symbol)
# 1. 止损先挂新的再撤旧的(只减仓止损可以并存),任何时候都不留无保护窗口
for spec in [d for d in to_place if d.purpose == "stop"]:
error = await self.place(session, client, plan.symbol, plan.id, "stop", spec)
if error is not None:
await self.stop_failed(session, plan, error)
return
self.stop_failures.pop(plan.symbol, None)
# 2. 撤多余的单
canceled_entry = False
for row in to_cancel:
await self.cancel(client, row)
canceled_entry = canceled_entry or row.purpose == "entry"
# 3. 补止盈;入场单只在本轮没有撤旧入场单、且止损在场时挂
has_stop = any(r.purpose == "stop" for r in active if r not in to_cancel) or any(d.purpose == "stop" for d in to_place)
for spec in [d for d in to_place if d.purpose == "tp"]:
await self.place(session, client, plan.symbol, plan.id, "tp", spec)
if entries and has_stop and not canceled_entry and not block_entries:
for spec in entries:
await self.place(session, client, plan.symbol, plan.id, "entry", spec)
async def stop_failed(self, session: AsyncSession, plan: TradingAnalysis, error: ExchangeError) -> None:
"""会立即触发(-2021)或不可重试的错误:撤单平仓;可重试的错误连续失败多次后同样处理"""
if error.retryable or error.status is None:
count = self.stop_failures[plan.symbol] = self.stop_failures.get(plan.symbol, 0) + 1
if count < STOP_FAILURE_LIMIT:
await self.report(session, plan.symbol, f"挂止损失败(第 {count} 次),下一轮重试:{error}", "warn", plan.id)
return
self.stop_failures.pop(plan.symbol, None)
reason = "止损价已越过现价" if error.code == -2021 else f"挂止损失败:{error}"
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="stop_failed", level="error", message=reason))
await self.start_closing(session, plan, "stop_failed")
# ---------- 主循环 ----------
async def tick(self) -> None:
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
symbols = list((await session.scalars(select(TradingSymbol))).all())
client = await self.ensure_client(account)
if client is None or not symbols:
return
positions = {}
for p in await client.request("GET", "/fapi/v3/positionRisk"):
amount = Decimal(p["positionAmt"])
if amount != 0:
positions[p["symbol"]] = Position(
side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]),
break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]),
)
for symbol in symbols:
try:
await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol))
except ExchangeError as e:
await session.rollback()
await self.report(session, symbol.symbol, f"对账失败:{e}")
await session.commit()
except Exception as e:
logger.exception("%s 对账出错", symbol.symbol)
await session.rollback()
await self.report(session, symbol.symbol, f"对账出错:{e!r}")
await session.commit()
async def executor_loop() -> None:
executor = Executor()
while True:
try:
await executor.tick()
except ExchangeError as e:
logger.warning("执行器同步失败:%s", e)
except Exception:
logger.exception("执行器出错,稍后重试")
await asyncio.sleep(EXECUTOR_SECONDS)
+161 -4
View File
@@ -4,6 +4,7 @@
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Annotated, Literal from typing import Annotated, Literal
@@ -32,6 +33,10 @@ TAKER_FEE_RATE = Decimal("0.0004")
# 模拟盘 PERCENT_PRICE:买单价不高于标记价 × 1.05,卖单价不低于标记价 × 0.95 # 模拟盘 PERCENT_PRICE:买单价不高于标记价 × 1.05,卖单价不低于标记价 × 0.95
PERCENT_PRICE_LIMIT = Decimal("0.05") PERCENT_PRICE_LIMIT = Decimal("0.05")
RATIO_TOLERANCE = Decimal("0.001") RATIO_TOLERANCE = Decimal("0.001")
# 例行分析周期(小时):短线跟 1h、长线跟 4h 收盘
ROUTINE_HOURS = {"short": 1, "long": 4}
# 同一个币两次自动分析的最短间隔
MIN_ANALYSIS_GAP = timedelta(minutes=10)
Price = Annotated[Decimal, Field(gt=0)] Price = Annotated[Decimal, Field(gt=0)]
@@ -99,6 +104,11 @@ class PlanContext:
rules: SymbolRules rules: SymbolRules
has_plan: bool has_plan: bool
position_side: Direction | None position_side: Direction | None
position_qty: Decimal = Decimal(0)
# 持仓均价
position_entry: Decimal | None = None
# 当前生效的止损价(可能已移到保本价);新计划只能收紧
current_stop: Decimal | None = None
class PlanError(Exception): class PlanError(Exception):
@@ -142,14 +152,27 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
def check_plan_fields(plan: TradePlan, ctx: PlanContext) -> list[str]: def check_plan_fields(plan: TradePlan, ctx: PlanContext) -> list[str]:
"""action=plan 时的字段完整性、档数、比例、有效期""" """action=plan 时的字段完整性、档数、比例、有效期。有持仓时只调整止损止盈等,不加仓"""
required = ("direction", "confidence", "entries", "stop", "take_profits", "entry_valid_hours", "max_hold_hours", "invalidation") holding = ctx.position_side is not None
required = ("direction", "confidence", "stop", "take_profits", "invalidation")
if not holding:
required += ("entries", "entry_valid_hours", "max_hold_hours")
missing = [name for name in required if getattr(plan, name) is None] missing = [name for name in required if getattr(plan, name) is None]
if missing: if missing:
return [f"action=plan 时缺少:{', '.join(missing)}"] return [f"action=plan 时缺少:{', '.join(missing)}"]
errors = [] errors = []
angle = ctx.angle angle = ctx.angle
label = "短线" if angle == "short" else "长线" label = "短线" if angle == "short" else "长线"
if holding:
if plan.entries:
errors.append("已有持仓,新计划不加仓:entries 须为空,只调整止损、止盈、失效条件和 reevaluate")
if plan.direction != ctx.position_side:
errors.append(f"已有{'多' if ctx.position_side == 'long' else '空'}头持仓,新计划方向必须一致;要反手请先用 exit")
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.invalidation.timeframe not in INVALIDATION_TIMEFRAMES[angle]:
errors.append(f"{label}失效条件的周期只能是 {'/'.join(INVALIDATION_TIMEFRAMES[angle])}")
return errors
low, high = ENTRY_COUNT[angle] low, high = ENTRY_COUNT[angle]
if not low <= len(plan.entries) <= high: 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)}") errors.append(f"{label}入场档数须为 {low}–{high},现为 {len(plan.entries)}" if low != high else f"{label}只能 {low} 档入场,现为 {len(plan.entries)}")
@@ -163,8 +186,6 @@ def check_plan_fields(plan: TradePlan, ctx: PlanContext) -> list[str]:
errors.append(f"{label} 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]: if plan.invalidation.timeframe not in INVALIDATION_TIMEFRAMES[angle]:
errors.append(f"{label}失效条件的周期只能是 {'/'.join(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 return errors
@@ -214,6 +235,8 @@ def size_plan(plan: TradePlan, ctx: PlanContext) -> dict:
errors += check_plan_fields(plan, ctx) errors += check_plan_fields(plan, ctx)
if errors: if errors:
raise PlanError(errors) raise PlanError(errors)
if ctx.position_side is not None:
return size_managed(plan, ctx)
angle, direction, rules = ctx.angle, plan.direction, ctx.rules angle, direction, rules = ctx.angle, plan.direction, ctx.rules
# 向保守方向取整:做多的入场、止损、止盈都向下(入场更低、止损更远、止盈更近),做空都向上 # 向保守方向取整:做多的入场、止损、止盈都向下(入场更低、止损更远、止盈更近),做空都向上
@@ -282,4 +305,138 @@ def size_plan(plan: TradePlan, ctx: PlanContext) -> dict:
"capped_by_leverage": by_leverage < by_risk, "capped_by_leverage": by_leverage < by_risk,
"reward_risk": str(rr.quantize(Decimal("0.01"))), "reward_risk": str(rr.quantize(Decimal("0.01"))),
"reward_risk_first_only": str(rr_first.quantize(Decimal("0.01"))), "reward_risk_first_only": str(rr_first.quantize(Decimal("0.01"))),
# 执行器采纳前核对:持仓变了说明计划基于过时状态
"position_qty": "0",
"managed": False,
} }
def size_managed(plan: TradePlan, ctx: PlanContext) -> dict:
"""有持仓时的新计划:不加仓,止损只能收紧,止盈按当前持仓数量拆分"""
direction, rules = plan.direction, ctx.rules
rounding = ROUND_FLOOR if direction == "long" else ROUND_CEILING
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]
sign = 1 if direction == "long" else -1
mark, invalidation = ctx.mark_price, plan.invalidation.price
def below(a: Decimal, b: Decimal) -> bool:
return sign * (b - a) > 0
side = "做多" if direction == "long" else "做空"
errors = []
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) and below(invalidation, mark)):
errors.append(f"{side}时须满足 止损 {stop} → 失效价 {invalidation} → 现价 {mark} 的顺序")
if ctx.current_stop is not None and below(stop, ctx.current_stop):
errors.append(f"止损只能收紧:当前止损 {ctx.current_stop},新止损 {stop} 更宽")
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("止盈价须按由近到远排列且不重复")
if errors:
raise PlanError(errors)
tp_ratios = TAKE_PROFIT_RATIOS[ctx.angle]
entry = ctx.position_entry
quantity = ctx.position_qty
rr = reward_risk(mark, stop, targets, tp_ratios) if ctx.angle == "long" else reward_risk(mark, stop, targets[:1], (Decimal(1),))
loss = quantity * (sign * (entry - stop) + (entry + stop) * TAKER_FEE_RATE)
return {
"direction": direction,
"entries": [],
"stop": str(stop),
"take_profits": [
{"price": str(t), "ratio": str(r), "quantity": str(q)}
for t, r, q in zip(targets, tp_ratios, split_take_profits(quantity, tp_ratios, rules.step_size), strict=True)
],
"average_price": str(entry),
"quantity": str(quantity),
"notional": str((entry * quantity).quantize(Decimal("0.01"))),
"capital": str(ctx.capital),
"leverage": str((entry * quantity / ctx.capital).quantize(Decimal("0.01"))),
"risk_amount": "0",
# 止损已越过成本价时为负,表示锁定盈利
"risk_at_stop": str(loss.quantize(Decimal("0.01"))),
"risk_pct": str((loss / ctx.capital * 100).quantize(Decimal("0.01"))),
"capped_by_leverage": False,
# 按现价计算
"reward_risk": str(rr.quantize(Decimal("0.01"))),
"reward_risk_first_only": str(rr.quantize(Decimal("0.01"))),
"position_qty": str(quantity),
"managed": True,
}
# ---------- 执行:应有订单 ----------
@dataclass(frozen=True)
class OrderSpec:
"""一张应有(或现有)的订单;quantity 为剩余未成交数量"""
purpose: Literal["entry", "stop", "tp", "close"]
leg: int | None
side: Literal["BUY", "SELL"]
type: Literal["LIMIT", "STOP_MARKET", "MARKET"]
price: Decimal | None
trigger_price: Decimal | None
quantity: Decimal
def desired_orders(
sizing: dict, *, entries_open: bool, entry_filled: list[Decimal], tp_filled: list[Decimal],
position_qty: Decimal, stop_price: Decimal, step_size: Decimal,
) -> list[OrderSpec]:
"""计划在当前成交状态下应挂的单。
- 入场:entries_open 时各档剩余数量。
- 止损:有持仓或有入场单时一张只减仓止损,数量取计划总量与持仓的较大者(数量大于持仓时交易所只平实际持仓)。
- 止盈:有持仓时按累计成交数量拆分,减去各档已成交。
"""
long = sizing["direction"] == "long"
open_side, close_side = ("BUY", "SELL") if long else ("SELL", "BUY")
orders = []
if entries_open:
for i, e in enumerate(sizing["entries"]):
remaining = Decimal(e["quantity"]) - entry_filled[i]
if remaining > 0:
orders.append(OrderSpec("entry", i, open_side, "LIMIT", Decimal(e["price"]), None, remaining))
if position_qty > 0 or orders:
quantity = max(Decimal(sizing["quantity"]), position_qty)
orders.append(OrderSpec("stop", None, close_side, "STOP_MARKET", None, stop_price, quantity))
if position_qty > 0:
filled_total = Decimal(sizing["quantity"]) if sizing["managed"] else sum(entry_filled, Decimal(0))
ratios = tuple(Decimal(t["ratio"]) for t in sizing["take_profits"])
for j, (t, target) in enumerate(zip(sizing["take_profits"], split_take_profits(filled_total, ratios, step_size), strict=True)):
remaining = target - tp_filled[j]
if remaining > 0:
orders.append(OrderSpec("tp", j, close_side, "LIMIT", Decimal(t["price"]), None, remaining))
return orders
def diff_orders[K](desired: list[OrderSpec], actual: list[tuple[K, OrderSpec]]) -> tuple[list[K], list[OrderSpec]]:
"""对比应有与现有订单:返回(要撤销的现有订单,要新挂的订单)。价格、数量完全一致才算同一张"""
missing = list(desired)
cancel = []
for key, spec in actual:
if spec in missing:
missing.remove(spec)
else:
cancel.append(key)
return cancel, missing
def condition_met(direction: Literal["above", "below"], threshold: Decimal, price: Decimal) -> bool:
return price > threshold if direction == "above" else price < threshold
def next_routine_at(last_started: datetime, angle: Angle) -> datetime:
"""例行分析时间:上次分析后约 3/4 个周期之后的第一个 K 线收盘,再加 1 分钟等收盘数据就绪。
任何一次分析(包括触发式)都会让例行分析重新计时"""
period = timedelta(hours=ROUTINE_HOURS[angle])
# 币安 K 线按 UTC 对齐
earliest = (last_started + period * 3 / 4).astimezone(UTC)
day = earliest.replace(hour=0, minute=0, second=0, microsecond=0)
boundary = day + period * -(-(earliest - day) // period)
return boundary + timedelta(minutes=1)
@@ -0,0 +1,68 @@
"""自动分析的排队:触发式(成交、止盈、平仓、到期、条件满足)和例行。只在交易中排队,手动分析走 API"""
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai.tools import LOCAL_TZ
from alphapulse.core.db import SessionLocal
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT, MIN_ANALYSIS_GAP, next_routine_at
# 自动分析失败后的重试:间隔、连续失败上限
RETRY_AFTER = timedelta(minutes=5)
MAX_CONSECUTIVE_FAILURES = 3
async def daily_limit_reached(session: AsyncSession) -> bool:
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
count = await session.scalar(select(func.count()).where(TradingAnalysis.started_at >= today))
if count < DAILY_ANALYSIS_LIMIT:
return False
noticed = await session.scalar(select(func.count()).where(TradingEvent.kind == "daily_limit", TradingEvent.created_at >= today))
if not noticed:
session.add(TradingEvent(kind="daily_limit", level="warn",
message=f"今日自动分析已达 {DAILY_ANALYSIS_LIMIT} 次上限,之后只执行程序规则(止损、止盈、到期平仓等)"))
return True
async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> None:
"""排队一次自动分析:多个原因合并;距上次分析不足最短间隔时顺延。调用方负责 commit"""
account = await session.get(TradingAccount, 1)
target = await session.get(TradingSymbol, symbol)
if account is None or not account.running or target is None or await daily_limit_reached(session):
return
now = datetime.now(UTC)
last_started = await session.scalar(select(func.max(TradingAnalysis.started_at)).where(TradingAnalysis.symbol == symbol))
at = max(now, last_started + MIN_ANALYSIS_GAP) if last_started else now
target.analysis_requested_at = min(target.analysis_requested_at, at) if target.analysis_requested_at else at
if reason not in target.analysis_reasons:
# JSONB 列要整体赋值才会被识别为修改
target.analysis_reasons = [*target.analysis_reasons, reason]
async def routine_requests() -> None:
"""交易中:到例行时间的币排队例行分析;自动分析失败的隔一段时间重试"""
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
if account is None or not account.running:
return
now = datetime.now(UTC)
for target in (await session.scalars(select(TradingSymbol))).all():
if target.analysis_requested_at is not None:
continue
recent = (await session.scalars(
select(TradingAnalysis).where(TradingAnalysis.symbol == target.symbol)
.order_by(TradingAnalysis.id.desc()).limit(MAX_CONSECUTIVE_FAILURES)
)).all()
if recent and recent[0].status == "running":
continue
if not recent or now >= next_routine_at(recent[0].started_at, target.angle):
await request_analysis(session, target.symbol, "例行分析")
continue
last = recent[0]
all_failed = len(recent) == MAX_CONSECUTIVE_FAILURES and all(a.status == "failed" for a in recent)
if last.status == "failed" and last.reasons != ["手动分析"] and not all_failed and now >= last.finished_at + RETRY_AFTER:
await request_analysis(session, target.symbol, f"重试:{';'.join(last.reasons)}")
await session.commit()
+101 -1
View File
@@ -1,8 +1,12 @@
from datetime import UTC, datetime, timedelta, timezone
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
import pytest import pytest
from alphapulse.trading.plan import PlanContext, PlanError, SymbolRules, TradePlan, round_to_step, size_plan, split_take_profits from alphapulse.trading.plan import (
OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, desired_orders, diff_orders, next_routine_at, round_to_step,
size_plan, split_take_profits,
)
BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50")) BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50"))
@@ -137,3 +141,99 @@ def test_action_rules():
reevaluate=[{"direction": "above", "price": "81000", "timeframe": "1h", "basis": "x"}]) reevaluate=[{"direction": "above", "price": "81000", "timeframe": "1h", "basis": "x"}])
assert "已经满足" in errors_of(satisfied, context()) assert "已经满足" in errors_of(satisfied, context())
assert "方向必须一致" in errors_of(long_plan(), context(position_side="short")) assert "方向必须一致" in errors_of(long_plan(), context(position_side="short"))
# ---------- 有持仓时的新计划 ----------
def managed_plan(**overrides) -> TradePlan:
values = {
"action": "plan", "summary": "调整", "reasons": "r", "risks": "x", "direction": "long", "confidence": "medium",
"stop": "80000", "take_profits": [{"price": "83000", "basis": "a"}, {"price": "84000", "basis": "b"}, {"price": "85000", "basis": "c"}],
"invalidation": {"direction": "below", "price": "80500", "timeframe": "4h", "basis": "x"},
}
return TradePlan.model_validate({**values, **overrides})
def holding(**overrides) -> PlanContext:
return context(position_side="long", position_qty=Decimal("0.0100"), position_entry=Decimal("81000"), current_stop=Decimal("79500"), has_plan=True, **overrides)
def test_managed_plan_does_not_add_and_splits_position():
sizing = size_plan(managed_plan(), holding())
assert sizing["managed"] is True
assert sizing["entries"] == []
assert [t["quantity"] for t in sizing["take_profits"]] == ["0.0050", "0.0030", "0.0020"]
assert sizing["position_qty"] == "0.0100"
assert "entries 须为空" in errors_of(managed_plan(entries=[{"price": "80000", "ratio": "1", "basis": "a"}]), holding())
def test_managed_plan_only_tightens_stop():
assert "止损只能收紧" in errors_of(managed_plan(stop="79000", invalidation={"direction": "below", "price": "79400", "timeframe": "4h", "basis": "x"}), holding())
# ---------- 应有订单 ----------
LONG_SIZING = {
"direction": "long", "managed": False, "quantity": "0.0030",
"entries": [{"price": "81000.0", "quantity": "0.0012"}, {"price": "80000.0", "quantity": "0.0018"}],
"take_profits": [{"price": "83000.0", "ratio": "0.5"}, {"price": "84000.0", "ratio": "0.3"}, {"price": "85000.0", "ratio": "0.2"}],
}
ZERO3 = [Decimal(0)] * 3
def test_desired_orders_before_fill_places_entries_and_stop_together():
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3,
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001"))
assert [(o.purpose, o.leg, o.side, o.quantity) for o in orders] == [
("entry", 0, "BUY", Decimal("0.0012")), ("entry", 1, "BUY", Decimal("0.0018")), ("stop", None, "SELL", Decimal("0.0030")),
]
def test_desired_orders_partial_fill_splits_take_profits_by_filled():
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal("0.0012"), Decimal("0.0004")], tp_filled=ZERO3,
position_qty=Decimal("0.0016"), stop_price=Decimal("79500"), step_size=Decimal("0.0001"))
assert [(o.purpose, o.leg, o.quantity) for o in orders] == [
("entry", 1, Decimal("0.0014")), ("stop", None, Decimal("0.0030")),
("tp", 0, Decimal("0.0008")), ("tp", 1, Decimal("0.0004")), ("tp", 2, Decimal("0.0004")),
]
def test_desired_orders_after_tp1_keeps_remaining_targets_only():
orders = desired_orders(LONG_SIZING, entries_open=False, entry_filled=[Decimal("0.0012"), Decimal("0.0018")],
tp_filled=[Decimal("0.0015"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0015"),
stop_price=Decimal("81050"), step_size=Decimal("0.0001"))
assert [(o.purpose, o.leg, o.quantity, o.trigger_price) for o in orders] == [
("stop", None, Decimal("0.0030"), Decimal("81050")), ("tp", 1, Decimal("0.0009"), None), ("tp", 2, Decimal("0.0006"), None),
]
def test_desired_orders_nothing_when_flat_and_entries_closed():
assert desired_orders(LONG_SIZING, entries_open=False, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3,
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001")) == []
def test_diff_orders_matches_by_value():
keep = OrderSpec("stop", None, "SELL", "STOP_MARKET", None, Decimal("79500.0"), Decimal("0.0030"))
stale = OrderSpec("entry", 0, "BUY", "LIMIT", Decimal("81000"), None, Decimal("0.0012"))
wanted = OrderSpec("entry", 0, "BUY", "LIMIT", Decimal("80900"), None, Decimal("0.0012"))
cancel, place = diff_orders([OrderSpec("stop", None, "SELL", "STOP_MARKET", None, Decimal("79500"), Decimal("0.003")), wanted],
[(1, keep), (2, stale)])
assert cancel == [2]
assert place == [wanted]
def test_next_routine_at_aligns_to_candle_close():
tz8 = timezone(timedelta(hours=8))
# 北京 10:20 = UTC 02:20;短线 +45 分钟后的第一个整点 UTC 04:00,再加 1 分钟
assert next_routine_at(datetime(2026, 10, 15, 10, 20, tzinfo=tz8), "short") == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
assert next_routine_at(datetime(2026, 10, 15, 3, 1, tzinfo=UTC), "short") == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
# 长线:UTC 04:01 + 3h = 07:01,下一个 4h 收盘 08:00
assert next_routine_at(datetime(2026, 10, 15, 4, 1, tzinfo=UTC), "long") == datetime(2026, 10, 15, 8, 1, tzinfo=UTC)
def test_step_decimal_never_uses_exponent():
from alphapulse.trading.client import step_decimal
assert str(step_decimal("0.10")) == "0.1"
assert str(step_decimal("100")) == "100"
assert str(round_to_step(Decimal("12345"), step_decimal("100"), ROUND_FLOOR)) == "12300"
+45 -3
View File
@@ -77,7 +77,10 @@ export interface TradingSymbol {
added_at: string added_at: string
analysis_requested_at: string | null analysis_requested_at: string | null
analyzing: boolean analyzing: boolean
plan_status: "pending" | "open" | null /** 执行器正在管理的计划 */
plan_id: number | null
plan_status: "pending" | "open" | "closing" | null
close_deadline: string | null
} }
export interface SymbolOption { export interface SymbolOption {
@@ -183,6 +186,10 @@ export interface Sizing {
capped_by_leverage: boolean capped_by_leverage: boolean
reward_risk: string reward_risk: string
reward_risk_first_only: string reward_risk_first_only: string
/** 采纳前核对用:提交时的持仓数量 */
position_qty: string
/** 持仓中调整的计划:不加仓 */
managed: boolean
} }
export interface Analysis { export interface Analysis {
@@ -201,13 +208,13 @@ export interface Analysis {
usage: { input_tokens: number, output_tokens: number } | null usage: { input_tokens: number, output_tokens: number } | null
started_at: string started_at: string
finished_at: string | null finished_at: string | null
plan_status: "pending" | "open" | "closed" | "canceled" | "replaced" | null plan_status: PlanStatus | null
valid_until: string | null valid_until: string | null
first_fill_at: string | null first_fill_at: string | null
close_deadline: string | null close_deadline: string | null
closed_at: string | null closed_at: string | null
close_reason: string | null close_reason: string | null
result: Record<string, unknown> | null result: { realized_pnl: string, commission: string, net: string } | null
} }
/** 与协议无关的消息格式,同 Chat */ /** 与协议无关的消息格式,同 Chat */
@@ -221,6 +228,41 @@ export interface AnalysisDetail extends Analysis {
transcript: TranscriptMessage[] | null transcript: TranscriptMessage[] | null
} }
export type PlanStatus = "proposed" | "pending" | "open" | "closing" | "closed" | "canceled" | "replaced" | "stale" | "applied"
export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = {
proposed: "待采纳",
pending: "挂单中",
open: "持仓中",
closing: "平仓中",
closed: "已结束",
canceled: "已撤销",
replaced: "已被替换",
stale: "已作废",
applied: "已执行"
}
/** 执行器的订单流水 */
export interface OrderRow {
id: number
purpose: "entry" | "stop" | "tp" | "close"
leg: number | null
side: "BUY" | "SELL"
type: string
price: string | null
trigger_price: string | null
quantity: string
status: string
filled_qty: string
avg_price: string | null
error: string | null
created_at: string
}
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
return http.get(`/trading/symbols/${symbol}/plan`)
}
export function requestAnalysis(symbol: string): Promise<void> { export function requestAnalysis(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/analyze`) return http.post(`/trading/symbols/${symbol}/analyze`)
} }
+55 -4
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Analysis, PlanCondition, TradingSymbol } from "@/apis/trading" import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading"
import { ANGLE_LABELS } from "@/apis/trading" import { ANGLE_LABELS, PLAN_STATUS_LABELS } from "@/apis/trading"
import { formatTime } from "@/utils/text" import { formatTime } from "@/utils/text"
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, requesting: boolean }>() /** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean }>()
const emit = defineEmits<{ analyze: [], detail: [id: number] }>() const emit = defineEmits<{ analyze: [], detail: [id: number] }>()
const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const
@@ -19,6 +20,20 @@ const sizing = computed(() => props.analysis?.sizing ?? null)
const busy = computed(() => props.requesting || props.symbol.analyzing || !!props.symbol.analysis_requested_at) 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) const elapsed = computed(() => props.analysis ? Math.max(0, Math.round((now.value - new Date(props.analysis.started_at).getTime()) / 1000)) : 0)
function filled(purpose: "entry" | "tp", leg: number) {
const total = props.orders.filter(o => o.purpose === purpose && o.leg === leg).reduce((sum, o) => sum + Number(o.filled_qty), 0)
return total ? String(Number(total.toFixed(8))) : "-"
}
function countdown(deadline: string | null) {
if (!deadline)
return ""
const seconds = Math.round((new Date(deadline).getTime() - now.value) / 1000)
if (seconds <= 0)
return "已到期"
return `${Math.floor(seconds / 3600)} 小时 ${Math.floor(seconds % 3600 / 60)} 分`
}
function condition(c: PlanCondition) { function condition(c: PlanCondition) {
return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}` return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}`
} }
@@ -56,6 +71,9 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
<el-tag v-if="analysis.dry_run" type="info" size="small"> <el-tag v-if="analysis.dry_run" type="info" size="small">
试运行 试运行
</el-tag> </el-tag>
<el-tag v-if="analysis.plan_status" type="success" effect="dark" size="small">
{{ PLAN_STATUS_LABELS[analysis.plan_status] }}
</el-tag>
<el-tag size="small"> <el-tag size="small">
{{ ACTION_LABELS[decision.action] }} {{ ACTION_LABELS[decision.action] }}
</el-tag> </el-tag>
@@ -73,10 +91,33 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
<div class="font-bold"> <div class="font-bold">
{{ decision.summary }} {{ decision.summary }}
</div> </div>
<div v-if="latest && latest.id !== analysis.id" class="text-xs text-[var(--el-text-color-secondary)]">
最近一次分析 #{{ latest.id }}({{ formatTime(latest.started_at) }}):
<template v-if="latest.status === 'done'">
{{ latest.decision?.summary }}
</template>
<template v-else>
{{ latest.status === "running" ? "分析中" : `失败:${latest.error}` }}
</template>
<el-link type="primary" underline="never" class="ml-1 text-xs!" @click="emit('detail', latest.id)">
查看
</el-link>
</div>
<div v-if="analysis.valid_until || analysis.close_deadline" class="flex flex-wrap gap-x-6">
<span v-if="analysis.plan_status === 'pending' && analysis.valid_until"><span class="label">入场单有效至</span>{{ formatTime(analysis.valid_until, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.valid_until) }})</span>
<span v-if="analysis.first_fill_at"><span class="label">首档成交</span>{{ formatTime(analysis.first_fill_at, "MM-DD HH:mm") }}</span>
<span v-if="analysis.close_deadline"><span class="label">最晚平仓</span>{{ formatTime(analysis.close_deadline, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.close_deadline) }})</span>
</div>
<div v-if="analysis.result">
<span class="label">结果</span>{{ analysis.close_reason }},净盈亏 {{ analysis.result.net }} USDT(手续费 {{ analysis.result.commission }})
</div>
<div><span class="label">为什么</span>{{ decision.reasons }}</div> <div><span class="label">为什么</span>{{ decision.reasons }}</div>
<div v-if="sizing?.managed" class="text-xs text-[var(--el-text-color-secondary)]">
持仓中调整的计划:不加仓,止盈按当前持仓数量拆分
</div>
<template v-if="decision.action === 'plan' && sizing"> <template v-if="decision.action === 'plan' && sizing">
<el-table :data="sizing.entries" border size="small"> <el-table v-if="sizing.entries.length" :data="sizing.entries" border size="small">
<el-table-column label="入场档位" min-width="110"> <el-table-column label="入场档位" min-width="110">
<template #default="{ $index }"> <template #default="{ $index }">
第 {{ $index + 1 }} 档 第 {{ $index + 1 }} 档
@@ -90,6 +131,11 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
</el-table-column> </el-table-column>
<el-table-column prop="quantity" label="数量" min-width="90" /> <el-table-column prop="quantity" label="数量" min-width="90" />
<el-table-column prop="notional" label="名义 USDT" min-width="100" /> <el-table-column prop="notional" label="名义 USDT" min-width="100" />
<el-table-column label="已成交" min-width="80">
<template #default="{ $index }">
{{ filled("entry", $index) }}
</template>
</el-table-column>
<el-table-column label="依据" min-width="200"> <el-table-column label="依据" min-width="200">
<template #default="{ $index }"> <template #default="{ $index }">
{{ decision.entries?.[$index]?.basis }} {{ decision.entries?.[$index]?.basis }}
@@ -109,6 +155,11 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="quantity" label="数量" min-width="90" /> <el-table-column prop="quantity" label="数量" min-width="90" />
<el-table-column label="已成交" min-width="80">
<template #default="{ $index }">
{{ filled("tp", $index) }}
</template>
</el-table-column>
<el-table-column label="依据" min-width="300"> <el-table-column label="依据" min-width="300">
<template #default="{ $index }"> <template #default="{ $index }">
{{ decision.take_profits?.[$index]?.basis }} {{ decision.take_profits?.[$index]?.basis }}
+32 -6
View File
@@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings" import type { Effort, Provider } from "@/apis/settings"
import type { Analysis, Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading" import type { Analysis, Angle, OrderRow, 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, listAnalyses, listEvents, listSymbols, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading" import { ANGLE_LABELS, deleteSymbol, getStatus, getSymbolPlan, 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"
@@ -23,6 +23,8 @@ 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 latestAnalysis = ref<Analysis | null>(null)
const livePlan = ref<Analysis | null>(null)
const planOrders = ref<OrderRow[]>([])
const requesting = ref(false) const requesting = ref(false)
const drawerVisible = ref(false) const drawerVisible = ref(false)
const drawerAnalysisId = ref<number | null>(null) const drawerAnalysisId = ref<number | null>(null)
@@ -81,6 +83,8 @@ function exitPrices(p: SnapshotPosition) {
function symbolState(s: TradingSymbol) { function symbolState(s: TradingSymbol) {
if (s.analyzing) if (s.analyzing)
return { label: "分析中", type: "warning" } as const return { label: "分析中", type: "warning" } as const
if (s.plan_status === "closing")
return { label: "平仓中", type: "danger" } as const
if (positions.value.some(p => p.symbol === s.symbol)) if (positions.value.some(p => p.symbol === s.symbol))
return { label: "持仓中", type: "success" } as const return { label: "持仓中", type: "success" } as const
if (s.plan_status === "pending") if (s.plan_status === "pending")
@@ -97,9 +101,23 @@ async function refresh() {
async function refreshAnalysis() { async function refreshAnalysis() {
const symbol = selected.value const symbol = selected.value
const latest = symbol ? (await listAnalyses({ symbol, limit: 1 }))[0] ?? null : null if (!symbol)
if (symbol === selected.value) return
latestAnalysis.value = latest const [latest, { plan, orders }] = await Promise.all([listAnalyses({ symbol, limit: 1 }), getSymbolPlan(symbol)])
if (symbol !== selected.value)
return
latestAnalysis.value = latest[0] ?? null
livePlan.value = plan
planOrders.value = orders
}
/** 持仓的平仓倒计时:取该币执行中计划的最晚平仓时间 */
function closeCountdown(symbol: string) {
const deadline = symbols.value.find(s => s.symbol === symbol)?.close_deadline
if (!deadline)
return "-"
const minutes = Math.round((new Date(deadline).getTime() - Date.now()) / 60000)
return minutes <= 0 ? "已到期" : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
} }
async function analyze() { async function analyze() {
@@ -144,6 +162,8 @@ async function loadMoreEvents() {
watch(selected, () => { watch(selected, () => {
events.value = [] events.value = []
latestAnalysis.value = null latestAnalysis.value = null
livePlan.value = null
planOrders.value = []
refreshEvents() refreshEvents()
refreshAnalysis() refreshAnalysis()
}) })
@@ -348,6 +368,11 @@ onBeforeUnmount(() => clearInterval(timer))
{{ exitPrices(row as SnapshotPosition).stop }} / {{ exitPrices(row as SnapshotPosition).target }} {{ exitPrices(row as SnapshotPosition).stop }} / {{ exitPrices(row as SnapshotPosition).target }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="平仓倒计时" width="100">
<template #default="{ row }">
{{ closeCountdown(row.symbol) }}
</template>
</el-table-column>
</el-table> </el-table>
<div class="min-h-0 flex flex-1 gap-3"> <div class="min-h-0 flex flex-1 gap-3">
@@ -388,7 +413,8 @@ 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">
<PlanCard <PlanCard
v-if="current" :symbol="current" :analysis="latestAnalysis" :requesting="requesting" v-if="current" :symbol="current" :analysis="livePlan ?? latestAnalysis" :latest="latestAnalysis" :orders="planOrders"
:requesting="requesting"
@analyze="analyze" @detail="openAnalysis" @analyze="analyze" @detail="openAnalysis"
/> />