中控增加条件单委托

This commit is contained in:
dekun
2026-05-24 08:09:08 +08:00
parent 4b5fae2946
commit 3b97a59562
9 changed files with 731 additions and 14 deletions
+62
View File
@@ -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)
+281 -1
View File
@@ -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,
}
+40 -1
View File
@@ -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)
+124
View File
@@ -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;
+146 -8
View File
@@ -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, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(/"/g, "&quot;");
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, "&quot;");
const tpAttr = esc(String(guess.tp)).replace(/"/g, "&quot;");
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(() => {});
+23 -2
View File
@@ -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>
+4 -2
View File
@@ -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 |
+22
View File
@@ -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
| 原因 | 处理 |
+29
View File
@@ -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 时)