feat(hub): exchange price precision, entry price, and trend DCA display
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,9 +22,16 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
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
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -363,6 +370,44 @@ def _finite_or_none(x: Any) -> float | 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:
|
||||
"""从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)。"""
|
||||
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
|
||||
except (TypeError, ValueError):
|
||||
notional_f = None
|
||||
entry = p.get("entryPrice")
|
||||
try:
|
||||
entry_f = float(entry) if entry is not None else None
|
||||
except (TypeError, ValueError):
|
||||
entry_f = None
|
||||
entry_f = _position_entry_price(p)
|
||||
_, entry_fmt, price_tick = _position_price_fmt(ex, sym, entry_f)
|
||||
positions_out.append(
|
||||
{
|
||||
"symbol": sym,
|
||||
@@ -549,7 +591,9 @@ def _status_inner(x_control_token: str | None) -> Any:
|
||||
"contracts_signed": c,
|
||||
"notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +181,77 @@
|
||||
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) {
|
||||
const n = Number(v);
|
||||
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) {
|
||||
const hint =
|
||||
kind === "conditional"
|
||||
@@ -859,7 +930,11 @@
|
||||
const oidAttr = esc(o.id || "").replace(/"/g, """);
|
||||
const chAttr = esc(o.channel || "regular").replace(/"/g, """);
|
||||
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>
|
||||
<td>${esc(o.label || o.type || "委托")}</td>
|
||||
<td>${fmt(o.amount, 4)}</td>
|
||||
@@ -875,7 +950,7 @@
|
||||
return inferTpslFromCondOrders(side, cond, entry);
|
||||
}
|
||||
|
||||
function renderOrdersCollapse(exchangeId, symbol, cond, reg) {
|
||||
function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) {
|
||||
const symAttr = esc(symbol || "").replace(/"/g, """);
|
||||
const orderTotal = cond.length + reg.length;
|
||||
const collapseKey = ordersCollapseKey(exchangeId, symbol);
|
||||
@@ -884,8 +959,8 @@
|
||||
cond.length > 0
|
||||
? `<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 regBody = renderOrderRows(exchangeId, symbol, reg, "limit");
|
||||
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap);
|
||||
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap);
|
||||
return `<details class="pos-orders-collapse"${openAttr} data-collapse-key="${esc(collapseKey)}">
|
||||
<summary class="pos-orders-collapse-summary">
|
||||
<span class="pos-orders-collapse-label">委托单 <em>${orderTotal}</em></span>
|
||||
@@ -923,7 +998,7 @@
|
||||
return { sl, tp };
|
||||
}
|
||||
|
||||
function renderExTpslRows(exchangeId, symbol, cond) {
|
||||
function renderExTpslRows(exchangeId, symbol, cond, tickMap) {
|
||||
const symAttr = esc(symbol || "").replace(/"/g, """);
|
||||
const { sl, tp } = pickExTpslOrders(cond);
|
||||
function row(label, o) {
|
||||
@@ -932,7 +1007,8 @@
|
||||
}
|
||||
const oid = esc(o.id || "").replace(/"/g, """);
|
||||
const ch = esc(o.channel || "regular").replace(/"/g, """);
|
||||
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">
|
||||
<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>
|
||||
@@ -941,7 +1017,42 @@
|
||||
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 exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
|
||||
const side = (pos.side || "long").toLowerCase();
|
||||
@@ -985,6 +1096,9 @@
|
||||
meta.push(
|
||||
`<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 mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
|
||||
return `<div class="pos-card hub-pos-card">
|
||||
@@ -1000,9 +1114,9 @@
|
||||
</div>
|
||||
<div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div>
|
||||
<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">${sl != null && sl !== "" ? fmt(sl, 4) : "—"}</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">${fmtEntryPrice(pos, tickMap)}</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 !== "" ? 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">${fmt(pos.contracts, 4)}</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 class="pos-ex-orders">
|
||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||
${renderExTpslRows(exchangeId, symbol, cond)}
|
||||
${renderExTpslRows(exchangeId, symbol, cond, tickMap)}
|
||||
</div>
|
||||
${renderOrdersCollapse(exchangeId, symbol, cond, reg)}
|
||||
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1055,43 +1169,32 @@
|
||||
return `<div class="hub-key-list">${cards}</div>`;
|
||||
}
|
||||
|
||||
function renderOrderMonitorSection(orders) {
|
||||
function renderOrderMonitorSection(orders, tickMap) {
|
||||
if (!orders || !orders.length) return "";
|
||||
return orders
|
||||
.map(
|
||||
(o) => `<div class="hub-mini-card">
|
||||
.map((o) => {
|
||||
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-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>`
|
||||
)
|
||||
<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>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTrendSection(trends) {
|
||||
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) {
|
||||
function renderRollSection(rolls, tickMap) {
|
||||
if (!rolls || !rolls.length) return "";
|
||||
return rolls
|
||||
.map(
|
||||
(g) => `<div class="hub-mini-card">
|
||||
<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>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan) {
|
||||
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap) {
|
||||
const symAttr = esc(x.symbol || "").replace(/"/g, """);
|
||||
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
|
||||
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
|
||||
@@ -1107,10 +1210,11 @@
|
||||
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
|
||||
return `<div class="pos-block">
|
||||
<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>
|
||||
<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="td-entry">${fmtEntryPrice(x, tickMap)}</td>
|
||||
<td>${fmt(x.contracts, 4)}</td>
|
||||
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>
|
||||
<td class="td-actions">
|
||||
@@ -1122,11 +1226,12 @@
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg)}
|
||||
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
|
||||
const tickMap = buildPriceTickMap(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 ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 2)}</div></div>
|
||||
@@ -1140,7 +1245,8 @@
|
||||
row.key || row.id,
|
||||
p,
|
||||
findMonitorOrder(orders, p.symbol, p.side),
|
||||
findTrendPlan(trends, p.symbol, p.side)
|
||||
findTrendPlan(trends, p.symbol, p.side),
|
||||
tickMap
|
||||
)
|
||||
)
|
||||
.join("");
|
||||
@@ -1150,7 +1256,8 @@
|
||||
if (orders.length) {
|
||||
inner += `<div class="section-title">下单监控 · ${orders.length}</div>`;
|
||||
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")) {
|
||||
@@ -1183,7 +1290,10 @@
|
||||
if ((row.capabilities || []).includes("trend") && trends.length) {
|
||||
inner += `<div class="section-title">趋势回调 · ${trends.length}</div>`;
|
||||
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) {
|
||||
@@ -1197,6 +1307,7 @@
|
||||
}
|
||||
|
||||
function renderFullscreenExchange(row) {
|
||||
const tickMap = buildPriceTickMap(row);
|
||||
const ag = row.agent || {};
|
||||
const pos = Array.isArray(ag.positions) ? ag.positions : [];
|
||||
const hm = row.hub_monitor || {};
|
||||
@@ -1241,7 +1352,8 @@
|
||||
row.key || row.id,
|
||||
p,
|
||||
findMonitorOrder(orders, p.symbol, p.side),
|
||||
findTrendPlan(trends, p.symbol, p.side)
|
||||
findTrendPlan(trends, p.symbol, p.side),
|
||||
tickMap
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -1259,11 +1371,11 @@
|
||||
);
|
||||
}
|
||||
}
|
||||
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders), "暂无运行中的下单监控");
|
||||
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
app.extensions["strategy_trend_cfg"] = cfg
|
||||
register_trend_routes(app, cfg)
|
||||
_patch_hub_monitor_enrich(app, cfg)
|
||||
|
||||
@app.context_processor
|
||||
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:
|
||||
m = _m(cfg)
|
||||
d = _row(cfg, row)
|
||||
d = _trend_add_leg_fields(cfg, d)
|
||||
try:
|
||||
d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0
|
||||
except Exception:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user