- 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
328 lines
16 KiB
Python
328 lines
16 KiB
Python
"""交易计划的纯计算:模型提交的区间和限价、校验、止损与仓位、止盈阶梯、应有订单。
|
||
|
||
只做短线做波动:模型划出支撑区(压力区)并给出区内的限价,采纳后立即挂限价单(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)
|