Files
AlphaPulse/backend/src/alphapulse/trading/plan.py
T
eezd 250b16b389 Place limit entries as soon as a demo plan is adopted (plan 3.2)
- The model gives a zone and a limit price inside it (below the mark for
  longs, above for shorts); the executor places the GTC limit order with
  the reduce-only stop right away instead of waiting for a 5m
  confirmation and entering at market, so entries pay maker fees
- Stop beyond the far edge of the zone by 0.5 x ATR14(5m), computed with
  the quantity at submission and rejected above 1%
- Unfilled limits are cancelled at expiry; a rejected entry cancels the
  plan instead of being retried every round
2026-10-15 17:13:55 +08:00

328 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""交易计划的纯计算:模型提交的区间和限价、校验、止损与仓位、止盈阶梯、应有订单。
只做短线做波动:模型划出支撑区(压力区)并给出区内的限价,采纳后立即挂限价单(maker 手续费);
止损放在区间远端外,与入场单一起挂;止盈按成交均价的固定比例。
下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
"""
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, Field
Direction = Literal["long", "short"]
# 所有币合计每天(北京时间)最多自动分析的次数
DAILY_ANALYSIS_LIMIT = 60
RISK_PCT = Decimal("0.01")
LOW_CONFIDENCE_FACTOR = Decimal("0.5")
MAX_LEVERAGE = 3
# 限价单的最长有效小时数;首次成交后的最长持仓小时数
SETUP_VALID_HOURS = 2
MAX_HOLD_HOURS = 4
ATR_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%;限价入场实际是 maker 0.02%)
TAKER_FEE_RATE = Decimal("0.0004")
# 例行分析周期:跟 1h 收盘
ROUTINE_HOURS = 1
# 同一个币两次自动分析的最短间隔
MIN_ANALYSIS_GAP = timedelta(minutes=10)
Price = Annotated[Decimal, Field(gt=0)]
class Condition(BaseModel):
direction: Annotated[Literal["above", "below"], Field(description="above:价格高于 price 时满足;below:低于时满足")]
price: Price
timeframe: Annotated[
Literal["mark", "1m", "5m", "15m", "1h", "4h"],
Field(description="mark:标记价触及即满足;其他:该周期 K 线收盘价满足才算"),
]
basis: Annotated[str, Field(description="依据,如对应的支撑区、压力区")]
class Setup(BaseModel):
zone_low: Price
zone_high: Price
entry_price: Annotated[Decimal, Field(gt=0, description=(
"限价单价格,须在区间内:做多低于现价,等价格回落成交;做空高于现价,等价格反弹成交"
))]
basis: Annotated[str, Field(description="区间和挂单价的依据:对应的支撑区或压力区、周期、触碰次数")]
class TradePlan(BaseModel):
"""submit_trade_plan 的参数"""
action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=(
"plan:给出新的限价计划(替换当前未成交的计划,仅在没有持仓时);keep:当前计划或持仓不变;"
"exit:结束当前计划,撤销未成交的限价单,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)"
))]
summary: Annotated[str, Field(description="一句话结论,含方向和置信度")]
reasons: Annotated[str, Field(description="依据要点:新闻、结构、衍生品,引用新闻写 [新闻 id]")]
risks: Annotated[str, Field(description="有效期和持仓时限内的重大事件及时间、与结论相反的信号")]
reevaluate: Annotated[list[Condition], Field(max_length=MAX_REEVALUATE, description=(
"满足任一条件时程序重新请你分析:用来表达另一侧方向、需要重新划区的位置。提交时不能已经满足"
))] = []
direction: Direction | None = None
confidence: Literal["high", "medium", "low"] | None = None
setup: Annotated[Setup | None, Field(description="做多为支撑区,做空为压力区,以及区内的挂单价")] = None
valid_hours: Annotated[Decimal | None, Field(gt=0, description=f"限价单有效小时数(≤ {SETUP_VALID_HOURS}),到期未成交则撤单")] = None
@dataclass
class SymbolRules:
tick_size: Decimal
step_size: Decimal
min_notional: Decimal
@dataclass
class PlanContext:
mark_price: Decimal
# min(设定的交易资金, 账户权益)
capital: Decimal
# 交易列表中其他币的持仓名义价值合计
other_notional: Decimal
rules: SymbolRules
# 最近已收盘 5m K 线的 ATR14,用于计算止损
atr: Decimal
has_plan: bool
position_side: Direction | None
@dataclass(frozen=True)
class Bar:
open_time: int
# 币安的 close_time:收盘前 1 毫秒
close_time: int
high: Decimal
low: Decimal
close: Decimal
class PlanError(Exception):
"""计划不通过校验;errors 逐条返回给模型修正"""
def __init__(self, errors: list[str]) -> None:
super().__init__(";".join(errors))
self.errors = errors
def round_to_step(value: Decimal, step: Decimal, rounding: str) -> Decimal:
# quantize 统一小数位,否则可能得到 8.05E+4 这类指数形式,转成字符串下单会被拒
return ((value / step).to_integral_value(rounding) * step).quantize(step)
def split_take_profits(quantity: Decimal, ratios: tuple[Decimal, ...], step: Decimal) -> list[Decimal]:
"""按比例拆分止盈数量:前几档向下取整,最后一档取余数,保证合计等于 quantity。取整后为 0 的档位由调用方跳过"""
parts = [round_to_step(quantity * r, step, ROUND_FLOOR) for r in ratios[:-1]]
return [*parts, quantity - sum(parts, Decimal(0))]
def 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 setup_stop(direction: Direction, far_edge: Decimal, atr_value: Decimal, tick: Decimal) -> Decimal:
"""止损 = 区间远端外 STOP_ATR_FACTOR × ATR,向远离入场的方向取整"""
if direction == "long":
return round_to_step(far_edge - STOP_ATR_FACTOR * atr_value, tick, ROUND_FLOOR)
return round_to_step(far_edge + 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]:
errors = []
if plan.action == "keep" and not ctx.has_plan:
errors.append("当前没有计划,不能用 keep")
if plan.action == "exit" and not ctx.has_plan and ctx.position_side is None:
errors.append("当前没有计划也没有持仓,不能用 exit,观望请用 wait")
if plan.action == "wait" and (ctx.has_plan or ctx.position_side is not None):
errors.append("有当前计划或持仓时不能用 wait:保留用 keep,结束用 exit")
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:
errors.append(f"reevaluate 第 {i} 个条件({c.direction} {c.price})按现价 {ctx.mark_price} 已经满足,提交后会立即触发")
return errors
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 {}
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, rules = plan.direction, plan.setup, ctx.mark_price, ctx.rules
long = direction == "long"
# 向保守方向取整:做多的挂单价向下、做空向上
entry = round_to_step(setup.entry_price, rules.tick_size, ROUND_FLOOR if long else ROUND_CEILING)
if setup.zone_low >= setup.zone_high:
errors.append(f"区间下沿 {setup.zone_low} 须低于上沿 {setup.zone_high}")
if not setup.zone_low <= entry <= setup.zone_high:
errors.append(f"挂单价 {entry} 须在区间 {setup.zone_low}–{setup.zone_high} 内")
# 限价单要挂在现价下方(做空在上方)等价格过来,否则会立即按市价成交,付吃单手续费
if long and entry >= mark:
errors.append(f"做多的挂单价 {entry} 须低于现价 {mark}")
if not long and entry <= mark:
errors.append(f"做空的挂单价 {entry} 须高于现价 {mark}")
if plan.valid_hours > SETUP_VALID_HOURS:
errors.append(f"valid_hours 不能超过 {SETUP_VALID_HOURS}")
if errors:
raise PlanError(errors)
stop = setup_stop(direction, setup.zone_low if long else setup.zone_high, ctx.atr, rules.tick_size)
try:
sizing = size_entry(direction, entry, stop, capital=ctx.capital, other_notional=ctx.other_notional,
confidence=plan.confidence, rules=rules)
except PlanError as e:
raise PlanError([f"止损放在区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m)={stop}:{e}。"
"区间太宽或挂单价离区间远端太远,请收窄区间或调整挂单价"]) from e
return {
"direction": direction,
"setup": {"zone_low": str(setup.zone_low), "zone_high": str(setup.zone_high)},
"entry": sizing,
"atr": str(ctx.atr.quantize(rules.tick_size)),
# 执行器采纳前核对:持仓变了说明计划基于过时状态
"position_qty": "0",
}
# ---------- 执行:应有订单 ----------
@dataclass(frozen=True)
class OrderSpec:
"""一张应有(或现有)的订单;quantity 为剩余未成交数量"""
purpose: Literal["entry", "stop", "tp", "close"]
leg: int | None
side: Literal["BUY", "SELL"]
type: Literal["LIMIT", "STOP_MARKET", "MARKET"]
price: Decimal | None
trigger_price: Decimal | None
quantity: Decimal
def desired_orders(
sizing: dict, *, entries_open: bool, entry_filled: Decimal, entry_average: Decimal | None, tp_filled: list[Decimal],
position_qty: Decimal, stop_price: Decimal, rules: SymbolRules,
) -> list[OrderSpec]:
"""计划在当前成交状态下应挂的单。
- 入场:entries_open 时一张限价单,数量为剩余未成交部分。
- 止损:有持仓或有入场单时一张只减仓止损,数量取计划数量与持仓的较大者(大于持仓时交易所只平实际持仓)。
- 止盈:有持仓时按已成交数量和成交均价排出阶梯,减去各档已成交。
"""
direction = sizing["direction"]
open_side, close_side = ("BUY", "SELL") if direction == "long" else ("SELL", "BUY")
quantity = Decimal(sizing["entry"]["quantity"])
orders = []
if entries_open and entry_filled < quantity:
orders.append(OrderSpec("entry", 0, open_side, "LIMIT", Decimal(sizing["entry"]["price"]), None, quantity - entry_filled))
if position_qty > 0 or orders:
orders.append(OrderSpec("stop", None, close_side, "STOP_MARKET", None, stop_price, max(quantity, position_qty)))
if position_qty > 0 and entry_average is not None:
for j, (price, target) in enumerate(take_profit_targets(direction, entry_average, entry_filled, rules)):
remaining = target - tp_filled[j]
if remaining > 0:
orders.append(OrderSpec("tp", j, close_side, "LIMIT", price, None, remaining))
return orders
def diff_orders[K](desired: list[OrderSpec], actual: list[tuple[K, OrderSpec]]) -> tuple[list[K], list[OrderSpec]]:
"""对比应有与现有订单:返回(要撤销的现有订单,要新挂的订单)。价格、数量完全一致才算同一张"""
missing = list(desired)
cancel = []
for key, spec in actual:
if spec in missing:
missing.remove(spec)
else:
cancel.append(key)
return cancel, missing
def condition_met(direction: Literal["above", "below"], threshold: Decimal, price: Decimal) -> bool:
return price > threshold if direction == "above" else price < threshold
def next_routine_at(last_started: datetime) -> datetime:
"""例行分析时间:上次分析后约 3/4 个周期之后的第一个 K 线收盘,再加 1 分钟等收盘数据就绪。
任何一次分析(包括触发式)都会让例行分析重新计时"""
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)
boundary = day + period * -(-(earliest - day) // period)
return boundary + timedelta(minutes=1)