diff --git a/backend/src/alphapulse/api/trading.py b/backend/src/alphapulse/api/trading.py index 966a452..490ef3b 100644 --- a/backend/src/alphapulse/api/trading.py +++ b/backend/src/alphapulse/api/trading.py @@ -19,6 +19,7 @@ from alphapulse.core.db import get_session from alphapulse.core.redis import KEY_PREFIX, redis from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get +from alphapulse.trading.executor import MANUAL_ACTION_KEY from alphapulse.trading.positions import build_positions from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT from alphapulse.trading.snapshot import read_snapshot @@ -29,6 +30,8 @@ SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols" SYMBOLS_CACHE_SECONDS = 3600 # 删除前要求快照足够新,否则无法确认没有仓位和挂单 SNAPSHOT_FRESH_SECONDS = 30 +# 平仓、清除挂单的请求等执行器取走的时限:引擎没及时处理就作废,不在之后意外执行 +MANUAL_ACTION_TTL_SECONDS = 30 # 执行器正在管理的计划状态 LIVE_PLAN_STATUSES = ("pending", "open", "closing") @@ -312,6 +315,15 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在交易列表中") from None +async def fresh_snapshot(symbol: str, action: str) -> dict: + """引擎在线且刚和币安同步过的快照""" + snapshot = await read_snapshot() + synced_at = snapshot and snapshot["synced_at"] and datetime.fromisoformat(snapshot["synced_at"]) + if not synced_at or datetime.now(LOCAL_TZ) - synced_at > timedelta(seconds=SNAPSHOT_FRESH_SECONDS): + raise HTTPException(status.HTTP_409_CONFLICT, f"无法确认 {symbol} 的仓位和挂单(引擎离线或连接币安失败),暂时不能{action}") + return snapshot + + async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol: """只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除""" s = await session.get(TradingSymbol, symbol) @@ -323,10 +335,7 @@ async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> Tradin )) if busy: raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}") - snapshot = await read_snapshot() - synced_at = snapshot and snapshot["synced_at"] and datetime.fromisoformat(snapshot["synced_at"]) - if not synced_at or datetime.now(LOCAL_TZ) - synced_at > timedelta(seconds=SNAPSHOT_FRESH_SECONDS): - raise HTTPException(status.HTTP_409_CONFLICT, f"无法确认 {symbol} 的仓位和挂单(引擎离线或连接币安失败),暂时不能{action}") + snapshot = await fresh_snapshot(symbol, action) if any(p["symbol"] == symbol for p in snapshot["positions"]): raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有持仓,不能{action}") if any(o["symbol"] == symbol and OWN_CLIENT_ID.match(o["client_id"]) for o in snapshot["orders"] + snapshot["algo_orders"]): @@ -343,6 +352,19 @@ async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(ge await session.commit() +@router.post("/symbols/{symbol}/manual/{action}", status_code=status.HTTP_202_ACCEPTED) +async def manual_action(symbol: str, action: Literal["close", "cancel"], session: Annotated[AsyncSession, Depends(get_session)]) -> None: + """一键平仓(同时停止行情交易)、清除挂单:交给执行器执行,结果写入时间线""" + label = "平仓" if action == "close" else "清除挂单" + if await session.get(TradingSymbol, symbol) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") + await fresh_snapshot(symbol, label) + if action == "close": + # 不停止的话,平仓后会重新分析,可能马上又开仓 + await set_running(session, False) + await redis.set(MANUAL_ACTION_KEY.format(symbol), action, ex=MANUAL_ACTION_TTL_SECONDS) + + @router.get("/events") async def list_events( session: Annotated[AsyncSession, Depends(get_session)], diff --git a/backend/src/alphapulse/trading/executor.py b/backend/src/alphapulse/trading/executor.py index e1ab895..314ef87 100644 --- a/backend/src/alphapulse/trading/executor.py +++ b/backend/src/alphapulse/trading/executor.py @@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from alphapulse.ai.tools import LOCAL_TZ from alphapulse.core.crypto import decrypt from alphapulse.core.db import SessionLocal +from alphapulse.core.redis import KEY_PREFIX, redis from alphapulse.market.binance import BinanceClient from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules @@ -46,7 +47,11 @@ ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED" "EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"} PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"} CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束", - "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓"} + "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓", + "manual_close": "一键平仓", "manual_cancel": "清除挂单"} +# 页面上的「平仓」「清除挂单」:API 写入请求,执行器下一轮取走执行,避免和对账同时改计划 +MANUAL_ACTION_KEY = f"{KEY_PREFIX}trader:manual:{{}}" +MANUAL_ACTIONS = {"close": "一键平仓", "cancel": "清除挂单"} CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒"} @@ -333,7 +338,8 @@ class Executor: # ---------- 对账 ---------- - async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None) -> None: + async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None, + manual_action: str | None) -> None: name = symbol.symbol rules = await symbol_rules(name) open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})} @@ -359,6 +365,9 @@ class Executor: ) plan = await self.adopt(session, client, symbol, plan, position) await session.commit() + if manual_action is not None: + await self.manual(session, client, name, manual_action, plan, position, open_orders, open_algos) + await session.commit() # 不属于当前计划的活动订单(被替换、已结束的计划):入场和止盈先撤;止损等新止损挂上后再撤,不留无保护窗口 stale = [r for r in rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose != "close" and (plan is None or r.analysis_id != plan.id)] @@ -428,6 +437,31 @@ class Executor: await self.check_reevaluate(session, name, position.mark if position else None) await session.commit() + async def manual(self, session: AsyncSession, client: DemoClient, symbol: str, action: str, plan: TradingAnalysis | None, + position: Position | None, open_orders: dict, open_algos: dict) -> None: + """一键平仓、清除挂单:撤掉该币全部挂单(含止损止盈和手动挂的),有持仓就市价平仓——程序不留没有止损的仓位。 + 本程序的单通过结束计划撤销,否则下一轮对账会按计划补挂回来。撤销计划不像到期那样排队重新分析,免得马上又挂单""" + reason = f"manual_{action}" + label = MANUAL_ACTIONS[action] + for client_id in [*open_orders, *open_algos]: + if not OWN_CLIENT_ID.match(client_id): + await self.cancel_client_id(client, symbol, client_id, client_id in open_algos) + detail = f",市价平仓 {fmt(position.qty)}" if position else "" + session.add(TradingEvent(symbol=symbol, analysis_id=plan.id if plan else None, kind=reason, level="warn", + message=f"{label}:撤销全部挂单{detail}")) + if plan is not None and plan.plan_status in ("pending", "open") and position is not None: + # 平仓中的计划:撤入场和止盈、市价平仓,持仓归零后结束并撤掉止损 + plan.plan_status, plan.close_reason = "closing", reason + elif plan is not None and plan.plan_status == "pending": + plan.plan_status, plan.close_reason, plan.closed_at = "canceled", reason, utcnow() + elif plan is None and position is not None: + closing = await session.scalar(select(func.count()).where( + TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES), + )) + if not closing: + close_side = "SELL" if position.side == "long" else "BUY" + await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty)) + async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None: """最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析""" latest = await session.scalar( @@ -564,7 +598,8 @@ class Executor: logger.warning("同步成交记录失败,%ds 后重试", FILLS_SYNC_SECONDS, exc_info=True) for symbol in symbols: try: - await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol)) + manual_action = await redis.getdel(MANUAL_ACTION_KEY.format(symbol.symbol)) + await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol), manual_action) except ExchangeError as e: await session.rollback() await self.report(session, symbol.symbol, f"对账失败:{e}") diff --git a/web/src/apis/trading.ts b/web/src/apis/trading.ts index fed5cf8..48d5046 100644 --- a/web/src/apis/trading.ts +++ b/web/src/apis/trading.ts @@ -253,6 +253,8 @@ export const CLOSE_REASON_LABELS: Record = { external: "外部平仓", stop_failed: "止损挂单失败", close: "市价平仓", + manual_close: "一键平仓", + manual_cancel: "清除挂单", // 计划 3.1 之前的旧原因,历史仓位里还会出现 invalidation: "短线失效条件触发", replaced: "被新计划替换", @@ -319,6 +321,11 @@ export function requestAnalysis(symbol: string): Promise { return http.post(`/trading/symbols/${symbol}/analyze`) } +/** 一键平仓(同时停止行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */ +export function manualAction(symbol: string, action: "close" | "cancel"): Promise { + return http.post(`/trading/symbols/${symbol}/manual/${action}`) +} + export function listAnalyses(params: { symbol?: string, limit?: number }): Promise { return http.get("/trading/analyses", { params }) } diff --git a/web/src/pages/trading/index.vue b/web/src/pages/trading/index.vue index 221ecfd..19fb83d 100644 --- a/web/src/pages/trading/index.vue +++ b/web/src/pages/trading/index.vue @@ -3,7 +3,7 @@ import type { Effort, Provider } from "@/apis/settings" import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading" import { Clock, Edit, Plus } from "@element-plus/icons-vue" import { EFFORTS, listProviders } from "@/apis/settings" -import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, requestAnalysis, startTrading, stopTrading } from "@/apis/trading" +import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, manualAction, patchConfig, requestAnalysis, startTrading, stopTrading } from "@/apis/trading" import { usePersisted } from "@/utils/storage" import { formatTime } from "@/utils/text" import AddSymbolDialog from "./components/AddSymbolDialog.vue" @@ -231,6 +231,27 @@ async function changeEffort(effort: Effort | null) { status.value = await patchConfig({ reasoning_effort: effort }) } +async function closePosition(symbol: string) { + await ElMessageBox.confirm( + `撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止行情交易(所有币不再做新的分析)。`, + "一键平仓", + { type: "warning", confirmButtonText: "平仓" } + ) + await manualAction(symbol, "close") + ElMessage.success("已提交,几秒内执行,结果见时间线") + await refresh() +} + +async function clearOrders() { + await ElMessageBox.confirm( + `撤销 ${selected.value} 的全部挂单,包括止损、止盈和手动挂的单。有持仓时会一并市价平仓,程序不保留没有止损的仓位。行情交易不停止。`, + "清除挂单", + { type: "warning", confirmButtonText: "清除" } + ) + await manualAction(selected.value, "cancel") + ElMessage.success("已提交,几秒内执行,结果见时间线") +} + async function remove(s: TradingSymbol) { await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" }) await deleteSymbol(s.symbol) @@ -454,6 +475,14 @@ onBeforeUnmount(() => clearInterval(timer)) + + + @@ -508,7 +537,12 @@ onBeforeUnmount(() => clearInterval(timer))