中控增加条件单委托
This commit is contained in:
@@ -35,6 +35,8 @@ from exchange_orders import (
|
||||
cancel_order as hub_cancel_order,
|
||||
cancel_orders_for_symbol,
|
||||
list_open_orders,
|
||||
replace_position_tpsl,
|
||||
symbols_match,
|
||||
)
|
||||
|
||||
HOST = os.getenv("HOST", "127.0.0.1")
|
||||
@@ -299,6 +301,14 @@ class CancelSymbolOrdersBody(BaseModel):
|
||||
scope: str = "all" # all | conditional | limit
|
||||
|
||||
|
||||
class PlaceTpslBody(BaseModel):
|
||||
symbol: str
|
||||
side: str # long | short
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
contracts: float | None = None
|
||||
|
||||
|
||||
def _close_position_market(
|
||||
ex: Any, sym: str, side: str, contracts: float
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
@@ -629,6 +639,58 @@ def cancel_symbol_orders(
|
||||
)
|
||||
|
||||
|
||||
@app.post("/orders/place-tpsl")
|
||||
def place_tpsl_orders(
|
||||
body: PlaceTpslBody,
|
||||
x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
|
||||
):
|
||||
"""先撤该合约全部条件单,再挂止盈+止损(与四实例策略逻辑一致)。"""
|
||||
_check_token(x_control_token)
|
||||
sym = (body.symbol or "").strip()
|
||||
side = (body.side or "").strip().lower()
|
||||
if not sym or side not in ("long", "short"):
|
||||
raise HTTPException(status_code=400, detail="symbol 与 side(long/short) 必填")
|
||||
try:
|
||||
sl = float(body.stop_loss)
|
||||
tp = float(body.take_profit)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail="stop_loss / take_profit 须为数字") from e
|
||||
try:
|
||||
ex = get_exchange()
|
||||
_ensure_markets()
|
||||
amt = body.contracts
|
||||
if amt is None or float(amt) <= 0:
|
||||
raw = ex.fetch_positions() or []
|
||||
found = None
|
||||
for p in raw:
|
||||
psym = p.get("symbol") or ""
|
||||
if not symbols_match(sym, psym):
|
||||
continue
|
||||
c = abs(float(p.get("contracts") or 0))
|
||||
if c <= 0:
|
||||
continue
|
||||
ps = (p.get("side") or "").lower()
|
||||
if ps and ps != side:
|
||||
continue
|
||||
found = c
|
||||
break
|
||||
if found is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"未找到持仓 {sym} {side}", "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
amt = found
|
||||
info = replace_position_tpsl(ex, EXCHANGE_KIND, sym, side, float(amt), sl, tp)
|
||||
return {"ok": True, "exchange": EXCHANGE_KIND, "placed": info}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": str(e), "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/emergency/close-all")
|
||||
def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
|
||||
_check_token(x_control_token)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销。
|
||||
中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销;挂止盈止损(先撤条件单再挂)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -353,3 +355,281 @@ def cancel_orders_for_symbol(
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
|
||||
|
||||
def _binance_cancel_algo_open(ex: Any, symbol: str) -> None:
|
||||
try:
|
||||
market = ex.market(symbol)
|
||||
cid = market.get("id")
|
||||
if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"):
|
||||
ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _binance_trigger_params() -> dict[str, Any]:
|
||||
wt = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper()
|
||||
if wt not in ("CONTRACT_PRICE", "MARK_PRICE"):
|
||||
wt = "CONTRACT_PRICE"
|
||||
return {"workingType": wt}
|
||||
|
||||
|
||||
def _binance_place_tp_sl(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
position_mode: str = "hedge",
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
market = ex.market(symbol)
|
||||
if not market.get("swap"):
|
||||
raise RuntimeError("仅支持永续合约")
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
amt = float(ex.amount_to_precision(symbol, float(amount)))
|
||||
if amt <= 0:
|
||||
raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
|
||||
sl_px = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_px = ex.price_to_precision(symbol, float(take_profit))
|
||||
common = dict(_binance_trigger_params())
|
||||
if (position_mode or "hedge").lower() in ("hedge", "dual", "double", "hedged"):
|
||||
common["positionSide"] = "LONG" if direction == "long" else "SHORT"
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.create_order(
|
||||
symbol, "STOP_MARKET", close_side, amt, None, dict(common, stopPrice=sl_px)
|
||||
)
|
||||
time.sleep(0.05)
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"TAKE_PROFIT_MARKET",
|
||||
close_side,
|
||||
amt,
|
||||
None,
|
||||
dict(common, stopPrice=tp_px),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
cancel_orders_for_symbol(ex, "binance", symbol, scope="conditional")
|
||||
_binance_cancel_algo_open(ex, symbol)
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"Binance 未接受止盈/止损:{last_err}")
|
||||
|
||||
|
||||
def _okx_order_params(direction: str, *, reduce_only: bool, pos_mode: str, td_mode: str) -> dict:
|
||||
params: dict[str, Any] = {"tdMode": td_mode or "cross"}
|
||||
if (pos_mode or "hedge").lower() in ("hedge", "long_short_mode", "dual"):
|
||||
params["posSide"] = "long" if direction == "long" else "short"
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = True
|
||||
return params
|
||||
|
||||
|
||||
def _okx_place_tp_sl(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
pos_mode: str = "hedge",
|
||||
td_mode: str = "cross",
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
amt = float(ex.amount_to_precision(symbol, float(amount)))
|
||||
if amt <= 0:
|
||||
raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
|
||||
params = _okx_order_params(direction, reduce_only=True, pos_mode=pos_mode, td_mode=td_mode)
|
||||
sl_s = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_s = ex.price_to_precision(symbol, float(take_profit))
|
||||
params["stopLoss"] = {"triggerPrice": sl_s, "type": "market"}
|
||||
params["takeProfit"] = {"triggerPrice": tp_s, "type": "market"}
|
||||
ex.create_order(symbol, "market", close_side, amt, None, params)
|
||||
|
||||
|
||||
def _gate_tpsl_env() -> tuple[bool, int, int, str]:
|
||||
use_pos = (os.getenv("GATE_TPSL_USE_POSITION_ORDER") or "true").lower() in ("1", "true", "yes")
|
||||
exp = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400)))
|
||||
pt = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0"))
|
||||
if pt < 0 or pt > 2:
|
||||
pt = 0
|
||||
pos_mode = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
|
||||
return use_pos, exp, pt, pos_mode
|
||||
|
||||
|
||||
def _gate_place_tp_sl_position(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
pos_mode: str,
|
||||
price_type: int,
|
||||
expiration: int,
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
market = ex.market(symbol)
|
||||
if not market.get("swap"):
|
||||
raise RuntimeError("仅支持永续合约")
|
||||
settle = market["settleId"]
|
||||
contract = market["id"]
|
||||
order_type = "close-long-position" if direction == "long" else "close-short-position"
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
sl_rule, tp_rule = (2, 1) if close_side == "sell" else (1, 2)
|
||||
initial: dict[str, Any] = {
|
||||
"contract": contract,
|
||||
"size": 0,
|
||||
"price": "0",
|
||||
"close": True,
|
||||
"reduce_only": True,
|
||||
"tif": "ioc",
|
||||
"text": "api",
|
||||
}
|
||||
if pos_mode in ("hedge", "dual", "double"):
|
||||
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
|
||||
sl_s = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_s = ex.price_to_precision(symbol, float(take_profit))
|
||||
|
||||
def _payload(trigger_price: str, rule: int) -> dict:
|
||||
trig: dict[str, Any] = {
|
||||
"strategy_type": 0,
|
||||
"price_type": price_type,
|
||||
"price": trigger_price,
|
||||
"rule": rule,
|
||||
}
|
||||
if expiration > 0:
|
||||
trig["expiration"] = expiration
|
||||
return {
|
||||
"settle": settle,
|
||||
"initial": dict(initial),
|
||||
"trigger": trig,
|
||||
"order_type": order_type,
|
||||
}
|
||||
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
|
||||
try:
|
||||
ex.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule))
|
||||
except Exception:
|
||||
cancel_orders_for_symbol(ex, "gate", symbol, scope="conditional")
|
||||
raise
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"Gate 仓位类止盈/止损未接受:{last_err}")
|
||||
|
||||
|
||||
def _gate_place_tp_sl_legacy(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
base = {"reduceOnly": True}
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amount,
|
||||
None,
|
||||
dict(base, stopLossPrice=float(stop_loss)),
|
||||
)
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amount,
|
||||
None,
|
||||
dict(base, takeProfitPrice=float(take_profit)),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"Gate 条件止盈/止损未接受:{last_err}")
|
||||
|
||||
|
||||
def _gate_place_tp_sl(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> None:
|
||||
use_pos, exp, pt, pos_mode = _gate_tpsl_env()
|
||||
if use_pos:
|
||||
try:
|
||||
_gate_place_tp_sl_position(
|
||||
ex, symbol, direction, stop_loss, take_profit,
|
||||
pos_mode=pos_mode, price_type=pt, expiration=exp,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_gate_place_tp_sl_legacy(ex, symbol, direction, amount, stop_loss, take_profit)
|
||||
|
||||
|
||||
def replace_position_tpsl(
|
||||
ex: Any,
|
||||
exchange_kind: str,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
先撤销该合约全部条件单,再挂止盈+止损。与四实例策略页逻辑对齐(读各目录 .env 中 GATE_/BINANCE_/OKX_ 参数)。
|
||||
"""
|
||||
kind = (exchange_kind or "binance").lower()
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
raise ValueError("direction 须为 long 或 short")
|
||||
sl = float(stop_loss)
|
||||
tp = float(take_profit)
|
||||
if sl <= 0 or tp <= 0:
|
||||
raise ValueError("止损、止盈价格须大于 0")
|
||||
ex.load_markets()
|
||||
cancelled = cancel_orders_for_symbol(ex, kind, symbol, scope="conditional")
|
||||
if kind == "binance":
|
||||
_binance_cancel_algo_open(ex, symbol)
|
||||
time.sleep(0.08)
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
raise ValueError("持仓数量无效")
|
||||
if kind == "binance":
|
||||
pm = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
|
||||
_binance_place_tp_sl(ex, symbol, direction, amt, sl, tp, position_mode=pm)
|
||||
elif kind == "okx":
|
||||
pm = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower()
|
||||
td = (os.getenv("OKX_TD_MODE") or "cross").strip()
|
||||
_okx_place_tp_sl(ex, symbol, direction, amt, sl, tp, pos_mode=pm, td_mode=td)
|
||||
else:
|
||||
_gate_place_tp_sl(ex, symbol, direction, amt, sl, tp)
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"amount": amt,
|
||||
"stop_loss": sl,
|
||||
"take_profit": tp,
|
||||
"cancelled_conditional": cancelled,
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN")
|
||||
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
|
||||
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
||||
DIR = Path(__file__).resolve().parent
|
||||
HUB_BUILD = "20260524-open-orders"
|
||||
HUB_BUILD = "20260525-tpsl-ui"
|
||||
|
||||
|
||||
def _is_local(host: str | None) -> bool:
|
||||
@@ -401,6 +401,14 @@ class CancelSymbolOrdersBody(BaseModel):
|
||||
scope: str = "all"
|
||||
|
||||
|
||||
class PlaceTpslBody(BaseModel):
|
||||
symbol: str
|
||||
side: str
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
contracts: float | None = None
|
||||
|
||||
|
||||
@app.post("/api/orders/{exchange_id}/cancel")
|
||||
async def api_cancel_order(exchange_id: str, body: CancelOrderBody):
|
||||
ex = _find_exchange(exchange_id)
|
||||
@@ -486,6 +494,37 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/orders/{exchange_id}/place-tpsl")
|
||||
async def api_place_tpsl(exchange_id: str, body: PlaceTpslBody):
|
||||
ex = _find_exchange(exchange_id)
|
||||
if not ex or not ex.get("enabled"):
|
||||
raise HTTPException(status_code=404, detail="账户未启用")
|
||||
url = f"{ex['agent_url'].rstrip('/')}/orders/place-tpsl"
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
url,
|
||||
headers=_agent_headers(),
|
||||
json={
|
||||
"symbol": body.symbol,
|
||||
"side": body.side,
|
||||
"stop_loss": body.stop_loss,
|
||||
"take_profit": body.take_profit,
|
||||
"contracts": body.contracts,
|
||||
},
|
||||
timeout=120.0,
|
||||
)
|
||||
try:
|
||||
payload = r.json()
|
||||
except Exception:
|
||||
payload = {"raw": (r.text or "")[:2000]}
|
||||
return {
|
||||
"exchange": ex,
|
||||
"status_code": r.status_code,
|
||||
"payload": payload,
|
||||
"ok": bool(isinstance(payload, dict) and payload.get("ok")),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/close/{exchange_id}")
|
||||
async def api_close_exchange(exchange_id: str):
|
||||
ex = _find_exchange(exchange_id)
|
||||
|
||||
@@ -594,6 +594,130 @@ button:disabled {
|
||||
padding: 6px 4px 8px;
|
||||
}
|
||||
|
||||
.td-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.orders-collapse {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.orders-collapse-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.orders-collapse-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.orders-collapse-summary::before {
|
||||
content: "▸";
|
||||
color: var(--muted);
|
||||
margin-right: 6px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.orders-collapse[open] > .orders-collapse-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.orders-collapse-body {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 20px 22px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-panel h3 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--display);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-field label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.modal-field input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
let settingsCache = null;
|
||||
let monitorTimer = null;
|
||||
let authState = { required: false, logged_in: true };
|
||||
let tpslPending = null;
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
@@ -146,8 +147,22 @@
|
||||
);
|
||||
});
|
||||
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
|
||||
btn.onclick = () =>
|
||||
btn.onclick = (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
|
||||
};
|
||||
});
|
||||
box.querySelectorAll(".btn-place-tpsl").forEach((btn) => {
|
||||
btn.onclick = () =>
|
||||
openTpslModal(
|
||||
btn.dataset.exId,
|
||||
btn.dataset.symbol,
|
||||
btn.dataset.side,
|
||||
btn.dataset.contracts,
|
||||
btn.dataset.sl || "",
|
||||
btn.dataset.tp || ""
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="err">${esc(e)}</div>`;
|
||||
@@ -180,15 +195,34 @@
|
||||
return `<table class="data-table data-table-sub"><thead><tr><th>类型</th><th>数量</th><th>触发/价格</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
function guessTpslFromCondOrders(side, cond) {
|
||||
const triggers = (cond || [])
|
||||
.map((o) => o.trigger_price)
|
||||
.filter((v) => v != null && !Number.isNaN(Number(v)))
|
||||
.map(Number);
|
||||
if (!triggers.length) return { sl: "", tp: "" };
|
||||
triggers.sort((a, b) => a - b);
|
||||
const s = (side || "long").toLowerCase();
|
||||
if (s === "short") {
|
||||
return { sl: triggers[triggers.length - 1], tp: triggers[0] };
|
||||
}
|
||||
return { sl: triggers[0], tp: triggers[triggers.length - 1] };
|
||||
}
|
||||
|
||||
function renderPositionBlock(exchangeId, x) {
|
||||
const symAttr = esc(x.symbol || "").replace(/"/g, """);
|
||||
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
|
||||
const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(/"/g, """);
|
||||
const cond = Array.isArray(x.conditional_orders) ? x.conditional_orders : [];
|
||||
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
|
||||
const guess = guessTpslFromCondOrders(x.side, cond);
|
||||
const slAttr = esc(String(guess.sl)).replace(/"/g, """);
|
||||
const tpAttr = esc(String(guess.tp)).replace(/"/g, """);
|
||||
const condAllBtn =
|
||||
cond.length > 0
|
||||
? `<button type="button" class="btn-cancel-cond-all ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销全部条件单</button>`
|
||||
? `<button type="button" class="btn-cancel-cond-all ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销全部</button>`
|
||||
: "";
|
||||
const condBody = renderOrderRows(exchangeId, x.symbol, cond, "conditional");
|
||||
return `<div class="pos-block">
|
||||
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
|
||||
<tr>
|
||||
@@ -196,15 +230,20 @@
|
||||
<td>${esc(x.side)}</td>
|
||||
<td>${fmt(x.contracts, 4)}</td>
|
||||
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td>
|
||||
<td class="td-actions"><button type="button" class="btn-close-pos danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button></td>
|
||||
<td class="td-actions td-actions-row">
|
||||
<button type="button" class="btn-place-tpsl ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}" data-contracts="${contractsAttr}" data-sl="${slAttr}" data-tp="${tpAttr}">委托</button>
|
||||
<button type="button" class="btn-close-pos danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<div class="pos-orders">
|
||||
<div class="pos-orders-head">
|
||||
<span class="pos-orders-title">条件单 · ${cond.length}</span>
|
||||
${condAllBtn}
|
||||
</div>
|
||||
${renderOrderRows(exchangeId, x.symbol, cond, "conditional")}
|
||||
<details class="orders-collapse">
|
||||
<summary class="orders-collapse-summary">
|
||||
<span class="pos-orders-title">条件单 · ${cond.length}</span>
|
||||
${condAllBtn}
|
||||
</summary>
|
||||
<div class="orders-collapse-body">${condBody}</div>
|
||||
</details>
|
||||
<div class="pos-orders-head" style="margin-top:10px">
|
||||
<span class="pos-orders-title">普通委托 · ${reg.length}</span>
|
||||
</div>
|
||||
@@ -213,6 +252,103 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
|
||||
tpslPending = {
|
||||
exchangeId,
|
||||
symbol,
|
||||
side: (side || "long").toLowerCase(),
|
||||
contracts: parseFloat(contracts),
|
||||
};
|
||||
const modal = document.getElementById("tpsl-modal");
|
||||
const meta = document.getElementById("tpsl-modal-meta");
|
||||
const slIn = document.getElementById("tpsl-sl");
|
||||
const tpIn = document.getElementById("tpsl-tp");
|
||||
if (!modal || !meta || !slIn || !tpIn) return;
|
||||
meta.textContent = `${symbol} · ${side} · ${contracts} 张`;
|
||||
slIn.value = slHint !== "" && slHint != null ? String(slHint) : "";
|
||||
tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : "";
|
||||
modal.classList.remove("hidden");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
slIn.focus();
|
||||
}
|
||||
|
||||
function closeTpslModal() {
|
||||
tpslPending = null;
|
||||
const modal = document.getElementById("tpsl-modal");
|
||||
if (modal) {
|
||||
modal.classList.add("hidden");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTpslModal() {
|
||||
if (!tpslPending) return;
|
||||
const slIn = document.getElementById("tpsl-sl");
|
||||
const tpIn = document.getElementById("tpsl-tp");
|
||||
const sl = parseFloat(slIn && slIn.value);
|
||||
const tp = parseFloat(tpIn && tpIn.value);
|
||||
if (!sl || sl <= 0 || !tp || tp <= 0) {
|
||||
showToast("请填写有效的止损价与止盈价", true);
|
||||
return;
|
||||
}
|
||||
const { exchangeId, symbol, side, contracts } = tpslPending;
|
||||
if (
|
||||
!confirm(
|
||||
`确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl}、止盈 ${tp}?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById("tpsl-submit");
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await apiFetch(
|
||||
"/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
symbol,
|
||||
side,
|
||||
stop_loss: sl,
|
||||
take_profit: tp,
|
||||
contracts: contracts > 0 ? contracts : null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const j = await r.json();
|
||||
const pl = j.payload || {};
|
||||
const ok = j.ok && pl.ok !== false;
|
||||
const n = pl.placed && pl.placed.cancelled_conditional;
|
||||
showToast(
|
||||
ok
|
||||
? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)`
|
||||
: pl.error || JSON.stringify(j),
|
||||
!ok
|
||||
);
|
||||
if (ok) {
|
||||
closeTpslModal();
|
||||
loadMonitorBoard();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initTpslModal() {
|
||||
const backdrop = document.getElementById("tpsl-modal-backdrop");
|
||||
const cancel = document.getElementById("tpsl-cancel");
|
||||
const submit = document.getElementById("tpsl-submit");
|
||||
if (backdrop) backdrop.onclick = closeTpslModal;
|
||||
if (cancel) cancel.onclick = closeTpslModal;
|
||||
if (submit) submit.onclick = () => submitTpslModal();
|
||||
document.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape") closeTpslModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelOneOrder(exchangeId, symbol, orderId, channel) {
|
||||
if (!confirm(`撤销委托 ${symbol} #${orderId}?`)) return;
|
||||
try {
|
||||
@@ -557,6 +693,8 @@
|
||||
showToast("已添加一行,请填写 URL 后点「保存设置」");
|
||||
};
|
||||
|
||||
initTpslModal();
|
||||
|
||||
initAuth().then((ok) => {
|
||||
if (!ok) return;
|
||||
loadSettings().catch(() => {});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260524-open-orders" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-tpsl-ui" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -79,7 +79,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tpsl-modal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-backdrop" id="tpsl-modal-backdrop"></div>
|
||||
<div class="modal-panel" role="dialog" aria-labelledby="tpsl-modal-title">
|
||||
<h3 id="tpsl-modal-title">挂止盈 / 止损</h3>
|
||||
<p id="tpsl-modal-meta" class="modal-meta"></p>
|
||||
<div class="modal-field">
|
||||
<label for="tpsl-sl">止损价</label>
|
||||
<input id="tpsl-sl" type="number" step="any" autocomplete="off" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label for="tpsl-tp">止盈价</label>
|
||||
<input id="tpsl-tp" type="number" step="any" autocomplete="off" />
|
||||
</div>
|
||||
<p class="modal-hint">先撤销该合约全部条件单,再挂新止盈与止损(四所统一)。</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="tpsl-cancel" class="ghost">取消</button>
|
||||
<button type="button" id="tpsl-submit" class="primary">确认挂单</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<script src="/assets/app.js?v=20260524-open-orders"></script>
|
||||
<script src="/assets/app.js?v=20260525-tpsl-ui"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -177,8 +177,10 @@ curl -s http://127.0.0.1:5100/api/ping
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **2×2 卡片** | 仅显示「已启用」账户;每卡含子代理持仓、浮盈、余额 |
|
||||
| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(止盈/止损等)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) |
|
||||
| **撤单** | 单笔「撤单」或「撤销全部条件单」;经中控转发子代理 `POST /orders/cancel`、`/orders/cancel-symbol` |
|
||||
| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) |
|
||||
| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`、`cancel-symbol` |
|
||||
| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate趋势 四所统一,逻辑与各实例 `.env` 参数一致) |
|
||||
| **平仓** | 持仓行「平仓」:仅平该方向仓位(子代理市价减仓) |
|
||||
| **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 |
|
||||
| **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) |
|
||||
| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) |
|
||||
|
||||
@@ -116,6 +116,28 @@ curl -s http://127.0.0.1:15202/status | head -c 300
|
||||
|
||||
应 `ok: true` 且有 `balance_usdt`。
|
||||
|
||||
### 3.3 Gate 子代理「一会正常、一会连不上」(仅 Gate 两户)
|
||||
|
||||
| 现象 | 说明 |
|
||||
|------|------|
|
||||
| 中控 LINK 2/4,仅 Gate 红 | 本机 `15202`/`15203` 在 PM2 重启间隙连不上 |
|
||||
| 日志 `$'\r': command not found` | `crypto_monitor_gate*` 的 `.env` 为 Windows CRLF |
|
||||
| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent |
|
||||
|
||||
**修复**(服务器):
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor
|
||||
sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate_bot/.env
|
||||
bash manual_trading_hub/scripts/fix_env_crlf.sh
|
||||
cd manual_trading_hub && pm2 restart manual-agent-gate manual-agent-gate-bot
|
||||
# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6)
|
||||
```
|
||||
|
||||
修好后 `pm2 describe manual-agent-gate` 的 **restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志。
|
||||
|
||||
**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`、API 权限、止损止盈价格是否合理(与各实例策略页相同 `.env` 参数)。
|
||||
|
||||
### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404
|
||||
|
||||
| 原因 | 处理 |
|
||||
|
||||
@@ -147,6 +147,35 @@ pm2 status
|
||||
# crypto_binance / crypto_gate …(各策略目录自有 ecosystem.config.cjs)
|
||||
```
|
||||
|
||||
### 5.6 Gate 子代理「一会能连、一会子代理不可用」(Windows `.env` 换行)
|
||||
|
||||
**现象**:仅 Gate 训练 / Gate 趋势卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现:
|
||||
|
||||
```text
|
||||
./.env: line 22: $'\r': command not found
|
||||
agent start: exchange=gate port=15202 ...
|
||||
```
|
||||
|
||||
**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env`、`crypto_monitor_gate_bot/.env` 为 **CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**)。
|
||||
|
||||
**处理**(在服务器仓库根执行):
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor
|
||||
sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate_bot/.env
|
||||
bash manual_trading_hub/scripts/fix_env_crlf.sh
|
||||
cd manual_trading_hub
|
||||
pm2 delete manual-agent-gate manual-agent-gate-bot 2>/dev/null || true
|
||||
pm2 start ecosystem.config.cjs --only manual-agent-gate
|
||||
pm2 start ecosystem.config.cjs --only manual-agent-gate-bot
|
||||
pm2 save
|
||||
curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true
|
||||
```
|
||||
|
||||
**预防**:`.env` 保存为 **LF**;Windows 用 `deploy/setup_env.ps1` 复制 `.env.example` 时已写 LF。子代理须经 **`scripts/run_agent.sh`** 启动(内置去 CRLF 的 `load_dotenv_file`),勿裸跑 `python agent.py`。
|
||||
|
||||
详见 [常见问题.md](./常见问题.md) **§3.1**、**§3.3**。
|
||||
|
||||
---
|
||||
|
||||
## 六、手动启动(不用 PM2 时)
|
||||
|
||||
Reference in New Issue
Block a user