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
This commit is contained in:
eezd committed 2026-10-15 17:13:55 +08:00
1 parent 7efa2eb68b
commit 250b16b389
7 files changed
+185 -330

No files matched your search

+19 -22
View File
@@ -1,4 +1,4 @@
"""模拟盘分析器:为一个币调用模型,划出经程序校验、由执行器在 5m 收盘确认后自动入场的短线计划。 """模拟盘分析器:为一个币调用模型,划出经程序校验、由执行器立即挂限价单执行的短线计划。
只开放只读工具和 submit_trade_plan;行情预先取好放进用户消息,减少工具往返。 只开放只读工具和 submit_trade_plan;行情预先取好放进用户消息,减少工具往返。
""" """
@@ -48,31 +48,28 @@ def _ladder(direction: str) -> str:
return f"{' / '.join(sign + _pct(p) for p, _ in steps)} 分别平 {' / '.join(_pct(r) for _, r in steps)}" return f"{' / '.join(sign + _pct(p) for p, _ in steps)} 分别平 {' / '.join(_pct(r) for _, r in steps)}"
INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币种划出短线「待确认入场」,由程序在 5m 收盘确认后自动入场并管理持仓。 INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币种划出短线计划(区间 + 区内的挂单价),由程序立即挂限价单并管理持仓。
## 任务 ## 任务
- 下方 Skills 是交易规范。以下几处按本节执行,与 Skills 冲突时以本节为准: - 下方 Skills 是交易规范。以下几处按本节执行,与 Skills 冲突时以本节为准:
- main/README 第 8 节的输出表格不适用:用简短中文写出结论、所用的区间、依据(新闻、结构、衍生品)和风险;数字只通过 submit_trade_plan 提交。 - main/README 第 8 节的输出表格不适用:用简短中文写出结论、所用的区间、依据(新闻、结构、衍生品)和风险;数字只通过 submit_trade_plan 提交。
- main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,要按实时数据复核,不直接沿用旧点位。 - main/README 第 9 节不适用:没有 CONTEXT.md,不要写。之前的结论见用户消息里的「最近决策」,要按实时数据复核,不直接沿用旧点位。
- 数量、止损、止盈由程序计算,你只给方向、区间、确认价、有效期和置信度。 - 数量、止损、止盈由程序计算,你只给方向、区间、挂单价、有效期和置信度。
- 用户消息里已预先取好实盘行情(ticker、{' / '.join(KLINE_INTERVALS)} K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。 - 用户消息里已预先取好实盘行情(ticker、{' / '.join(KLINE_INTERVALS)} K 线、衍生品),不要用相同参数重复调用;需要其他周期或其他币时再调用。
- 新闻没有预取,必须用 search_news 检索相关新闻,并查清有效期和持仓时限内的重大数据及公布时间。 - 新闻没有预取,必须用 search_news 检索相关新闻,并查清有效期和持仓时限内的重大数据及公布时间。
- 最后必须调用一次 submit_trade_plan。校验不通过会返回原因,按原因修正后重新提交。 - 最后必须调用一次 submit_trade_plan。校验不通过会返回原因,按原因修正后重新提交。
## 程序如何执行 ## 程序如何执行
- 待确认入场(action=plan):程序在每根 5m K 线收盘时检查,只看计划生效后收盘的 K 线,交易所上不预挂入场单。 - 计划(action=plan)被采纳后,程序立即在 entry_price 挂限价单(maker 手续费),同时在交易所挂只减仓止损(按标记价触发)。到 valid_hours 仍未成交则撤单,并重新请你分析。
- 做多:某根 5m 的最低价进入区间(≤ zone_high)之后,出现一根收盘 ≥ confirm_price(可以是同一根)→ 立即市价做多。在此之前若有 5m 收盘 < zone_low,视为支撑破位,计划撤销并重新请你分析。 - 止损:区间远端外 {rules.STOP_ATR_FACTOR}×ATR14(5m),做多放在 zone_low 下方,做空放在 zone_high 上方。
- 做空:某根 5m 的最高价进入区间(≥ zone_low)之后,出现一根收盘 ≤ confirm_price → 立即市价做空。在此之前若有 5m 收盘 > zone_high,视为压力突破,撤销。 - 止盈:按成交均价,做多 {_ladder("long")};做空 {_ladder("short")}。止盈 1 成交后撤销未成交的入场部分,止损移到保本价。
- 到 valid_hours 仍未触发则作废,并重新请你分析。 - 首次成交后最长持仓 {rules.MAX_HOLD_HOURS} 小时,到时市价平仓。平仓后程序会请你重新分析。
- 止损:触发时由程序计算,放在进区以来的最低价(做空为最高价)外 {rules.STOP_ATR_FACTOR}×ATR14(5m),在市价入场前挂到交易所(按标记价触发)。止损距现价超过 {_pct(rules.MAX_STOP_PCT)} 就放弃这次入场。
- 止盈:按成交均价,做多 {_ladder("long")};做空 {_ladder("short")}。止盈 1 成交后止损移到保本价。
- 持仓最长 {rules.MAX_HOLD_HOURS} 小时,到时市价平仓。平仓后程序会请你重新分析。
- 仓位:单笔风险为交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。 - 仓位:单笔风险为交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。
- reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个),用来表达另一侧方向、需要重新划区的位置。 - reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个),用来表达另一侧方向、需要重新划区的位置。
## 程序的硬性校验 ## 程序的硬性校验
- zone_low < zone_high。做多:confirm_price ≥ zone_high,现价 ≥ zone_low;做空:confirm_price ≤ zone_low,现价 ≤ zone_high。确认就是「进区后收盘回到区间外侧」。 - zone_low < zone_high,entry_price 在区间内。做多 entry_price 低于现价,做空高于现价(限价单要等价格过来,不能一挂就成交)。
- 按确认价入场、止损放在区间远端外 {rules.STOP_ATR_FACTOR}×ATR 估算:止损距离不超过 {_pct(rules.MAX_STOP_PCT)},数量不低于交易所最小下单金额。 - 止损距挂单价不超过 {_pct(rules.MAX_STOP_PCT)},数量不低于交易所最小下单金额。
- valid_hours ≤ {rules.SETUP_VALID_HOURS}。 - valid_hours ≤ {rules.SETUP_VALID_HOURS}。
## 有持仓时 ## 有持仓时
@@ -106,13 +103,13 @@ def plan_brief(decision: dict, sizing: dict | None) -> dict:
brief = {k: decision.get(k) for k in ("action", "summary", "direction", "confidence", "setup", "valid_hours")} brief = {k: decision.get(k) for k in ("action", "summary", "direction", "confidence", "setup", "valid_hours")}
brief["reevaluate"] = [{k: c[k] for k in ("direction", "price", "timeframe")} for c in decision.get("reevaluate") or []] brief["reevaluate"] = [{k: c[k] for k in ("direction", "price", "timeframe")} for c in decision.get("reevaluate") or []]
if sizing and "entry" in sizing: if sizing and "entry" in sizing:
brief["entry"] = {k: sizing["entry"][k] for k in ("triggered_at", "price", "stop", "quantity", "risk_at_stop")} brief["entry"] = {k: sizing["entry"].get(k) for k in ("price", "stop", "quantity", "risk_at_stop")}
return brief return brief
async def confirm_atr(symbol: str) -> Decimal: async def recent_atr(symbol: str) -> Decimal:
"""最近已收盘 5m K 线的 ATR14,用于预估止损""" """最近已收盘 5m K 线的 ATR14,用于计算止损"""
bars = parse_bars(await market_client.get("um", "klines", {"symbol": symbol, "interval": rules.CONFIRM_INTERVAL, "limit": rules.ATR_PERIOD + 2})) bars = parse_bars(await market_client.get("um", "klines", {"symbol": symbol, "interval": rules.ATR_INTERVAL, "limit": rules.ATR_PERIOD + 2}))
now_ms = datetime.now(UTC).timestamp() * 1000 now_ms = datetime.now(UTC).timestamp() * 1000
return atr([b for b in bars if b.close_time < now_ms]) return atr([b for b in bars if b.close_time < now_ms])
@@ -179,7 +176,7 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
symbol_rules_ = await symbol_rules(symbol) symbol_rules_ = await symbol_rules(symbol)
mark = await mark_price(symbol) mark = await mark_price(symbol)
context = PlanContext( context = PlanContext(
mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, atr=await confirm_atr(symbol), mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, atr=await recent_atr(symbol),
has_plan=current is not None, position_side=position_side(snapshot, symbol), has_plan=current is not None, position_side=position_side(snapshot, symbol),
) )
@@ -239,11 +236,11 @@ def describe(plan: TradePlan, sizing: dict, dry_run: bool) -> str:
prefix = "试运行" if dry_run else "" prefix = "试运行" if dry_run else ""
if plan.action != "plan": if plan.action != "plan":
return f"{prefix}{ACTION_LABELS[plan.action]}:{plan.summary}" return f"{prefix}{ACTION_LABELS[plan.action]}:{plan.summary}"
setup, estimate = sizing["setup"], sizing["estimate"] setup, entry = sizing["setup"], sizing["entry"]
return ( return (
f"{prefix}待确认入场:{DIRECTION_LABELS[plan.direction]},置信度{CONFIDENCE_LABELS[plan.confidence]};" f"{prefix}计划:限价{DIRECTION_LABELS[plan.direction]} {entry['price']} × {entry['quantity']},置信度{CONFIDENCE_LABELS[plan.confidence]};"
f"区间 {setup['zone_low']}–{setup['zone_high']},5m 收盘{'≥' if plan.direction == 'long' else '≤'} {setup['confirm_price']} 入场," f"区间 {setup['zone_low']}–{setup['zone_high']},止损 {entry['stop']}({entry['stop_pct']}%),止损亏损 {entry['risk_at_stop']} USDT,"
f"有效 {plan.valid_hours} 小时;按确认价预估止损 {estimate['stop']}({estimate['stop_pct']}%)、数量 {estimate['quantity']}。{plan.summary}" f"有效 {plan.valid_hours} 小时。{plan.summary}"
) )
@@ -254,7 +251,7 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None
# 分析可能持续几分钟,按提交时的标记价、持仓和 ATR 校验 # 分析可能持续几分钟,按提交时的标记价、持仓和 ATR 校验
prepared.context.mark_price = await mark_price(symbol) prepared.context.mark_price = await mark_price(symbol)
prepared.context.atr = await confirm_atr(symbol) prepared.context.atr = await recent_atr(symbol)
snapshot = await read_snapshot() snapshot = await read_snapshot()
if snapshot: if snapshot:
prepared.context.position_side = position_side(snapshot, symbol) prepared.context.position_side = position_side(snapshot, symbol)
+27 -90
View File
@@ -2,8 +2,8 @@
每轮对交易列表里的每个币: 每轮对交易列表里的每个币:
1. 同步订单流水与交易所(成交、撤销、触发)。 1. 同步订单流水与交易所(成交、撤销、触发)。
2. 采纳分析器提交的待确认入场(有持仓或当前计划已触发时作废)。 2. 采纳分析器提交的限价计划(有持仓时作废)。
3. 推进计划状态,执行程序规则:5m 收盘确认入场、破位撤销、到期、时间止损、复核条件、无计划持仓。 3. 推进计划状态,执行程序规则:限价单到期、时间止损、复核条件、无计划持仓。
4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不下新入场单)。 4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不下新入场单)。
""" """
@@ -24,8 +24,7 @@ from alphapulse.market.binance import BinanceClient
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules
from alphapulse.trading.plan import ( from alphapulse.trading.plan import (
CONFIRM_INTERVAL, MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, Bar, OrderSpec, PlanError, SymbolRules, atr, condition_met, desired_orders, MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step,
diff_orders, parse_bars, round_to_step, scan_setup, setup_stop, size_entry,
) )
from alphapulse.trading.schedule import request_analysis from alphapulse.trading.schedule import request_analysis
@@ -40,15 +39,12 @@ SUBMIT_GRACE_SECONDS = 10
STOP_FAILURE_LIMIT = 3 STOP_FAILURE_LIMIT = 3
# 同一错误的时间线事件间隔,避免刷屏 # 同一错误的时间线事件间隔,避免刷屏
ERROR_EVENT_SECONDS = 600 ERROR_EVENT_SECONDS = 600
# 每次取的 5m K 线根数:覆盖待确认入场的最长有效期(24 根)和 ATR14
CONFIRM_BARS = 40
ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled", ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled",
"EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"} "EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"}
PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"} PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"}
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束", CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束",
"external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓"} "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓"}
CANCEL_REASON_LABELS = {"expired": "到期未触发", "stopped": "已停止交易", "broken": "区间被突破", "missed": "错过确认", CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒"}
"skipped": "放弃入场", "entry_failed": "入场失败"}
@dataclass @dataclass
@@ -80,8 +76,6 @@ class Executor:
self.error_events: dict[str, float] = {} self.error_events: dict[str, float] = {}
# (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价) # (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价)
self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {} self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {}
# symbol -> (下一根收盘时间 ms, 最近的 5m K 线)
self.bars_cache: dict[str, tuple[int, list[Bar]]] = {}
# 已提示过的无计划持仓:symbol -> 持仓均价,同一笔持仓只提示一次 # 已提示过的无计划持仓:symbol -> 持仓均价,同一笔持仓只提示一次
self.unmanaged_noticed: dict[str, Decimal] = {} self.unmanaged_noticed: dict[str, Decimal] = {}
@@ -122,16 +116,6 @@ class Executor:
self.kline_cache[(symbol, interval)] = (next_close, Decimal(closed[4])) self.kline_cache[(symbol, interval)] = (next_close, Decimal(closed[4]))
return Decimal(closed[4]) return Decimal(closed[4])
async def confirm_bars(self, symbol: str) -> list[Bar]:
"""实盘最近的 5m K 线(最后一根可能未收盘),缓存到下一根收盘"""
now_ms = int(time.time() * 1000)
cached = self.bars_cache.get(symbol)
if cached and now_ms < cached[0]:
return cached[1]
bars = parse_bars(await self.market.get("um", "klines", {"symbol": symbol, "interval": CONFIRM_INTERVAL, "limit": CONFIRM_BARS}))
self.bars_cache[symbol] = (bars[-1].close_time + 1, bars)
return bars
async def condition_met(self, symbol: str, condition: dict, mark: Decimal) -> tuple[bool, Decimal]: async def condition_met(self, symbol: str, condition: dict, mark: Decimal) -> tuple[bool, Decimal]:
price = mark if condition["timeframe"] == "mark" else await self.last_close(symbol, condition["timeframe"]) price = mark if condition["timeframe"] == "mark" else await self.last_close(symbol, condition["timeframe"])
return condition_met(condition["direction"], Decimal(condition["price"]), price), price return condition_met(condition["direction"], Decimal(condition["price"]), price), price
@@ -276,11 +260,11 @@ class Executor:
close_side = "SELL" if position.side == "long" else "BUY" close_side = "SELL" if position.side == "long" else "BUY"
await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty)) await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
return current return current
# 分析期间可能已经入场:持仓中或已触发的计划不能被替换(持仓由程序管理,平仓后会重新分析) # 分析期间可能已经成交:持仓由程序管理,不能被新计划替换(平仓后会重新分析)
qty = position.qty if position else Decimal(0) qty = position.qty if position else Decimal(0)
if qty > 0 or (current is not None and (current.plan_status != "pending" or "entry" in current.sizing)): if qty > 0 or (current is not None and current.plan_status != "pending"):
proposal.plan_status = "stale" proposal.plan_status = "stale"
reason = f"已有持仓 {fmt(qty)}" if qty > 0 else ("计划正在平仓" if current.plan_status == "closing" else "当前计划已触发入场") reason = f"已有持仓 {fmt(qty)}" if qty > 0 else "计划正在平仓"
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn", session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
message=f"计划未采纳:{reason}")) message=f"计划未采纳:{reason}"))
return current return current
@@ -288,12 +272,12 @@ class Executor:
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
proposal.plan_status = "pending" proposal.plan_status = "pending"
proposal.valid_until = now + timedelta(hours=float(proposal.decision["valid_hours"])) proposal.valid_until = now + timedelta(hours=float(proposal.decision["valid_hours"]))
setup = proposal.sizing["setup"] entry = proposal.sizing["entry"]
side = "做多" if proposal.sizing["direction"] == "long" else "做空" side = "做多" if proposal.sizing["direction"] == "long" else "做空"
session.add(TradingEvent( session.add(TradingEvent(
symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted", symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "") message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")
+ f":等待 5m 确认{side}(区间 {setup['zone_low']}–{setup['zone_high']},确认价 {setup['confirm_price']})," + f":限价{side} {entry['price']} × {entry['quantity']},止损 {entry['stop']}({entry['stop_pct']}%),"
f"有效至 {proposal.valid_until.astimezone(LOCAL_TZ).strftime('%H:%M')}", f"有效至 {proposal.valid_until.astimezone(LOCAL_TZ).strftime('%H:%M')}",
)) ))
return proposal return proposal
@@ -345,8 +329,7 @@ class Executor:
# ---------- 对账 ---------- # ---------- 对账 ----------
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None, async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None) -> None:
other_notional: Decimal) -> None:
name = symbol.symbol name = symbol.symbol
rules = await symbol_rules(name) rules = await symbol_rules(name)
open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})} open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})}
@@ -404,21 +387,20 @@ class Executor:
if plan.plan_status == "pending": if plan.plan_status == "pending":
entry_rows = [r for r in plan_rows if r.purpose == "entry"] entry_rows = [r for r in plan_rows if r.purpose == "entry"]
# 入场成交后持仓可能已被止损或手动平掉:照样转入持仓,下一轮按持仓归零结束 rejected = next((r for r in entry_rows if r.status in ("rejected", "failed")), None)
# 成交后持仓可能已被止损或手动平掉:照样转入持仓,下一轮按持仓归零结束
if qty > 0 or any(r.filled_qty > 0 for r in entry_rows): if qty > 0 or any(r.filled_qty > 0 for r in entry_rows):
plan.plan_status, plan.first_fill_at = "open", now plan.plan_status, plan.first_fill_at = "open", now
plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS) plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS)
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open", session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open",
message=f"入场成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓")) message=f"限价单成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
elif "entry" in plan.sizing:
if entry_rows and all(r.status not in ACTIVE_ORDER_STATUSES for r in entry_rows):
await self.cancel_plan(session, plan, "entry_failed", f"市价入场失败:{entry_rows[-1].error or entry_rows[-1].status}", "error")
elif not running: elif not running:
await self.cancel_plan(session, plan, "stopped", "已停止交易") await self.cancel_plan(session, plan, "stopped", "已停止交易,撤销未成交的限价单")
elif now >= plan.valid_until: elif now >= plan.valid_until:
await self.cancel_plan(session, plan, "expired", "到期未触发") await self.cancel_plan(session, plan, "expired", "限价单到期未成交")
else: elif rejected:
await self.check_setup(session, client, plan, rules, other_notional) # 被拒后不重挂,免得每轮重复下同一张会被拒的单
await self.cancel_plan(session, plan, "entry_failed", f"限价单被拒:{rejected.error or rejected.status}", "error")
elif plan.plan_status == "open": elif plan.plan_status == "open":
if qty == 0: if qty == 0:
await self.finish(session, client, plan, plan_rows) await self.finish(session, client, plan, plan_rows)
@@ -429,7 +411,7 @@ class Executor:
await session.commit() await session.commit()
if plan.plan_status in LIVE_PLAN_STATUSES: if plan.plan_status in LIVE_PLAN_STATUSES:
await self.sync_orders(session, client, plan, plan_rows, position, rules, stale_entry_canceled) await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
else: else:
# 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位 # 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位
for row in plan_rows: for row in plan_rows:
@@ -442,49 +424,6 @@ class Executor:
await self.check_reevaluate(session, name, position.mark if position else None) await self.check_reevaluate(session, name, position.mark if position else None)
await session.commit() await session.commit()
async def check_setup(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rules: SymbolRules, other_notional: Decimal) -> None:
"""待确认入场:5m 收盘确认后算止损和数量,写入 sizing.entry,由 sync_orders 先挂止损再市价入场。
other_notional:交易列表中其他币的持仓名义价值"""
now_ms = int(time.time() * 1000)
bars = await self.confirm_bars(plan.symbol)
sizing = plan.sizing
direction, setup = sizing["direction"], sizing["setup"]
scan = scan_setup(direction, setup, bars, int(plan.finished_at.timestamp() * 1000), now_ms)
if scan.state == "waiting":
return
long = direction == "long"
at = datetime.fromtimestamp((scan.bar.close_time + 1) / 1000, LOCAL_TZ).strftime("%H:%M")
if scan.state == "broken":
edge = f"跌破区间下沿 {setup['zone_low']}" if long else f"突破区间上沿 {setup['zone_high']}"
await self.cancel_plan(session, plan, "broken", f"{at} 5m 收盘 {fmt(scan.bar.close)} {edge}")
return
if scan.state == "missed":
await self.cancel_plan(session, plan, "missed", f"{at} 的 5m 收盘已满足确认,但程序没有及时执行,不补做", "warn")
return
# 触发很少发生,直接查最新权益,不依赖快照循环
account = await session.get(TradingAccount, 1)
equity = Decimal((await client.request("GET", "/fapi/v3/account"))["totalMarginBalance"])
capital = min(account.capital_usdt, equity)
atr_value = atr([b for b in bars if b.close_time < now_ms])
stop = setup_stop(direction, scan.extreme, atr_value, rules.tick_size)
mark = await mark_price(plan.symbol)
confirm = f"5m 收盘 {fmt(scan.bar.close)},{'最低' if long else '最高'} {fmt(scan.extreme)}"
try:
entry = size_entry(direction, mark, stop, capital=capital, other_notional=other_notional,
confidence=plan.decision["confidence"], rules=rules)
except PlanError as e:
await self.cancel_plan(session, plan, "skipped", f"{at} {confirm} 已确认,但{e},放弃入场", "warn")
return
plan.sizing = {**sizing, "entry": {**entry, "triggered_at": utcnow().isoformat(), "bar_close": fmt(scan.bar.close),
"extreme": fmt(scan.extreme), "atr": fmt(atr_value.quantize(rules.tick_size))}}
session.add(TradingEvent(
symbol=plan.symbol, analysis_id=plan.id, kind="setup_triggered",
message=f"{at} {confirm},确认{'做多' if long else '做空'}:市价入场 {entry['quantity']}(现价 {fmt(mark)}),"
f"止损 {entry['stop']}({entry['stop_pct']}%),止损亏损 {entry['risk_at_stop']} USDT",
data={"bar_open_time": scan.bar.open_time},
))
async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None: async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None:
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析""" """最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
latest = await session.scalar( latest = await session.scalar(
@@ -521,7 +460,7 @@ class Executor:
await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty)) await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
async def sync_orders(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, plan_rows: list[TradingOrder], async def sync_orders(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, plan_rows: list[TradingOrder],
position: Position | None, rules: SymbolRules, block_entries: bool) -> None: position: Position | None, running: bool, rules: SymbolRules, block_entries: bool) -> None:
sizing = plan.sizing sizing = plan.sizing
qty = position.qty if position else Decimal(0) qty = position.qty if position else Decimal(0)
long = sizing["direction"] == "long" long = sizing["direction"] == "long"
@@ -535,13 +474,11 @@ class Executor:
close_side = "SELL" if long else "BUY" close_side = "SELL" if long else "BUY"
await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty)) await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty))
return return
if "entry" not in sizing:
# 还在等 5m 确认,交易所上没有本计划的单
return
entry_rows = [r for r in plan_rows if r.purpose == "entry"] entry_rows = [r for r in plan_rows if r.purpose == "entry" and r.filled_qty > 0]
filled = sum((r.filled_qty for r in entry_rows), Decimal(0)) filled = sum((r.filled_qty for r in entry_rows), Decimal(0))
average = next((r.avg_price for r in entry_rows if r.filled_qty > 0 and r.avg_price), None) # 限价单撤了重挂时会有多行成交,按成交量加权
average = sum((r.filled_qty * r.avg_price for r in entry_rows), Decimal(0)) / filled if filled else None
tp_filled = [sum((r.filled_qty for r in plan_rows if r.purpose == "tp" and r.leg == j), Decimal(0)) tp_filled = [sum((r.filled_qty for r in plan_rows if r.purpose == "tp" and r.leg == j), Decimal(0))
for j in range(len(TAKE_PROFIT_STEPS[sizing["direction"]]))] for j in range(len(TAKE_PROFIT_STEPS[sizing["direction"]]))]
stop = Decimal(sizing["entry"]["stop"]) stop = Decimal(sizing["entry"]["stop"])
@@ -550,7 +487,9 @@ class Executor:
break_even = round_to_step(position.break_even, rules.tick_size, ROUND_CEILING if long else ROUND_FLOOR) break_even = round_to_step(position.break_even, rules.tick_size, ROUND_CEILING if long else ROUND_FLOOR)
stop = max(stop, break_even) if long else min(stop, break_even) stop = max(stop, break_even) if long else min(stop, break_even)
desired = desired_orders(sizing, entry_sent=bool(entry_rows), entry_fill=(filled, average) if average else None, # 止盈 1 成交后不再加仓
entries_open = running and utcnow() < plan.valid_until and tp_filled[0] == 0
desired = desired_orders(sizing, entries_open=entries_open, entry_filled=filled, entry_average=average,
tp_filled=tp_filled, position_qty=qty, stop_price=stop, rules=rules) tp_filled=tp_filled, position_qty=qty, stop_price=stop, rules=rules)
actual = [(r, OrderSpec(r.purpose, r.leg, r.side, r.type, r.price, r.trigger_price, r.quantity - r.filled_qty)) for r in active] actual = [(r, OrderSpec(r.purpose, r.leg, r.side, r.type, r.price, r.trigger_price, r.quantity - r.filled_qty)) for r in active]
to_cancel, to_place = diff_orders(desired, actual) to_cancel, to_place = diff_orders(desired, actual)
@@ -608,11 +547,9 @@ class Executor:
side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]), side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]),
break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]), break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]),
) )
names = {s.symbol for s in symbols}
for symbol in symbols: for symbol in symbols:
other_notional = sum((p.qty * p.mark for name, p in positions.items() if name != symbol.symbol and name in names), Decimal(0))
try: try:
await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol), other_notional) await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol))
except ExchangeError as e: except ExchangeError as e:
await session.rollback() await session.rollback()
await self.report(session, symbol.symbol, f"对账失败:{e}") await self.report(session, symbol.symbol, f"对账失败:{e}")
+51 -84
View File
@@ -1,6 +1,7 @@
"""交易计划的纯计算:模型提交的待确认入场、校验、5m 确认扫描、触发时的止损与仓位、止盈阶梯。 """交易计划的纯计算:模型提交的区间和限价、校验、止损与仓位、止盈阶梯、应有订单。
只做短线做波动:模型划出区间和确认价,程序在 5m 收盘确认后市价入场,止损和止盈由程序按固定规则计算。 只做短线做波动:模型划出支撑区(压力区)并给出区内的限价,采纳后立即挂限价单(maker 手续费);
止损放在区间远端外,与入场单一起挂;止盈按成交均价的固定比例。
下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。 下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
""" """
@@ -18,12 +19,12 @@ DAILY_ANALYSIS_LIMIT = 60
RISK_PCT = Decimal("0.01") RISK_PCT = Decimal("0.01")
LOW_CONFIDENCE_FACTOR = Decimal("0.5") LOW_CONFIDENCE_FACTOR = Decimal("0.5")
MAX_LEVERAGE = 3 MAX_LEVERAGE = 3
# 待确认入场的最长有效小时数;成交后的最长持仓小时数 # 限价单的最长有效小时数;首次成交后的最长持仓小时数
SETUP_VALID_HOURS = 2 SETUP_VALID_HOURS = 2
MAX_HOLD_HOURS = 4 MAX_HOLD_HOURS = 4
CONFIRM_INTERVAL = "5m" ATR_INTERVAL = "5m"
ATR_PERIOD = 14 ATR_PERIOD = 14
# 止损放在确认过程的极值外 STOP_ATR_FACTOR × ATR14(5m);离现价超过 MAX_STOP_PCT 就放弃这次入场 # 止损放在区间远端外 STOP_ATR_FACTOR × ATR14(5m);离挂单价超过 MAX_STOP_PCT 的计划不做
STOP_ATR_FACTOR = Decimal("0.5") STOP_ATR_FACTOR = Decimal("0.5")
MAX_STOP_PCT = Decimal("0.01") MAX_STOP_PCT = Decimal("0.01")
# 止盈阶梯:(距成交均价的涨跌幅,平仓比例)。牛市里空单不恋战,第一档就平掉大部分 # 止盈阶梯:(距成交均价的涨跌幅,平仓比例)。牛市里空单不恋战,第一档就平掉大部分
@@ -32,7 +33,7 @@ TAKE_PROFIT_STEPS: dict[str, tuple[tuple[Decimal, Decimal], ...]] = {
"short": ((Decimal("0.005"), Decimal("0.8")), (Decimal("0.0075"), Decimal("0.2"))), "short": ((Decimal("0.005"), Decimal("0.8")), (Decimal("0.0075"), Decimal("0.2"))),
} }
MAX_REEVALUATE = 4 MAX_REEVALUATE = 4
# 入场(市价)和止损都按吃单手续费估算(C1 实测 taker 0.04%) # 风险按吃单手续费保守估算(C1 实测 taker 0.04%;限价入场实际是 maker 0.02%)
TAKER_FEE_RATE = Decimal("0.0004") TAKER_FEE_RATE = Decimal("0.0004")
# 例行分析周期:跟 1h 收盘 # 例行分析周期:跟 1h 收盘
ROUTINE_HOURS = 1 ROUTINE_HOURS = 1
@@ -55,18 +56,18 @@ class Condition(BaseModel):
class Setup(BaseModel): class Setup(BaseModel):
zone_low: Price zone_low: Price
zone_high: Price zone_high: Price
confirm_price: Annotated[Decimal, Field(gt=0, description=( entry_price: Annotated[Decimal, Field(gt=0, description=(
"做多:不低于 zone_high,进区后 5m 收盘 ≥ 此价入场;做空:不高于 zone_low,进区后 5m 收盘 ≤ 此价入场" "限价单价格,须在区间内:做多低于现价,等价格回落成交;做空高于现价,等价格反弹成交"
))] ))]
basis: Annotated[str, Field(description="区间和确认价的依据:对应的支撑区或压力区、周期、触碰次数")] basis: Annotated[str, Field(description="区间和挂单价的依据:对应的支撑区或压力区、周期、触碰次数")]
class TradePlan(BaseModel): class TradePlan(BaseModel):
"""submit_trade_plan 的参数""" """submit_trade_plan 的参数"""
action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=( action: Annotated[Literal["plan", "keep", "exit", "wait"], Field(description=(
"plan:给出新的待确认入场(替换当前未触发的计划,仅在没有持仓时);keep:当前计划或持仓不变;" "plan:给出新的限价计划(替换当前未成交的计划,仅在没有持仓时);keep:当前计划或持仓不变;"
"exit:结束当前计划,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)" "exit:结束当前计划,撤销未成交的限价单,有持仓则市价平仓;wait:观望,不给计划(仅在没有持仓和当前计划时)"
))] ))]
summary: Annotated[str, Field(description="一句话结论,含方向和置信度")] summary: Annotated[str, Field(description="一句话结论,含方向和置信度")]
reasons: Annotated[str, Field(description="依据要点:新闻、结构、衍生品,引用新闻写 [新闻 id]")] reasons: Annotated[str, Field(description="依据要点:新闻、结构、衍生品,引用新闻写 [新闻 id]")]
@@ -76,8 +77,8 @@ class TradePlan(BaseModel):
))] = [] ))] = []
direction: Direction | None = None direction: Direction | None = None
confidence: Literal["high", "medium", "low"] | None = None confidence: Literal["high", "medium", "low"] | None = None
setup: Annotated[Setup | None, Field(description="待确认入场:做多为支撑区,做空为压力区")] = None setup: Annotated[Setup | None, Field(description="做多为支撑区,做空为压力区,以及区内的挂单价")] = None
valid_hours: Annotated[Decimal | None, Field(gt=0, description=f"有效小时数(≤ {SETUP_VALID_HOURS}),到期未触发则作废")] = None valid_hours: Annotated[Decimal | None, Field(gt=0, description=f"限价单有效小时数(≤ {SETUP_VALID_HOURS}),到期未成交则撤单")] = None
@dataclass @dataclass
@@ -95,7 +96,7 @@ class PlanContext:
# 交易列表中其他币的持仓名义价值合计 # 交易列表中其他币的持仓名义价值合计
other_notional: Decimal other_notional: Decimal
rules: SymbolRules rules: SymbolRules
# 最近已收盘 5m K 线的 ATR14,用于预估止损 # 最近已收盘 5m K 线的 ATR14,用于计算止损
atr: Decimal atr: Decimal
has_plan: bool has_plan: bool
position_side: Direction | None position_side: Direction | None
@@ -111,14 +112,6 @@ class Bar:
close: 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): class PlanError(Exception):
"""计划不通过校验;errors 逐条返回给模型修正""" """计划不通过校验;errors 逐条返回给模型修正"""
@@ -152,46 +145,22 @@ def atr(bars: list[Bar], period: int = ATR_PERIOD) -> Decimal:
return sum(ranges, Decimal(0)) / period return sum(ranges, Decimal(0)) / period
def scan_setup(direction: Direction, setup: dict, bars: list[Bar], after_ms: int, now_ms: int) -> SetupScan: def setup_stop(direction: Direction, far_edge: Decimal, atr_value: Decimal, tick: Decimal) -> Decimal:
"""按计划生效后收盘的 5m K 线判断待确认入场(按时间顺序,先发生的为准): """止损 = 区间远端外 STOP_ATR_FACTOR × ATR,向远离入场的方向取整"""
- 做多:某根最低价进入区间(≤ 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": if direction == "long":
return round_to_step(extreme - STOP_ATR_FACTOR * atr_value, tick, ROUND_FLOOR) return round_to_step(far_edge - STOP_ATR_FACTOR * atr_value, tick, ROUND_FLOOR)
return round_to_step(extreme + STOP_ATR_FACTOR * atr_value, tick, ROUND_CEILING) 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, def size_entry(direction: Direction, price: Decimal, stop: Decimal, *, capital: Decimal, other_notional: Decimal,
confidence: str, rules: SymbolRules) -> dict: confidence: str, rules: SymbolRules) -> dict:
"""按估计入场价和止损算数量:单笔风险上限与全账户杠杆取小。止损离现价太远、方向不对或数量不足时抛 PlanError""" """按入场价和止损算数量:单笔风险上限与全账户杠杆取小。止损离入场价太远、方向不对或数量不足时抛 PlanError"""
sign = 1 if direction == "long" else -1 sign = 1 if direction == "long" else -1
stop_pct = sign * (price - stop) / price stop_pct = sign * (price - stop) / price
if stop_pct <= 0: if stop_pct <= 0:
raise PlanError([f"止损 {stop} 已越过现价 {price}"]) raise PlanError([f"止损 {stop} 不在入场价 {price} 的亏损一侧"])
if stop_pct > MAX_STOP_PCT: if stop_pct > MAX_STOP_PCT:
raise PlanError([f"止损 {stop} 距现价 {price} {stop_pct * 100:.2f}%,超过 {MAX_STOP_PCT * 100:.0f}%"]) raise PlanError([f"止损 {stop} 距入场价 {price} {stop_pct * 100:.2f}%,超过 {MAX_STOP_PCT * 100:.0f}%"])
unit_loss = abs(price - stop) + (price + stop) * TAKER_FEE_RATE unit_loss = abs(price - stop) + (price + stop) * TAKER_FEE_RATE
risk_amount = capital * RISK_PCT * (LOW_CONFIDENCE_FACTOR if confidence == "low" else 1) risk_amount = capital * RISK_PCT * (LOW_CONFIDENCE_FACTOR if confidence == "low" else 1)
max_notional = capital * MAX_LEVERAGE - other_notional max_notional = capital * MAX_LEVERAGE - other_notional
@@ -247,7 +216,7 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
def check_setup(plan: TradePlan, ctx: PlanContext) -> dict: def check_setup(plan: TradePlan, ctx: PlanContext) -> dict:
"""校验计划;action=plan 时返回待确认入场和预估(按确认价入场、止损放在区间远端外)。不通过时抛 PlanError""" """校验计划;action=plan 时算出限价、止损和数量。不通过时抛 PlanError"""
errors = check_action(plan, ctx) errors = check_action(plan, ctx)
if plan.action != "plan": if plan.action != "plan":
if errors: if errors:
@@ -256,36 +225,35 @@ def check_setup(plan: TradePlan, ctx: PlanContext) -> dict:
missing = [name for name in ("direction", "confidence", "setup", "valid_hours") if getattr(plan, name) is None] missing = [name for name in ("direction", "confidence", "setup", "valid_hours") if getattr(plan, name) is None]
if missing: if missing:
raise PlanError([*errors, f"action=plan 时缺少:{', '.join(missing)}"]) raise PlanError([*errors, f"action=plan 时缺少:{', '.join(missing)}"])
direction, setup, mark = plan.direction, plan.setup, ctx.mark_price direction, setup, mark, rules = plan.direction, plan.setup, ctx.mark_price, ctx.rules
long = direction == "long" 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: if setup.zone_low >= setup.zone_high:
errors.append(f"区间下沿 {setup.zone_low} 须低于上沿 {setup.zone_high}") errors.append(f"区间下沿 {setup.zone_low} 须低于上沿 {setup.zone_high}")
# 确认 = 进区后收盘回到区间外侧;确认价在区间内会让刚碰到区间边缘、没有真正回落或收回的 K 线也触发 if not setup.zone_low <= entry <= setup.zone_high:
if long and setup.confirm_price < setup.zone_high: errors.append(f"挂单价 {entry} 须在区间 {setup.zone_low}–{setup.zone_high} 内")
errors.append(f"做多的确认价 {setup.confirm_price} 须不低于区间上沿 {setup.zone_high}(进区后收盘回到区间上方才算确认)") # 限价单要挂在现价下方(做空在上方)等价格过来,否则会立即按市价成交,付吃单手续费
if not long and setup.confirm_price > setup.zone_low: if long and entry >= mark:
errors.append(f"做空的确认价 {setup.confirm_price} 须不高于区间下沿 {setup.zone_low}(进区后收盘回到区间下方才算确认)") errors.append(f"做多的挂单价 {entry} 须低于现价 {mark}")
if long and mark < setup.zone_low: if not long and entry <= mark:
errors.append(f"现价 {mark} 已跌破区间下沿 {setup.zone_low},支撑已破") errors.append(f"做空的挂单价 {entry} 须高于现价 {mark}")
if not long and mark > setup.zone_high:
errors.append(f"现价 {mark} 已突破区间上沿 {setup.zone_high},压力已破")
if plan.valid_hours > SETUP_VALID_HOURS: if plan.valid_hours > SETUP_VALID_HOURS:
errors.append(f"valid_hours 不能超过 {SETUP_VALID_HOURS}") errors.append(f"valid_hours 不能超过 {SETUP_VALID_HOURS}")
if errors: if errors:
raise PlanError(errors) raise PlanError(errors)
far_edge = setup.zone_low if long else setup.zone_high stop = setup_stop(direction, setup.zone_low if long else setup.zone_high, ctx.atr, rules.tick_size)
stop = setup_stop(direction, far_edge, ctx.atr, ctx.rules.tick_size)
try: try:
estimate = size_entry(direction, setup.confirm_price, stop, capital=ctx.capital, other_notional=ctx.other_notional, sizing = size_entry(direction, entry, stop, capital=ctx.capital, other_notional=ctx.other_notional,
confidence=plan.confidence, rules=ctx.rules) confidence=plan.confidence, rules=rules)
except PlanError as e: except PlanError as e:
raise PlanError([f"按确认价入场、止损放在区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m)={stop} 估算:{e}。" raise PlanError([f"止损放在区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m)={stop}:{e}。"
"区间太宽或离确认价太远,触发后也会被放弃,请收窄区间或调整确认价"]) from e "区间太宽或挂单价离区间远端太远,请收窄区间或调整挂单价"]) from e
return { return {
"direction": direction, "direction": direction,
"setup": {k: str(getattr(setup, k)) for k in ("zone_low", "zone_high", "confirm_price")}, "setup": {"zone_low": str(setup.zone_low), "zone_high": str(setup.zone_high)},
"estimate": estimate, "entry": sizing,
"atr": str(ctx.atr.quantize(ctx.rules.tick_size)), "atr": str(ctx.atr.quantize(rules.tick_size)),
# 执行器采纳前核对:持仓变了说明计划基于过时状态 # 执行器采纳前核对:持仓变了说明计划基于过时状态
"position_qty": "0", "position_qty": "0",
} }
@@ -307,26 +275,25 @@ class OrderSpec:
def desired_orders( def desired_orders(
sizing: dict, *, entry_sent: bool, entry_fill: tuple[Decimal, Decimal] | None, tp_filled: list[Decimal], sizing: dict, *, entries_open: bool, entry_filled: Decimal, entry_average: Decimal | None, tp_filled: list[Decimal],
position_qty: Decimal, stop_price: Decimal, rules: SymbolRules, position_qty: Decimal, stop_price: Decimal, rules: SymbolRules,
) -> list[OrderSpec]: ) -> list[OrderSpec]:
"""已触发的计划在当前状态下应挂的单。 """计划在当前成交状态下应挂的单。
- 入场:还没下过入场单时一张市价单;只下一次,被拒不重试。 - 入场:entries_open 时一张限价单,数量为剩余未成交部分。
- 止损:有持仓或入场还没成交(未下单、已发出待成交)时一张只减仓止损,数量取计划数量与持仓的较大者(大于持仓时交易所只平实际持仓)。 - 止损:有持仓或有入场单时一张只减仓止损,数量取计划数量与持仓的较大者(大于持仓时交易所只平实际持仓)。
- 止盈:有持仓时按入场成交(数量,均价)排出阶梯,减去各档已成交。 - 止盈:有持仓时按已成交数量和成交均价排出阶梯,减去各档已成交。
""" """
direction = sizing["direction"] direction = sizing["direction"]
open_side, close_side = ("BUY", "SELL") if direction == "long" else ("SELL", "BUY") open_side, close_side = ("BUY", "SELL") if direction == "long" else ("SELL", "BUY")
quantity = Decimal(sizing["entry"]["quantity"]) quantity = Decimal(sizing["entry"]["quantity"])
orders = [] orders = []
if not entry_sent: if entries_open and entry_filled < quantity:
orders.append(OrderSpec("entry", 0, open_side, "MARKET", None, None, quantity)) orders.append(OrderSpec("entry", 0, open_side, "LIMIT", Decimal(sizing["entry"]["price"]), None, quantity - entry_filled))
if position_qty > 0 or entry_fill is None: if position_qty > 0 or orders:
orders.append(OrderSpec("stop", None, close_side, "STOP_MARKET", None, stop_price, max(quantity, position_qty))) 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: if position_qty > 0 and entry_average is not None:
filled, average = entry_fill for j, (price, target) in enumerate(take_profit_targets(direction, entry_average, entry_filled, rules)):
for j, (price, target) in enumerate(take_profit_targets(direction, average, filled, rules)):
remaining = target - tp_filled[j] remaining = target - tp_filled[j]
if remaining > 0: if remaining > 0:
orders.append(OrderSpec("tp", j, close_side, "LIMIT", price, None, remaining)) orders.append(OrderSpec("tp", j, close_side, "LIMIT", price, None, remaining))
+57 -82
View File
@@ -5,7 +5,7 @@ import pytest
from alphapulse.trading.plan import ( from alphapulse.trading.plan import (
Bar, OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, atr, check_setup, desired_orders, diff_orders, next_routine_at, Bar, OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, atr, check_setup, desired_orders, diff_orders, next_routine_at,
round_to_step, scan_setup, setup_stop, size_entry, split_take_profits, take_profit_targets, round_to_step, setup_stop, size_entry, split_take_profits, take_profit_targets,
) )
BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50")) BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50"))
@@ -39,11 +39,6 @@ def bars_until(hhmm: str) -> list[Bar]:
return [Bar(ms(t), ms(t) + FIVE_MINUTES_MS - 1, Decimal(h), Decimal(lo), Decimal(c)) for t, h, lo, c in BTC_BARS if ms(t) <= ms(hhmm)] return [Bar(ms(t), ms(t) + FIVE_MINUTES_MS - 1, Decimal(h), Decimal(lo), Decimal(c)) for t, h, lo, c in BTC_BARS if ms(t) <= ms(hhmm)]
def scan(direction, zone_low, zone_high, confirm, adopted: str, now: str):
setup = {"zone_low": zone_low, "zone_high": zone_high, "confirm_price": confirm}
return scan_setup(direction, setup, bars_until(now), ms(adopted), ms(now) + 2000)
def context(**overrides) -> PlanContext: def context(**overrides) -> PlanContext:
values = { values = {
"mark_price": Decimal("81000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "rules": BTC, "mark_price": Decimal("81000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "rules": BTC,
@@ -55,7 +50,7 @@ def context(**overrides) -> PlanContext:
def setup_plan(**overrides) -> TradePlan: def setup_plan(**overrides) -> TradePlan:
values = { values = {
"action": "plan", "summary": "回踩支撑做多", "reasons": "支撑区", "risks": "无", "direction": "long", "confidence": "medium", "action": "plan", "summary": "回踩支撑做多", "reasons": "支撑区", "risks": "无", "direction": "long", "confidence": "medium",
"setup": {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750", "basis": "1h 支撑区"}, "valid_hours": "2", "setup": {"zone_low": "80200", "zone_high": "80700", "entry_price": "80500", "basis": "1h 支撑区"}, "valid_hours": "2",
} }
return TradePlan.model_validate({**values, **overrides}) return TradePlan.model_validate({**values, **overrides})
@@ -81,55 +76,26 @@ def test_split_take_profits_keeps_total():
assert split_take_profits(Decimal("0.001"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.001")) == [0, 0, Decimal("0.001")] assert split_take_profits(Decimal("0.001"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.001")) == [0, 0, Decimal("0.001")]
# ---------- 5m 确认(用户的两个例子) ---------- # ---------- 止损与仓位(用户的例子) ----------
def test_long_example_triggers_on_close_back_above_after_dip():
# 10:20 收在 80700 还低于确认价;10:25 下探到 80188(影线低于区间下沿但收盘没破)收回 80804 → 10:30 触发
result = scan("long", "80200", "80700", "80750", adopted="10:12", now="10:30")
assert result.state == "triggered"
assert result.bar.open_time == ms("10:25")
assert result.extreme == Decimal("80188.00")
assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:25").state == "waiting"
def test_short_example_triggers_on_rejection_from_resistance():
# 10:50 只到 81411.7,未进区;10:55 冲到 81646.5 后收在 81500 → 11:00 触发
result = scan("short", "81500", "81700", "81500", adopted="10:47", now="11:00")
assert (result.state, result.bar.open_time, result.extreme) == ("triggered", ms("10:55"), Decimal("81646.50"))
def test_bars_closed_before_adoption_are_ignored():
# 10:25 那根在计划生效(10:30)前已收盘,不算
assert scan("long", "80200", "80700", "80750", adopted="10:30", now="10:30").state == "waiting"
def test_trigger_not_on_latest_bar_is_missed():
assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:35").state == "missed"
def test_close_beyond_far_edge_breaks_setup():
result = scan("long", "80750", "81000", "81050", adopted="10:12", now="10:30")
assert (result.state, result.bar.open_time) == ("broken", ms("10:20"))
def test_atr_and_stop_from_example(): def test_atr_and_stop_from_example():
closed = bars_until("10:25") value = atr(bars_until("10:25"))
value = atr(closed)
assert value.quantize(Decimal("0.0001")) == Decimal("302.9714") assert value.quantize(Decimal("0.0001")) == Decimal("302.9714")
# 做多:80188 − 0.5 × 302.97 = 80036.51 → 向下取整 80036.5,距 80804.2 约 0.95%,仍在 1% 以内 # 做多:支撑区 80200–80700 内挂 80500;止损 80200 − 0.5 × 302.97 = 80048.51 → 向下取整 80048.5,距挂单价 0.56%
stop = setup_stop("long", Decimal("80188.00"), value, BTC.tick_size) stop = setup_stop("long", Decimal("80200"), value, BTC.tick_size)
assert stop == Decimal("80036.5") assert stop == Decimal("80048.5")
entry = size_entry("long", Decimal("80804.2"), stop, capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC) entry = size_entry("long", Decimal("80500"), stop, capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC)
assert entry["stop_pct"] == "0.95" assert entry["stop_pct"] == "0.56"
# 10 ÷ (767.7 + (80804.2 + 80036.5) × 0.0004 = 832.04) = 0.01201… → 0.0120 # 10 ÷ (451.5 + (80500 + 80048.5) × 0.0004 = 515.72) = 0.01939… → 0.0193
assert entry["quantity"] == "0.0120" assert entry["quantity"] == "0.0193"
assert setup_stop("short", Decimal("81646.50"), atr(bars_until("10:55")), BTC.tick_size) == Decimal("81827.8") # 做空:压力区上沿 81700 + 0.5 × 362.56 = 81881.28 → 向上取整 81881.3
assert setup_stop("short", Decimal("81700"), atr(bars_until("10:55")), BTC.tick_size) == Decimal("81881.3")
def test_size_entry_rejects_far_stop_and_halves_low_confidence(): def test_size_entry_rejects_far_stop_and_halves_low_confidence():
with pytest.raises(PlanError, match="超过 1%"): with pytest.raises(PlanError, match="超过 1%"):
size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC) size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
with pytest.raises(PlanError, match="越过现价"): with pytest.raises(PlanError, match="亏损一侧"):
size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC) size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
medium = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC) medium = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC)
low = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="low", rules=BTC) low = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="low", rules=BTC)
@@ -150,24 +116,25 @@ def test_take_profit_ladders():
# ---------- 提交校验 ---------- # ---------- 提交校验 ----------
def test_check_setup_accepts_and_estimates(): def test_check_setup_sizes_limit_entry():
sizing = check_setup(setup_plan(), context()) sizing = check_setup(setup_plan(), context())
assert sizing["setup"] == {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750"} assert sizing["setup"] == {"zone_low": "80200", "zone_high": "80700"}
# 预估止损 = 80200 − 0.5 × 100 = 80150,距确认价 80750 约 0.74% # 止损 = 80200 − 0.5 × 100 = 80150,距 80500 为 0.43%;数量 10 ÷ (350 + 64.26) = 0.0241
assert (sizing["estimate"]["stop"], sizing["estimate"]["stop_pct"]) == ("80150.0", "0.74") entry = sizing["entry"]
assert (entry["price"], entry["stop"], entry["stop_pct"], entry["quantity"]) == ("80500.0", "80150.0", "0.43", "0.0241")
assert sizing["position_qty"] == "0" assert sizing["position_qty"] == "0"
def test_check_setup_rejects_bad_setups(): def test_check_setup_rejects_bad_setups():
bad_order = setup_plan(setup={"zone_low": "80700", "zone_high": "80200", "confirm_price": "80750", "basis": "x"}) def plan(direction="long", **setup):
assert "须低于上沿" in errors_of(bad_order, context()) return setup_plan(direction=direction, setup={"zone_low": "80200", "zone_high": "80700", "entry_price": "80500", "basis": "x", **setup})
inside = setup_plan(setup={"zone_low": "80200", "zone_high": "80700", "confirm_price": "80500", "basis": "x"})
assert "不低于区间上沿" in errors_of(inside, context()) assert "须低于上沿" in errors_of(plan(zone_low="80700", zone_high="80200"), context())
short_inside = setup_plan(direction="short", setup={"zone_low": "81500", "zone_high": "81700", "confirm_price": "81600", "basis": "x"}) assert "须在区间" in errors_of(plan(entry_price="80800"), context())
assert "不高于区间下沿" in errors_of(short_inside, context()) # 限价单必须挂在现价下方(做空在上方),否则一挂就按市价成交
assert "支撑已破" in errors_of(setup_plan(), context(mark_price=Decimal("80100"))) assert "须低于现价" in errors_of(plan(), context(mark_price=Decimal("80400")))
wide = setup_plan(setup={"zone_low": "79800", "zone_high": "80700", "confirm_price": "80750", "basis": "x"}) assert "须高于现价" in errors_of(plan("short"), context(mark_price=Decimal("80600")))
assert "超过 1%" in errors_of(wide, context()) assert "超过 1%" in errors_of(plan(zone_low="79600"), context())
assert "valid_hours" in errors_of(setup_plan(valid_hours="3"), context()) assert "valid_hours" in errors_of(setup_plan(valid_hours="3"), context())
@@ -183,35 +150,43 @@ def test_action_rules():
# ---------- 应有订单 ---------- # ---------- 应有订单 ----------
TRIGGERED = {"direction": "long", "setup": {}, "entry": {"quantity": "0.0120", "stop": "80036.5"}} LIMIT_PLAN = {"direction": "long", "setup": {}, "entry": {"price": "80500.0", "quantity": "0.0241", "stop": "80150.0"}}
NO_TP = [Decimal(0)] * 3 NO_TP = [Decimal(0)] * 3
def test_desired_orders_places_stop_and_market_entry_once(): def desired(**overrides):
orders = desired_orders(TRIGGERED, entry_sent=False, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0), values = {"entries_open": True, "entry_filled": Decimal(0), "entry_average": None, "tp_filled": NO_TP,
stop_price=Decimal("80036.5"), rules=BTC) "position_qty": Decimal(0), "stop_price": Decimal("80150.0"), "rules": BTC}
assert [(o.purpose, o.side, o.type, o.quantity) for o in orders] == [ return desired_orders(LIMIT_PLAN, **{**values, **overrides})
("entry", "BUY", "MARKET", Decimal("0.0120")), ("stop", "SELL", "STOP_MARKET", Decimal("0.0120")),
]
# 入场单已发出、还没成交:只保留止损,不重发入场
orders = desired_orders(TRIGGERED, entry_sent=True, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0),
stop_price=Decimal("80036.5"), rules=BTC)
assert [o.purpose for o in orders] == ["stop"]
def test_desired_orders_take_profits_follow_fill_price(): def test_desired_orders_places_limit_entry_with_stop():
orders = desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")), assert [(o.purpose, o.side, o.type, o.price, o.trigger_price, o.quantity) for o in desired()] == [
tp_filled=[Decimal("0.0072"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0048"), ("entry", "BUY", "LIMIT", Decimal("80500.0"), None, Decimal("0.0241")),
stop_price=Decimal("80850.0"), rules=BTC) ("stop", "SELL", "STOP_MARKET", None, Decimal("80150.0"), Decimal("0.0241")),
assert [(o.purpose, o.leg, o.price, o.trigger_price, o.quantity) for o in orders] == [
("stop", None, None, Decimal("80850.0"), Decimal("0.0120")),
("tp", 1, Decimal("81416.0"), None, Decimal("0.0036")), ("tp", 2, Decimal("81618.1"), None, Decimal("0.0012")),
] ]
def test_desired_orders_nothing_after_flat(): def test_desired_orders_partial_fill_splits_take_profits_by_filled():
assert desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")), tp_filled=NO_TP, orders = desired(entry_filled=Decimal("0.0100"), entry_average=Decimal("80500"), position_qty=Decimal("0.0100"))
position_qty=Decimal(0), stop_price=Decimal("80036.5"), rules=BTC) == [] # 止盈:80500 × 1.005 = 80902.5,× 1.0075 = 81103.75 → 81103.7,× 1.01 = 81305;0.0100 按 60/30/10 拆
assert [(o.purpose, o.leg, o.price, o.quantity) for o in orders] == [
("entry", 0, Decimal("80500.0"), Decimal("0.0141")), ("stop", None, None, Decimal("0.0241")),
("tp", 0, Decimal("80902.5"), Decimal("0.0060")), ("tp", 1, Decimal("81103.7"), Decimal("0.0030")),
("tp", 2, Decimal("81305.0"), Decimal("0.0010")),
]
def test_desired_orders_after_tp1_keeps_remaining_targets_only():
orders = desired(entries_open=False, entry_filled=Decimal("0.0241"), entry_average=Decimal("80500"),
tp_filled=[Decimal("0.0144"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0097"), stop_price=Decimal("80540.0"))
assert [(o.purpose, o.leg, o.trigger_price, o.quantity) for o in orders] == [
("stop", None, Decimal("80540.0"), Decimal("0.0241")), ("tp", 1, None, Decimal("0.0072")), ("tp", 2, None, Decimal("0.0025")),
]
def test_desired_orders_nothing_when_flat_and_entries_closed():
assert desired(entries_open=False) == []
def test_diff_orders_matches_by_value(): def test_diff_orders_matches_by_value():
+8 -13
View File
@@ -151,8 +151,8 @@ export interface TradeDecision {
reevaluate: PlanCondition[] reevaluate: PlanCondition[]
direction: "long" | "short" | null direction: "long" | "short" | null
confidence: "high" | "medium" | "low" | null confidence: "high" | "medium" | "low" | null
/** 待确认入场;计划 3.1 之前的分析没有这个字段 */ /** 区间与挂单价;更早格式的分析没有这个字段 */
setup?: { zone_low: string, zone_high: string, confirm_price: string, basis: string } | null setup?: { zone_low: string, zone_high: string, entry_price?: string, basis: string } | null
valid_hours?: string | null valid_hours?: string | null
} }
@@ -172,13 +172,11 @@ export interface EntrySizing {
export interface Sizing { export interface Sizing {
direction: "long" | "short" direction: "long" | "short"
setup: { zone_low: string, zone_high: string, confirm_price: string } setup: { zone_low: string, zone_high: string }
/** 提交时按确认价入场、止损放在区间远端外估算 */ /** 限价、止损和数量 */
estimate: EntrySizing entry: EntrySizing
atr: string atr: string
position_qty: string position_qty: string
/** 5m 确认后由执行器写入;有它表示已触发 */
entry?: EntrySizing & { triggered_at: string, bar_close: string, extreme: string, atr: string }
} }
export interface Analysis { export interface Analysis {
@@ -220,7 +218,7 @@ export type PlanStatus = "proposed" | "pending" | "open" | "closing" | "closed"
export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = { export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = {
proposed: "待采纳", proposed: "待采纳",
pending: "等待确认", pending: "挂单中",
open: "持仓中", open: "持仓中",
closing: "平仓中", closing: "平仓中",
closed: "已结束", closed: "已结束",
@@ -256,12 +254,9 @@ export const CLOSE_REASON_LABELS: Record<string, string> = {
stop_failed: "止损挂单失败", stop_failed: "止损挂单失败",
close: "市价平仓", close: "市价平仓",
replaced: "被新计划替换", replaced: "被新计划替换",
expired: "到期未触发", expired: "限价单到期未成交",
stopped: "已停止交易", stopped: "已停止交易",
broken: "区间被突破", entry_failed: "入场单被拒"
missed: "错过确认",
skipped: "止损过远,放弃入场",
entry_failed: "入场失败"
} }
/** 止盈阶梯(与后端 plan.TAKE_PROFIT_STEPS 一致):[距成交均价的涨跌幅, 平仓比例] */ /** 止盈阶梯(与后端 plan.TAKE_PROFIT_STEPS 一致):[距成交均价的涨跌幅, 平仓比例] */
+21 -37
View File
@@ -43,7 +43,7 @@ const long = computed(() => sizing.value?.direction === "long")
const entryRow = computed(() => props.orders.find(o => o.purpose === "entry" && Number(o.filled_qty) > 0)) const entryRow = computed(() => props.orders.find(o => o.purpose === "entry" && Number(o.filled_qty) > 0))
/** 交易所上生效的止损:止盈 1 后会移到保本价 */ /** 交易所上生效的止损:止盈 1 后会移到保本价 */
const currentStop = computed(() => props.orders.filter(o => o.purpose === "stop" && ACTIVE_ORDER_STATUSES.includes(o.status)).at(-1)?.trigger_price) const currentStop = computed(() => props.orders.filter(o => o.purpose === "stop" && ACTIVE_ORDER_STATUSES.includes(o.status)).at(-1)?.trigger_price)
const ladder = computed(() => sizing.value?.entry const ladder = computed(() => sizing.value?.entry && entryRow.value
? TAKE_PROFIT_STEPS[sizing.value.direction].map(([pct, ratio], leg) => ({ ? TAKE_PROFIT_STEPS[sizing.value.direction].map(([pct, ratio], leg) => ({
leg, leg,
move: `${long.value ? "+" : "−"}${pct * 100}%`, move: `${long.value ? "+" : "−"}${pct * 100}%`,
@@ -58,7 +58,7 @@ const ladder = computed(() => sizing.value?.entry
<div class="border border-[var(--el-border-color)] rounded border-solid p-3"> <div class="border border-[var(--el-border-color)] rounded border-solid p-3">
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2">
<span class="font-bold">{{ symbol.symbol }}</span> <span class="font-bold">{{ symbol.symbol }}</span>
<span class="text-xs text-[var(--el-text-color-secondary)]">短线做波动(5m 确认入场,最长持仓 4 小时)</span> <span class="text-xs text-[var(--el-text-color-secondary)]">短线做波动(区间内挂限价单,最长持仓 4 小时)</span>
<div class="flex-1" /> <div class="flex-1" />
<el-button v-if="analysis && analysis.status !== 'running'" link type="primary" @click="emit('detail', analysis.id)"> <el-button v-if="analysis && analysis.status !== 'running'" link type="primary" @click="emit('detail', analysis.id)">
查看完整分析 查看完整分析
@@ -116,7 +116,7 @@ const ladder = computed(() => sizing.value?.entry
</el-link> </el-link>
</div> </div>
<div v-if="analysis.valid_until || analysis.close_deadline" class="flex flex-wrap gap-x-6"> <div v-if="analysis.valid_until || analysis.close_deadline" class="flex flex-wrap gap-x-6">
<span v-if="analysis.plan_status === 'pending' && analysis.valid_until && !sizing?.entry"><span class="label">等待确认至</span>{{ formatTime(analysis.valid_until, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.valid_until) }})</span> <span v-if="analysis.plan_status === 'pending' && analysis.valid_until"><span class="label">限价单有效至</span>{{ formatTime(analysis.valid_until, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.valid_until) }})</span>
<span v-if="analysis.first_fill_at"><span class="label">入场</span>{{ formatTime(analysis.first_fill_at, "MM-DD HH:mm") }}</span> <span v-if="analysis.first_fill_at"><span class="label">入场</span>{{ formatTime(analysis.first_fill_at, "MM-DD HH:mm") }}</span>
<span v-if="analysis.close_deadline"><span class="label">最晚平仓</span>{{ formatTime(analysis.close_deadline, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.close_deadline) }})</span> <span v-if="analysis.close_deadline"><span class="label">最晚平仓</span>{{ formatTime(analysis.close_deadline, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.close_deadline) }})</span>
</div> </div>
@@ -125,49 +125,33 @@ const ladder = computed(() => sizing.value?.entry
</div> </div>
<div><span class="label">为什么</span>{{ decision.reasons }}</div> <div><span class="label">为什么</span>{{ decision.reasons }}</div>
<template v-if="decision.action === 'plan' && decision.setup && sizing?.setup"> <template v-if="decision.action === 'plan' && decision.setup && sizing?.entry">
<div class="grid grid-cols-2 gap-x-6 gap-y-1"> <div class="grid grid-cols-2 gap-x-6 gap-y-1">
<div> <div>
<span class="label">{{ long ? "支撑区" : "压力区" }}</span>{{ sizing.setup.zone_low }} – {{ sizing.setup.zone_high }} · {{ decision.setup.basis }} <span class="label">{{ long ? "支撑区" : "压力区" }}</span>{{ sizing.setup.zone_low }} – {{ sizing.setup.zone_high }} · {{ decision.setup.basis }}
</div> </div>
<div> <div>
<span class="label">确认</span>5m {{ long ? "最低价进区后收盘 ≥" : "最高价进区后收盘 ≤" }} {{ sizing.setup.confirm_price }} → 市价{{ long ? "做多" : "做空" }}; <span class="label">限价{{ long ? "做多" : "做空" }}</span>{{ sizing.entry.price }} × {{ sizing.entry.quantity }},已成交 {{ filled("entry", 0) }}{{ entryRow?.avg_price ? `,均价 ${entryRow.avg_price}` : "" }}
先有收盘{{ long ? "低于" : "高于" }} {{ long ? sizing.setup.zone_low : sizing.setup.zone_high }} 则撤销 (名义 {{ sizing.entry.notional }} USDT,{{ sizing.entry.leverage }} 倍交易资金)
</div> </div>
<div v-if="!sizing.entry"> <div>
<span class="label">预估</span>按确认价入场:止损 {{ sizing.estimate.stop }}({{ sizing.estimate.stop_pct }}%),数量 {{ sizing.estimate.quantity }},止损亏损 {{ sizing.estimate.risk_at_stop }} USDT <span class="label">止损</span>{{ currentStop ?? sizing.entry.stop }}(初始 {{ sizing.entry.stop }},距挂单价 {{ sizing.entry.stop_pct }}%;区间远端外 0.5×ATR(5m),止盈 1 后移到保本价)
</div> </div>
<div v-if="!sizing.entry"> <div>
<span class="label">有效期</span>{{ decision.valid_hours }} 小时;止损按触发时的极值外 0.5×ATR(5m) 计算,超过 1% 放弃入场 <span class="label">止损亏损</span>{{ sizing.entry.risk_at_stop }} USDT(交易资金的 {{ sizing.entry.risk_pct }}%){{ sizing.entry.capped_by_leverage ? ",受 3 倍上限限制" : "" }}
</div> </div>
</div> </div>
<template v-if="sizing.entry"> <el-table v-if="ladder.length" :data="ladder" border size="small">
<div class="grid grid-cols-2 gap-x-6 gap-y-1"> <el-table-column label="分批止盈" min-width="90">
<div> <template #default="{ row }">
<span class="label">触发</span>{{ formatTime(sizing.entry.triggered_at, "MM-DD HH:mm:ss") }},5m 收盘 {{ sizing.entry.bar_close }},{{ long ? "最低" : "最高" }} {{ sizing.entry.extreme }},ATR {{ sizing.entry.atr }} 止盈 {{ row.leg + 1 }}
</div> </template>
<div> </el-table-column>
<span class="label">入场</span>市价 {{ sizing.entry.quantity }},成交均价 {{ entryRow?.avg_price ?? "待成交" }}(名义 {{ sizing.entry.notional }} USDT,{{ sizing.entry.leverage }} 倍交易资金) <el-table-column prop="move" label="距成交均价" min-width="90" />
</div> <el-table-column prop="ratio" label="平仓比例" min-width="80" />
<div> <el-table-column prop="price" label="价格" min-width="100" />
<span class="label">止损</span>{{ currentStop ?? sizing.entry.stop }}(初始 {{ sizing.entry.stop }},{{ sizing.entry.stop_pct }}%;止盈 1 后移到保本价) <el-table-column prop="filled" label="已成交" min-width="80" />
</div> </el-table>
<div>
<span class="label">止损亏损</span>{{ sizing.entry.risk_at_stop }} USDT(交易资金的 {{ sizing.entry.risk_pct }}%){{ sizing.entry.capped_by_leverage ? ",受 3 倍上限限制" : "" }}
</div>
</div>
<el-table :data="ladder" border size="small">
<el-table-column label="分批止盈" min-width="90">
<template #default="{ row }">
止盈 {{ row.leg + 1 }}
</template>
</el-table-column>
<el-table-column prop="move" label="距成交均价" min-width="90" />
<el-table-column prop="ratio" label="平仓比例" min-width="80" />
<el-table-column prop="price" label="价格" min-width="100" />
<el-table-column prop="filled" label="已成交" min-width="80" />
</el-table>
</template>
</template> </template>
<div v-if="decision.reevaluate.length"> <div v-if="decision.reevaluate.length">
+2 -2
View File
@@ -87,7 +87,7 @@ function symbolState(s: TradingSymbol) {
if (positions.value.some(p => p.symbol === s.symbol)) if (positions.value.some(p => p.symbol === s.symbol))
return { label: "持仓中", type: "success" } as const return { label: "持仓中", type: "success" } as const
if (s.plan_status === "pending") if (s.plan_status === "pending")
return { label: "等待确认", type: "primary" } as const return { label: "挂单中", type: "primary" } as const
return { label: "空仓", type: "info" } as const return { label: "空仓", type: "info" } as const
} }
@@ -170,7 +170,7 @@ watch(selected, () => {
async function toggleRunning() { async function toggleRunning() {
if (status.value!.running) { if (status.value!.running) {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"停止后不再做新的分析,并撤销还在等待确认的计划。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。", "停止后不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。",
"停止行情交易", "停止行情交易",
{ type: "warning", confirmButtonText: "停止" } { type: "warning", confirmButtonText: "停止" }
) )