Switch demo trading to 5m short-term swing setups (plan 3.1)

- The model now submits a pending setup (direction, zone, confirm price,
  validity); the executor enters at market once a 5m close confirms a
  dip into support or a rejection from resistance, and cancels the setup
  when the zone breaks, the trigger is missed or the stop is too far
- Stop beyond the confirmation extreme by 0.5 x ATR14(5m), skipped above
  1%; fixed take-profit ladders from the fill price (long 60/30/10 at
  +0.5/0.75/1%, short 80/20 at -0.5/0.75%); 4h time stop
- While holding, the model may only keep or exit
- Drop the long-term angle from the program, API, tables and page
This commit is contained in:
eezd committed 2026-10-15 13:09:19 +08:00
1 parent 1fcf41952c
commit 7efa2eb68b
12 files changed
+696 -746

No files matched your search

@@ -0,0 +1,31 @@
"""trading drop angle
Revision ID: e8b25c71d4a9
Revises: c3f19d7a2e58
Create Date: 2026-10-15 13:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e8b25c71d4a9'
down_revision: Union[str, Sequence[str], None] = 'c3f19d7a2e58'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 用户决定模拟盘只做短线(2026-10-15),不再有角度之分
op.drop_column('trading_symbols', 'angle')
op.drop_column('trading_analyses', 'angle')
def downgrade() -> None:
"""Downgrade schema."""
op.add_column('trading_analyses', sa.Column('angle', sa.String(length=8), nullable=False, server_default='short'))
op.add_column('trading_symbols', sa.Column('angle', sa.String(length=8), nullable=False, server_default='short'))
+6 -25
View File
@@ -3,7 +3,7 @@
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Literal from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -24,11 +24,9 @@ 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)])
Angle = Literal["short", "long"]
ANGLE_LABELS = {"short": "短线", "long": "长线"}
SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols" 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") LIVE_PLAN_STATUSES = ("pending", "open", "closing")
@@ -68,7 +66,6 @@ class SymbolOption(BaseModel):
class SymbolOut(BaseModel): class SymbolOut(BaseModel):
symbol: str symbol: str
angle: str
added_at: datetime added_at: datetime
analysis_requested_at: datetime | None analysis_requested_at: datetime | None
analyzing: bool analyzing: bool
@@ -80,17 +77,11 @@ class SymbolOut(BaseModel):
class SymbolIn(BaseModel): class SymbolIn(BaseModel):
symbol: str symbol: str
angle: Angle
class SymbolPatch(BaseModel):
angle: Angle
class AnalysisOut(BaseModel): class AnalysisOut(BaseModel):
id: int id: int
symbol: str symbol: str
angle: str
reasons: list[str] reasons: list[str]
dry_run: bool dry_run: bool
status: str status: str
@@ -267,7 +258,7 @@ async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -
)} )}
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, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at,
analyzing=s.symbol in analyzing, plan_id=plans[s.symbol].id if s.symbol in plans else None, 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, 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, close_deadline=plans[s.symbol].close_deadline if s.symbol in plans else None,
@@ -281,8 +272,8 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge
symbol = body.symbol.strip().upper() symbol = body.symbol.strip().upper()
if not any(o["symbol"] == symbol for o in await symbol_options()): if not any(o["symbol"] == symbol for o in await symbol_options()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约") raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约")
session.add(TradingSymbol(symbol=symbol, angle=body.angle)) session.add(TradingSymbol(symbol=symbol))
session.add(TradingEvent(symbol=symbol, kind="symbol_added", message=f"加入交易列表,角度:{ANGLE_LABELS[body.angle]}")) session.add(TradingEvent(symbol=symbol, kind="symbol_added", message="加入交易列表"))
try: try:
await session.commit() await session.commit()
except IntegrityError: except IntegrityError:
@@ -290,7 +281,7 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge
async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol: async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol:
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除或切换角度""" """只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除"""
s = await session.get(TradingSymbol, symbol) s = await session.get(TradingSymbol, symbol)
if s is None: if s is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
@@ -311,16 +302,6 @@ async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> Tradin
return s return s
@router.patch("/symbols/{symbol}")
async def patch_symbol(symbol: str, body: SymbolPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
s = await ensure_idle(session, symbol, "切换角度")
if s.angle != body.angle:
session.add(TradingEvent(symbol=symbol, kind="angle_changed",
message=f"角度从{ANGLE_LABELS[s.angle]}改为{ANGLE_LABELS[body.angle]}"))
s.angle = body.angle
await session.commit()
@router.delete("/symbols/{symbol}") @router.delete("/symbols/{symbol}")
async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None: async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""只移出列表;分析、订单流水和时间线保留""" """只移出列表;分析、订单流水和时间线保留"""
+2 -5
View File
@@ -32,8 +32,6 @@ class TradingSymbol(Base):
__tablename__ = "trading_symbols" __tablename__ = "trading_symbols"
symbol: Mapped[str] = mapped_column(String(32), primary_key=True) symbol: Mapped[str] = mapped_column(String(32), primary_key=True)
# short:24h 内平仓;long:12–48h 平仓
angle: Mapped[str] = mapped_column(String(8))
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 待执行的分析请求:多个触发在执行前合并为一次 # 待执行的分析请求:多个触发在执行前合并为一次
analysis_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) analysis_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -48,7 +46,6 @@ class TradingAnalysis(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
symbol: Mapped[str] = mapped_column(String(32)) symbol: Mapped[str] = mapped_column(String(32))
angle: Mapped[str] = mapped_column(String(8))
reasons: Mapped[list[str]] = mapped_column(JSONB) reasons: Mapped[list[str]] = mapped_column(JSONB)
# 停止状态下的分析只记录计划、不下单 # 停止状态下的分析只记录计划、不下单
dry_run: Mapped[bool] = mapped_column(Boolean) dry_run: Mapped[bool] = mapped_column(Boolean)
@@ -69,8 +66,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))
# proposed(待执行器采纳)/ pending(挂单中)/ open(持仓中)/ closing(平仓中)/ closed / canceled(未成交即结束) # proposed(待执行器采纳)/ pending(等待 5m 确认,sizing 有 entry 表示已触发)/ open(持仓中)/ closing(平仓中)
# / replaced(被新计划替换)/ stale(采纳前持仓已变,作废)/ applied(exit 决定已执行) # / 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))
+71 -89
View File
@@ -1,4 +1,4 @@
"""模拟盘分析器:为一个币的一个角度调用模型,产出经程序校验、可自动执行的交易计划。 """模拟盘分析器:为一个币调用模型,划出经程序校验、由执行器在 5m 收盘确认后自动入场的短线计划。
只开放只读工具和 submit_trade_plan;行情预先取好放进用户消息,减少工具往返。 只开放只读工具和 submit_trade_plan;行情预先取好放进用户消息,减少工具往返。
""" """
@@ -16,11 +16,12 @@ from sqlalchemy import select
from alphapulse.ai import llm 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.api.market import client as market_client
from alphapulse.core.db import SessionLocal from alphapulse.core.db import SessionLocal
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, 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 OWN_CLIENT_ID, ExchangeError, mark_price, symbol_rules from alphapulse.trading.client import ExchangeError, mark_price, symbol_rules
from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, size_plan from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, atr, check_setup, parse_bars
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")
@@ -29,54 +30,54 @@ 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
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": "低"}
ACTION_LABELS = {"plan": "新计划", "keep": "维持当前计划", "exit": "结束当前计划", "wait": "观望"} ACTION_LABELS = {"plan": "新计划", "keep": "维持当前计划", "exit": "结束当前计划", "wait": "观望"}
# 与 README 第 1 节一致:定方向和找区的周期、衍生品参数 # 找区、确认的周期和衍生品参数(与 README 一致)
KLINE_INTERVALS = {"short": ("4h", "1h", "15m"), "long": ("1d", "4h", "1h")} KLINE_INTERVALS = ("1d", "4h", "1h", "15m", "5m")
DERIVATIVES_PARAMS = {"short": ("1h", 24), "long": ("4h", 18)} DERIVATIVES_PARAMS = ("1h", 24)
def _pct(value: Decimal) -> str: def _pct(value: Decimal) -> str:
return f"{(value * 100).normalize()}%" return f"{(value * 100).normalize()}%"
INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币种的一个角度给出由程序自动执行的交易计划。 def _ladder(direction: str) -> str:
sign = "+" if direction == "long" else "−"
steps = rules.TAKE_PROFIT_STEPS[direction]
return f"{' / '.join(sign + _pct(p) for p, _ in steps)} 分别平 {' / '.join(_pct(r) for _, r in steps)}"
INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币种划出短线「待确认入场」,由程序在 5m 收盘确认后自动入场并管理持仓。
## 任务 ## 任务
- 只分析用户消息指定的币种和角度(短线或长线),不给另一个角度。
- 下方 Skills 是交易规范。以下几处按本节执行,与 Skills 冲突时以本节为准: - 下方 Skills 是交易规范。以下几处按本节执行,与 Skills 冲突时以本节为准:
- main/README 第 1 节「每次同时给出两个角度」不适用,只给指定角度。 - main/README 第 8 节的输出表格不适用:用简短中文写出结论、所用的区间、依据(新闻、结构、衍生品)和风险;数字只通过 submit_trade_plan 提交。
- main/README 第 8 节的输出表格不适用:用简短中文写出结论、用到的支撑区与压力区、依据(新闻、结构、衍生品)和风险;计划的数字只通过 submit_trade_plan 提交。 - main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,要按实时数据复核,不直接沿用旧点位。
- main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,按 README 第 6 节做实时复核,不直接沿用旧点位。 - 数量、止损、止盈由程序计算,你只给方向、区间、确认价、有效期和置信度。
- main/README 5.4 的条件计划(突破回踩、事件落地后再入场等)不能直接挂单,改写成 reevaluate 条件:价格到达后程序会重新请你分析。 - 用户消息里已预先取好实盘行情(ticker、{' / '.join(KLINE_INTERVALS)} K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。
- 数量由程序按交易资金计算,你只给价格、档位比例和置信度。 - 新闻没有预取,必须用 search_news 检索相关新闻,并查清有效期和持仓时限内的重大数据及公布时间。
- 用户消息里已预先取好实盘行情(ticker、K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。
- 新闻没有预取,必须按 main/README 第 3 节第 3 步用 search_news 检索相关新闻,并查清持仓时限内的重大数据及公布时间。
- 最后必须调用一次 submit_trade_plan。校验不通过会返回原因,按原因修正后重新提交。 - 最后必须调用一次 submit_trade_plan。校验不通过会返回原因,按原因修正后重新提交。
## 程序如何执行计划 ## 程序如何执行
- 入场:每档一张限价单;到 entry_valid_hours 仍未成交的档位撤销。买单价高于现价(卖单价低于现价)会立即成交。 - 待确认入场(action=plan):程序在每根 5m K 线收盘时检查,只看计划生效后收盘的 K 线,交易所上不预挂入场单。
- 止损:与入场单同时在交易所挂只减仓止损单(按标记价触发),首档成交即受保护。 - 做多:某根 5m 的最低价进入区间(≤ zone_high)之后,出现一根收盘 ≥ confirm_price(可以是同一根)→ 立即市价做多。在此之前若有 5m 收盘 < zone_low,视为支撑破位,计划撤销并重新请你分析。
- 止盈:短线止盈 1 平一半、止盈 2 平剩余;长线 3 个止盈依次平 50% / 30% / 20%。止盈 1 成交后撤销剩余入场单,止损移到保本价。 - 做空:某根 5m 的最高价进入区间(≥ zone_low)之后,出现一根收盘 ≤ confirm_price → 立即市价做空。在此之前若有 5m 收盘 > zone_high,视为压力突破,撤销。
- invalidation(失效条件):短线用 1m 或 15m 收盘,触发后程序立即市价平仓;长线只能用 4h 收盘,触发后程序请你复核。 - 到 valid_hours 仍未触发则作废,并重新请你分析。
- reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个)。用来表达另一侧方向、条件计划和需要复核的位置。 - 止损:触发时由程序计算,放在进区以来的最低价(做空为最高价)外 {rules.STOP_ATR_FACTOR}×ATR14(5m),在市价入场前挂到交易所(按标记价触发)。止损距现价超过 {_pct(rules.MAX_STOP_PCT)} 就放弃这次入场。
- max_hold_hours 从首档成交起算,到时程序市价平仓。 - 止盈:按成交均价,做多 {_ladder("long")};做空 {_ladder("short")}。止盈 1 成交后止损移到保本价。
- 持仓最长 {rules.MAX_HOLD_HOURS} 小时,到时市价平仓。平仓后程序会请你重新分析。
- 仓位:单笔风险为交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。
- reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个),用来表达另一侧方向、需要重新划区的位置。
## 程序的硬性校验 ## 程序的硬性校验
- 短线 1 档入场、2 个止盈;长线 1–3 档入场、3 个止盈;入场比例之和为 1。 - zone_low < zone_high。做多:confirm_price ≥ zone_high,现价 ≥ zone_low;做空:confirm_price ≤ zone_low,现价 ≤ zone_high。确认就是「进区后收盘回到区间外侧」。
- 做多:止损 < 失效价 < 所有入场价 < 止盈 1 < 止盈 2 < 止盈 3,且止损低于现价、止盈 1 高于现价;做空反过来。 - 按确认价入场、止损放在区间远端外 {rules.STOP_ATR_FACTOR}×ATR 估算:止损距离不超过 {_pct(rules.MAX_STOP_PCT)},数量不低于交易所最小下单金额。
- 盈亏比 ≥ {rules.MIN_REWARD_RISK}:短线按止盈 1,长线按 50/30/20 加权(全部档位成交时)。 - valid_hours ≤ {rules.SETUP_VALID_HOURS}。
- 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。
## 有持仓时 ## 有持仓时
- action=plan 只用来调整止损(只能收紧)、止盈、失效条件和 reevaluate:entries 留空,不加仓(README 第 10 节禁止计划外加仓)。止盈按当前持仓数量拆分;平仓截止时间沿用首档成交时确定的,不延长。 - 持仓由程序按止损、止盈和持仓时限管理,你只能 keep(继续持有)或 exit(立即市价平仓),不能提交新计划。
- 计划仍然合适就用 keep;要提前结束用 exit(撤销未成交的入场单并市价平仓)。 - 只有出现与持仓方向明显相反的重大消息或结构破坏时才 exit。
- 计划提交后由执行器在下一轮(约 2 秒内)按最新持仓核对后生效;期间持仓变了,计划作废并重新请你分析。
""" """
SUBMIT_SPEC = llm.ToolSpec( SUBMIT_SPEC = llm.ToolSpec(
@@ -95,36 +96,44 @@ class Prepared:
provider: AiProvider provider: AiProvider
effort: str | None effort: str | None
dry_run: bool dry_run: bool
angle: str
system: str system: str
user_text: str user_text: str
context: PlanContext context: PlanContext
# 当前计划的仓位计算结果,提交时重算当前止损用
current_sizing: dict | None
def plan_brief(decision: dict, sizing: dict | None) -> dict: 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 = {k: decision.get(k) for k in ("action", "summary", "direction", "confidence", "setup", "valid_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 []] brief["reevaluate"] = [{k: c[k] for k in ("direction", "price", "timeframe")} for c in decision.get("reevaluate") or []]
if sizing: if sizing and "entry" in sizing:
brief["sizing"] = {k: sizing.get(k) for k in ("quantity", "notional", "risk_at_stop", "reward_risk")} brief["entry"] = {k: sizing["entry"][k] for k in ("triggered_at", "price", "stop", "quantity", "risk_at_stop")}
return brief return brief
async def prefetch(symbol: str, angle: str) -> str: async def confirm_atr(symbol: str) -> Decimal:
"""最近已收盘 5m K 线的 ATR14,用于预估止损"""
bars = parse_bars(await market_client.get("um", "klines", {"symbol": symbol, "interval": rules.CONFIRM_INTERVAL, "limit": rules.ATR_PERIOD + 2}))
now_ms = datetime.now(UTC).timestamp() * 1000
return atr([b for b in bars if b.close_time < now_ms])
async def prefetch(symbol: str) -> str:
"""预取行情:工具出错时把错误原样放进消息,不中断分析""" """预取行情:工具出错时把错误原样放进消息,不中断分析"""
period, limit = DERIVATIVES_PARAMS[angle] period, limit = DERIVATIVES_PARAMS
calls = [("get_ticker", {"symbol": symbol})] calls = [("get_ticker", {"symbol": symbol})]
calls += [("get_klines", {"symbol": symbol, "interval": i, "limit": 200}) for i in KLINE_INTERVALS[angle]] calls += [("get_klines", {"symbol": symbol, "interval": i, "limit": 200}) for i in KLINE_INTERVALS]
calls.append(("get_derivatives", {"symbol": symbol, "period": period, "limit": limit})) calls.append(("get_derivatives", {"symbol": symbol, "period": period, "limit": limit}))
outcomes = await asyncio.gather(*(run_tool(name, args) for name, args in calls)) 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)) 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))
def position_side(snapshot: dict, symbol: str) -> str | None:
position = next((p for p in snapshot["positions"] if p["symbol"] == symbol), None)
if position is None:
return None
return "long" if position["side"] == "LONG" else "short"
async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared: async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared:
async with SessionLocal() as session: async with SessionLocal() as session:
account = await session.get(TradingAccount, 1) account = await session.get(TradingAccount, 1)
@@ -157,7 +166,7 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
)).all() )).all()
] if current else [] ] 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 dry_run = not account.running
effort = account.reasoning_effort or provider.reasoning_effort effort = account.reasoning_effort or provider.reasoning_effort
snapshot = await read_snapshot() snapshot = await read_snapshot()
@@ -170,26 +179,25 @@ 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_, has_plan=current is not None, mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, atr=await confirm_atr(symbol),
position_side=None, has_plan=current is not None, position_side=position_side(snapshot, symbol),
) )
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
lines = [ lines = [
"## 任务", "## 任务",
f"- 币种:{symbol}(币安 U 本位永续)", f"- 币种:{symbol}(币安 U 本位永续)",
f"- 角度:{ANGLE_LABELS[angle]}",
f"- 触发原因:{';'.join(reasons)}", f"- 触发原因:{';'.join(reasons)}",
f"- 运行模式:{'试运行(引擎已停止,计划只记录、不下单)' if dry_run else '交易中(计划会在模拟盘执行)'}", f"- 运行模式:{'试运行(引擎已停止,计划只记录、不下单)' if dry_run else '交易中(计划会在模拟盘执行)'}",
"", "",
"## 账户(程序据此计算数量)", "## 账户(程序据此计算数量)",
f"- 交易资金:{capital:.2f} USDT(设定 {account.capital_usdt} USDT,账户权益 {equity:.2f} USDT,取较小者)", 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"- 本次单笔风险金额:{risk:.2f} USDT({_pct(rules.RISK_PCT)};置信度低时 {risk * rules.LOW_CONFIDENCE_FACTOR:.2f})",
f"- 可用名义仓位:{capital * rules.MAX_LEVERAGE - other_notional:.2f} USDT({rules.MAX_LEVERAGE} 倍交易资金减去交易列表其他币的持仓 {other_notional:.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"- 模拟盘交易规则:价格精度 {symbol_rules_.tick_size},数量精度 {symbol_rules_.step_size},最小下单金额 {symbol_rules_.min_notional} USDT",
f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)", f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)",
f"- 最近已收盘 5m K 线的 ATR14:{context.atr:.{max(0, -symbol_rules_.tick_size.as_tuple().exponent)}f}(程序按它计算止损)",
"", "",
"## 当前持仓与挂单(模拟盘)", "## 当前持仓与挂单(模拟盘)",
f"- 持仓:{dump(position) if position else '无'}", f"- 持仓:{dump(position) if position else '无'}",
@@ -201,7 +209,6 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
"valid_until": current.valid_until and local(current.valid_until), "valid_until": current.valid_until and local(current.valid_until),
"first_fill_at": current.first_fill_at and local(current.first_fill_at), "first_fill_at": current.first_fill_at and local(current.first_fill_at),
"close_deadline": current.close_deadline and local(current.close_deadline), "close_deadline": current.close_deadline and local(current.close_deadline),
"current_stop": str(context.current_stop) if context.current_stop else None,
"fills": fills, "fills": fills,
**plan_brief(current.decision, current.sizing), **plan_brief(current.decision, current.sizing),
}) if current else "无", }) if current else "无",
@@ -210,30 +217,9 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
*([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 ["无"]),
"", "",
"## 预取的行情(实盘数据,已取,不要用相同参数重复调用)", "## 预取的行情(实盘数据,已取,不要用相同参数重复调用)",
await prefetch(symbol, angle), await prefetch(symbol),
] ]
return Prepared(provider=provider, effort=effort, dry_run=dry_run, angle=angle, system=system, user_text="\n".join(lines), return Prepared(provider=provider, effort=effort, dry_run=dry_run, system=system, user_text="\n".join(lines), context=context)
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:
@@ -253,12 +239,11 @@ def describe(plan: TradePlan, sizing: dict, dry_run: bool) -> str:
prefix = "试运行" if dry_run else "" prefix = "试运行" if dry_run else ""
if plan.action != "plan": if plan.action != "plan":
return f"{prefix}{ACTION_LABELS[plan.action]}:{plan.summary}" return f"{prefix}{ACTION_LABELS[plan.action]}:{plan.summary}"
entries = "、".join(f"{e['price']}×{e['quantity']}" for e in sizing["entries"]) setup, estimate = sizing["setup"], sizing["estimate"]
targets = " / ".join(t["price"] for t in sizing["take_profits"])
return ( return (
f"{prefix}计划:{DIRECTION_LABELS[plan.direction]},置信度{CONFIDENCE_LABELS[plan.confidence]};入场 {entries}," f"{prefix}待确认入场:{DIRECTION_LABELS[plan.direction]},置信度{CONFIDENCE_LABELS[plan.confidence]};"
f"止损 {sizing['stop']},止盈 {targets};止损亏损 {sizing['risk_at_stop']} USDT(交易资金的 {sizing['risk_pct']}%)," f"区间 {setup['zone_low']}–{setup['zone_high']},5m 收盘{'≥' if plan.direction == 'long' else '≤'} {setup['confirm_price']} 入场,"
f"盈亏比 {sizing['reward_risk']}(仅首档 {sizing['reward_risk_first_only']})。{plan.summary}" f"有效 {plan.valid_hours} 小时;按确认价预估止损 {estimate['stop']}({estimate['stop_pct']}%)、数量 {estimate['quantity']}。{plan.summary}"
) )
@@ -267,13 +252,14 @@ 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
# 分析可能持续几分钟,按提交时的标记价和持仓校验 # 分析可能持续几分钟,按提交时的标记价、持仓和 ATR 校验
prepared.context.mark_price = await mark_price(symbol) prepared.context.mark_price = await mark_price(symbol)
prepared.context.atr = await confirm_atr(symbol)
snapshot = await read_snapshot() snapshot = await read_snapshot()
if snapshot: if snapshot:
apply_position(prepared.context, snapshot, symbol, prepared.current_sizing) prepared.context.position_side = position_side(snapshot, symbol)
try: try:
sizing = size_plan(plan, prepared.context) sizing = check_setup(plan, prepared.context)
except PlanError as e: except PlanError as e:
await add_event(symbol, analysis_id, "plan_rejected", f"计划未通过校验,已退回模型修正:{';'.join(e.errors)}", "warn") 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({"error": "计划未通过校验", "details": e.errors, "mark_price": str(prepared.context.mark_price)}), None
@@ -284,11 +270,7 @@ async def run_analysis(symbol: str, reasons: list[str]) -> None:
"""一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出""" """一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出"""
async with SessionLocal() as session: async with SessionLocal() as session:
account = await session.get(TradingAccount, 1) account = await session.get(TradingAccount, 1)
target = await session.get(TradingSymbol, symbol) analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (account and account.running), status="running")
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) session.add(analysis)
await session.commit() await session.commit()
analysis_id, dry_run = analysis.id, analysis.dry_run analysis_id, dry_run = analysis.id, analysis.dry_run
+133 -75
View File
@@ -2,9 +2,9 @@
每轮对交易列表里的每个币: 每轮对交易列表里的每个币:
1. 同步订单流水与交易所(成交、撤销、触发)。 1. 同步订单流水与交易所(成交、撤销、触发)。
2. 采纳分析器提交的新计划(按最新持仓核对,过时则作废重算)。 2. 采纳分析器提交的待确认入场(有持仓或当前计划已触发时作废)。
3. 推进计划状态,执行程序规则:到期、时间止损、失效条件、复核条件、无计划持仓。 3. 推进计划状态,执行程序规则:5m 收盘确认入场、破位撤销、到期、时间止损、复核条件、无计划持仓。
4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不挂新入场单)。 4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不下新入场单)。
""" """
import asyncio import asyncio
@@ -23,7 +23,10 @@ from alphapulse.core.db import SessionLocal
from alphapulse.market.binance import BinanceClient from alphapulse.market.binance import BinanceClient
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol 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.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.plan import (
CONFIRM_INTERVAL, MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, Bar, OrderSpec, PlanError, SymbolRules, atr, condition_met, desired_orders,
diff_orders, parse_bars, round_to_step, scan_setup, setup_stop, size_entry,
)
from alphapulse.trading.schedule import request_analysis from alphapulse.trading.schedule import request_analysis
logger = logging.getLogger("alphapulse.trading.executor") logger = logging.getLogger("alphapulse.trading.executor")
@@ -37,12 +40,15 @@ SUBMIT_GRACE_SECONDS = 10
STOP_FAILURE_LIMIT = 3 STOP_FAILURE_LIMIT = 3
# 同一错误的时间线事件间隔,避免刷屏 # 同一错误的时间线事件间隔,避免刷屏
ERROR_EVENT_SECONDS = 600 ERROR_EVENT_SECONDS = 600
# 每次取的 5m K 线根数:覆盖待确认入场的最长有效期(24 根)和 ATR14
CONFIRM_BARS = 40
ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled", ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled",
"EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"} "EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"}
PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"} PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"}
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "invalidation": "短线失效条件触发", CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束",
"exit": "模型决定结束", "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓"}
"close": "市价平仓"} CANCEL_REASON_LABELS = {"expired": "到期未触发", "stopped": "已停止交易", "broken": "区间被突破", "missed": "错过确认",
"skipped": "放弃入场", "entry_failed": "入场失败"}
@dataclass @dataclass
@@ -74,8 +80,10 @@ class Executor:
self.error_events: dict[str, float] = {} self.error_events: dict[str, float] = {}
# (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价) # (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价)
self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {} self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {}
# 已请模型接管的无计划持仓:symbol -> 持仓均价,同一笔持仓只请求一次 # symbol -> (下一根收盘时间 ms, 最近的 5m K 线)
self.takeover_requested: dict[str, Decimal] = {} self.bars_cache: dict[str, tuple[int, list[Bar]]] = {}
# 已提示过的无计划持仓:symbol -> 持仓均价,同一笔持仓只提示一次
self.unmanaged_noticed: dict[str, Decimal] = {}
# ---------- 基础 ---------- # ---------- 基础 ----------
@@ -114,6 +122,16 @@ class Executor:
self.kline_cache[(symbol, interval)] = (next_close, Decimal(closed[4])) self.kline_cache[(symbol, interval)] = (next_close, Decimal(closed[4]))
return Decimal(closed[4]) return Decimal(closed[4])
async def confirm_bars(self, symbol: str) -> list[Bar]:
"""实盘最近的 5m K 线(最后一根可能未收盘),缓存到下一根收盘"""
now_ms = int(time.time() * 1000)
cached = self.bars_cache.get(symbol)
if cached and now_ms < cached[0]:
return cached[1]
bars = parse_bars(await self.market.get("um", "klines", {"symbol": symbol, "interval": CONFIRM_INTERVAL, "limit": CONFIRM_BARS}))
self.bars_cache[symbol] = (bars[-1].close_time + 1, bars)
return bars
async def condition_met(self, symbol: str, condition: dict, mark: Decimal) -> tuple[bool, Decimal]: 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"]) price = mark if condition["timeframe"] == "mark" else await self.last_close(symbol, condition["timeframe"])
return condition_met(condition["direction"], Decimal(condition["price"]), price), price return condition_met(condition["direction"], Decimal(condition["price"]), price), price
@@ -223,15 +241,13 @@ class Executor:
return return
if row.filled_qty > before: if row.filled_qty > before:
delta = 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 "") leg = f" {row.leg + 1} " if row.purpose == "tp" else ""
session.add(TradingEvent( session.add(TradingEvent(
symbol=row.symbol, analysis_id=row.analysis_id, kind=f"{row.purpose}_filled", symbol=row.symbol, analysis_id=row.analysis_id, kind=f"{row.purpose}_filled",
level="warn" if row.purpose == "stop" else "info", level="warn" if row.purpose == "stop" else "info",
message=f"{PURPOSE_LABELS[row.purpose]}{leg}成交 {fmt(delta)} @ {row.avg_price}", message=f"{PURPOSE_LABELS[row.purpose]}{leg}成交 {fmt(delta)} @ {row.avg_price}",
data={"order_id": row.id, "quantity": fmt(delta)}, data={"order_id": row.id, "quantity": fmt(delta)},
)) ))
if row.purpose == "tp":
await request_analysis(session, row.symbol, f"止盈 {row.leg + 1} 成交")
# ---------- 计划 ---------- # ---------- 计划 ----------
@@ -260,27 +276,26 @@ class Executor:
close_side = "SELL" if position.side == "long" else "BUY" 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)) await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
return current return current
# 分析期间可能已经入场:持仓中或已触发的计划不能被替换(持仓由程序管理,平仓后会重新分析)
qty = position.qty if position else Decimal(0) qty = position.qty if position else Decimal(0)
expected = Decimal(proposal.sizing["position_qty"]) if qty > 0 or (current is not None and (current.plan_status != "pending" or "entry" in current.sizing)):
if qty != expected or (current is not None and current.plan_status == "closing"):
proposal.plan_status = "stale" proposal.plan_status = "stale"
reason = "计划正在平仓" if current is not None and current.plan_status == "closing" else f"持仓已从 {fmt(expected)} 变为 {fmt(qty)}" reason = f"已有持仓 {fmt(qty)}" if qty > 0 else ("计划正在平仓" if current.plan_status == "closing" else "当前计划已触发入场")
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn", session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
message=f"计划未采纳:{reason}")) message=f"计划未采纳:{reason}"))
await request_analysis(session, symbol.symbol, "持仓变化,计划作废重算")
return current return current
if current is not None: if current is not None:
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
if qty > 0: proposal.plan_status = "pending"
proposal.plan_status = "open" proposal.valid_until = now + timedelta(hours=float(proposal.decision["valid_hours"]))
proposal.first_fill_at = current.first_fill_at if current and current.first_fill_at else now setup = proposal.sizing["setup"]
# 持仓时限沿用原计划,不延长;接管无计划持仓时从现在起算 side = "做多" if proposal.sizing["direction"] == "long" else "做空"
proposal.close_deadline = current.close_deadline if current and current.close_deadline else now + timedelta(hours=MAX_HOLD_HOURS[symbol.angle]) session.add(TradingEvent(
else: symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
proposal.plan_status = "pending" message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")
proposal.valid_until = now + timedelta(hours=float(proposal.decision["entry_valid_hours"])) + f":等待 5m 确认{side}(区间 {setup['zone_low']}–{setup['zone_high']},确认价 {setup['confirm_price']}),"
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted", f"有效至 {proposal.valid_until.astimezone(LOCAL_TZ).strftime('%H:%M')}",
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else ""))) ))
return proposal return proposal
async def realized(self, client: DemoClient, symbol: str, since: datetime | None) -> dict | None: async def realized(self, client: DemoClient, symbol: str, since: datetime | None) -> dict | None:
@@ -306,6 +321,13 @@ class Executor:
message=f"计划 #{plan.id} 结束:{label}{net}", data=plan.result)) message=f"计划 #{plan.id} 结束:{label}{net}", data=plan.result))
await request_analysis(session, plan.symbol, f"平仓({label})") await request_analysis(session, plan.symbol, f"平仓({label})")
async def cancel_plan(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str, level: str = "info") -> None:
"""未入场的计划撤销,排队重新分析(停止交易时 request_analysis 不排队)"""
plan.plan_status, plan.close_reason, plan.closed_at = "canceled", reason, utcnow()
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_canceled", level=level,
message=f"计划 #{plan.id} 撤销:{detail}"))
await request_analysis(session, plan.symbol, f"计划撤销({CANCEL_REASON_LABELS[reason]})")
async def start_closing(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str = "") -> None: async def start_closing(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str = "") -> None:
plan.plan_status, plan.close_reason = "closing", reason plan.plan_status, plan.close_reason = "closing", reason
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closing", level="warn", session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closing", level="warn",
@@ -323,7 +345,8 @@ class Executor:
# ---------- 对账 ---------- # ---------- 对账 ----------
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None) -> None: async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None,
other_notional: Decimal) -> None:
name = symbol.symbol name = symbol.symbol
rules = await symbol_rules(name) rules = await symbol_rules(name)
open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})} open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})}
@@ -362,7 +385,7 @@ class Executor:
if row.purpose == "stop": if row.purpose == "stop":
await self.cancel(client, row) await self.cancel(client, row)
if position is None: if position is None:
self.takeover_requested.pop(name, None) self.unmanaged_noticed.pop(name, None)
elif running: elif running:
await self.unmanaged(session, client, name, position, open_algos) await self.unmanaged(session, client, name, position, open_algos)
if running: if running:
@@ -380,32 +403,33 @@ class Executor:
plan_rows = list((await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id))).all()) plan_rows = list((await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id))).all())
if plan.plan_status == "pending": if plan.plan_status == "pending":
if qty > 0: entry_rows = [r for r in plan_rows if r.purpose == "entry"]
# 入场成交后持仓可能已被止损或手动平掉:照样转入持仓,下一轮按持仓归零结束
if qty > 0 or any(r.filled_qty > 0 for r in entry_rows):
plan.plan_status, plan.first_fill_at = "open", now plan.plan_status, plan.first_fill_at = "open", now
plan.close_deadline = now + timedelta(hours=float(plan.decision["max_hold_hours"])) plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS)
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open", 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')} 平仓")) message=f"入场成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
await request_analysis(session, name, "首档成交") elif "entry" in plan.sizing:
elif now >= plan.valid_until or not running: if entry_rows and all(r.status not in ACTIVE_ORDER_STATUSES for r in entry_rows):
plan.plan_status, plan.closed_at = "canceled", now await self.cancel_plan(session, plan, "entry_failed", f"市价入场失败:{entry_rows[-1].error or entry_rows[-1].status}", "error")
plan.close_reason = "expired" if running else "stopped" elif not running:
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_canceled", await self.cancel_plan(session, plan, "stopped", "已停止交易")
message=f"计划 #{plan.id} 撤销:{'入场单到期未成交' if running else '已停止交易,撤销未成交的入场单'}")) elif now >= plan.valid_until:
if running: await self.cancel_plan(session, plan, "expired", "到期未触发")
await request_analysis(session, name, "计划到期未成交") else:
await self.check_setup(session, client, plan, rules, other_notional)
elif plan.plan_status == "open": elif plan.plan_status == "open":
if qty == 0: if qty == 0:
await self.finish(session, client, plan, plan_rows) await self.finish(session, client, plan, plan_rows)
elif now >= plan.close_deadline: elif now >= plan.close_deadline:
await self.start_closing(session, plan, "time") await self.start_closing(session, plan, "time")
else:
await self.check_invalidation(session, plan, position)
elif plan.plan_status == "closing" and qty == 0: elif plan.plan_status == "closing" and qty == 0:
await self.finish(session, client, plan, plan_rows) await self.finish(session, client, plan, plan_rows)
await session.commit() await session.commit()
if plan.plan_status in LIVE_PLAN_STATUSES: if plan.plan_status in LIVE_PLAN_STATUSES:
await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled) await self.sync_orders(session, client, plan, plan_rows, position, rules, stale_entry_canceled)
else: else:
# 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位 # 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位
for row in plan_rows: for row in plan_rows:
@@ -418,17 +442,48 @@ class Executor:
await self.check_reevaluate(session, name, position.mark if position else None) await self.check_reevaluate(session, name, position.mark if position else None)
await session.commit() await session.commit()
async def check_invalidation(self, session: AsyncSession, plan: TradingAnalysis, position: Position) -> None: async def check_setup(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rules: SymbolRules, other_notional: Decimal) -> None:
condition = plan.decision["invalidation"] """待确认入场:5m 收盘确认后算止损和数量,写入 sizing.entry,由 sync_orders 先挂止损再市价入场。
met, price = await self.condition_met(plan.symbol, condition, position.mark) other_notional:交易列表中其他币的持仓名义价值"""
if not met: now_ms = int(time.time() * 1000)
bars = await self.confirm_bars(plan.symbol)
sizing = plan.sizing
direction, setup = sizing["direction"], sizing["setup"]
scan = scan_setup(direction, setup, bars, int(plan.finished_at.timestamp() * 1000), now_ms)
if scan.state == "waiting":
return return
detail = f"({condition['timeframe']} 收盘 {fmt(price)} {'高于' if condition['direction'] == 'above' else '低于'} {condition['price']})" long = direction == "long"
if plan.angle == "short": at = datetime.fromtimestamp((scan.bar.close_time + 1) / 1000, LOCAL_TZ).strftime("%H:%M")
await self.start_closing(session, plan, "invalidation", detail) if scan.state == "broken":
else: edge = f"跌破区间下沿 {setup['zone_low']}" if long else f"突破区间上沿 {setup['zone_high']}"
await self.review_once(session, plan.id, plan.symbol, "invalidation_review", 0, await self.cancel_plan(session, plan, "broken", f"{at} 5m 收盘 {fmt(scan.bar.close)} {edge}")
f"长线失效条件触发{detail},请模型复核", f"失效条件触发{detail}") return
if scan.state == "missed":
await self.cancel_plan(session, plan, "missed", f"{at} 的 5m 收盘已满足确认,但程序没有及时执行,不补做", "warn")
return
# 触发很少发生,直接查最新权益,不依赖快照循环
account = await session.get(TradingAccount, 1)
equity = Decimal((await client.request("GET", "/fapi/v3/account"))["totalMarginBalance"])
capital = min(account.capital_usdt, equity)
atr_value = atr([b for b in bars if b.close_time < now_ms])
stop = setup_stop(direction, scan.extreme, atr_value, rules.tick_size)
mark = await mark_price(plan.symbol)
confirm = f"5m 收盘 {fmt(scan.bar.close)},{'最低' if long else '最高'} {fmt(scan.extreme)}"
try:
entry = size_entry(direction, mark, stop, capital=capital, other_notional=other_notional,
confidence=plan.decision["confidence"], rules=rules)
except PlanError as e:
await self.cancel_plan(session, plan, "skipped", f"{at} {confirm} 已确认,但{e},放弃入场", "warn")
return
plan.sizing = {**sizing, "entry": {**entry, "triggered_at": utcnow().isoformat(), "bar_close": fmt(scan.bar.close),
"extreme": fmt(scan.extreme), "atr": fmt(atr_value.quantize(rules.tick_size))}}
session.add(TradingEvent(
symbol=plan.symbol, analysis_id=plan.id, kind="setup_triggered",
message=f"{at} {confirm},确认{'做多' if long else '做空'}:市价入场 {entry['quantity']}(现价 {fmt(mark)}),"
f"止损 {entry['stop']}({entry['stop_pct']}%),止损亏损 {entry['risk_at_stop']} USDT",
data={"bar_open_time": scan.bar.open_time},
))
async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None: async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None:
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析""" """最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
@@ -447,15 +502,14 @@ class Executor:
await self.review_once(session, latest.id, symbol, "reevaluate_triggered", i, f"复核条件满足:{text}", f"复核条件满足:{text}") 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: 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" 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()) has_stop = any(o["side"] == close_side and o["orderType"].startswith("STOP") for o in open_algos.values())
if has_stop: if has_stop:
if self.takeover_requested.get(symbol) != position.entry: if self.unmanaged_noticed.get(symbol) != position.entry:
self.takeover_requested[symbol] = position.entry self.unmanaged_noticed[symbol] = position.entry
session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn", session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn",
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划,请模型接管")) message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划:程序不管理,由止损保护"))
await request_analysis(session, symbol, "有持仓但没有计划,请接管")
return return
closing = await session.scalar(select(func.count()).where( closing = await session.scalar(select(func.count()).where(
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES), TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
@@ -467,22 +521,11 @@ class Executor:
await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, 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], 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: position: Position | None, rules: SymbolRules, block_entries: bool) -> None:
sizing = plan.sizing sizing = plan.sizing
qty = position.qty if position else Decimal(0) 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" long = sizing["direction"] == "long"
active = [r for r in plan_rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose in ("entry", "stop", "tp")]
if plan.plan_status == "closing": if plan.plan_status == "closing":
# 平仓中:撤入场和止盈,保留已有止损直到市价平仓成交(止损可能正因越过现价挂不上,不再补挂) # 平仓中:撤入场和止盈,保留已有止损直到市价平仓成交(止损可能正因越过现价挂不上,不再补挂)
for row in active: for row in active:
@@ -492,10 +535,23 @@ class Executor:
close_side = "SELL" if long else "BUY" 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)) await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty))
return return
if "entry" not in sizing:
# 还在等 5m 确认,交易所上没有本计划的单
return
entries_open = running and utcnow() < (plan.valid_until or utcnow()) and not tp1_hit entry_rows = [r for r in plan_rows if r.purpose == "entry"]
desired = desired_orders(sizing, entries_open=entries_open, entry_filled=entry_filled, tp_filled=tp_filled, filled = sum((r.filled_qty for r in entry_rows), Decimal(0))
position_qty=qty, stop_price=stop, step_size=rules.step_size) average = next((r.avg_price for r in entry_rows if r.filled_qty > 0 and r.avg_price), None)
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(len(TAKE_PROFIT_STEPS[sizing["direction"]]))]
stop = Decimal(sizing["entry"]["stop"])
if tp_filled[0] > 0 and position is not None:
# 止盈 1 后止损移到保本价:做多向上取整、做空向下取整,保证不亏;只收紧不放宽
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)
desired = desired_orders(sizing, entry_sent=bool(entry_rows), entry_fill=(filled, average) if average else None,
tp_filled=tp_filled, position_qty=qty, stop_price=stop, rules=rules)
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] 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) to_cancel, to_place = diff_orders(desired, actual)
@@ -515,7 +571,7 @@ class Executor:
for row in to_cancel: for row in to_cancel:
await self.cancel(client, row) await self.cancel(client, row)
canceled_entry = canceled_entry or row.purpose == "entry" canceled_entry = canceled_entry or row.purpose == "entry"
# 3. 补止盈;入场单只在本轮没有撤旧入场单、且止损在场时挂 # 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) 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"]: for spec in [d for d in to_place if d.purpose == "tp"]:
await self.place(session, client, plan.symbol, plan.id, "tp", spec) await self.place(session, client, plan.symbol, plan.id, "tp", spec)
@@ -552,9 +608,11 @@ class Executor:
side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]), side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]),
break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]), break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]),
) )
names = {s.symbol for s in symbols}
for symbol in symbols: for symbol in symbols:
other_notional = sum((p.qty * p.mark for name, p in positions.items() if name != symbol.symbol and name in names), Decimal(0))
try: try:
await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol)) await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol), other_notional)
except ExchangeError as e: except ExchangeError as e:
await session.rollback() await session.rollback()
await self.report(session, symbol.symbol, f"对账失败:{e}") await self.report(session, symbol.symbol, f"对账失败:{e}")
+195 -277
View File
@@ -1,6 +1,7 @@
"""交易计划的纯计算:模型提交的计划参数、校验、价格取整、仓位计算、止盈拆分。 """交易计划的纯计算:模型提交的待确认入场、校验、5m 确认扫描、触发时的止损与仓位、止盈阶梯。
下方常量与 main/README 第 1、2 节的数字重复,并写进了分析器的提示词;改 README 时要同步修改。 只做短线做波动:模型划出区间和确认价,程序在 5m 收盘确认后市价入场,止损和止盈由程序按固定规则计算。
下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
""" """
from dataclasses import dataclass from dataclasses import dataclass
@@ -10,31 +11,31 @@ from typing import Annotated, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
Angle = Literal["short", "long"]
Direction = Literal["long", "short"] Direction = Literal["long", "short"]
# 所有币合计每天(北京时间)最多自动分析的次数 # 所有币合计每天(北京时间)最多自动分析的次数
DAILY_ANALYSIS_LIMIT = 60 DAILY_ANALYSIS_LIMIT = 60
RISK_PCT = {"short": Decimal("0.01"), "long": Decimal("0.02")} RISK_PCT = Decimal("0.01")
LOW_CONFIDENCE_FACTOR = Decimal("0.5") LOW_CONFIDENCE_FACTOR = Decimal("0.5")
MAX_LEVERAGE = 3 MAX_LEVERAGE = 3
MIN_REWARD_RISK = Decimal("1.5") # 待确认入场的最长有效小时数;成交后的最长持仓小时数
ENTRY_VALID_HOURS = {"short": 4, "long": 24} SETUP_VALID_HOURS = 2
MAX_HOLD_HOURS = {"short": 24, "long": 48} MAX_HOLD_HOURS = 4
# 入场档数(最少,最多) CONFIRM_INTERVAL = "5m"
ENTRY_COUNT = {"short": (1, 1), "long": (1, 3)} ATR_PERIOD = 14
# 止盈档数由角度决定:短线止盈 1 平一半、止盈 2 平剩余;长线 50/30/20 # 止损放在确认过程的极值外 STOP_ATR_FACTOR × ATR14(5m);离现价超过 MAX_STOP_PCT 就放弃这次入场
TAKE_PROFIT_RATIOS = {"short": (Decimal("0.5"), Decimal("0.5")), "long": (Decimal("0.5"), Decimal("0.3"), Decimal("0.2"))} STOP_ATR_FACTOR = Decimal("0.5")
# 短线失效立即市价平仓,要用短周期;长线等 4h 收盘后复核 MAX_STOP_PCT = Decimal("0.01")
INVALIDATION_TIMEFRAMES = {"short": ("1m", "15m"), "long": ("4h",)} # 止盈阶梯:(距成交均价的涨跌幅,平仓比例)。牛市里空单不恋战,第一档就平掉大部分
TAKE_PROFIT_STEPS: dict[str, tuple[tuple[Decimal, Decimal], ...]] = {
"long": ((Decimal("0.005"), Decimal("0.6")), (Decimal("0.0075"), Decimal("0.3")), (Decimal("0.01"), Decimal("0.1"))),
"short": ((Decimal("0.005"), Decimal("0.8")), (Decimal("0.0075"), Decimal("0.2"))),
}
MAX_REEVALUATE = 4 MAX_REEVALUATE = 4
# 开仓、止损都按吃单手续费估算(C1 实测 taker 0.04%) # 入场(市价)和止损都按吃单手续费估算(C1 实测 taker 0.04%)
TAKER_FEE_RATE = Decimal("0.0004") TAKER_FEE_RATE = Decimal("0.0004")
# 模拟盘 PERCENT_PRICE:买单价不高于标记价 × 1.05,卖单价不低于标记价 × 0.95 # 例行分析周期:跟 1h 收盘
PERCENT_PRICE_LIMIT = Decimal("0.05") ROUTINE_HOURS = 1
RATIO_TOLERANCE = Decimal("0.001")
# 例行分析周期(小时):短线跟 1h、长线跟 4h 收盘
ROUTINE_HOURS = {"short": 1, "long": 4}
# 同一个币两次自动分析的最短间隔 # 同一个币两次自动分析的最短间隔
MIN_ANALYSIS_GAP = timedelta(minutes=10) MIN_ANALYSIS_GAP = timedelta(minutes=10)
@@ -45,45 +46,38 @@ class Condition(BaseModel):
direction: Annotated[Literal["above", "below"], Field(description="above:价格高于 price 时满足;below:低于时满足")] direction: Annotated[Literal["above", "below"], Field(description="above:价格高于 price 时满足;below:低于时满足")]
price: Price price: Price
timeframe: Annotated[ timeframe: Annotated[
Literal["mark", "1m", "15m", "1h", "4h"], Literal["mark", "1m", "5m", "15m", "1h", "4h"],
Field(description="mark:标记价触及即满足;其他:该周期 K 线收盘价满足才算"), Field(description="mark:标记价触及即满足;其他:该周期 K 线收盘价满足才算"),
] ]
basis: Annotated[str, Field(description="依据,如对应的支撑区、压力区")] basis: Annotated[str, Field(description="依据,如对应的支撑区、压力区")]
class Entry(BaseModel): class Setup(BaseModel):
price: Price zone_low: Price
ratio: Annotated[Decimal, Field(gt=0, le=1, description="占总仓位的比例,所有档位之和为 1")] zone_high: Price
basis: str confirm_price: Annotated[Decimal, Field(gt=0, description=(
"做多:不低于 zone_high,进区后 5m 收盘 ≥ 此价入场;做空:不高于 zone_low,进区后 5m 收盘 ≤ 此价入场"
))]
class TakeProfit(BaseModel): basis: Annotated[str, Field(description="区间和确认价的依据:对应的支撑区或压力区、周期、触碰次数")]
price: Price
basis: str
class TradePlan(BaseModel): class TradePlan(BaseModel):
"""submit_trade_plan 的参数""" """submit_trade_plan 的参数"""
action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=( action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=(
"plan:给出新计划(替换当前计划);keep:当前计划不变;" "plan:给出新的待确认入场(替换当前未触发的计划,仅在没有持仓时);keep:当前计划或持仓不变;"
"exit:结束当前计划,撤销未成交的入场单,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)" "exit:结束当前计划,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)"
))] ))]
summary: Annotated[str, Field(description="一句话结论,含方向和置信度")] summary: Annotated[str, Field(description="一句话结论,含方向和置信度")]
reasons: Annotated[str, Field(description="依据要点:新闻、结构、衍生品,引用新闻写 [新闻 id]")] reasons: Annotated[str, Field(description="依据要点:新闻、结构、衍生品,引用新闻写 [新闻 id]")]
risks: Annotated[str, Field(description="持仓时限内的重大事件及时间、与结论相反的信号")] risks: Annotated[str, Field(description="有效期和持仓时限内的重大事件及时间、与结论相反的信号")]
reevaluate: Annotated[list[Condition], Field(max_length=MAX_REEVALUATE, description=( reevaluate: Annotated[list[Condition], Field(max_length=MAX_REEVALUATE, description=(
"满足任一条件时程序重新请你分析:用来表达另一侧方向、突破回踩等条件计划、需要复核的位置。提交时不能已经满足" "满足任一条件时程序重新请你分析:用来表达另一侧方向、需要重新划区的位置。提交时不能已经满足"
))] = [] ))] = []
direction: Direction | None = None direction: Direction | None = None
confidence: Literal["high", "medium", "low"] | None = None confidence: Literal["high", "medium", "low"] | None = None
entries: Annotated[list[Entry] | None, Field(description="入场限价单,每档一张")] = None setup: Annotated[Setup | None, Field(description="待确认入场:做多为支撑区,做空为压力区")] = None
stop: Annotated[Price | None, Field(description="统一止损价")] = None valid_hours: Annotated[Decimal | None, Field(gt=0, description=f"有效小时数(≤ {SETUP_VALID_HOURS}),到期未触发则作废")] = 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 @dataclass
@@ -95,20 +89,34 @@ class SymbolRules:
@dataclass @dataclass
class PlanContext: class PlanContext:
angle: Angle
mark_price: Decimal mark_price: Decimal
# min(设定的交易资金, 账户权益) # min(设定的交易资金, 账户权益)
capital: Decimal capital: Decimal
# 交易列表中其他币的持仓名义价值合计 # 交易列表中其他币的持仓名义价值合计
other_notional: Decimal other_notional: Decimal
rules: SymbolRules rules: SymbolRules
# 最近已收盘 5m K 线的 ATR14,用于预估止损
atr: Decimal
has_plan: bool has_plan: bool
position_side: Direction | None position_side: Direction | None
position_qty: Decimal = Decimal(0)
# 持仓均价
position_entry: Decimal | None = None @dataclass(frozen=True)
# 当前生效的止损价(可能已移到保本价);新计划只能收紧 class Bar:
current_stop: Decimal | None = None open_time: int
# 币安的 close_time:收盘前 1 毫秒
close_time: int
high: Decimal
low: Decimal
close: Decimal
@dataclass(frozen=True)
class SetupScan:
state: Literal["waiting", "triggered", "broken", "missed"]
bar: Bar | None = None
# 进区以来的极值:做多为最低价,做空为最高价
extreme: Decimal | None = None
class PlanError(Exception): class PlanError(Exception):
@@ -130,10 +138,95 @@ def split_take_profits(quantity: Decimal, ratios: tuple[Decimal, ...], step: Dec
return [*parts, quantity - sum(parts, Decimal(0))] return [*parts, quantity - sum(parts, Decimal(0))]
def reward_risk(entry: Decimal, stop: Decimal, targets: list[Decimal], ratios: tuple[Decimal, ...]) -> Decimal: def parse_bars(rows: list[list]) -> list[Bar]:
"""综合盈亏比 = Σ(止盈比例 × |止盈价 − 入场价|) ÷ |入场价 − 止损价|,与 README 第 4 节一致(不含手续费)""" """币安 klines 原始行"""
reward = sum((r * abs(t - entry) for t, r in zip(targets, ratios, strict=True)), Decimal(0)) return [Bar(int(r[0]), int(r[6]), Decimal(r[2]), Decimal(r[3]), Decimal(r[4])) for r in rows]
return reward / abs(entry - stop)
def atr(bars: list[Bar], period: int = ATR_PERIOD) -> Decimal:
"""最后 period 根的真实波幅简单平均,与 get_klines 的 atr14 口径一致"""
if len(bars) < period + 1:
raise ValueError(f"计算 ATR 需要至少 {period + 1} 根 K 线,现有 {len(bars)} 根")
recent = bars[-period - 1:]
ranges = [max(b.high - b.low, abs(b.high - prev.close), abs(b.low - prev.close)) for prev, b in zip(recent, recent[1:], strict=False)]
return sum(ranges, Decimal(0)) / period
def scan_setup(direction: Direction, setup: dict, bars: list[Bar], after_ms: int, now_ms: int) -> SetupScan:
"""按计划生效后收盘的 5m K 线判断待确认入场(按时间顺序,先发生的为准):
- 做多:某根最低价进入区间(≤ zone_high)后,出现收盘 ≥ 确认价(可以是同一根)→ 触发;
在此之前任何一根收盘 < zone_low → 支撑破位。做空镜像。
- 触发的那根不是最新一根已收盘 K 线(如程序停过)→ missed,不补做。
"""
zone_low, zone_high, confirm = (Decimal(setup[k]) for k in ("zone_low", "zone_high", "confirm_price"))
closed = [b for b in bars if after_ms <= b.close_time < now_ms]
long = direction == "long"
extreme: Decimal | None = None
for i, bar in enumerate(closed):
if (bar.close < zone_low) if long else (bar.close > zone_high):
return SetupScan("broken", bar)
if extreme is None and ((bar.low <= zone_high) if long else (bar.high >= zone_low)):
extreme = bar.low if long else bar.high
if extreme is None:
continue
extreme = min(extreme, bar.low) if long else max(extreme, bar.high)
if (bar.close >= confirm) if long else (bar.close <= confirm):
return SetupScan("triggered" if i == len(closed) - 1 else "missed", bar, extreme)
return SetupScan("waiting")
def setup_stop(direction: Direction, extreme: Decimal, atr_value: Decimal, tick: Decimal) -> Decimal:
"""止损 = 极值外 STOP_ATR_FACTOR × ATR,向远离现价的方向取整"""
if direction == "long":
return round_to_step(extreme - STOP_ATR_FACTOR * atr_value, tick, ROUND_FLOOR)
return round_to_step(extreme + STOP_ATR_FACTOR * atr_value, tick, ROUND_CEILING)
def size_entry(direction: Direction, price: Decimal, stop: Decimal, *, capital: Decimal, other_notional: Decimal,
confidence: str, rules: SymbolRules) -> dict:
"""按估计入场价和止损算数量:单笔风险上限与全账户杠杆取小。止损离现价太远、方向不对或数量不足时抛 PlanError"""
sign = 1 if direction == "long" else -1
stop_pct = sign * (price - stop) / price
if stop_pct <= 0:
raise PlanError([f"止损 {stop} 已越过现价 {price}"])
if stop_pct > MAX_STOP_PCT:
raise PlanError([f"止损 {stop} 距现价 {price} {stop_pct * 100:.2f}%,超过 {MAX_STOP_PCT * 100:.0f}%"])
unit_loss = abs(price - stop) + (price + stop) * TAKER_FEE_RATE
risk_amount = capital * RISK_PCT * (LOW_CONFIDENCE_FACTOR if confidence == "low" else 1)
max_notional = capital * MAX_LEVERAGE - other_notional
if max_notional <= 0:
raise PlanError([f"交易列表其他币的名义仓位 {other_notional:.2f} 已达到 {MAX_LEVERAGE} 倍交易资金,不能开新仓"])
by_risk = risk_amount / unit_loss
by_leverage = max_notional / price
quantity = round_to_step(min(by_risk, by_leverage), rules.step_size, ROUND_FLOOR)
notional = quantity * price
if notional < rules.min_notional:
raise PlanError([f"数量 {quantity} 的名义价值 {notional:.2f} USDT 低于最小下单金额 {rules.min_notional} USDT"])
risk_at_stop = quantity * unit_loss
return {
"price": str(price),
"stop": str(stop),
"stop_pct": str((stop_pct * 100).quantize(Decimal("0.01"))),
"quantity": str(quantity),
"notional": str(notional.quantize(Decimal("0.01"))),
"leverage": str((notional / 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 / capital * 100).quantize(Decimal("0.01"))),
"capped_by_leverage": by_leverage < by_risk,
}
def take_profit_targets(direction: Direction, average: Decimal, quantity: Decimal, rules: SymbolRules) -> list[tuple[Decimal, Decimal]]:
"""止盈阶梯(价格,数量):价格 = 成交均价 ×(1 ± 涨跌幅),向靠近均价的方向取整;数量按比例拆分"""
steps = TAKE_PROFIT_STEPS[direction]
quantities = split_take_profits(quantity, tuple(r for _, r in steps), rules.step_size)
if direction == "long":
prices = [round_to_step(average * (1 + pct), rules.tick_size, ROUND_FLOOR) for pct, _ in steps]
else:
prices = [round_to_step(average * (1 - pct), rules.tick_size, ROUND_CEILING) for pct, _ in steps]
return list(zip(prices, quantities, strict=True))
def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]: def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
@@ -144,6 +237,8 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
errors.append("当前没有计划也没有持仓,不能用 exit,观望请用 wait") errors.append("当前没有计划也没有持仓,不能用 exit,观望请用 wait")
if plan.action == "wait" and (ctx.has_plan or ctx.position_side is not None): if plan.action == "wait" and (ctx.has_plan or ctx.position_side is not None):
errors.append("有当前计划或持仓时不能用 wait:保留用 keep,结束用 exit") errors.append("有当前计划或持仓时不能用 wait:保留用 keep,结束用 exit")
if plan.action == "plan" and ctx.position_side is not None:
errors.append("已有持仓:持仓由程序按止损、止盈和持仓时限管理,只能 keep 或 exit")
for i, c in enumerate(plan.reevaluate, 1): for i, c in enumerate(plan.reevaluate, 1):
satisfied = ctx.mark_price > c.price if c.direction == "above" else ctx.mark_price < c.price satisfied = ctx.mark_price > c.price if c.direction == "above" else ctx.mark_price < c.price
if satisfied: if satisfied:
@@ -151,221 +246,48 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
return errors return errors
def check_plan_fields(plan: TradePlan, ctx: PlanContext) -> list[str]: def check_setup(plan: TradePlan, ctx: PlanContext) -> dict:
"""action=plan 时的字段完整性、档数、比例、有效期。有持仓时只调整止损止盈等,不加仓""" """校验计划;action=plan 时返回待确认入场和预估(按确认价入场、止损放在区间远端外)。不通过时抛 PlanError"""
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]
if missing:
return [f"action=plan 时缺少:{', '.join(missing)}"]
errors = []
angle = ctx.angle
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]
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])}")
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) errors = check_action(plan, ctx)
if plan.action != "plan": if plan.action != "plan":
if errors: if errors:
raise PlanError(errors) raise PlanError(errors)
return {} return {}
errors += check_plan_fields(plan, ctx) missing = [name for name in ("direction", "confidence", "setup", "valid_hours") if getattr(plan, name) is None]
if missing:
raise PlanError([*errors, f"action=plan 时缺少:{', '.join(missing)}"])
direction, setup, mark = plan.direction, plan.setup, ctx.mark_price
long = direction == "long"
if setup.zone_low >= setup.zone_high:
errors.append(f"区间下沿 {setup.zone_low} 须低于上沿 {setup.zone_high}")
# 确认 = 进区后收盘回到区间外侧;确认价在区间内会让刚碰到区间边缘、没有真正回落或收回的 K 线也触发
if long and setup.confirm_price < setup.zone_high:
errors.append(f"做多的确认价 {setup.confirm_price} 须不低于区间上沿 {setup.zone_high}(进区后收盘回到区间上方才算确认)")
if not long and setup.confirm_price > setup.zone_low:
errors.append(f"做空的确认价 {setup.confirm_price} 须不高于区间下沿 {setup.zone_low}(进区后收盘回到区间下方才算确认)")
if long and mark < setup.zone_low:
errors.append(f"现价 {mark} 已跌破区间下沿 {setup.zone_low},支撑已破")
if not long and mark > setup.zone_high:
errors.append(f"现价 {mark} 已突破区间上沿 {setup.zone_high},压力已破")
if plan.valid_hours > SETUP_VALID_HOURS:
errors.append(f"valid_hours 不能超过 {SETUP_VALID_HOURS}")
if errors: if errors:
raise PlanError(errors) raise PlanError(errors)
if ctx.position_side is not None: far_edge = setup.zone_low if long else setup.zone_high
return size_managed(plan, ctx) stop = setup_stop(direction, far_edge, ctx.atr, ctx.rules.tick_size)
try:
angle, direction, rules = ctx.angle, plan.direction, ctx.rules estimate = size_entry(direction, setup.confirm_price, stop, capital=ctx.capital, other_notional=ctx.other_notional,
# 向保守方向取整:做多的入场、止损、止盈都向下(入场更低、止损更远、止盈更近),做空都向上 confidence=plan.confidence, rules=ctx.rules)
rounding = ROUND_FLOOR if direction == "long" else ROUND_CEILING except PlanError as e:
entry_prices = [round_to_step(e.price, rules.tick_size, rounding) for e in plan.entries] raise PlanError([f"按确认价入场、止损放在区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m)={stop} 估算:{e}。"
stop = round_to_step(plan.stop, rules.tick_size, rounding) "区间太宽或离确认价太远,触发后也会被放弃,请收窄区间或调整确认价"]) from e
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 { return {
"direction": direction, "direction": direction,
"entries": [ "setup": {k: str(getattr(setup, k)) for k in ("zone_low", "zone_high", "confirm_price")},
{"price": str(p), "ratio": str(r.quantize(Decimal("0.0001"))), "quantity": str(q), "notional": str((p * q).quantize(Decimal("0.01")))} "estimate": estimate,
for p, r, q in zip(entry_prices, ratios, quantities, strict=True) "atr": str(ctx.atr.quantize(ctx.rules.tick_size)),
],
"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"))),
# 执行器采纳前核对:持仓变了说明计划基于过时状态 # 执行器采纳前核对:持仓变了说明计划基于过时状态
"position_qty": "0", "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,
} }
@@ -385,33 +307,29 @@ class OrderSpec:
def desired_orders( def desired_orders(
sizing: dict, *, entries_open: bool, entry_filled: list[Decimal], tp_filled: list[Decimal], sizing: dict, *, entry_sent: bool, entry_fill: tuple[Decimal, Decimal] | None, tp_filled: list[Decimal],
position_qty: Decimal, stop_price: Decimal, step_size: Decimal, position_qty: Decimal, stop_price: Decimal, rules: SymbolRules,
) -> list[OrderSpec]: ) -> list[OrderSpec]:
"""计划在当前成交状态下应挂的单。 """已触发的计划在当前状态下应挂的单。
- 入场:entries_open 时各档剩余数量。 - 入场:还没下过入场单时一张市价单;只下一次,被拒不重试。
- 止损:有持仓或有入场单时一张只减仓止损,数量取计划总量与持仓的较大者(数量大于持仓时交易所只平实际持仓)。 - 止损:有持仓或入场还没成交(未下单、已发出待成交)时一张只减仓止损,数量取计划数量与持仓的较大者(大于持仓时交易所只平实际持仓)。
- 止盈:有持仓时按累计成交数量拆分,减去各档已成交。 - 止盈:有持仓时按入场成交(数量,均价)排出阶梯,减去各档已成交。
""" """
long = sizing["direction"] == "long" direction = sizing["direction"]
open_side, close_side = ("BUY", "SELL") if long else ("SELL", "BUY") open_side, close_side = ("BUY", "SELL") if direction == "long" else ("SELL", "BUY")
quantity = Decimal(sizing["entry"]["quantity"])
orders = [] orders = []
if entries_open: if not entry_sent:
for i, e in enumerate(sizing["entries"]): orders.append(OrderSpec("entry", 0, open_side, "MARKET", None, None, quantity))
remaining = Decimal(e["quantity"]) - entry_filled[i] if position_qty > 0 or entry_fill is None:
if remaining > 0: orders.append(OrderSpec("stop", None, close_side, "STOP_MARKET", None, stop_price, max(quantity, position_qty)))
orders.append(OrderSpec("entry", i, open_side, "LIMIT", Decimal(e["price"]), None, remaining)) if position_qty > 0 and entry_fill is not None:
if position_qty > 0 or orders: filled, average = entry_fill
quantity = max(Decimal(sizing["quantity"]), position_qty) for j, (price, target) in enumerate(take_profit_targets(direction, average, filled, rules)):
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] remaining = target - tp_filled[j]
if remaining > 0: if remaining > 0:
orders.append(OrderSpec("tp", j, close_side, "LIMIT", Decimal(t["price"]), None, remaining)) orders.append(OrderSpec("tp", j, close_side, "LIMIT", price, None, remaining))
return orders return orders
@@ -431,10 +349,10 @@ def condition_met(direction: Literal["above", "below"], threshold: Decimal, pric
return price > threshold if direction == "above" else price < threshold return price > threshold if direction == "above" else price < threshold
def next_routine_at(last_started: datetime, angle: Angle) -> datetime: def next_routine_at(last_started: datetime) -> datetime:
"""例行分析时间:上次分析后约 3/4 个周期之后的第一个 K 线收盘,再加 1 分钟等收盘数据就绪。 """例行分析时间:上次分析后约 3/4 个周期之后的第一个 K 线收盘,再加 1 分钟等收盘数据就绪。
任何一次分析(包括触发式)都会让例行分析重新计时""" 任何一次分析(包括触发式)都会让例行分析重新计时"""
period = timedelta(hours=ROUTINE_HOURS[angle]) period = timedelta(hours=ROUTINE_HOURS)
# 币安 K 线按 UTC 对齐 # 币安 K 线按 UTC 对齐
earliest = (last_started + period * 3 / 4).astimezone(UTC) earliest = (last_started + period * 3 / 4).astimezone(UTC)
day = earliest.replace(hour=0, minute=0, second=0, microsecond=0) day = earliest.replace(hour=0, minute=0, second=0, microsecond=0)
+1 -1
View File
@@ -58,7 +58,7 @@ async def routine_requests() -> None:
)).all() )).all()
if recent and recent[0].status == "running": if recent and recent[0].status == "running":
continue continue
if not recent or now >= next_routine_at(recent[0].started_at, target.angle): if not recent or now >= next_routine_at(recent[0].started_at):
await request_analysis(session, target.symbol, "例行分析") await request_analysis(session, target.symbol, "例行分析")
continue continue
last = recent[0] last = recent[0]
+143 -144
View File
@@ -4,49 +4,65 @@ from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
import pytest import pytest
from alphapulse.trading.plan import ( from alphapulse.trading.plan import (
OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, desired_orders, diff_orders, next_routine_at, round_to_step, Bar, OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, atr, check_setup, desired_orders, diff_orders, next_routine_at,
size_plan, split_take_profits, round_to_step, scan_setup, setup_stop, size_entry, split_take_profits, take_profit_targets,
) )
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"))
TZ8 = timezone(timedelta(hours=8))
FIVE_MINUTES_MS = 5 * 60_000
# BTCUSDT 永续 5m,2026-10-15 北京时间(开盘时间, 最高, 最低, 收盘),即用户举例的那段行情
BTC_BARS = [
("09:10", "81858.10", "81629.20", "81687.60"), ("09:15", "81771.40", "81570.20", "81716.40"),
("09:20", "81732.40", "81584.80", "81584.90"), ("09:25", "81615.70", "81451.60", "81544.00"),
("09:30", "81810.00", "81523.80", "81751.80"), ("09:35", "81827.40", "81675.00", "81731.70"),
("09:40", "81832.80", "81700.80", "81809.50"), ("09:45", "81810.00", "81637.20", "81680.20"),
("09:50", "81795.00", "81656.70", "81678.90"), ("09:55", "81730.00", "81575.10", "81602.00"),
("10:00", "81619.40", "81495.20", "81507.10"), ("10:05", "81737.00", "81500.00", "81554.70"),
("10:10", "81620.00", "81412.80", "81412.80"), ("10:15", "81775.10", "81406.20", "81769.20"),
("10:20", "81770.10", "80664.20", "80700.10"), ("10:25", "81038.10", "80188.00", "80804.20"),
("10:30", "81057.50", "80476.70", "80979.30"), ("10:35", "81293.20", "80959.00", "81081.90"),
("10:40", "81293.00", "81053.30", "81293.00"), ("10:45", "81350.00", "81111.00", "81213.70"),
("10:50", "81411.70", "81152.70", "81410.00"), ("10:55", "81646.50", "81409.90", "81500.00"),
("11:00", "81514.40", "81077.70", "81153.30"), ("11:05", "81332.10", "81116.00", "81288.10"),
]
def ms(hhmm: str) -> int:
hour, minute = map(int, hhmm.split(":"))
return int(datetime(2026, 10, 15, hour, minute, tzinfo=TZ8).timestamp() * 1000)
def bars_until(hhmm: str) -> list[Bar]:
"""到 hhmm 为止的 K 线,最后一根是 hhmm 开盘、尚未收盘的那根"""
return [Bar(ms(t), ms(t) + FIVE_MINUTES_MS - 1, Decimal(h), Decimal(lo), Decimal(c)) for t, h, lo, c in BTC_BARS if ms(t) <= ms(hhmm)]
def scan(direction, zone_low, zone_high, confirm, adopted: str, now: str):
setup = {"zone_low": zone_low, "zone_high": zone_high, "confirm_price": confirm}
return scan_setup(direction, setup, bars_until(now), ms(adopted), ms(now) + 2000)
def context(**overrides) -> PlanContext: def context(**overrides) -> PlanContext:
values = { values = {
"angle": "long", "mark_price": Decimal("82000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "mark_price": Decimal("81000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "rules": BTC,
"rules": BTC, "has_plan": False, "position_side": None, "atr": Decimal("100"), "has_plan": False, "position_side": None,
} }
return PlanContext(**{**values, **overrides}) return PlanContext(**{**values, **overrides})
def long_plan(**overrides) -> TradePlan: def setup_plan(**overrides) -> TradePlan:
values = { values = {
"action": "plan", "summary": "长线做多", "reasons": "支撑区", "risks": "无", "action": "plan", "summary": "回踩支撑做多", "reasons": "支撑区", "risks": "无", "direction": "long", "confidence": "medium",
"direction": "long", "confidence": "medium", "setup": {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750", "basis": "1h 支撑区"}, "valid_hours": "2",
"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}) return TradePlan.model_validate({**values, **overrides})
def errors_of(plan: TradePlan, ctx: PlanContext) -> str: def errors_of(plan: TradePlan, ctx: PlanContext) -> str:
with pytest.raises(PlanError) as e: with pytest.raises(PlanError) as e:
size_plan(plan, ctx) check_setup(plan, ctx)
return ";".join(e.value.errors) return ";".join(e.value.errors)
@@ -65,151 +81,137 @@ def test_split_take_profits_keeps_total():
assert split_take_profits(Decimal("0.001"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.001")) == [0, 0, Decimal("0.001")] 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(): # ---------- 5m 确认(用户的两个例子) ----------
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 def test_long_example_triggers_on_close_back_above_after_dip():
assert sizing["average_price"] == "80400.0" # 10:20 收在 80700 还低于确认价;10:25 下探到 80188(影线低于区间下沿但收盘没破)收回 80804 → 10:30 触发
assert [e["quantity"] for e in sizing["entries"]] == ["0.0082", "0.0124"] result = scan("long", "80200", "80700", "80750", adopted="10:12", now="10:30")
assert sizing["quantity"] == "0.0206" assert result.state == "triggered"
assert sizing["notional"] == "1656.20" assert result.bar.open_time == ms("10:25")
assert sizing["risk_amount"] == "20.00" assert result.extreme == Decimal("80188.00")
assert sizing["risk_at_stop"] == "19.82" assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:25").state == "waiting"
# (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(): def test_short_example_triggers_on_rejection_from_resistance():
sizing = size_plan(long_plan(confidence="low"), context()) # 10:50 只到 81411.7,未进区;10:55 冲到 81646.5 后收在 81500 → 11:00 触发
assert sizing["risk_amount"] == "10.00" result = scan("short", "81500", "81700", "81500", adopted="10:47", now="11:00")
# 10 ÷ 963.96 = 0.010374:0.4 档 0.0041,0.6 档 0.0062 assert (result.state, result.bar.open_time, result.extreme) == ("triggered", ms("10:55"), Decimal("81646.50"))
assert sizing["quantity"] == "0.0103"
def test_leverage_cap_counts_other_positions(): def test_bars_closed_before_adoption_are_ignored():
sizing = size_plan(long_plan(), context(other_notional=Decimal("2000"))) # 10:25 那根在计划生效(10:30)前已收盘,不算
assert sizing["capped_by_leverage"] is True assert scan("long", "80200", "80700", "80750", adopted="10:30", now="10:30").state == "waiting"
assert Decimal(sizing["notional"]) <= Decimal("1000")
def test_short_term_short_plan(): def test_trigger_not_on_latest_bar_is_missed():
sizing = size_plan(short_term_plan(), context(angle="short")) assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:35").state == "missed"
# 止盈 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(): def test_close_beyond_far_edge_breaks_setup():
sizing = size_plan(short_term_plan(entries=[{"price": "82500.01", "ratio": "1", "basis": "x"}]), context(angle="short")) result = scan("long", "80750", "81000", "81050", adopted="10:12", now="10:30")
assert sizing["entries"][0]["price"] == "82500.1" assert (result.state, result.bar.open_time) == ("broken", ms("10:20"))
def test_rejects_wrong_counts_and_timeframe(): def test_atr_and_stop_from_example():
message = errors_of(short_term_plan(entries=[{"price": "82500", "ratio": "0.5", "basis": "a"}, {"price": "82600", "ratio": "0.5", "basis": "b"}]), context(angle="short")) closed = bars_until("10:25")
assert "短线只能 1 档入场" in message value = atr(closed)
assert "长线失效条件的周期只能是 4h" in errors_of(long_plan(invalidation={"direction": "below", "price": "79800", "timeframe": "1h", "basis": "x"}), context()) assert value.quantize(Decimal("0.0001")) == Decimal("302.9714")
assert "长线须给 3 个止盈" in errors_of(long_plan(take_profits=[{"price": "83000", "basis": "a"}]), context()) # 做多:80188 − 0.5 × 302.97 = 80036.51 → 向下取整 80036.5,距 80804.2 约 0.95%,仍在 1% 以内
stop = setup_stop("long", Decimal("80188.00"), value, BTC.tick_size)
assert stop == Decimal("80036.5")
entry = size_entry("long", Decimal("80804.2"), stop, capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC)
assert entry["stop_pct"] == "0.95"
# 10 ÷ (767.7 + (80804.2 + 80036.5) × 0.0004 = 832.04) = 0.01201… → 0.0120
assert entry["quantity"] == "0.0120"
assert setup_stop("short", Decimal("81646.50"), atr(bars_until("10:55")), BTC.tick_size) == Decimal("81827.8")
def test_rejects_bad_price_order(): def test_size_entry_rejects_far_stop_and_halves_low_confidence():
message = errors_of(long_plan(stop="80500"), context()) with pytest.raises(PlanError, match="超过 1%"):
assert "做多时止损 80500.0 应在失效价 79800 之外" in message size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
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()) with pytest.raises(PlanError, match="越过现价"):
size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
medium = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC)
low = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="low", rules=BTC)
assert (medium["risk_amount"], low["risk_amount"]) == ("10.00", "5.00")
# 止损很近时按 3 倍杠杆封顶:3000 − 其他币 1000 = 2000 ÷ 80000 = 0.025
capped = size_entry("long", Decimal("80000"), Decimal("79990"), capital=Decimal("1000"), other_notional=Decimal("1000"), confidence="high", rules=BTC)
assert (capped["quantity"], capped["capped_by_leverage"]) == ("0.0250", True)
def test_rejects_low_reward_risk(): def test_take_profit_ladders():
# (0.5×400 + 0.3×600 + 0.2×800) ÷ 500 = 1.08 long = take_profit_targets("long", Decimal("80804.2"), Decimal("0.0120"), BTC)
plan = long_plan(take_profits=[{"price": "80400", "basis": "a"}, {"price": "80600", "basis": "b"}, {"price": "80800", "basis": "c"}], # +0.5% = 81208.22 → 81208.2;+0.75% = 81410.23 → 81410.2;+1% = 81612.24 → 81612.2;数量 60/30/10
entries=[{"price": "80000", "ratio": "1", "basis": "a"}]) assert long == [(Decimal("81208.2"), Decimal("0.0072")), (Decimal("81410.2"), Decimal("0.0036")), (Decimal("81612.2"), Decimal("0.0012"))]
assert "盈亏比 1.08" in errors_of(plan, context(mark_price=Decimal("80300"))) short = take_profit_targets("short", Decimal("81500"), Decimal("0.0123"), BTC)
# −0.5% = 81092.5;−0.75% = 80888.75 → 向上取整 80888.8;数量 80/20
assert short == [(Decimal("81092.5"), Decimal("0.0098")), (Decimal("80888.8"), Decimal("0.0025"))]
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 def test_check_setup_accepts_and_estimates():
assert "第 1 档" in message sizing = check_setup(setup_plan(), context())
assert sizing["setup"] == {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750"}
# 预估止损 = 80200 − 0.5 × 100 = 80150,距确认价 80750 约 0.74%
assert (sizing["estimate"]["stop"], sizing["estimate"]["stop_pct"]) == ("80150.0", "0.74")
assert sizing["position_qty"] == "0"
def test_check_setup_rejects_bad_setups():
bad_order = setup_plan(setup={"zone_low": "80700", "zone_high": "80200", "confirm_price": "80750", "basis": "x"})
assert "须低于上沿" in errors_of(bad_order, context())
inside = setup_plan(setup={"zone_low": "80200", "zone_high": "80700", "confirm_price": "80500", "basis": "x"})
assert "不低于区间上沿" in errors_of(inside, context())
short_inside = setup_plan(direction="short", setup={"zone_low": "81500", "zone_high": "81700", "confirm_price": "81600", "basis": "x"})
assert "不高于区间下沿" in errors_of(short_inside, context())
assert "支撑已破" in errors_of(setup_plan(), context(mark_price=Decimal("80100")))
wide = setup_plan(setup={"zone_low": "79800", "zone_high": "80700", "confirm_price": "80750", "basis": "x"})
assert "超过 1%" in errors_of(wide, context())
assert "valid_hours" in errors_of(setup_plan(valid_hours="3"), context())
def test_action_rules(): def test_action_rules():
assert "不能用 keep" in errors_of(TradePlan(action="keep", summary="s", reasons="r", risks="x"), context()) 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 "不能用 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()) == {} assert check_setup(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {}
satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x", satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x",
reevaluate=[{"direction": "above", "price": "81000", "timeframe": "1h", "basis": "x"}]) reevaluate=[{"direction": "above", "price": "80000", "timeframe": "5m", "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 "只能 keep 或 exit" in errors_of(setup_plan(), context(position_side="long", has_plan=True))
# ---------- 有持仓时的新计划 ----------
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 = { TRIGGERED = {"direction": "long", "setup": {}, "entry": {"quantity": "0.0120", "stop": "80036.5"}}
"direction": "long", "managed": False, "quantity": "0.0030", NO_TP = [Decimal(0)] * 3
"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(): def test_desired_orders_places_stop_and_market_entry_once():
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3, orders = desired_orders(TRIGGERED, entry_sent=False, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0),
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001")) stop_price=Decimal("80036.5"), rules=BTC)
assert [(o.purpose, o.leg, o.side, o.quantity) for o in orders] == [ assert [(o.purpose, o.side, o.type, o.quantity) for o in orders] == [
("entry", 0, "BUY", Decimal("0.0012")), ("entry", 1, "BUY", Decimal("0.0018")), ("stop", None, "SELL", Decimal("0.0030")), ("entry", "BUY", "MARKET", Decimal("0.0120")), ("stop", "SELL", "STOP_MARKET", Decimal("0.0120")),
]
# 入场单已发出、还没成交:只保留止损,不重发入场
orders = desired_orders(TRIGGERED, entry_sent=True, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0),
stop_price=Decimal("80036.5"), rules=BTC)
assert [o.purpose for o in orders] == ["stop"]
def test_desired_orders_take_profits_follow_fill_price():
orders = desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")),
tp_filled=[Decimal("0.0072"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0048"),
stop_price=Decimal("80850.0"), rules=BTC)
assert [(o.purpose, o.leg, o.price, o.trigger_price, o.quantity) for o in orders] == [
("stop", None, None, Decimal("80850.0"), Decimal("0.0120")),
("tp", 1, Decimal("81416.0"), None, Decimal("0.0036")), ("tp", 2, Decimal("81618.1"), None, Decimal("0.0012")),
] ]
def test_desired_orders_partial_fill_splits_take_profits_by_filled(): def test_desired_orders_nothing_after_flat():
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal("0.0012"), Decimal("0.0004")], tp_filled=ZERO3, assert desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")), tp_filled=NO_TP,
position_qty=Decimal("0.0016"), stop_price=Decimal("79500"), step_size=Decimal("0.0001")) position_qty=Decimal(0), stop_price=Decimal("80036.5"), rules=BTC) == []
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(): def test_diff_orders_matches_by_value():
@@ -223,12 +225,9 @@ def test_diff_orders_matches_by_value():
def test_next_routine_at_aligns_to_candle_close(): def test_next_routine_at_aligns_to_candle_close():
tz8 = timezone(timedelta(hours=8)) # 北京 10:20 = UTC 02:20;+45 分钟后的第一个整点 UTC 04:00,再加 1 分钟
# 北京 10:20 = UTC 02:20;短线 +45 分钟后的第一个整点 UTC 04:00,再加 1 分钟 assert next_routine_at(datetime(2026, 10, 15, 10, 20, tzinfo=TZ8)) == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
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)) == 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(): def test_step_decimal_never_uses_exponent():
+44 -33
View File
@@ -1,10 +1,6 @@
import type { Effort } from "@/apis/settings" import type { Effort } from "@/apis/settings"
import { http } from "@/http" import { http } from "@/http"
export type Angle = "short" | "long"
export const ANGLE_LABELS: Record<Angle, string> = { short: "短线(24h 内平仓)", long: "长线(12–48h 平仓)" }
/** 以下数值字段都是币安返回的十进制字符串,避免浮点误差 */ /** 以下数值字段都是币安返回的十进制字符串,避免浮点误差 */
export interface SnapshotPosition { export interface SnapshotPosition {
symbol: string symbol: string
@@ -73,7 +69,6 @@ export interface TradingStatus {
export interface TradingSymbol { export interface TradingSymbol {
symbol: string symbol: string
angle: Angle
added_at: string added_at: string
analysis_requested_at: string | null analysis_requested_at: string | null
analyzing: boolean analyzing: boolean
@@ -128,12 +123,8 @@ export function listSymbols(): Promise<TradingSymbol[]> {
return http.get("/trading/symbols") return http.get("/trading/symbols")
} }
export function addSymbol(symbol: string, angle: Angle): Promise<void> { export function addSymbol(symbol: string): Promise<void> {
return http.post("/trading/symbols", { symbol, angle }) return http.post("/trading/symbols", { symbol })
}
export function patchSymbol(symbol: string, angle: Angle): Promise<void> {
return http.patch(`/trading/symbols/${symbol}`, { angle })
} }
export function deleteSymbol(symbol: string): Promise<void> { export function deleteSymbol(symbol: string): Promise<void> {
@@ -147,7 +138,7 @@ export function listEvents(params: { symbol?: string, before?: number, limit?: n
export interface PlanCondition { export interface PlanCondition {
direction: "above" | "below" direction: "above" | "below"
price: string price: string
timeframe: "mark" | "1m" | "15m" | "1h" | "4h" timeframe: "mark" | "1m" | "5m" | "15m" | "1h" | "4h"
basis: string basis: string
} }
@@ -160,42 +151,39 @@ export interface TradeDecision {
reevaluate: PlanCondition[] reevaluate: PlanCondition[]
direction: "long" | "short" | null direction: "long" | "short" | null
confidence: "high" | "medium" | "low" | null confidence: "high" | "medium" | "low" | null
entries: { price: string, ratio: string, basis: string }[] | null /** 待确认入场;计划 3.1 之前的分析没有这个字段 */
stop: string | null setup?: { zone_low: string, zone_high: string, confirm_price: string, basis: string } | null
stop_basis: string | null valid_hours?: 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 { export interface EntrySizing {
direction: "long" | "short" price: string
entries: { price: string, ratio: string, quantity: string, notional: string }[]
stop: string stop: string
take_profits: { price: string, ratio: string, quantity: string }[] stop_pct: string
average_price: string
quantity: string quantity: string
notional: string notional: string
capital: string
leverage: string leverage: string
risk_amount: string risk_amount: string
risk_at_stop: string risk_at_stop: string
risk_pct: string risk_pct: string
capped_by_leverage: boolean capped_by_leverage: boolean
reward_risk: string }
reward_risk_first_only: string
/** 采纳前核对用:提交时的持仓数量 */ export interface Sizing {
direction: "long" | "short"
setup: { zone_low: string, zone_high: string, confirm_price: string }
/** 提交时按确认价入场、止损放在区间远端外估算 */
estimate: EntrySizing
atr: string
position_qty: string position_qty: string
/** 持仓中调整的计划:不加仓 */ /** 5m 确认后由执行器写入;有它表示已触发 */
managed: boolean entry?: EntrySizing & { triggered_at: string, bar_close: string, extreme: string, atr: string }
} }
export interface Analysis { export interface Analysis {
id: number id: number
symbol: string symbol: string
angle: Angle
reasons: string[] reasons: string[]
dry_run: boolean dry_run: boolean
status: "running" | "done" | "failed" status: "running" | "done" | "failed"
@@ -232,7 +220,7 @@ export type PlanStatus = "proposed" | "pending" | "open" | "closing" | "closed"
export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = { export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = {
proposed: "待采纳", proposed: "待采纳",
pending: "挂单中", pending: "等待确认",
open: "持仓中", open: "持仓中",
closing: "平仓中", closing: "平仓中",
closed: "已结束", closed: "已结束",
@@ -259,6 +247,29 @@ export interface OrderRow {
created_at: string created_at: string
} }
export const CLOSE_REASON_LABELS: Record<string, string> = {
stop: "止损",
take_profit: "止盈",
time: "到达持仓时限",
exit: "模型决定结束",
external: "外部平仓",
stop_failed: "止损挂单失败",
close: "市价平仓",
replaced: "被新计划替换",
expired: "到期未触发",
stopped: "已停止交易",
broken: "区间被突破",
missed: "错过确认",
skipped: "止损过远,放弃入场",
entry_failed: "入场失败"
}
/** 止盈阶梯(与后端 plan.TAKE_PROFIT_STEPS 一致):[距成交均价的涨跌幅, 平仓比例] */
export const TAKE_PROFIT_STEPS: Record<"long" | "short", [number, number][]> = {
long: [[0.005, 0.6], [0.0075, 0.3], [0.01, 0.1]],
short: [[0.005, 0.8], [0.0075, 0.2]]
}
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> { export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
return http.get(`/trading/symbols/${symbol}/plan`) return http.get(`/trading/symbols/${symbol}/plan`)
} }
@@ -1,12 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Angle, SymbolOption } from "@/apis/trading" import type { SymbolOption } from "@/apis/trading"
import { addSymbol, ANGLE_LABELS, listSymbolOptions } from "@/apis/trading" import { addSymbol, listSymbolOptions } from "@/apis/trading"
const emit = defineEmits<{ added: [symbol: string] }>() const emit = defineEmits<{ added: [symbol: string] }>()
const visible = defineModel<boolean>({ required: true }) const visible = defineModel<boolean>({ required: true })
const symbol = ref("") const symbol = ref("")
const angle = ref<Angle>("long")
const symbolOptions = ref<SymbolOption[]>([]) const symbolOptions = ref<SymbolOption[]>([])
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -28,7 +27,7 @@ watch(visible, async (open) => {
async function submit() { async function submit() {
submitting.value = true submitting.value = true
try { try {
await addSymbol(symbol.value, angle.value) await addSymbol(symbol.value)
ElMessage.success(`已加入 ${symbol.value}`) ElMessage.success(`已加入 ${symbol.value}`)
emit("added", symbol.value) emit("added", symbol.value)
visible.value = false visible.value = false
@@ -50,13 +49,6 @@ async function submit() {
只列出模拟盘和实盘都在交易的加密货币 USDT 永续合约 只列出模拟盘和实盘都在交易的加密货币 USDT 永续合约
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="角度">
<el-radio-group v-model="angle">
<el-radio v-for="(label, value) in ANGLE_LABELS" :key="value" :value="value">
{{ label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visible = false"> <el-button @click="visible = false">
+59 -68
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading" import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading"
import { ANGLE_LABELS, PLAN_STATUS_LABELS } from "@/apis/trading" import { CLOSE_REASON_LABELS, PLAN_STATUS_LABELS, TAKE_PROFIT_STEPS } from "@/apis/trading"
import { formatTime } from "@/utils/text" import { formatTime } from "@/utils/text"
/** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */ /** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */
@@ -9,7 +9,8 @@ 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
const CONFIDENCE_LABELS = { high: "高", medium: "中", low: "低" } 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 TIMEFRAME_LABELS = { "mark": "标记价触及", "1m": "1m 收盘", "5m": "5m 收盘", "15m": "15m 收盘", "1h": "1h 收盘", "4h": "4h 收盘" } as const
const ACTIVE_ORDER_STATUSES = ["submitting", "new", "partially_filled"]
const now = ref(Date.now()) const now = ref(Date.now())
const ticker = setInterval(() => now.value = Date.now(), 1000) const ticker = setInterval(() => now.value = Date.now(), 1000)
@@ -38,15 +39,26 @@ function condition(c: PlanCondition) {
return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}` return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}`
} }
/** 失效后的应急动作:短线立即市价平仓,长线等 4h 收盘后请模型复核 */ const long = computed(() => sizing.value?.direction === "long")
const invalidationAction = computed(() => props.analysis?.angle === "short" ? "立即市价平仓" : "请模型复核") const entryRow = computed(() => props.orders.find(o => o.purpose === "entry" && Number(o.filled_qty) > 0))
/** 交易所上生效的止损:止盈 1 后会移到保本价 */
const currentStop = computed(() => props.orders.filter(o => o.purpose === "stop" && ACTIVE_ORDER_STATUSES.includes(o.status)).at(-1)?.trigger_price)
const ladder = computed(() => sizing.value?.entry
? TAKE_PROFIT_STEPS[sizing.value.direction].map(([pct, ratio], leg) => ({
leg,
move: `${long.value ? "+" : "−"}${pct * 100}%`,
ratio: `${Math.round(ratio * 100)}%`,
price: props.orders.filter(o => o.purpose === "tp" && o.leg === leg).at(-1)?.price ?? "-",
filled: filled("tp", leg)
}))
: [])
</script> </script>
<template> <template>
<div class="border border-[var(--el-border-color)] rounded border-solid p-3"> <div class="border border-[var(--el-border-color)] rounded border-solid p-3">
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2">
<span class="font-bold">{{ symbol.symbol }}</span> <span class="font-bold">{{ symbol.symbol }}</span>
<span class="text-xs text-[var(--el-text-color-secondary)]">{{ ANGLE_LABELS[symbol.angle] }}</span> <span class="text-xs text-[var(--el-text-color-secondary)]">短线做波动(5m 确认入场,最长持仓 4 小时)</span>
<div class="flex-1" /> <div class="flex-1" />
<el-button v-if="analysis && analysis.status !== 'running'" link type="primary" @click="emit('detail', analysis.id)"> <el-button v-if="analysis && analysis.status !== 'running'" link type="primary" @click="emit('detail', analysis.id)">
查看完整分析 查看完整分析
@@ -104,79 +116,58 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
</el-link> </el-link>
</div> </div>
<div v-if="analysis.valid_until || analysis.close_deadline" class="flex flex-wrap gap-x-6"> <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.plan_status === 'pending' && analysis.valid_until && !sizing?.entry"><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.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> <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>
<div v-if="analysis.result"> <div v-if="analysis.result">
<span class="label">结果</span>{{ analysis.close_reason }},净盈亏 {{ analysis.result.net }} USDT(手续费 {{ analysis.result.commission }}) <span class="label">结果</span>{{ CLOSE_REASON_LABELS[analysis.close_reason ?? ""] ?? analysis.close_reason }},净盈亏 {{ analysis.result.net }} USDT(手续费 {{ analysis.result.commission }})
</div> </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)]"> <template v-if="decision.action === 'plan' && decision.setup && sizing?.setup">
持仓中调整的计划:不加仓,止盈按当前持仓数量拆分
</div>
<template v-if="decision.action === 'plan' && sizing">
<el-table v-if="sizing.entries.length" :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="80">
<template #default="{ $index }">
{{ filled("entry", $index) }}
</template>
</el-table-column>
<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="80">
<template #default="{ $index }">
{{ filled("tp", $index) }}
</template>
</el-table-column>
<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 class="grid grid-cols-2 gap-x-6 gap-y-1">
<div><span class="label">止损</span>{{ sizing.stop }}(交易所挂单,标记价触发)· {{ decision.stop_basis }}</div>
<div> <div>
<span class="label">失效条件</span>{{ decision.invalidation ? condition(decision.invalidation) : "-" }} <span class="label">{{ long ? "支撑区" : "压力区" }}</span>{{ sizing.setup.zone_low }} – {{ sizing.setup.zone_high }} · {{ decision.setup.basis }}
→ {{ invalidationAction }} · {{ decision.invalidation?.basis }} </div>
<div>
<span class="label">确认</span>5m {{ long ? "最低价进区后收盘 ≥" : "最高价进区后收盘 ≤" }} {{ sizing.setup.confirm_price }} → 市价{{ long ? "做多" : "做空" }};
先有收盘{{ long ? "低于" : "高于" }} {{ long ? sizing.setup.zone_low : sizing.setup.zone_high }} 则撤销
</div>
<div v-if="!sizing.entry">
<span class="label">预估</span>按确认价入场:止损 {{ sizing.estimate.stop }}({{ sizing.estimate.stop_pct }}%),数量 {{ sizing.estimate.quantity }},止损亏损 {{ sizing.estimate.risk_at_stop }} USDT
</div>
<div v-if="!sizing.entry">
<span class="label">有效期</span>{{ decision.valid_hours }} 小时;止损按触发时的极值外 0.5×ATR(5m) 计算,超过 1% 放弃入场
</div> </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> </div>
<template v-if="sizing.entry">
<div class="grid grid-cols-2 gap-x-6 gap-y-1">
<div>
<span class="label">触发</span>{{ formatTime(sizing.entry.triggered_at, "MM-DD HH:mm:ss") }},5m 收盘 {{ sizing.entry.bar_close }},{{ long ? "最低" : "最高" }} {{ sizing.entry.extreme }},ATR {{ sizing.entry.atr }}
</div>
<div>
<span class="label">入场</span>市价 {{ sizing.entry.quantity }},成交均价 {{ entryRow?.avg_price ?? "待成交" }}(名义 {{ sizing.entry.notional }} USDT,{{ sizing.entry.leverage }} 倍交易资金)
</div>
<div>
<span class="label">止损</span>{{ currentStop ?? sizing.entry.stop }}(初始 {{ sizing.entry.stop }},{{ sizing.entry.stop_pct }}%;止盈 1 后移到保本价)
</div>
<div>
<span class="label">止损亏损</span>{{ sizing.entry.risk_at_stop }} USDT(交易资金的 {{ sizing.entry.risk_pct }}%){{ sizing.entry.capped_by_leverage ? ",受 3 倍上限限制" : "" }}
</div>
</div>
<el-table :data="ladder" border size="small">
<el-table-column label="分批止盈" min-width="90">
<template #default="{ row }">
止盈 {{ row.leg + 1 }}
</template>
</el-table-column>
<el-table-column prop="move" label="距成交均价" min-width="90" />
<el-table-column prop="ratio" label="平仓比例" min-width="80" />
<el-table-column prop="price" label="价格" min-width="100" />
<el-table-column prop="filled" label="已成交" min-width="80" />
</el-table>
</template>
</template> </template>
<div v-if="decision.reevaluate.length"> <div v-if="decision.reevaluate.length">
+8 -18
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, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading" import type { Analysis, 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, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading" import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, 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"
@@ -11,7 +11,6 @@ import PlanCard from "./components/PlanCard.vue"
const STATUS_REFRESH_MS = 3000 const STATUS_REFRESH_MS = 3000
const EVENTS_PAGE = 50 const EVENTS_PAGE = 50
const SHORT_ANGLE_LABELS: Record<Angle, string> = { short: "短线", long: "长线" }
const LEVEL_TYPES = { info: "primary", warn: "warning", error: "danger" } as const const LEVEL_TYPES = { info: "primary", warn: "warning", error: "danger" } as const
const status = ref<TradingStatus>() const status = ref<TradingStatus>()
@@ -88,7 +87,7 @@ function symbolState(s: TradingSymbol) {
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")
return { label: "挂单中", type: "primary" } as const return { label: "等待确认", type: "primary" } as const
return { label: "空仓", type: "info" } as const return { label: "空仓", type: "info" } as const
} }
@@ -171,7 +170,7 @@ watch(selected, () => {
async function toggleRunning() { async function toggleRunning() {
if (status.value!.running) { if (status.value!.running) {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"停止后不再做新的分析,并撤销未成交的入场单。已有持仓保留,止损、止盈、到期平仓、短线失效平仓等程序规则继续执行。", "停止后不再做新的分析,并撤销还在等待确认的计划。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。",
"停止行情交易", "停止行情交易",
{ type: "warning", confirmButtonText: "停止" } { type: "warning", confirmButtonText: "停止" }
) )
@@ -215,13 +214,6 @@ async function changeEffort(effort: Effort | null) {
status.value = await patchConfig({ reasoning_effort: effort }) status.value = await patchConfig({ reasoning_effort: effort })
} }
async function switchAngle(s: TradingSymbol) {
const angle: Angle = s.angle === "short" ? "long" : "short"
await ElMessageBox.confirm(`把 ${s.symbol} 从${SHORT_ANGLE_LABELS[s.angle]}改为${SHORT_ANGLE_LABELS[angle]}?`, "切换角度")
await patchSymbol(s.symbol, angle)
await Promise.all([refresh(), refreshEvents()])
}
async function remove(s: TradingSymbol) { async function remove(s: TradingSymbol) {
await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" }) await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
await deleteSymbol(s.symbol) await deleteSymbol(s.symbol)
@@ -397,12 +389,10 @@ onBeforeUnmount(() => clearInterval(timer))
{{ symbolState(s).label }} {{ symbolState(s).label }}
</el-tag> </el-tag>
</div> </div>
<div class="mt-1 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]"> <div class="mt-1 flex justify-end text-xs">
<span>{{ ANGLE_LABELS[s.angle] }}</span> <el-link type="danger" underline="never" class="text-xs!" @click.stop="remove(s)">
<span> 删除
<el-link type="primary" underline="never" class="text-xs!" @click.stop="switchAngle(s)">切换角度</el-link> </el-link>
<el-link type="danger" underline="never" class="ml-2 text-xs!" @click.stop="remove(s)">删除</el-link>
</span>
</div> </div>
</div> </div>
<el-empty v-if="!symbols.length" description="还没有交易币种" :image-size="60" /> <el-empty v-if="!symbols.length" description="还没有交易币种" :image-size="60" />