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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user