Add per-symbol close position and clear orders actions to demo trading
The API queues the request in Redis and the executor applies it on its next pass, so it never races reconciliation. Both actions cancel every open order on the symbol, including manual ones, and market-close any position. Plans end as manual_close or manual_cancel without queueing a new analysis. Closing also stops trading so the engine does not reopen right away.
This commit is contained in:
1 parent
ac1e278b3b
commit
e3242f2b7a
4 files changed
+106
-8
No files matched your search
@@ -19,6 +19,7 @@ from alphapulse.core.db import get_session
|
|||||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||||
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
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.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.positions import build_positions
|
||||||
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
|
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
|
||||||
from alphapulse.trading.snapshot import read_snapshot
|
from alphapulse.trading.snapshot import read_snapshot
|
||||||
@@ -29,6 +30,8 @@ SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols"
|
|||||||
SYMBOLS_CACHE_SECONDS = 3600
|
SYMBOLS_CACHE_SECONDS = 3600
|
||||||
# 删除前要求快照足够新,否则无法确认没有仓位和挂单
|
# 删除前要求快照足够新,否则无法确认没有仓位和挂单
|
||||||
SNAPSHOT_FRESH_SECONDS = 30
|
SNAPSHOT_FRESH_SECONDS = 30
|
||||||
|
# 平仓、清除挂单的请求等执行器取走的时限:引擎没及时处理就作废,不在之后意外执行
|
||||||
|
MANUAL_ACTION_TTL_SECONDS = 30
|
||||||
# 执行器正在管理的计划状态
|
# 执行器正在管理的计划状态
|
||||||
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
|
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
|
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:
|
async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol:
|
||||||
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除"""
|
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除"""
|
||||||
s = await session.get(TradingSymbol, symbol)
|
s = await session.get(TradingSymbol, symbol)
|
||||||
@@ -323,10 +335,7 @@ async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> Tradin
|
|||||||
))
|
))
|
||||||
if busy:
|
if busy:
|
||||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}")
|
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}")
|
||||||
snapshot = await read_snapshot()
|
snapshot = await fresh_snapshot(symbol, action)
|
||||||
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}")
|
|
||||||
if any(p["symbol"] == symbol for p in snapshot["positions"]):
|
if any(p["symbol"] == symbol for p in snapshot["positions"]):
|
||||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有持仓,不能{action}")
|
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"]):
|
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()
|
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")
|
@router.get("/events")
|
||||||
async def list_events(
|
async def list_events(
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from alphapulse.ai.tools import LOCAL_TZ
|
from alphapulse.ai.tools import LOCAL_TZ
|
||||||
from alphapulse.core.crypto import decrypt
|
from alphapulse.core.crypto import decrypt
|
||||||
from alphapulse.core.db import SessionLocal
|
from alphapulse.core.db import SessionLocal
|
||||||
|
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||||
from alphapulse.market.binance import BinanceClient
|
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
|
||||||
@@ -46,7 +47,11 @@ ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED"
|
|||||||
"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": "市价平仓",
|
||||||
|
"manual_close": "一键平仓", "manual_cancel": "清除挂单"}
|
||||||
|
# 页面上的「平仓」「清除挂单」:API 写入请求,执行器下一轮取走执行,避免和对账同时改计划
|
||||||
|
MANUAL_ACTION_KEY = f"{KEY_PREFIX}trader:manual:{{}}"
|
||||||
|
MANUAL_ACTIONS = {"close": "一键平仓", "cancel": "清除挂单"}
|
||||||
CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒"}
|
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
|
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})}
|
||||||
@@ -359,6 +365,9 @@ class Executor:
|
|||||||
)
|
)
|
||||||
plan = await self.adopt(session, client, symbol, plan, position)
|
plan = await self.adopt(session, client, symbol, plan, position)
|
||||||
await session.commit()
|
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)]
|
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 self.check_reevaluate(session, name, position.mark if position else None)
|
||||||
await session.commit()
|
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:
|
async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None:
|
||||||
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
|
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
|
||||||
latest = await session.scalar(
|
latest = await session.scalar(
|
||||||
@@ -564,7 +598,8 @@ class Executor:
|
|||||||
logger.warning("同步成交记录失败,%ds 后重试", FILLS_SYNC_SECONDS, exc_info=True)
|
logger.warning("同步成交记录失败,%ds 后重试", FILLS_SYNC_SECONDS, exc_info=True)
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
try:
|
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:
|
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}")
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ export const CLOSE_REASON_LABELS: Record<string, string> = {
|
|||||||
external: "外部平仓",
|
external: "外部平仓",
|
||||||
stop_failed: "止损挂单失败",
|
stop_failed: "止损挂单失败",
|
||||||
close: "市价平仓",
|
close: "市价平仓",
|
||||||
|
manual_close: "一键平仓",
|
||||||
|
manual_cancel: "清除挂单",
|
||||||
// 计划 3.1 之前的旧原因,历史仓位里还会出现
|
// 计划 3.1 之前的旧原因,历史仓位里还会出现
|
||||||
invalidation: "短线失效条件触发",
|
invalidation: "短线失效条件触发",
|
||||||
replaced: "被新计划替换",
|
replaced: "被新计划替换",
|
||||||
@@ -319,6 +321,11 @@ export function requestAnalysis(symbol: string): Promise<void> {
|
|||||||
return http.post(`/trading/symbols/${symbol}/analyze`)
|
return http.post(`/trading/symbols/${symbol}/analyze`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一键平仓(同时停止行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */
|
||||||
|
export function manualAction(symbol: string, action: "close" | "cancel"): Promise<void> {
|
||||||
|
return http.post(`/trading/symbols/${symbol}/manual/${action}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function listAnalyses(params: { symbol?: string, limit?: number }): Promise<Analysis[]> {
|
export function listAnalyses(params: { symbol?: string, limit?: number }): Promise<Analysis[]> {
|
||||||
return http.get("/trading/analyses", { params })
|
return http.get("/trading/analyses", { params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Effort, Provider } from "@/apis/settings"
|
|||||||
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
||||||
import { Clock, Edit, Plus } from "@element-plus/icons-vue"
|
import { Clock, Edit, Plus } from "@element-plus/icons-vue"
|
||||||
import { EFFORTS, listProviders } from "@/apis/settings"
|
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 { usePersisted } from "@/utils/storage"
|
||||||
import { formatTime } from "@/utils/text"
|
import { formatTime } from "@/utils/text"
|
||||||
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
||||||
@@ -231,6 +231,27 @@ async function changeEffort(effort: Effort | null) {
|
|||||||
status.value = await patchConfig({ reasoning_effort: effort })
|
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) {
|
async function remove(s: TradingSymbol) {
|
||||||
await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
|
await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
|
||||||
await deleteSymbol(s.symbol)
|
await deleteSymbol(s.symbol)
|
||||||
@@ -454,6 +475,14 @@ onBeforeUnmount(() => clearInterval(timer))
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="symbols.some(s => s.symbol === row.symbol)" link type="danger" @click="closePosition(row.symbol)">
|
||||||
|
平仓
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="font-sans text-slate-400" title="不在交易列表中,程序不管理,请到币安网页处理">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -508,7 +537,12 @@ onBeforeUnmount(() => clearInterval(timer))
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<el-card v-if="current" shadow="never" body-class="flex flex-col gap-2">
|
<el-card v-if="current" shadow="never" body-class="flex flex-col gap-2">
|
||||||
<template #header>
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
当前挂单({{ symbolOrders.length }})
|
当前挂单({{ symbolOrders.length }})
|
||||||
|
<el-button link type="danger" :disabled="!symbolOrders.length" @click="clearOrders">
|
||||||
|
清除挂单
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
v-for="o in symbolOrders" :key="`${o.client_id}-${o.time}`"
|
v-for="o in symbolOrders" :key="`${o.client_id}-${o.time}`"
|
||||||
|
|||||||
Reference in new issue
Block a user