feat(hub): exchange price precision, entry price, and trend DCA display

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 21:25:24 +08:00
parent fac28c402b
commit f95118065d
5 changed files with 368 additions and 49 deletions
+50 -6
View File
@@ -22,9 +22,16 @@ from __future__ import annotations
import math import math
import os import os
import sys
import time import time
from pathlib import Path
from typing import Any from typing import Any
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from hub_ohlcv_lib import format_price_by_tick, price_tick_from_market
import ccxt import ccxt
from fastapi import FastAPI, Header, HTTPException, Request from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -363,6 +370,44 @@ def _finite_or_none(x: Any) -> float | None:
return None return None
def _position_price_fmt(ex: Any, symbol: str, price: float | None) -> tuple[float | None, str | None, float | None]:
"""返回 (原价, 交易所精度字符串, price_tick)。"""
if price is None or price <= 0 or not symbol:
return None, None, None
tick: float | None = None
try:
ex.load_markets()
unified = ex.market(symbol)["symbol"]
tick = price_tick_from_market(ex, unified)
px_str = str(ex.price_to_precision(unified, price))
return _finite_or_none(float(px_str)), px_str, tick
except Exception:
return price, format_price_by_tick(price, tick), tick
def _position_entry_price(p: dict[str, Any]) -> float | None:
"""四所 ccxt 持仓统一解析开仓均价(Binance/OKX/Gate 字段名不一致)。"""
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
for key in (
p.get("entryPrice"),
p.get("entry_price"),
p.get("average"),
info.get("entryPrice"),
info.get("entry_price"),
info.get("avgPx"),
info.get("avgEntryPrice"),
info.get("avg_entry_price"),
info.get("avgPrice"),
info.get("openAvgPx"),
):
px = _finite_or_none(key)
if px is not None and px > 0:
return px
return None
def _extract_usdt_total(balance: dict[str, Any]) -> float | None: def _extract_usdt_total(balance: dict[str, Any]) -> float | None:
"""从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)。""" """从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)。"""
usdt_info = balance.get("USDT") or {} usdt_info = balance.get("USDT") or {}
@@ -536,11 +581,8 @@ def _status_inner(x_control_token: str | None) -> Any:
notional_f = float(notional) if notional is not None else None notional_f = float(notional) if notional is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
notional_f = None notional_f = None
entry = p.get("entryPrice") entry_f = _position_entry_price(p)
try: _, entry_fmt, price_tick = _position_price_fmt(ex, sym, entry_f)
entry_f = float(entry) if entry is not None else None
except (TypeError, ValueError):
entry_f = None
positions_out.append( positions_out.append(
{ {
"symbol": sym, "symbol": sym,
@@ -549,7 +591,9 @@ def _status_inner(x_control_token: str | None) -> Any:
"contracts_signed": c, "contracts_signed": c,
"notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None, "notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None,
"unrealized_pnl": _finite_or_none(upnl_f), "unrealized_pnl": _finite_or_none(upnl_f),
"entry_price": _finite_or_none(entry_f) if entry_f is not None else None, "entry_price": entry_f,
"entry_price_fmt": entry_fmt,
"price_tick": _finite_or_none(price_tick) if price_tick is not None else None,
} }
) )
+155 -43
View File
@@ -181,6 +181,77 @@
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
} }
/** 交易所持仓开仓价(四所子代理 entry_price */
function positionEntryPrice(pos) {
if (!pos) return null;
const n = Number(pos.entry_price);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function symbolPriceKey(sym) {
return (sym || "").trim().toUpperCase();
}
function buildPriceTickMap(row) {
const map = Object.create(null);
const put = (sym, tick) => {
const k = symbolPriceKey(sym);
if (!k || tick == null || !Number.isFinite(Number(tick))) return;
if (map[k] == null) map[k] = Number(tick);
};
((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick));
const hm = (row && row.hub_monitor) || {};
(hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick));
(hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick));
return map;
}
function lookupPriceTick(symbol, tickMap) {
if (!tickMap || !symbol) return null;
const k = symbolPriceKey(symbol);
if (tickMap[k] != null) return tickMap[k];
const base = normSym(symbol);
if (base && tickMap[base] != null) return tickMap[base];
return null;
}
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const t = Number(tick);
if (t >= 1) return 0;
const s = t.toFixed(12).replace(/0+$/, "");
const frac = s.split(".")[1];
return frac ? Math.min(12, frac.length) : 0;
}
function defaultPriceDecimals(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 4;
const av = Math.abs(n);
if (av >= 10000) return 2;
if (av >= 100) return 3;
if (av >= 1) return 4;
if (av >= 0.01) return 6;
return 8;
}
/** 按交易所 tick(子代理/Flask 下发)格式化价格 */
function fmtSymbolPrice(value, symbol, tickMap, displayFallback) {
if (displayFallback != null && displayFallback !== "") return String(displayFallback);
if (value == null || value === "") return "—";
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const tick = lookupPriceTick(symbol, tickMap);
const d = decimalsFromTick(tick);
return fmt(n, d != null ? d : defaultPriceDecimals(n));
}
function fmtEntryPrice(pos, tickMap) {
if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt);
return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap);
}
function pnlCls(v) { function pnlCls(v) {
const n = Number(v); const n = Number(v);
if (!Number.isFinite(n) || n === 0) return ""; if (!Number.isFinite(n) || n === 0) return "";
@@ -845,7 +916,7 @@
}); });
} }
function renderOrderRows(exchangeId, symbol, orders, kind) { function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) {
if (!orders || !orders.length) { if (!orders || !orders.length) {
const hint = const hint =
kind === "conditional" kind === "conditional"
@@ -859,7 +930,11 @@
const oidAttr = esc(o.id || "").replace(/"/g, "&quot;"); const oidAttr = esc(o.id || "").replace(/"/g, "&quot;");
const chAttr = esc(o.channel || "regular").replace(/"/g, "&quot;"); const chAttr = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig = const trig =
o.trigger_price != null ? fmt(o.trigger_price, 4) : o.price != null ? fmt(o.price, 4) : "—"; o.trigger_price != null
? fmtSymbolPrice(o.trigger_price, symbol, tickMap)
: o.price != null
? fmtSymbolPrice(o.price, symbol, tickMap)
: "—";
return `<tr> return `<tr>
<td>${esc(o.label || o.type || "委托")}</td> <td>${esc(o.label || o.type || "委托")}</td>
<td>${fmt(o.amount, 4)}</td> <td>${fmt(o.amount, 4)}</td>
@@ -875,7 +950,7 @@
return inferTpslFromCondOrders(side, cond, entry); return inferTpslFromCondOrders(side, cond, entry);
} }
function renderOrdersCollapse(exchangeId, symbol, cond, reg) { function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;"); const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const orderTotal = cond.length + reg.length; const orderTotal = cond.length + reg.length;
const collapseKey = ordersCollapseKey(exchangeId, symbol); const collapseKey = ordersCollapseKey(exchangeId, symbol);
@@ -884,8 +959,8 @@
cond.length > 0 cond.length > 0
? `<button type="button" class="btn-cancel-cond-all btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销条件单</button>` ? `<button type="button" class="btn-cancel-cond-all btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销条件单</button>`
: ""; : "";
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional"); const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap);
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit"); const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap);
return `<details class="pos-orders-collapse"${openAttr} data-collapse-key="${esc(collapseKey)}"> return `<details class="pos-orders-collapse"${openAttr} data-collapse-key="${esc(collapseKey)}">
<summary class="pos-orders-collapse-summary"> <summary class="pos-orders-collapse-summary">
<span class="pos-orders-collapse-label">委托单 <em>${orderTotal}</em></span> <span class="pos-orders-collapse-label">委托单 <em>${orderTotal}</em></span>
@@ -923,7 +998,7 @@
return { sl, tp }; return { sl, tp };
} }
function renderExTpslRows(exchangeId, symbol, cond) { function renderExTpslRows(exchangeId, symbol, cond, tickMap) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;"); const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const { sl, tp } = pickExTpslOrders(cond); const { sl, tp } = pickExTpslOrders(cond);
function row(label, o) { function row(label, o) {
@@ -932,7 +1007,8 @@
} }
const oid = esc(o.id || "").replace(/"/g, "&quot;"); const oid = esc(o.id || "").replace(/"/g, "&quot;");
const ch = esc(o.channel || "regular").replace(/"/g, "&quot;"); const ch = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig = o.trigger_price != null ? fmt(o.trigger_price, 4) : "—"; const trig =
o.trigger_price != null ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) : "—";
return `<div class="pos-ex-order-row"> return `<div class="pos-ex-order-row">
<span class="pos-ex-order-main">${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}</span> <span class="pos-ex-order-main">${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}</span>
<button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button> <button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button>
@@ -941,7 +1017,42 @@
return row("止损", sl) + row("止盈", tp); return row("止损", sl) + row("止盈", tp);
} }
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan) { function trendAddSummaryHtml(t, tickMap) {
const done = t.add_count != null ? t.add_count : t.legs_done;
const total = t.add_count_total != null ? t.add_count_total : t.dca_legs;
const sym = t.exchange_symbol || t.symbol || "";
let html = "";
if (done != null && Number(done) >= 0) {
html += total != null ? ` · 补仓 <strong>${esc(done)}/${esc(total)}</strong>` : ` · 补仓 <strong>${esc(done)}</strong> 次`;
const pxs = t.add_prices_display;
if (Array.isArray(pxs) && pxs.length) {
html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`;
} else if (Array.isArray(t.add_prices) && t.add_prices.length) {
html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`;
} else if (Number(done) === 0) {
html += " · 加仓价 —";
}
}
return html;
}
function renderTrendSection(trends, tickMap) {
if (!trends || !trends.length) return "";
return trends
.map((t) => {
const sym = t.exchange_symbol || t.symbol || "";
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap);
const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap);
return `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}</div>
<div class="hub-mini-line">均价 ${esc(avg)} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)} · 状态 ${esc(t.status || "active")}</div>
</div>`;
})
.join("");
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) {
const symbol = pos.symbol || ""; const symbol = pos.symbol || "";
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;"); const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const side = (pos.side || "long").toLowerCase(); const side = (pos.side || "long").toLowerCase();
@@ -985,6 +1096,9 @@
meta.push( meta.push(
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>` `<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
); );
if (trendPlan && trendPlan.id) {
meta.push(`趋势回调${trendAddSummaryHtml(trendPlan, tickMap)}`);
}
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
return `<div class="pos-card hub-pos-card"> return `<div class="pos-card hub-pos-card">
@@ -1000,9 +1114,9 @@
</div> </div>
<div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div> <div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div>
<div class="pos-grid"> <div class="pos-grid">
<div class="pos-cell"><span class="pos-label">成交价</span><span class="pos-value">${entry != null ? fmt(entry, 4) : "—"}</span></div> <div class="pos-cell"><span class="pos-label">开仓价</span><span class="pos-value">${fmtEntryPrice(pos, tickMap)}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmt(sl, 4) : "—"}</span></div> <div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value">${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmt(tp, 4) : "—"}</span></div> <div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value">${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmtSymbolPrice(tp, symbol, tickMap) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}</span></div> <div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}</span></div>
<div class="pos-cell"><span class="pos-label">张数</span><span class="pos-value">${fmt(pos.contracts, 4)}</span></div> <div class="pos-cell"><span class="pos-label">张数</span><span class="pos-value">${fmt(pos.contracts, 4)}</span></div>
<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlCls(upnl)}">${pnlText}</span></div> <div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlCls(upnl)}">${pnlText}</span></div>
@@ -1014,9 +1128,9 @@
</div> </div>
<div class="pos-ex-orders"> <div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div> <div class="pos-ex-orders-title">交易所止盈止损</div>
${renderExTpslRows(exchangeId, symbol, cond)} ${renderExTpslRows(exchangeId, symbol, cond, tickMap)}
</div> </div>
${renderOrdersCollapse(exchangeId, symbol, cond, reg)} ${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
</div>`; </div>`;
} }
@@ -1055,43 +1169,32 @@
return `<div class="hub-key-list">${cards}</div>`; return `<div class="hub-key-list">${cards}</div>`;
} }
function renderOrderMonitorSection(orders) { function renderOrderMonitorSection(orders, tickMap) {
if (!orders || !orders.length) return ""; if (!orders || !orders.length) return "";
return orders return orders
.map( .map((o) => {
(o) => `<div class="hub-mini-card"> const sym = o.exchange_symbol || o.symbol || "";
return `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}</div> <div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}</div>
<div class="hub-mini-line">触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div> <div class="hub-mini-line">触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
</div>` </div>`;
) })
.join(""); .join("");
} }
function renderTrendSection(trends) { function renderRollSection(rolls, tickMap) {
if (!trends || !trends.length) return "";
return trends
.map(
(t) => `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}</div>
<div class="hub-mini-line">SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)} · 状态 ${esc(t.status || "active")}</div>
</div>`
)
.join("");
}
function renderRollSection(rolls) {
if (!rolls || !rolls.length) return ""; if (!rolls || !rolls.length) return "";
return rolls return rolls
.map( .map(
(g) => `<div class="hub-mini-card"> (g) => `<div class="hub-mini-card">
<div class="hub-mini-title">组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}</div> <div class="hub-mini-title">组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}</div>
<div class="hub-mini-line">腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmt(g.current_stop_loss, 4)} · ${esc(g.status || "active")}</div> <div class="hub-mini-line">腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}</div>
</div>` </div>`
) )
.join(""); .join("");
} }
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan) { function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap) {
const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;"); const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;"); const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;"); const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
@@ -1107,10 +1210,11 @@
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
return `<div class="pos-block"> return `<div class="pos-block">
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody> <table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>开仓价</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
<tr> <tr>
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${symBeBadge}</td> <td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${symBeBadge}</td>
<td class="${sideDirCls(x.side)}">${renderDirectionHtml(x.side)}</td> <td class="${sideDirCls(x.side)}">${renderDirectionHtml(x.side)}</td>
<td class="td-entry">${fmtEntryPrice(x, tickMap)}</td>
<td>${fmt(x.contracts, 4)}</td> <td>${fmt(x.contracts, 4)}</td>
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td> <td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>
<td class="td-actions"> <td class="td-actions">
@@ -1122,11 +1226,12 @@
</tr> </tr>
</tbody></table> </tbody></table>
</div> </div>
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg)} ${renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap)}
</div>`; </div>`;
} }
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
const tickMap = buildPriceTickMap(row);
let inner = `<div class="stat-row"> let inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div> <div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 2)}</div></div> <div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 2)}</div></div>
@@ -1140,7 +1245,8 @@
row.key || row.id, row.key || row.id,
p, p,
findMonitorOrder(orders, p.symbol, p.side), findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side) findTrendPlan(trends, p.symbol, p.side),
tickMap
) )
) )
.join(""); .join("");
@@ -1150,7 +1256,8 @@
if (orders.length) { if (orders.length) {
inner += `<div class="section-title">下单监控 · ${orders.length}</div>`; inner += `<div class="section-title">下单监控 · ${orders.length}</div>`;
orders.forEach((o) => { orders.forEach((o) => {
inner += `<div class="list-line">${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmt(o.trigger_price, 4)}</div>`; const sym = o.exchange_symbol || o.symbol || "";
inner += `<div class="list-line">${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)}</div>`;
}); });
} }
if ((row.capabilities || []).includes("key")) { if ((row.capabilities || []).includes("key")) {
@@ -1183,7 +1290,10 @@
if ((row.capabilities || []).includes("trend") && trends.length) { if ((row.capabilities || []).includes("trend") && trends.length) {
inner += `<div class="section-title">趋势回调 · ${trends.length}</div>`; inner += `<div class="section-title">趋势回调 · ${trends.length}</div>`;
trends.forEach((t) => { trends.forEach((t) => {
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${renderDirectionHtml(t.direction)} · SL ${t.stop_loss} · TP ${t.take_profit}</div>`; const sym = t.exchange_symbol || t.symbol || "";
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap);
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${renderDirectionHtml(t.direction)} · 均价 ${esc(t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap))} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)}</div>`;
}); });
} }
if (rolls.length) { if (rolls.length) {
@@ -1197,6 +1307,7 @@
} }
function renderFullscreenExchange(row) { function renderFullscreenExchange(row) {
const tickMap = buildPriceTickMap(row);
const ag = row.agent || {}; const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : []; const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {}; const hm = row.hub_monitor || {};
@@ -1241,7 +1352,8 @@
row.key || row.id, row.key || row.id,
p, p,
findMonitorOrder(orders, p.symbol, p.side), findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side) findTrendPlan(trends, p.symbol, p.side),
tickMap
); );
}); });
} else { } else {
@@ -1259,11 +1371,11 @@
); );
} }
} }
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders), "暂无运行中的下单监控"); html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控");
if ((row.capabilities || []).includes("trend")) { if ((row.capabilities || []).includes("trend")) {
html += renderHubSectionCard("趋势回调", renderTrendSection(trends), "暂无运行中的趋势回调计划"); html += renderHubSectionCard("趋势回调", renderTrendSection(trends, tickMap), "暂无运行中的趋势回调计划");
} }
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls), "暂无运行中的顺势加仓组"); html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组");
return html; return html;
} }
+87
View File
@@ -52,6 +52,7 @@ def install_strategy_trend(app: Flask, repo_root: str, app_module: Any = None, *
cfg = build_trend_config(app_module, **build_kw) cfg = build_trend_config(app_module, **build_kw)
app.extensions["strategy_trend_cfg"] = cfg app.extensions["strategy_trend_cfg"] = cfg
register_trend_routes(app, cfg) register_trend_routes(app, cfg)
_patch_hub_monitor_enrich(app, cfg)
@app.context_processor @app.context_processor
def _trend_ctx(): def _trend_ctx():
@@ -268,9 +269,95 @@ def _insert_preview_snapshot(conn, preview_id: str, created: str, exp_ms: int, p
) )
def _format_trend_price(cfg: dict, symbol: str, value) -> str:
if value in (None, ""):
return ""
m = _m(cfg)
sym = symbol or ""
norm = getattr(m, "normalize_exchange_symbol", None)
if callable(norm):
try:
sym = norm(sym) or sym
except Exception:
pass
try:
m.ensure_markets_loaded()
return str(m.exchange.price_to_precision(sym, float(value)))
except Exception:
fn = getattr(m, "format_price_for_symbol", None)
if callable(fn):
return fn(symbol, value)
return str(value)
def _trend_add_leg_fields(cfg: dict, d: dict) -> dict:
"""解析已补仓次数与已触达网格价(供策略页与中控 monitor 共用)。"""
import json
out = dict(d)
try:
legs_done = int(out.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
try:
dca_legs = int(out.get("dca_legs") or 0)
except (TypeError, ValueError):
dca_legs = 0
try:
grid = json.loads(out.get("grid_prices_json") or "[]")
if not isinstance(grid, list):
grid = []
except Exception:
grid = []
add_prices: list[float] = []
for x in grid[:legs_done]:
try:
add_prices.append(float(x))
except (TypeError, ValueError):
pass
sym = out.get("exchange_symbol") or out.get("symbol") or ""
out["add_count"] = legs_done
out["add_count_total"] = dca_legs
out["add_prices"] = add_prices
out["add_prices_display"] = [_format_trend_price(cfg, sym, p) for p in add_prices]
for field in ("stop_loss", "take_profit", "add_upper", "avg_entry_price"):
if out.get(field) not in (None, ""):
out[f"{field}_display"] = _format_trend_price(cfg, sym, out.get(field))
return out
def enrich_trend_plan_for_hub(cfg: dict, raw: dict) -> dict:
"""中控 /api/hub/monitor:补仓次数、加仓价(交易所精度)。"""
return _trend_add_leg_fields(cfg, dict(raw or {}))
def _patch_hub_monitor_enrich(app: Flask, cfg: dict) -> None:
ctx = dict(app.config.get("HUB_CTX") or {})
prev = ctx.get("enrich_monitor")
def enrich_monitor(keys=None, orders=None, trends=None, rolls=None):
payload: dict[str, Any] = {}
if callable(prev):
try:
prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls)
if isinstance(prev_out, dict):
payload.update(prev_out)
except Exception:
pass
if trends:
payload["trends"] = [
enrich_trend_plan_for_hub(cfg, t) for t in trends if isinstance(t, dict)
]
return payload
ctx["enrich_monitor"] = enrich_monitor
app.config["HUB_CTX"] = ctx
def enrich_trend_plan(cfg: dict, row) -> dict: def enrich_trend_plan(cfg: dict, row) -> dict:
m = _m(cfg) m = _m(cfg)
d = _row(cfg, row) d = _row(cfg, row)
d = _trend_add_leg_fields(cfg, d)
try: try:
d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0 d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0
except Exception: except Exception:
+32
View File
@@ -0,0 +1,32 @@
"""子代理持仓:四所开仓价字段统一解析。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
from agent import _position_entry_price # noqa: E402
class TestHubAgentEntryPrice(unittest.TestCase):
def test_binance_entry_price(self):
px = _position_entry_price({"entryPrice": 65851.6, "info": {}})
self.assertAlmostEqual(px, 65851.6)
def test_okx_avg_px(self):
px = _position_entry_price({"info": {"avgPx": "72.731"}})
self.assertAlmostEqual(px, 72.731)
def test_gate_info_entry(self):
px = _position_entry_price({"info": {"entry_price": "0.2232"}})
self.assertAlmostEqual(px, 0.2232)
def test_missing_returns_none(self):
self.assertIsNone(_position_entry_price({"info": {}}))
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""趋势回调中控 enrich:补仓次数与加仓价。"""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from strategy_trend_register import _trend_add_leg_fields # noqa: E402
class TestTrendHubEnrich(unittest.TestCase):
def test_add_count_and_prices(self):
mock_ex = MagicMock()
mock_ex.price_to_precision = lambda sym, px: f"{float(px):.4f}"
app_mod = MagicMock()
app_mod.exchange = mock_ex
app_mod.ensure_markets_loaded = MagicMock()
app_mod.normalize_exchange_symbol = lambda s: s
cfg = {"app_module": app_mod}
raw = {
"symbol": "ETH/USDT",
"exchange_symbol": "ETH/USDT:USDT",
"legs_done": 2,
"dca_legs": 5,
"grid_prices_json": json.dumps([1800.1, 1750.2, 1700.3]),
"stop_loss": 1600,
"take_profit": 2000,
"avg_entry_price": 1820.5,
}
out = _trend_add_leg_fields(cfg, raw)
self.assertEqual(out["add_count"], 2)
self.assertEqual(out["add_count_total"], 5)
self.assertEqual(out["add_prices"], [1800.1, 1750.2])
self.assertEqual(len(out["add_prices_display"]), 2)
self.assertEqual(out["stop_loss_display"], "1600.0000")
if __name__ == "__main__":
unittest.main()