Files
AlphaPulse/backend/src/alphapulse/trading/plan.py
T
eezd 7efa2eb68b 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
2026-10-15 13:09:19 +08:00

361 lines
18 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.
"""交易计划的纯计算:模型提交的待确认入场、校验、5m 确认扫描、触发时的止损与仓位、止盈阶梯。
只做短线做波动:模型划出区间和确认价,程序在 5m 收盘确认后市价入场,止损和止盈由程序按固定规则计算。
下方常量与 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
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%)
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
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:观望,不给计划(仅在没有持仓和当前计划时)"
))]
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
@dataclass(frozen=True)
class SetupScan:
state: Literal["waiting", "triggered", "broken", "missed"]
bar: Bar | None = None
# 进区以来的极值:做多为最低价,做空为最高价
extreme: Decimal | None = None
class PlanError(Exception):
"""计划不通过校验;errors 逐条返回给模型修正"""
def __init__(self, errors: list[str]) -> None:
super().__init__(";".join(errors))
self.errors = errors
def round_to_step(value: Decimal, step: Decimal, rounding: str) -> Decimal:
# quantize 统一小数位,否则可能得到 8.05E+4 这类指数形式,转成字符串下单会被拒
return ((value / step).to_integral_value(rounding) * step).quantize(step)
def split_take_profits(quantity: Decimal, ratios: tuple[Decimal, ...], step: Decimal) -> list[Decimal]:
"""按比例拆分止盈数量:前几档向下取整,最后一档取余数,保证合计等于 quantity。取整后为 0 的档位由调用方跳过"""
parts = [round_to_step(quantity * r, step, ROUND_FLOOR) for r in ratios[:-1]]
return [*parts, quantity - sum(parts, Decimal(0))]
def 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]:
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 = 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)
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,
"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",
}
# ---------- 执行:应有订单 ----------
@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, *, entry_sent: bool, entry_fill: tuple[Decimal, Decimal] | None, tp_filled: list[Decimal],
position_qty: Decimal, stop_price: Decimal, rules: SymbolRules,
) -> list[OrderSpec]:
"""已触发的计划在当前状态下应挂的单。
- 入场:还没下过入场单时一张市价单;只下一次,被拒不重试。
- 止损:有持仓或入场还没成交(未下单、已发出待成交)时一张只减仓止损,数量取计划数量与持仓的较大者(大于持仓时交易所只平实际持仓)。
- 止盈:有持仓时按入场成交(数量,均价)排出阶梯,减去各档已成交。
"""
direction = sizing["direction"]
open_side, close_side = ("BUY", "SELL") if direction == "long" else ("SELL", "BUY")
quantity = Decimal(sizing["entry"]["quantity"])
orders = []
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", 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)