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:
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'))
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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)])
|
||||
|
||||
Angle = Literal["short", "long"]
|
||||
ANGLE_LABELS = {"short": "短线", "long": "长线"}
|
||||
SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols"
|
||||
SYMBOLS_CACHE_SECONDS = 3600
|
||||
# 删除或切换角度前要求快照足够新,否则无法确认没有仓位和挂单
|
||||
# 删除前要求快照足够新,否则无法确认没有仓位和挂单
|
||||
SNAPSHOT_FRESH_SECONDS = 30
|
||||
# 执行器正在管理的计划状态
|
||||
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
|
||||
@@ -68,7 +66,6 @@ class SymbolOption(BaseModel):
|
||||
|
||||
class SymbolOut(BaseModel):
|
||||
symbol: str
|
||||
angle: str
|
||||
added_at: datetime
|
||||
analysis_requested_at: datetime | None
|
||||
analyzing: bool
|
||||
@@ -80,17 +77,11 @@ class SymbolOut(BaseModel):
|
||||
|
||||
class SymbolIn(BaseModel):
|
||||
symbol: str
|
||||
angle: Angle
|
||||
|
||||
|
||||
class SymbolPatch(BaseModel):
|
||||
angle: Angle
|
||||
|
||||
|
||||
class AnalysisOut(BaseModel):
|
||||
id: int
|
||||
symbol: str
|
||||
angle: str
|
||||
reasons: list[str]
|
||||
dry_run: bool
|
||||
status: str
|
||||
@@ -267,7 +258,7 @@ async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -
|
||||
)}
|
||||
return [
|
||||
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,
|
||||
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,
|
||||
@@ -281,8 +272,8 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge
|
||||
symbol = body.symbol.strip().upper()
|
||||
if not any(o["symbol"] == symbol for o in await symbol_options()):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约")
|
||||
session.add(TradingSymbol(symbol=symbol, angle=body.angle))
|
||||
session.add(TradingEvent(symbol=symbol, kind="symbol_added", message=f"加入交易列表,角度:{ANGLE_LABELS[body.angle]}"))
|
||||
session.add(TradingSymbol(symbol=symbol))
|
||||
session.add(TradingEvent(symbol=symbol, kind="symbol_added", message="加入交易列表"))
|
||||
try:
|
||||
await session.commit()
|
||||
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:
|
||||
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除或切换角度"""
|
||||
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除"""
|
||||
s = await session.get(TradingSymbol, symbol)
|
||||
if s is None:
|
||||
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
|
||||
|
||||
|
||||
@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}")
|
||||
async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
|
||||
"""只移出列表;分析、订单流水和时间线保留"""
|
||||
|
||||
@@ -32,8 +32,6 @@ class TradingSymbol(Base):
|
||||
__tablename__ = "trading_symbols"
|
||||
|
||||
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())
|
||||
# 待执行的分析请求:多个触发在执行前合并为一次
|
||||
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)
|
||||
symbol: Mapped[str] = mapped_column(String(32))
|
||||
angle: Mapped[str] = mapped_column(String(8))
|
||||
reasons: Mapped[list[str]] = mapped_column(JSONB)
|
||||
# 停止状态下的分析只记录计划、不下单
|
||||
dry_run: Mapped[bool] = mapped_column(Boolean)
|
||||
@@ -69,8 +66,8 @@ class TradingAnalysis(Base):
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# proposed(待执行器采纳)/ pending(挂单中)/ open(持仓中)/ closing(平仓中)/ closed / canceled(未成交即结束)
|
||||
# / replaced(被新计划替换)/ stale(采纳前持仓已变,作废)/ applied(exit 决定已执行)
|
||||
# proposed(待执行器采纳)/ pending(等待 5m 确认,sizing 有 entry 表示已触发)/ open(持仓中)/ closing(平仓中)
|
||||
# / closed / canceled(未入场即结束)/ replaced(被新计划替换)/ stale(采纳前已有持仓或已触发,作废)/ applied(exit 决定已执行)
|
||||
plan_status: Mapped[str | None] = mapped_column(String(16))
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
first_fill_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""模拟盘分析器:为一个币的一个角度调用模型,产出经程序校验、可自动执行的交易计划。
|
||||
"""模拟盘分析器:为一个币调用模型,划出经程序校验、由执行器在 5m 收盘确认后自动入场的短线计划。
|
||||
|
||||
只开放只读工具和 submit_trade_plan;行情预先取好放进用户消息,减少工具往返。
|
||||
"""
|
||||
@@ -16,11 +16,12 @@ from sqlalchemy import select
|
||||
from alphapulse.ai import llm
|
||||
from alphapulse.ai.prompt import data_overview, estimate_tokens, skills_section
|
||||
from alphapulse.ai.tools import TOOLS, dump, local, run_tool
|
||||
from alphapulse.api.market import client as market_client
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading import plan as rules
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, mark_price, symbol_rules
|
||||
from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, size_plan
|
||||
from alphapulse.trading.client import ExchangeError, mark_price, symbol_rules
|
||||
from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, atr, check_setup, parse_bars
|
||||
from alphapulse.trading.snapshot import read_snapshot
|
||||
|
||||
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,逼模型给出结论
|
||||
MAX_ROUNDS = 6
|
||||
RECENT_DECISIONS = 2
|
||||
ANGLE_LABELS = {"short": "短线(24 小时内平仓)", "long": "长线(12–48 小时内平仓)"}
|
||||
DIRECTION_LABELS = {"long": "做多", "short": "做空"}
|
||||
CONFIDENCE_LABELS = {"high": "高", "medium": "中", "low": "低"}
|
||||
ACTION_LABELS = {"plan": "新计划", "keep": "维持当前计划", "exit": "结束当前计划", "wait": "观望"}
|
||||
# 与 README 第 1 节一致:定方向和找区的周期、衍生品参数
|
||||
KLINE_INTERVALS = {"short": ("4h", "1h", "15m"), "long": ("1d", "4h", "1h")}
|
||||
DERIVATIVES_PARAMS = {"short": ("1h", 24), "long": ("4h", 18)}
|
||||
# 找区、确认的周期和衍生品参数(与 README 一致)
|
||||
KLINE_INTERVALS = ("1d", "4h", "1h", "15m", "5m")
|
||||
DERIVATIVES_PARAMS = ("1h", 24)
|
||||
|
||||
|
||||
def _pct(value: Decimal) -> str:
|
||||
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 冲突时以本节为准:
|
||||
- main/README 第 1 节「每次同时给出两个角度」不适用,只给指定角度。
|
||||
- main/README 第 8 节的输出表格不适用:用简短中文写出结论、用到的支撑区与压力区、依据(新闻、结构、衍生品)和风险;计划的数字只通过 submit_trade_plan 提交。
|
||||
- main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,按 README 第 6 节做实时复核,不直接沿用旧点位。
|
||||
- main/README 5.4 的条件计划(突破回踩、事件落地后再入场等)不能直接挂单,改写成 reevaluate 条件:价格到达后程序会重新请你分析。
|
||||
- 数量由程序按交易资金计算,你只给价格、档位比例和置信度。
|
||||
- 用户消息里已预先取好实盘行情(ticker、K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。
|
||||
- 新闻没有预取,必须按 main/README 第 3 节第 3 步用 search_news 检索相关新闻,并查清持仓时限内的重大数据及公布时间。
|
||||
- main/README 第 8 节的输出表格不适用:用简短中文写出结论、所用的区间、依据(新闻、结构、衍生品)和风险;数字只通过 submit_trade_plan 提交。
|
||||
- main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,要按实时数据复核,不直接沿用旧点位。
|
||||
- 数量、止损、止盈由程序计算,你只给方向、区间、确认价、有效期和置信度。
|
||||
- 用户消息里已预先取好实盘行情(ticker、{' / '.join(KLINE_INTERVALS)} K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。
|
||||
- 新闻没有预取,必须用 search_news 检索相关新闻,并查清有效期和持仓时限内的重大数据及公布时间。
|
||||
- 最后必须调用一次 submit_trade_plan。校验不通过会返回原因,按原因修正后重新提交。
|
||||
|
||||
## 程序如何执行计划
|
||||
- 入场:每档一张限价单;到 entry_valid_hours 仍未成交的档位撤销。买单价高于现价(卖单价低于现价)会立即成交。
|
||||
- 止损:与入场单同时在交易所挂只减仓止损单(按标记价触发),首档成交即受保护。
|
||||
- 止盈:短线止盈 1 平一半、止盈 2 平剩余;长线 3 个止盈依次平 50% / 30% / 20%。止盈 1 成交后撤销剩余入场单,止损移到保本价。
|
||||
- invalidation(失效条件):短线用 1m 或 15m 收盘,触发后程序立即市价平仓;长线只能用 4h 收盘,触发后程序请你复核。
|
||||
- reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个)。用来表达另一侧方向、条件计划和需要复核的位置。
|
||||
- max_hold_hours 从首档成交起算,到时程序市价平仓。
|
||||
## 程序如何执行
|
||||
- 待确认入场(action=plan):程序在每根 5m K 线收盘时检查,只看计划生效后收盘的 K 线,交易所上不预挂入场单。
|
||||
- 做多:某根 5m 的最低价进入区间(≤ zone_high)之后,出现一根收盘 ≥ confirm_price(可以是同一根)→ 立即市价做多。在此之前若有 5m 收盘 < zone_low,视为支撑破位,计划撤销并重新请你分析。
|
||||
- 做空:某根 5m 的最高价进入区间(≥ zone_low)之后,出现一根收盘 ≤ confirm_price → 立即市价做空。在此之前若有 5m 收盘 > zone_high,视为压力突破,撤销。
|
||||
- 到 valid_hours 仍未触发则作废,并重新请你分析。
|
||||
- 止损:触发时由程序计算,放在进区以来的最低价(做空为最高价)外 {rules.STOP_ATR_FACTOR}×ATR14(5m),在市价入场前挂到交易所(按标记价触发)。止损距现价超过 {_pct(rules.MAX_STOP_PCT)} 就放弃这次入场。
|
||||
- 止盈:按成交均价,做多 {_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。
|
||||
- 做多:止损 < 失效价 < 所有入场价 < 止盈 1 < 止盈 2 < 止盈 3,且止损低于现价、止盈 1 高于现价;做空反过来。
|
||||
- 盈亏比 ≥ {rules.MIN_REWARD_RISK}:短线按止盈 1,长线按 50/30/20 加权(全部档位成交时)。
|
||||
- entry_valid_hours:短线 ≤ {rules.ENTRY_VALID_HOURS['short']},长线 ≤ {rules.ENTRY_VALID_HOURS['long']};max_hold_hours:短线 ≤ {rules.MAX_HOLD_HOURS['short']},长线 ≤ {rules.MAX_HOLD_HOURS['long']}。
|
||||
- 每档入场的名义价值不低于交易所最小下单金额,不足时合并或删档。
|
||||
- 单笔风险为交易资金的 {_pct(rules.RISK_PCT['short'])}(短线)或 {_pct(rules.RISK_PCT['long'])}(长线),置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。这两条由程序计算数量来保证。
|
||||
- 有持仓时新计划的方向必须与持仓一致;要反手先用 exit。
|
||||
- zone_low < zone_high。做多:confirm_price ≥ zone_high,现价 ≥ zone_low;做空:confirm_price ≤ zone_low,现价 ≤ zone_high。确认就是「进区后收盘回到区间外侧」。
|
||||
- 按确认价入场、止损放在区间远端外 {rules.STOP_ATR_FACTOR}×ATR 估算:止损距离不超过 {_pct(rules.MAX_STOP_PCT)},数量不低于交易所最小下单金额。
|
||||
- valid_hours ≤ {rules.SETUP_VALID_HOURS}。
|
||||
|
||||
## 有持仓时
|
||||
- action=plan 只用来调整止损(只能收紧)、止盈、失效条件和 reevaluate:entries 留空,不加仓(README 第 10 节禁止计划外加仓)。止盈按当前持仓数量拆分;平仓截止时间沿用首档成交时确定的,不延长。
|
||||
- 计划仍然合适就用 keep;要提前结束用 exit(撤销未成交的入场单并市价平仓)。
|
||||
- 计划提交后由执行器在下一轮(约 2 秒内)按最新持仓核对后生效;期间持仓变了,计划作废并重新请你分析。
|
||||
- 持仓由程序按止损、止盈和持仓时限管理,你只能 keep(继续持有)或 exit(立即市价平仓),不能提交新计划。
|
||||
- 只有出现与持仓方向明显相反的重大消息或结构破坏时才 exit。
|
||||
"""
|
||||
|
||||
SUBMIT_SPEC = llm.ToolSpec(
|
||||
@@ -95,36 +96,44 @@ class Prepared:
|
||||
provider: AiProvider
|
||||
effort: str | None
|
||||
dry_run: bool
|
||||
angle: str
|
||||
system: str
|
||||
user_text: str
|
||||
context: PlanContext
|
||||
# 当前计划的仓位计算结果,提交时重算当前止损用
|
||||
current_sizing: dict | None
|
||||
|
||||
|
||||
def plan_brief(decision: dict, sizing: dict | None) -> dict:
|
||||
"""给模型看的计划摘要:只留价格和状态,不带长段文字"""
|
||||
brief = {k: decision.get(k) for k in ("action", "summary", "direction", "confidence", "stop", "entry_valid_hours", "max_hold_hours")}
|
||||
brief["entries"] = [{"price": e["price"], "ratio": e["ratio"]} for e in decision.get("entries") or []]
|
||||
brief["take_profits"] = [t["price"] for t in decision.get("take_profits") or []]
|
||||
brief["invalidation"] = decision.get("invalidation")
|
||||
brief = {k: decision.get(k) for k in ("action", "summary", "direction", "confidence", "setup", "valid_hours")}
|
||||
brief["reevaluate"] = [{k: c[k] for k in ("direction", "price", "timeframe")} for c in decision.get("reevaluate") or []]
|
||||
if sizing:
|
||||
brief["sizing"] = {k: sizing.get(k) for k in ("quantity", "notional", "risk_at_stop", "reward_risk")}
|
||||
if sizing and "entry" in sizing:
|
||||
brief["entry"] = {k: sizing["entry"][k] for k in ("triggered_at", "price", "stop", "quantity", "risk_at_stop")}
|
||||
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_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}))
|
||||
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))
|
||||
|
||||
|
||||
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 with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
@@ -157,7 +166,7 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
)).all()
|
||||
] if current else []
|
||||
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
|
||||
|
||||
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)
|
||||
mark = await mark_price(symbol)
|
||||
context = PlanContext(
|
||||
angle=angle, mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, has_plan=current is not None,
|
||||
position_side=None,
|
||||
mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, atr=await confirm_atr(symbol),
|
||||
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]
|
||||
risk = capital * rules.RISK_PCT[angle]
|
||||
risk = capital * rules.RISK_PCT
|
||||
lines = [
|
||||
"## 任务",
|
||||
f"- 币种:{symbol}(币安 U 本位永续)",
|
||||
f"- 角度:{ANGLE_LABELS[angle]}",
|
||||
f"- 触发原因:{';'.join(reasons)}",
|
||||
f"- 运行模式:{'试运行(引擎已停止,计划只记录、不下单)' if dry_run else '交易中(计划会在模拟盘执行)'}",
|
||||
"",
|
||||
"## 账户(程序据此计算数量)",
|
||||
f"- 交易资金:{capital:.2f} USDT(设定 {account.capital_usdt} USDT,账户权益 {equity:.2f} USDT,取较小者)",
|
||||
f"- 本次单笔风险金额:{risk:.2f} USDT({_pct(rules.RISK_PCT[angle])};置信度低时 {risk * rules.LOW_CONFIDENCE_FACTOR:.2f})",
|
||||
f"- 本次单笔风险金额:{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"- 模拟盘交易规则:价格精度 {symbol_rules_.tick_size},数量精度 {symbol_rules_.step_size},最小下单金额 {symbol_rules_.min_notional} USDT",
|
||||
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 '无'}",
|
||||
@@ -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),
|
||||
"first_fill_at": current.first_fill_at and local(current.first_fill_at),
|
||||
"close_deadline": current.close_deadline and local(current.close_deadline),
|
||||
"current_stop": str(context.current_stop) if context.current_stop else None,
|
||||
"fills": fills,
|
||||
**plan_brief(current.decision, current.sizing),
|
||||
}) if current else "无",
|
||||
@@ -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 ["无"]),
|
||||
"",
|
||||
"## 预取的行情(实盘数据,已取,不要用相同参数重复调用)",
|
||||
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),
|
||||
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
|
||||
return Prepared(provider=provider, effort=effort, dry_run=dry_run, system=system, user_text="\n".join(lines), context=context)
|
||||
|
||||
|
||||
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 ""
|
||||
if plan.action != "plan":
|
||||
return f"{prefix}{ACTION_LABELS[plan.action]}:{plan.summary}"
|
||||
entries = "、".join(f"{e['price']}×{e['quantity']}" for e in sizing["entries"])
|
||||
targets = " / ".join(t["price"] for t in sizing["take_profits"])
|
||||
setup, estimate = sizing["setup"], sizing["estimate"]
|
||||
return (
|
||||
f"{prefix}计划:{DIRECTION_LABELS[plan.direction]},置信度{CONFIDENCE_LABELS[plan.confidence]};入场 {entries},"
|
||||
f"止损 {sizing['stop']},止盈 {targets};止损亏损 {sizing['risk_at_stop']} USDT(交易资金的 {sizing['risk_pct']}%),"
|
||||
f"盈亏比 {sizing['reward_risk']}(仅首档 {sizing['reward_risk_first_only']})。{plan.summary}"
|
||||
f"{prefix}待确认入场:{DIRECTION_LABELS[plan.direction]},置信度{CONFIDENCE_LABELS[plan.confidence]};"
|
||||
f"区间 {setup['zone_low']}–{setup['zone_high']},5m 收盘{'≥' if plan.direction == 'long' else '≤'} {setup['confirm_price']} 入场,"
|
||||
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)
|
||||
except ValidationError as e:
|
||||
return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None
|
||||
# 分析可能持续几分钟,按提交时的标记价和持仓校验
|
||||
# 分析可能持续几分钟,按提交时的标记价、持仓和 ATR 校验
|
||||
prepared.context.mark_price = await mark_price(symbol)
|
||||
prepared.context.atr = await confirm_atr(symbol)
|
||||
snapshot = await read_snapshot()
|
||||
if snapshot:
|
||||
apply_position(prepared.context, snapshot, symbol, prepared.current_sizing)
|
||||
prepared.context.position_side = position_side(snapshot, symbol)
|
||||
try:
|
||||
sizing = size_plan(plan, prepared.context)
|
||||
sizing = check_setup(plan, prepared.context)
|
||||
except PlanError as e:
|
||||
await add_event(symbol, analysis_id, "plan_rejected", f"计划未通过校验,已退回模型修正:{';'.join(e.errors)}", "warn")
|
||||
return dump({"error": "计划未通过校验", "details": e.errors, "mark_price": str(prepared.context.mark_price)}), None
|
||||
@@ -284,11 +270,7 @@ async def run_analysis(symbol: str, reasons: list[str]) -> None:
|
||||
"""一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出"""
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
target = await session.get(TradingSymbol, symbol)
|
||||
analysis = TradingAnalysis(
|
||||
symbol=symbol, angle=target.angle if target else "long", reasons=reasons,
|
||||
dry_run=not (account and account.running), status="running",
|
||||
)
|
||||
analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (account and account.running), status="running")
|
||||
session.add(analysis)
|
||||
await session.commit()
|
||||
analysis_id, dry_run = analysis.id, analysis.dry_run
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
每轮对交易列表里的每个币:
|
||||
1. 同步订单流水与交易所(成交、撤销、触发)。
|
||||
2. 采纳分析器提交的新计划(按最新持仓核对,过时则作废重算)。
|
||||
3. 推进计划状态,执行程序规则:到期、时间止损、失效条件、复核条件、无计划持仓。
|
||||
4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不挂新入场单)。
|
||||
2. 采纳分析器提交的待确认入场(有持仓或当前计划已触发时作废)。
|
||||
3. 推进计划状态,执行程序规则:5m 收盘确认入场、破位撤销、到期、时间止损、复核条件、无计划持仓。
|
||||
4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不下新入场单)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -23,7 +23,10 @@ from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.market.binance import BinanceClient
|
||||
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules
|
||||
from alphapulse.trading.plan import MAX_HOLD_HOURS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step
|
||||
from alphapulse.trading.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
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.executor")
|
||||
@@ -37,12 +40,15 @@ SUBMIT_GRACE_SECONDS = 10
|
||||
STOP_FAILURE_LIMIT = 3
|
||||
# 同一错误的时间线事件间隔,避免刷屏
|
||||
ERROR_EVENT_SECONDS = 600
|
||||
# 每次取的 5m K 线根数:覆盖待确认入场的最长有效期(24 根)和 ATR14
|
||||
CONFIRM_BARS = 40
|
||||
ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled",
|
||||
"EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"}
|
||||
PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"}
|
||||
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "invalidation": "短线失效条件触发",
|
||||
"exit": "模型决定结束", "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败",
|
||||
"close": "市价平仓"}
|
||||
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束",
|
||||
"external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓"}
|
||||
CANCEL_REASON_LABELS = {"expired": "到期未触发", "stopped": "已停止交易", "broken": "区间被突破", "missed": "错过确认",
|
||||
"skipped": "放弃入场", "entry_failed": "入场失败"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -74,8 +80,10 @@ class Executor:
|
||||
self.error_events: dict[str, float] = {}
|
||||
# (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价)
|
||||
self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {}
|
||||
# 已请模型接管的无计划持仓:symbol -> 持仓均价,同一笔持仓只请求一次
|
||||
self.takeover_requested: dict[str, Decimal] = {}
|
||||
# symbol -> (下一根收盘时间 ms, 最近的 5m K 线)
|
||||
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]))
|
||||
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]:
|
||||
price = mark if condition["timeframe"] == "mark" else await self.last_close(symbol, condition["timeframe"])
|
||||
return condition_met(condition["direction"], Decimal(condition["price"]), price), price
|
||||
@@ -223,15 +241,13 @@ class Executor:
|
||||
return
|
||||
if row.filled_qty > before:
|
||||
delta = row.filled_qty - before
|
||||
leg = f"第 {row.leg + 1} 档" if row.purpose == "entry" else (f" {row.leg + 1} " if row.purpose == "tp" else "")
|
||||
leg = f" {row.leg + 1} " if row.purpose == "tp" else ""
|
||||
session.add(TradingEvent(
|
||||
symbol=row.symbol, analysis_id=row.analysis_id, kind=f"{row.purpose}_filled",
|
||||
level="warn" if row.purpose == "stop" else "info",
|
||||
message=f"{PURPOSE_LABELS[row.purpose]}{leg}成交 {fmt(delta)} @ {row.avg_price}",
|
||||
data={"order_id": row.id, "quantity": fmt(delta)},
|
||||
))
|
||||
if row.purpose == "tp":
|
||||
await request_analysis(session, row.symbol, f"止盈 {row.leg + 1} 成交")
|
||||
|
||||
# ---------- 计划 ----------
|
||||
|
||||
@@ -260,27 +276,26 @@ class Executor:
|
||||
close_side = "SELL" if position.side == "long" else "BUY"
|
||||
await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
|
||||
return current
|
||||
# 分析期间可能已经入场:持仓中或已触发的计划不能被替换(持仓由程序管理,平仓后会重新分析)
|
||||
qty = position.qty if position else Decimal(0)
|
||||
expected = Decimal(proposal.sizing["position_qty"])
|
||||
if qty != expected or (current is not None and current.plan_status == "closing"):
|
||||
if qty > 0 or (current is not None and (current.plan_status != "pending" or "entry" in current.sizing)):
|
||||
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",
|
||||
message=f"计划未采纳:{reason}"))
|
||||
await request_analysis(session, symbol.symbol, "持仓变化,计划作废重算")
|
||||
return current
|
||||
if current is not None:
|
||||
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
|
||||
if qty > 0:
|
||||
proposal.plan_status = "open"
|
||||
proposal.first_fill_at = current.first_fill_at if current and current.first_fill_at else now
|
||||
# 持仓时限沿用原计划,不延长;接管无计划持仓时从现在起算
|
||||
proposal.close_deadline = current.close_deadline if current and current.close_deadline else now + timedelta(hours=MAX_HOLD_HOURS[symbol.angle])
|
||||
else:
|
||||
proposal.plan_status = "pending"
|
||||
proposal.valid_until = now + timedelta(hours=float(proposal.decision["entry_valid_hours"]))
|
||||
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
|
||||
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")))
|
||||
proposal.plan_status = "pending"
|
||||
proposal.valid_until = now + timedelta(hours=float(proposal.decision["valid_hours"]))
|
||||
setup = proposal.sizing["setup"]
|
||||
side = "做多" if proposal.sizing["direction"] == "long" else "做空"
|
||||
session.add(TradingEvent(
|
||||
symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
|
||||
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")
|
||||
+ f":等待 5m 确认{side}(区间 {setup['zone_low']}–{setup['zone_high']},确认价 {setup['confirm_price']}),"
|
||||
f"有效至 {proposal.valid_until.astimezone(LOCAL_TZ).strftime('%H:%M')}",
|
||||
))
|
||||
return proposal
|
||||
|
||||
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))
|
||||
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:
|
||||
plan.plan_status, plan.close_reason = "closing", reason
|
||||
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
|
||||
rules = await symbol_rules(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":
|
||||
await self.cancel(client, row)
|
||||
if position is None:
|
||||
self.takeover_requested.pop(name, None)
|
||||
self.unmanaged_noticed.pop(name, None)
|
||||
elif running:
|
||||
await self.unmanaged(session, client, name, position, open_algos)
|
||||
if running:
|
||||
@@ -380,32 +403,33 @@ class Executor:
|
||||
plan_rows = list((await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id))).all())
|
||||
|
||||
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.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",
|
||||
message=f"首档成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
|
||||
await request_analysis(session, name, "首档成交")
|
||||
elif now >= plan.valid_until or not running:
|
||||
plan.plan_status, plan.closed_at = "canceled", now
|
||||
plan.close_reason = "expired" if running else "stopped"
|
||||
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_canceled",
|
||||
message=f"计划 #{plan.id} 撤销:{'入场单到期未成交' if running else '已停止交易,撤销未成交的入场单'}"))
|
||||
if running:
|
||||
await request_analysis(session, name, "计划到期未成交")
|
||||
message=f"入场成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
|
||||
elif "entry" in plan.sizing:
|
||||
if entry_rows and all(r.status not in ACTIVE_ORDER_STATUSES for r in entry_rows):
|
||||
await self.cancel_plan(session, plan, "entry_failed", f"市价入场失败:{entry_rows[-1].error or entry_rows[-1].status}", "error")
|
||||
elif not running:
|
||||
await self.cancel_plan(session, plan, "stopped", "已停止交易")
|
||||
elif now >= plan.valid_until:
|
||||
await self.cancel_plan(session, plan, "expired", "到期未触发")
|
||||
else:
|
||||
await self.check_setup(session, client, plan, rules, other_notional)
|
||||
elif plan.plan_status == "open":
|
||||
if qty == 0:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
elif now >= plan.close_deadline:
|
||||
await self.start_closing(session, plan, "time")
|
||||
else:
|
||||
await self.check_invalidation(session, plan, position)
|
||||
elif plan.plan_status == "closing" and qty == 0:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
await session.commit()
|
||||
|
||||
if plan.plan_status in LIVE_PLAN_STATUSES:
|
||||
await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
|
||||
await self.sync_orders(session, client, plan, plan_rows, position, rules, stale_entry_canceled)
|
||||
else:
|
||||
# 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位
|
||||
for row in plan_rows:
|
||||
@@ -418,17 +442,48 @@ class Executor:
|
||||
await self.check_reevaluate(session, name, position.mark if position else None)
|
||||
await session.commit()
|
||||
|
||||
async def check_invalidation(self, session: AsyncSession, plan: TradingAnalysis, position: Position) -> None:
|
||||
condition = plan.decision["invalidation"]
|
||||
met, price = await self.condition_met(plan.symbol, condition, position.mark)
|
||||
if not met:
|
||||
async def check_setup(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rules: SymbolRules, other_notional: Decimal) -> None:
|
||||
"""待确认入场:5m 收盘确认后算止损和数量,写入 sizing.entry,由 sync_orders 先挂止损再市价入场。
|
||||
other_notional:交易列表中其他币的持仓名义价值"""
|
||||
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
|
||||
detail = f"({condition['timeframe']} 收盘 {fmt(price)} {'高于' if condition['direction'] == 'above' else '低于'} {condition['price']})"
|
||||
if plan.angle == "short":
|
||||
await self.start_closing(session, plan, "invalidation", detail)
|
||||
else:
|
||||
await self.review_once(session, plan.id, plan.symbol, "invalidation_review", 0,
|
||||
f"长线失效条件触发{detail},请模型复核", f"失效条件触发{detail}")
|
||||
long = direction == "long"
|
||||
at = datetime.fromtimestamp((scan.bar.close_time + 1) / 1000, LOCAL_TZ).strftime("%H:%M")
|
||||
if scan.state == "broken":
|
||||
edge = f"跌破区间下沿 {setup['zone_low']}" if long else f"突破区间上沿 {setup['zone_high']}"
|
||||
await self.cancel_plan(session, plan, "broken", f"{at} 5m 收盘 {fmt(scan.bar.close)} {edge}")
|
||||
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:
|
||||
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
|
||||
@@ -447,15 +502,14 @@ class Executor:
|
||||
await self.review_once(session, latest.id, symbol, "reevaluate_triggered", i, f"复核条件满足:{text}", f"复核条件满足:{text}")
|
||||
|
||||
async def unmanaged(self, session: AsyncSession, client: DemoClient, symbol: str, position: Position, open_algos: dict) -> None:
|
||||
"""有持仓但没有计划:有止损就请模型接管,没有止损就市价平仓"""
|
||||
"""有持仓但没有计划:有止损就只提示,没有止损就市价平仓"""
|
||||
close_side = "SELL" if position.side == "long" else "BUY"
|
||||
has_stop = any(o["side"] == close_side and o["orderType"].startswith("STOP") for o in open_algos.values())
|
||||
if has_stop:
|
||||
if self.takeover_requested.get(symbol) != position.entry:
|
||||
self.takeover_requested[symbol] = position.entry
|
||||
if self.unmanaged_noticed.get(symbol) != position.entry:
|
||||
self.unmanaged_noticed[symbol] = position.entry
|
||||
session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn",
|
||||
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划,请模型接管"))
|
||||
await request_analysis(session, symbol, "有持仓但没有计划,请接管")
|
||||
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划:程序不管理,由止损保护"))
|
||||
return
|
||||
closing = await session.scalar(select(func.count()).where(
|
||||
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))
|
||||
|
||||
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
|
||||
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"
|
||||
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":
|
||||
# 平仓中:撤入场和止盈,保留已有止损直到市价平仓成交(止损可能正因越过现价挂不上,不再补挂)
|
||||
for row in active:
|
||||
@@ -492,10 +535,23 @@ class Executor:
|
||||
close_side = "SELL" if long else "BUY"
|
||||
await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty))
|
||||
return
|
||||
if "entry" not in sizing:
|
||||
# 还在等 5m 确认,交易所上没有本计划的单
|
||||
return
|
||||
|
||||
entries_open = running and utcnow() < (plan.valid_until or utcnow()) and not tp1_hit
|
||||
desired = desired_orders(sizing, entries_open=entries_open, entry_filled=entry_filled, tp_filled=tp_filled,
|
||||
position_qty=qty, stop_price=stop, step_size=rules.step_size)
|
||||
entry_rows = [r for r in plan_rows if r.purpose == "entry"]
|
||||
filled = sum((r.filled_qty for r in entry_rows), Decimal(0))
|
||||
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]
|
||||
to_cancel, to_place = diff_orders(desired, actual)
|
||||
|
||||
@@ -515,7 +571,7 @@ class Executor:
|
||||
for row in to_cancel:
|
||||
await self.cancel(client, row)
|
||||
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)
|
||||
for spec in [d for d in to_place if d.purpose == "tp"]:
|
||||
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"]),
|
||||
break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]),
|
||||
)
|
||||
names = {s.symbol for s 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:
|
||||
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:
|
||||
await session.rollback()
|
||||
await self.report(session, symbol.symbol, f"对账失败:{e}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""交易计划的纯计算:模型提交的计划参数、校验、价格取整、仓位计算、止盈拆分。
|
||||
"""交易计划的纯计算:模型提交的待确认入场、校验、5m 确认扫描、触发时的止损与仓位、止盈阶梯。
|
||||
|
||||
下方常量与 main/README 第 1、2 节的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
|
||||
只做短线做波动:模型划出区间和确认价,程序在 5m 收盘确认后市价入场,止损和止盈由程序按固定规则计算。
|
||||
下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -10,31 +11,31 @@ from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
Angle = Literal["short", "long"]
|
||||
Direction = Literal["long", "short"]
|
||||
|
||||
# 所有币合计每天(北京时间)最多自动分析的次数
|
||||
DAILY_ANALYSIS_LIMIT = 60
|
||||
RISK_PCT = {"short": Decimal("0.01"), "long": Decimal("0.02")}
|
||||
RISK_PCT = Decimal("0.01")
|
||||
LOW_CONFIDENCE_FACTOR = Decimal("0.5")
|
||||
MAX_LEVERAGE = 3
|
||||
MIN_REWARD_RISK = Decimal("1.5")
|
||||
ENTRY_VALID_HOURS = {"short": 4, "long": 24}
|
||||
MAX_HOLD_HOURS = {"short": 24, "long": 48}
|
||||
# 入场档数(最少,最多)
|
||||
ENTRY_COUNT = {"short": (1, 1), "long": (1, 3)}
|
||||
# 止盈档数由角度决定:短线止盈 1 平一半、止盈 2 平剩余;长线 50/30/20
|
||||
TAKE_PROFIT_RATIOS = {"short": (Decimal("0.5"), Decimal("0.5")), "long": (Decimal("0.5"), Decimal("0.3"), Decimal("0.2"))}
|
||||
# 短线失效立即市价平仓,要用短周期;长线等 4h 收盘后复核
|
||||
INVALIDATION_TIMEFRAMES = {"short": ("1m", "15m"), "long": ("4h",)}
|
||||
# 待确认入场的最长有效小时数;成交后的最长持仓小时数
|
||||
SETUP_VALID_HOURS = 2
|
||||
MAX_HOLD_HOURS = 4
|
||||
CONFIRM_INTERVAL = "5m"
|
||||
ATR_PERIOD = 14
|
||||
# 止损放在确认过程的极值外 STOP_ATR_FACTOR × ATR14(5m);离现价超过 MAX_STOP_PCT 就放弃这次入场
|
||||
STOP_ATR_FACTOR = Decimal("0.5")
|
||||
MAX_STOP_PCT = Decimal("0.01")
|
||||
# 止盈阶梯:(距成交均价的涨跌幅,平仓比例)。牛市里空单不恋战,第一档就平掉大部分
|
||||
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
|
||||
# 开仓、止损都按吃单手续费估算(C1 实测 taker 0.04%)
|
||||
# 入场(市价)和止损都按吃单手续费估算(C1 实测 taker 0.04%)
|
||||
TAKER_FEE_RATE = Decimal("0.0004")
|
||||
# 模拟盘 PERCENT_PRICE:买单价不高于标记价 × 1.05,卖单价不低于标记价 × 0.95
|
||||
PERCENT_PRICE_LIMIT = Decimal("0.05")
|
||||
RATIO_TOLERANCE = Decimal("0.001")
|
||||
# 例行分析周期(小时):短线跟 1h、长线跟 4h 收盘
|
||||
ROUTINE_HOURS = {"short": 1, "long": 4}
|
||||
# 例行分析周期:跟 1h 收盘
|
||||
ROUTINE_HOURS = 1
|
||||
# 同一个币两次自动分析的最短间隔
|
||||
MIN_ANALYSIS_GAP = timedelta(minutes=10)
|
||||
|
||||
@@ -45,45 +46,38 @@ class Condition(BaseModel):
|
||||
direction: Annotated[Literal["above", "below"], Field(description="above:价格高于 price 时满足;below:低于时满足")]
|
||||
price: Price
|
||||
timeframe: Annotated[
|
||||
Literal["mark", "1m", "15m", "1h", "4h"],
|
||||
Literal["mark", "1m", "5m", "15m", "1h", "4h"],
|
||||
Field(description="mark:标记价触及即满足;其他:该周期 K 线收盘价满足才算"),
|
||||
]
|
||||
basis: Annotated[str, Field(description="依据,如对应的支撑区、压力区")]
|
||||
|
||||
|
||||
class Entry(BaseModel):
|
||||
price: Price
|
||||
ratio: Annotated[Decimal, Field(gt=0, le=1, description="占总仓位的比例,所有档位之和为 1")]
|
||||
basis: str
|
||||
|
||||
|
||||
class TakeProfit(BaseModel):
|
||||
price: Price
|
||||
basis: str
|
||||
class Setup(BaseModel):
|
||||
zone_low: Price
|
||||
zone_high: Price
|
||||
confirm_price: Annotated[Decimal, Field(gt=0, description=(
|
||||
"做多:不低于 zone_high,进区后 5m 收盘 ≥ 此价入场;做空:不高于 zone_low,进区后 5m 收盘 ≤ 此价入场"
|
||||
))]
|
||||
basis: Annotated[str, Field(description="区间和确认价的依据:对应的支撑区或压力区、周期、触碰次数")]
|
||||
|
||||
|
||||
class TradePlan(BaseModel):
|
||||
"""submit_trade_plan 的参数"""
|
||||
|
||||
action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=(
|
||||
"plan:给出新计划(替换当前计划);keep:当前计划不变;"
|
||||
"exit:结束当前计划,撤销未成交的入场单,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)"
|
||||
"plan:给出新的待确认入场(替换当前未触发的计划,仅在没有持仓时);keep:当前计划或持仓不变;"
|
||||
"exit:结束当前计划,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)"
|
||||
))]
|
||||
summary: Annotated[str, Field(description="一句话结论,含方向和置信度")]
|
||||
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=(
|
||||
"满足任一条件时程序重新请你分析:用来表达另一侧方向、突破回踩等条件计划、需要复核的位置。提交时不能已经满足"
|
||||
"满足任一条件时程序重新请你分析:用来表达另一侧方向、需要重新划区的位置。提交时不能已经满足"
|
||||
))] = []
|
||||
direction: Direction | None = None
|
||||
confidence: Literal["high", "medium", "low"] | None = None
|
||||
entries: Annotated[list[Entry] | None, Field(description="入场限价单,每档一张")] = None
|
||||
stop: Annotated[Price | None, Field(description="统一止损价")] = None
|
||||
stop_basis: str | None = None
|
||||
take_profits: Annotated[list[TakeProfit] | None, Field(description="按价格由近到远排列")] = None
|
||||
entry_valid_hours: Annotated[Decimal | None, Field(gt=0, description="入场单有效小时数,到期未成交的档位撤销")] = None
|
||||
max_hold_hours: Annotated[Decimal | None, Field(gt=0, description="首档成交起最长持仓小时数,到时市价平仓")] = None
|
||||
invalidation: Annotated[Condition | None, Field(description="失效条件:做多为 below,做空为 above")] = None
|
||||
setup: Annotated[Setup | None, Field(description="待确认入场:做多为支撑区,做空为压力区")] = None
|
||||
valid_hours: Annotated[Decimal | None, Field(gt=0, description=f"有效小时数(≤ {SETUP_VALID_HOURS}),到期未触发则作废")] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -95,20 +89,34 @@ class SymbolRules:
|
||||
|
||||
@dataclass
|
||||
class PlanContext:
|
||||
angle: Angle
|
||||
mark_price: Decimal
|
||||
# min(设定的交易资金, 账户权益)
|
||||
capital: Decimal
|
||||
# 交易列表中其他币的持仓名义价值合计
|
||||
other_notional: Decimal
|
||||
rules: SymbolRules
|
||||
# 最近已收盘 5m K 线的 ATR14,用于预估止损
|
||||
atr: Decimal
|
||||
has_plan: bool
|
||||
position_side: Direction | None
|
||||
position_qty: Decimal = Decimal(0)
|
||||
# 持仓均价
|
||||
position_entry: Decimal | None = None
|
||||
# 当前生效的止损价(可能已移到保本价);新计划只能收紧
|
||||
current_stop: Decimal | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bar:
|
||||
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):
|
||||
@@ -130,10 +138,95 @@ def split_take_profits(quantity: Decimal, ratios: tuple[Decimal, ...], step: Dec
|
||||
return [*parts, quantity - sum(parts, Decimal(0))]
|
||||
|
||||
|
||||
def reward_risk(entry: Decimal, stop: Decimal, targets: list[Decimal], ratios: tuple[Decimal, ...]) -> Decimal:
|
||||
"""综合盈亏比 = Σ(止盈比例 × |止盈价 − 入场价|) ÷ |入场价 − 止损价|,与 README 第 4 节一致(不含手续费)"""
|
||||
reward = sum((r * abs(t - entry) for t, r in zip(targets, ratios, strict=True)), Decimal(0))
|
||||
return reward / abs(entry - stop)
|
||||
def parse_bars(rows: list[list]) -> list[Bar]:
|
||||
"""币安 klines 原始行"""
|
||||
return [Bar(int(r[0]), int(r[6]), Decimal(r[2]), Decimal(r[3]), Decimal(r[4])) for r in rows]
|
||||
|
||||
|
||||
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]:
|
||||
@@ -144,6 +237,8 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
|
||||
errors.append("当前没有计划也没有持仓,不能用 exit,观望请用 wait")
|
||||
if plan.action == "wait" and (ctx.has_plan or ctx.position_side is not None):
|
||||
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):
|
||||
satisfied = ctx.mark_price > c.price if c.direction == "above" else ctx.mark_price < c.price
|
||||
if satisfied:
|
||||
@@ -151,221 +246,48 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def check_plan_fields(plan: TradePlan, ctx: PlanContext) -> list[str]:
|
||||
"""action=plan 时的字段完整性、档数、比例、有效期。有持仓时只调整止损止盈等,不加仓"""
|
||||
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(数值为字符串)"""
|
||||
def check_setup(plan: TradePlan, ctx: PlanContext) -> dict:
|
||||
"""校验计划;action=plan 时返回待确认入场和预估(按确认价入场、止损放在区间远端外)。不通过时抛 PlanError"""
|
||||
errors = check_action(plan, ctx)
|
||||
if plan.action != "plan":
|
||||
if errors:
|
||||
raise PlanError(errors)
|
||||
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:
|
||||
raise PlanError(errors)
|
||||
if ctx.position_side is not None:
|
||||
return size_managed(plan, ctx)
|
||||
|
||||
angle, direction, rules = ctx.angle, plan.direction, ctx.rules
|
||||
# 向保守方向取整:做多的入场、止损、止盈都向下(入场更低、止损更远、止盈更近),做空都向上
|
||||
rounding = ROUND_FLOOR if direction == "long" else ROUND_CEILING
|
||||
entry_prices = [round_to_step(e.price, rules.tick_size, rounding) for e in plan.entries]
|
||||
stop = round_to_step(plan.stop, rules.tick_size, rounding)
|
||||
targets = [round_to_step(t.price, rules.tick_size, rounding) for t in plan.take_profits]
|
||||
ratios = [e.ratio / sum((e.ratio for e in plan.entries), Decimal(0)) for e in plan.entries]
|
||||
tp_ratios = TAKE_PROFIT_RATIOS[angle]
|
||||
|
||||
errors = check_prices(direction, entry_prices, stop, targets, plan, ctx)
|
||||
if errors:
|
||||
raise PlanError(errors)
|
||||
|
||||
average = sum((r * p for r, p in zip(ratios, entry_prices, strict=True)), Decimal(0))
|
||||
rr = reward_risk(average, stop, targets, tp_ratios) if angle == "long" else reward_risk(average, stop, targets[:1], (Decimal(1),))
|
||||
if rr < MIN_REWARD_RISK:
|
||||
basis = "50/30/20 加权" if angle == "long" else "止盈 1"
|
||||
raise PlanError([f"盈亏比 {rr:.2f}(按{basis})低于 {MIN_REWARD_RISK}。止盈必须放在有依据的区,不得为凑盈亏比倒推;凑不够就不给这个方向"])
|
||||
nearest_entry = max(entry_prices) if direction == "long" else min(entry_prices)
|
||||
rr_first = reward_risk(nearest_entry, stop, targets, tp_ratios) if angle == "long" else rr
|
||||
|
||||
def unit_loss(price: Decimal) -> Decimal:
|
||||
return abs(price - stop) + (price + stop) * TAKER_FEE_RATE
|
||||
|
||||
risk_amount = ctx.capital * RISK_PCT[angle] * (LOW_CONFIDENCE_FACTOR if plan.confidence == "low" else 1)
|
||||
max_notional = ctx.capital * MAX_LEVERAGE - ctx.other_notional
|
||||
if max_notional <= 0:
|
||||
raise PlanError([f"交易列表其他币的名义仓位 {ctx.other_notional:.2f} 已达到 {MAX_LEVERAGE} 倍交易资金,不能开新仓"])
|
||||
by_risk = risk_amount / unit_loss(average)
|
||||
by_leverage = max_notional / average
|
||||
total = min(by_risk, by_leverage)
|
||||
quantities = [round_to_step(total * r, rules.step_size, ROUND_FLOOR) for r in ratios]
|
||||
small = [
|
||||
f"第 {i} 档 {p} × {q} = {p * q:.2f} USDT"
|
||||
for i, (p, q) in enumerate(zip(entry_prices, quantities, strict=True), 1) if p * q < rules.min_notional
|
||||
]
|
||||
if small:
|
||||
raise PlanError([
|
||||
f"入场档位名义价值低于最小下单金额 {rules.min_notional} USDT:{'、'.join(small)}。"
|
||||
f"本次可用风险金额 {risk_amount:.2f} USDT、总数量约 {total:.6f},请合并或删档"
|
||||
])
|
||||
|
||||
quantity = sum(quantities, Decimal(0))
|
||||
notional = sum((p * q for p, q in zip(entry_prices, quantities, strict=True)), Decimal(0))
|
||||
risk_at_stop = sum((q * unit_loss(p) for p, q in zip(entry_prices, quantities, strict=True)), Decimal(0))
|
||||
tp_quantities = split_take_profits(quantity, tp_ratios, rules.step_size)
|
||||
far_edge = setup.zone_low if long else setup.zone_high
|
||||
stop = setup_stop(direction, far_edge, ctx.atr, ctx.rules.tick_size)
|
||||
try:
|
||||
estimate = size_entry(direction, setup.confirm_price, stop, capital=ctx.capital, other_notional=ctx.other_notional,
|
||||
confidence=plan.confidence, rules=ctx.rules)
|
||||
except PlanError as e:
|
||||
raise PlanError([f"按确认价入场、止损放在区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m)={stop} 估算:{e}。"
|
||||
"区间太宽或离确认价太远,触发后也会被放弃,请收窄区间或调整确认价"]) from e
|
||||
return {
|
||||
"direction": direction,
|
||||
"entries": [
|
||||
{"price": str(p), "ratio": str(r.quantize(Decimal("0.0001"))), "quantity": str(q), "notional": str((p * q).quantize(Decimal("0.01")))}
|
||||
for p, r, q in zip(entry_prices, ratios, quantities, strict=True)
|
||||
],
|
||||
"stop": str(stop),
|
||||
"take_profits": [
|
||||
{"price": str(t), "ratio": str(r), "quantity": str(q)} for t, r, q in zip(targets, tp_ratios, tp_quantities, strict=True)
|
||||
],
|
||||
"average_price": str(average.quantize(rules.tick_size)),
|
||||
"quantity": str(quantity),
|
||||
"notional": str(notional.quantize(Decimal("0.01"))),
|
||||
"capital": str(ctx.capital),
|
||||
"leverage": str((notional / ctx.capital).quantize(Decimal("0.01"))),
|
||||
"risk_amount": str(risk_amount.quantize(Decimal("0.01"))),
|
||||
"risk_at_stop": str(risk_at_stop.quantize(Decimal("0.01"))),
|
||||
"risk_pct": str((risk_at_stop / ctx.capital * 100).quantize(Decimal("0.01"))),
|
||||
"capped_by_leverage": by_leverage < by_risk,
|
||||
"reward_risk": str(rr.quantize(Decimal("0.01"))),
|
||||
"reward_risk_first_only": str(rr_first.quantize(Decimal("0.01"))),
|
||||
"setup": {k: str(getattr(setup, k)) for k in ("zone_low", "zone_high", "confirm_price")},
|
||||
"estimate": estimate,
|
||||
"atr": str(ctx.atr.quantize(ctx.rules.tick_size)),
|
||||
# 执行器采纳前核对:持仓变了说明计划基于过时状态
|
||||
"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(
|
||||
sizing: dict, *, entries_open: bool, entry_filled: list[Decimal], tp_filled: list[Decimal],
|
||||
position_qty: Decimal, stop_price: Decimal, step_size: Decimal,
|
||||
sizing: dict, *, entry_sent: bool, entry_fill: tuple[Decimal, Decimal] | None, tp_filled: list[Decimal],
|
||||
position_qty: Decimal, stop_price: Decimal, rules: SymbolRules,
|
||||
) -> list[OrderSpec]:
|
||||
"""计划在当前成交状态下应挂的单。
|
||||
"""已触发的计划在当前状态下应挂的单。
|
||||
|
||||
- 入场:entries_open 时各档剩余数量。
|
||||
- 止损:有持仓或有入场单时一张只减仓止损,数量取计划总量与持仓的较大者(数量大于持仓时交易所只平实际持仓)。
|
||||
- 止盈:有持仓时按累计成交数量拆分,减去各档已成交。
|
||||
- 入场:还没下过入场单时一张市价单;只下一次,被拒不重试。
|
||||
- 止损:有持仓或入场还没成交(未下单、已发出待成交)时一张只减仓止损,数量取计划数量与持仓的较大者(大于持仓时交易所只平实际持仓)。
|
||||
- 止盈:有持仓时按入场成交(数量,均价)排出阶梯,减去各档已成交。
|
||||
"""
|
||||
long = sizing["direction"] == "long"
|
||||
open_side, close_side = ("BUY", "SELL") if long else ("SELL", "BUY")
|
||||
direction = sizing["direction"]
|
||||
open_side, close_side = ("BUY", "SELL") if direction == "long" else ("SELL", "BUY")
|
||||
quantity = Decimal(sizing["entry"]["quantity"])
|
||||
orders = []
|
||||
if entries_open:
|
||||
for i, e in enumerate(sizing["entries"]):
|
||||
remaining = Decimal(e["quantity"]) - entry_filled[i]
|
||||
if remaining > 0:
|
||||
orders.append(OrderSpec("entry", i, open_side, "LIMIT", Decimal(e["price"]), None, remaining))
|
||||
if position_qty > 0 or orders:
|
||||
quantity = max(Decimal(sizing["quantity"]), position_qty)
|
||||
orders.append(OrderSpec("stop", None, close_side, "STOP_MARKET", None, stop_price, quantity))
|
||||
if position_qty > 0:
|
||||
filled_total = Decimal(sizing["quantity"]) if sizing["managed"] else sum(entry_filled, Decimal(0))
|
||||
ratios = tuple(Decimal(t["ratio"]) for t in sizing["take_profits"])
|
||||
for j, (t, target) in enumerate(zip(sizing["take_profits"], split_take_profits(filled_total, ratios, step_size), strict=True)):
|
||||
if not entry_sent:
|
||||
orders.append(OrderSpec("entry", 0, open_side, "MARKET", None, None, quantity))
|
||||
if position_qty > 0 or entry_fill is None:
|
||||
orders.append(OrderSpec("stop", None, close_side, "STOP_MARKET", None, stop_price, max(quantity, position_qty)))
|
||||
if position_qty > 0 and entry_fill is not None:
|
||||
filled, average = entry_fill
|
||||
for j, (price, target) in enumerate(take_profit_targets(direction, average, filled, rules)):
|
||||
remaining = target - tp_filled[j]
|
||||
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
|
||||
|
||||
|
||||
@@ -431,10 +349,10 @@ def condition_met(direction: Literal["above", "below"], threshold: Decimal, pric
|
||||
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 分钟等收盘数据就绪。
|
||||
任何一次分析(包括触发式)都会让例行分析重新计时"""
|
||||
period = timedelta(hours=ROUTINE_HOURS[angle])
|
||||
period = timedelta(hours=ROUTINE_HOURS)
|
||||
# 币安 K 线按 UTC 对齐
|
||||
earliest = (last_started + period * 3 / 4).astimezone(UTC)
|
||||
day = earliest.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -58,7 +58,7 @@ async def routine_requests() -> None:
|
||||
)).all()
|
||||
if recent and recent[0].status == "running":
|
||||
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, "例行分析")
|
||||
continue
|
||||
last = recent[0]
|
||||
|
||||
+143
-144
@@ -4,49 +4,65 @@ from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
||||
import pytest
|
||||
|
||||
from alphapulse.trading.plan import (
|
||||
OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, desired_orders, diff_orders, next_routine_at, round_to_step,
|
||||
size_plan, split_take_profits,
|
||||
Bar, OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, atr, check_setup, desired_orders, diff_orders, next_routine_at,
|
||||
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"))
|
||||
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:
|
||||
values = {
|
||||
"angle": "long", "mark_price": Decimal("82000"), "capital": Decimal("1000"), "other_notional": Decimal("0"),
|
||||
"rules": BTC, "has_plan": False, "position_side": None,
|
||||
"mark_price": Decimal("81000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "rules": BTC,
|
||||
"atr": Decimal("100"), "has_plan": False, "position_side": None,
|
||||
}
|
||||
return PlanContext(**{**values, **overrides})
|
||||
|
||||
|
||||
def long_plan(**overrides) -> TradePlan:
|
||||
def setup_plan(**overrides) -> TradePlan:
|
||||
values = {
|
||||
"action": "plan", "summary": "长线做多", "reasons": "支撑区", "risks": "无",
|
||||
"direction": "long", "confidence": "medium",
|
||||
"entries": [{"price": "81000", "ratio": "0.4", "basis": "区1"}, {"price": "80000", "ratio": "0.6", "basis": "区2"}],
|
||||
"stop": "79500", "stop_basis": "区下沿外",
|
||||
"take_profits": [{"price": "83000", "basis": "压1"}, {"price": "84000", "basis": "压2"}, {"price": "85000", "basis": "压3"}],
|
||||
"entry_valid_hours": "24", "max_hold_hours": "48",
|
||||
"invalidation": {"direction": "below", "price": "79800", "timeframe": "4h", "basis": "跌破区"},
|
||||
}
|
||||
return TradePlan.model_validate({**values, **overrides})
|
||||
|
||||
|
||||
def short_term_plan(**overrides) -> TradePlan:
|
||||
values = {
|
||||
"action": "plan", "summary": "短线做空", "reasons": "压力区", "risks": "无",
|
||||
"direction": "short", "confidence": "medium",
|
||||
"entries": [{"price": "82500", "ratio": "1", "basis": "压力区"}],
|
||||
"stop": "82900", "take_profits": [{"price": "81800", "basis": "支1"}, {"price": "81300", "basis": "支2"}],
|
||||
"entry_valid_hours": "4", "max_hold_hours": "24",
|
||||
"invalidation": {"direction": "above", "price": "82800", "timeframe": "15m", "basis": "站上区"},
|
||||
"action": "plan", "summary": "回踩支撑做多", "reasons": "支撑区", "risks": "无", "direction": "long", "confidence": "medium",
|
||||
"setup": {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750", "basis": "1h 支撑区"}, "valid_hours": "2",
|
||||
}
|
||||
return TradePlan.model_validate({**values, **overrides})
|
||||
|
||||
|
||||
def errors_of(plan: TradePlan, ctx: PlanContext) -> str:
|
||||
with pytest.raises(PlanError) as e:
|
||||
size_plan(plan, ctx)
|
||||
check_setup(plan, ctx)
|
||||
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")]
|
||||
|
||||
|
||||
def test_long_plan_sizing_matches_hand_calculation():
|
||||
sizing = size_plan(long_plan(), context())
|
||||
# 均价 0.4×81000 + 0.6×80000 = 80400;单位亏损 900 + (80400+79500)×0.0004 = 963.96;20 ÷ 963.96 = 0.020747
|
||||
assert sizing["average_price"] == "80400.0"
|
||||
assert [e["quantity"] for e in sizing["entries"]] == ["0.0082", "0.0124"]
|
||||
assert sizing["quantity"] == "0.0206"
|
||||
assert sizing["notional"] == "1656.20"
|
||||
assert sizing["risk_amount"] == "20.00"
|
||||
assert sizing["risk_at_stop"] == "19.82"
|
||||
# (0.5×2600 + 0.3×3600 + 0.2×4600) ÷ 900;只成交首档 (0.5×2000 + 0.3×3000 + 0.2×4000) ÷ 1500
|
||||
assert sizing["reward_risk"] == "3.67"
|
||||
assert sizing["reward_risk_first_only"] == "1.80"
|
||||
assert [t["quantity"] for t in sizing["take_profits"]] == ["0.0103", "0.0061", "0.0042"]
|
||||
assert sizing["capped_by_leverage"] is False
|
||||
# ---------- 5m 确认(用户的两个例子) ----------
|
||||
|
||||
def test_long_example_triggers_on_close_back_above_after_dip():
|
||||
# 10:20 收在 80700 还低于确认价;10:25 下探到 80188(影线低于区间下沿但收盘没破)收回 80804 → 10:30 触发
|
||||
result = scan("long", "80200", "80700", "80750", adopted="10:12", now="10:30")
|
||||
assert result.state == "triggered"
|
||||
assert result.bar.open_time == ms("10:25")
|
||||
assert result.extreme == Decimal("80188.00")
|
||||
assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:25").state == "waiting"
|
||||
|
||||
|
||||
def test_low_confidence_halves_risk():
|
||||
sizing = size_plan(long_plan(confidence="low"), context())
|
||||
assert sizing["risk_amount"] == "10.00"
|
||||
# 10 ÷ 963.96 = 0.010374:0.4 档 0.0041,0.6 档 0.0062
|
||||
assert sizing["quantity"] == "0.0103"
|
||||
def test_short_example_triggers_on_rejection_from_resistance():
|
||||
# 10:50 只到 81411.7,未进区;10:55 冲到 81646.5 后收在 81500 → 11:00 触发
|
||||
result = scan("short", "81500", "81700", "81500", adopted="10:47", now="11:00")
|
||||
assert (result.state, result.bar.open_time, result.extreme) == ("triggered", ms("10:55"), Decimal("81646.50"))
|
||||
|
||||
|
||||
def test_leverage_cap_counts_other_positions():
|
||||
sizing = size_plan(long_plan(), context(other_notional=Decimal("2000")))
|
||||
assert sizing["capped_by_leverage"] is True
|
||||
assert Decimal(sizing["notional"]) <= Decimal("1000")
|
||||
def test_bars_closed_before_adoption_are_ignored():
|
||||
# 10:25 那根在计划生效(10:30)前已收盘,不算
|
||||
assert scan("long", "80200", "80700", "80750", adopted="10:30", now="10:30").state == "waiting"
|
||||
|
||||
|
||||
def test_short_term_short_plan():
|
||||
sizing = size_plan(short_term_plan(), context(angle="short"))
|
||||
# 止盈 1 盈亏比 700 ÷ 400
|
||||
assert sizing["reward_risk"] == "1.75"
|
||||
assert sizing["risk_amount"] == "10.00"
|
||||
assert len(sizing["take_profits"]) == 2
|
||||
def test_trigger_not_on_latest_bar_is_missed():
|
||||
assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:35").state == "missed"
|
||||
|
||||
|
||||
def test_short_prices_round_up():
|
||||
sizing = size_plan(short_term_plan(entries=[{"price": "82500.01", "ratio": "1", "basis": "x"}]), context(angle="short"))
|
||||
assert sizing["entries"][0]["price"] == "82500.1"
|
||||
def test_close_beyond_far_edge_breaks_setup():
|
||||
result = scan("long", "80750", "81000", "81050", adopted="10:12", now="10:30")
|
||||
assert (result.state, result.bar.open_time) == ("broken", ms("10:20"))
|
||||
|
||||
|
||||
def test_rejects_wrong_counts_and_timeframe():
|
||||
message = errors_of(short_term_plan(entries=[{"price": "82500", "ratio": "0.5", "basis": "a"}, {"price": "82600", "ratio": "0.5", "basis": "b"}]), context(angle="short"))
|
||||
assert "短线只能 1 档入场" in message
|
||||
assert "长线失效条件的周期只能是 4h" in errors_of(long_plan(invalidation={"direction": "below", "price": "79800", "timeframe": "1h", "basis": "x"}), context())
|
||||
assert "长线须给 3 个止盈" in errors_of(long_plan(take_profits=[{"price": "83000", "basis": "a"}]), context())
|
||||
def test_atr_and_stop_from_example():
|
||||
closed = bars_until("10:25")
|
||||
value = atr(closed)
|
||||
assert value.quantize(Decimal("0.0001")) == Decimal("302.9714")
|
||||
# 做多: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():
|
||||
message = errors_of(long_plan(stop="80500"), context())
|
||||
assert "做多时止损 80500.0 应在失效价 79800 之外" in message
|
||||
assert "止盈 1 81000.0 已越过现价" in errors_of(long_plan(take_profits=[{"price": "81000", "basis": "a"}, {"price": "84000", "basis": "b"}, {"price": "85000", "basis": "c"}], entries=[{"price": "80500", "ratio": "1", "basis": "a"}]), context())
|
||||
def test_size_entry_rejects_far_stop_and_halves_low_confidence():
|
||||
with pytest.raises(PlanError, match="超过 1%"):
|
||||
size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
|
||||
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():
|
||||
# (0.5×400 + 0.3×600 + 0.2×800) ÷ 500 = 1.08
|
||||
plan = long_plan(take_profits=[{"price": "80400", "basis": "a"}, {"price": "80600", "basis": "b"}, {"price": "80800", "basis": "c"}],
|
||||
entries=[{"price": "80000", "ratio": "1", "basis": "a"}])
|
||||
assert "盈亏比 1.08" in errors_of(plan, context(mark_price=Decimal("80300")))
|
||||
def test_take_profit_ladders():
|
||||
long = take_profit_targets("long", Decimal("80804.2"), Decimal("0.0120"), BTC)
|
||||
# +0.5% = 81208.22 → 81208.2;+0.75% = 81410.23 → 81410.2;+1% = 81612.24 → 81612.2;数量 60/30/10
|
||||
assert long == [(Decimal("81208.2"), Decimal("0.0072")), (Decimal("81410.2"), Decimal("0.0036")), (Decimal("81612.2"), Decimal("0.0012"))]
|
||||
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
|
||||
assert "第 1 档" in message
|
||||
# ---------- 提交校验 ----------
|
||||
|
||||
def test_check_setup_accepts_and_estimates():
|
||||
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():
|
||||
assert "不能用 keep" in errors_of(TradePlan(action="keep", summary="s", reasons="r", risks="x"), context())
|
||||
assert "不能用 wait" in errors_of(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context(position_side="long"))
|
||||
assert size_plan(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {}
|
||||
assert check_setup(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {}
|
||||
satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x",
|
||||
reevaluate=[{"direction": "above", "price": "81000", "timeframe": "1h", "basis": "x"}])
|
||||
reevaluate=[{"direction": "above", "price": "80000", "timeframe": "5m", "basis": "x"}])
|
||||
assert "已经满足" in errors_of(satisfied, context())
|
||||
assert "方向必须一致" in errors_of(long_plan(), context(position_side="short"))
|
||||
|
||||
|
||||
# ---------- 有持仓时的新计划 ----------
|
||||
|
||||
def managed_plan(**overrides) -> TradePlan:
|
||||
values = {
|
||||
"action": "plan", "summary": "调整", "reasons": "r", "risks": "x", "direction": "long", "confidence": "medium",
|
||||
"stop": "80000", "take_profits": [{"price": "83000", "basis": "a"}, {"price": "84000", "basis": "b"}, {"price": "85000", "basis": "c"}],
|
||||
"invalidation": {"direction": "below", "price": "80500", "timeframe": "4h", "basis": "x"},
|
||||
}
|
||||
return TradePlan.model_validate({**values, **overrides})
|
||||
|
||||
|
||||
def holding(**overrides) -> PlanContext:
|
||||
return context(position_side="long", position_qty=Decimal("0.0100"), position_entry=Decimal("81000"), current_stop=Decimal("79500"), has_plan=True, **overrides)
|
||||
|
||||
|
||||
def test_managed_plan_does_not_add_and_splits_position():
|
||||
sizing = size_plan(managed_plan(), holding())
|
||||
assert sizing["managed"] is True
|
||||
assert sizing["entries"] == []
|
||||
assert [t["quantity"] for t in sizing["take_profits"]] == ["0.0050", "0.0030", "0.0020"]
|
||||
assert sizing["position_qty"] == "0.0100"
|
||||
assert "entries 须为空" in errors_of(managed_plan(entries=[{"price": "80000", "ratio": "1", "basis": "a"}]), holding())
|
||||
|
||||
|
||||
def test_managed_plan_only_tightens_stop():
|
||||
assert "止损只能收紧" in errors_of(managed_plan(stop="79000", invalidation={"direction": "below", "price": "79400", "timeframe": "4h", "basis": "x"}), holding())
|
||||
assert "只能 keep 或 exit" in errors_of(setup_plan(), context(position_side="long", has_plan=True))
|
||||
|
||||
|
||||
# ---------- 应有订单 ----------
|
||||
|
||||
LONG_SIZING = {
|
||||
"direction": "long", "managed": False, "quantity": "0.0030",
|
||||
"entries": [{"price": "81000.0", "quantity": "0.0012"}, {"price": "80000.0", "quantity": "0.0018"}],
|
||||
"take_profits": [{"price": "83000.0", "ratio": "0.5"}, {"price": "84000.0", "ratio": "0.3"}, {"price": "85000.0", "ratio": "0.2"}],
|
||||
}
|
||||
ZERO3 = [Decimal(0)] * 3
|
||||
TRIGGERED = {"direction": "long", "setup": {}, "entry": {"quantity": "0.0120", "stop": "80036.5"}}
|
||||
NO_TP = [Decimal(0)] * 3
|
||||
|
||||
|
||||
def test_desired_orders_before_fill_places_entries_and_stop_together():
|
||||
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3,
|
||||
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001"))
|
||||
assert [(o.purpose, o.leg, o.side, o.quantity) for o in orders] == [
|
||||
("entry", 0, "BUY", Decimal("0.0012")), ("entry", 1, "BUY", Decimal("0.0018")), ("stop", None, "SELL", Decimal("0.0030")),
|
||||
def test_desired_orders_places_stop_and_market_entry_once():
|
||||
orders = desired_orders(TRIGGERED, entry_sent=False, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0),
|
||||
stop_price=Decimal("80036.5"), rules=BTC)
|
||||
assert [(o.purpose, o.side, o.type, o.quantity) for o in orders] == [
|
||||
("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():
|
||||
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal("0.0012"), Decimal("0.0004")], tp_filled=ZERO3,
|
||||
position_qty=Decimal("0.0016"), stop_price=Decimal("79500"), step_size=Decimal("0.0001"))
|
||||
assert [(o.purpose, o.leg, o.quantity) for o in orders] == [
|
||||
("entry", 1, Decimal("0.0014")), ("stop", None, Decimal("0.0030")),
|
||||
("tp", 0, Decimal("0.0008")), ("tp", 1, Decimal("0.0004")), ("tp", 2, Decimal("0.0004")),
|
||||
]
|
||||
|
||||
|
||||
def test_desired_orders_after_tp1_keeps_remaining_targets_only():
|
||||
orders = desired_orders(LONG_SIZING, entries_open=False, entry_filled=[Decimal("0.0012"), Decimal("0.0018")],
|
||||
tp_filled=[Decimal("0.0015"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0015"),
|
||||
stop_price=Decimal("81050"), step_size=Decimal("0.0001"))
|
||||
assert [(o.purpose, o.leg, o.quantity, o.trigger_price) for o in orders] == [
|
||||
("stop", None, Decimal("0.0030"), Decimal("81050")), ("tp", 1, Decimal("0.0009"), None), ("tp", 2, Decimal("0.0006"), None),
|
||||
]
|
||||
|
||||
|
||||
def test_desired_orders_nothing_when_flat_and_entries_closed():
|
||||
assert desired_orders(LONG_SIZING, entries_open=False, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3,
|
||||
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001")) == []
|
||||
def test_desired_orders_nothing_after_flat():
|
||||
assert desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")), tp_filled=NO_TP,
|
||||
position_qty=Decimal(0), stop_price=Decimal("80036.5"), rules=BTC) == []
|
||||
|
||||
|
||||
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():
|
||||
tz8 = timezone(timedelta(hours=8))
|
||||
# 北京 10:20 = UTC 02:20;短线 +45 分钟后的第一个整点 UTC 04:00,再加 1 分钟
|
||||
assert next_routine_at(datetime(2026, 10, 15, 10, 20, tzinfo=tz8), "short") == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
|
||||
assert next_routine_at(datetime(2026, 10, 15, 3, 1, tzinfo=UTC), "short") == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
|
||||
# 长线:UTC 04:01 + 3h = 07:01,下一个 4h 收盘 08:00
|
||||
assert next_routine_at(datetime(2026, 10, 15, 4, 1, tzinfo=UTC), "long") == datetime(2026, 10, 15, 8, 1, tzinfo=UTC)
|
||||
# 北京 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, 3, 1, tzinfo=UTC)) == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_step_decimal_never_uses_exponent():
|
||||
|
||||
Reference in new issue
Block a user