- 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
361 lines
18 KiB
Python
361 lines
18 KiB
Python
"""交易计划的纯计算:模型提交的待确认入场、校验、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)
|