From f95118065dbc09a7a36c5664e5f513796ba80d46 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 3 Jun 2026 21:25:24 +0800 Subject: [PATCH] feat(hub): exchange price precision, entry price, and trend DCA display Co-authored-by: Cursor --- manual_trading_hub/agent.py | 56 +++++++- manual_trading_hub/static/app.js | 198 ++++++++++++++++++++++------ strategy_trend_register.py | 87 ++++++++++++ tests/test_hub_agent_entry_price.py | 32 +++++ tests/test_trend_hub_enrich.py | 44 +++++++ 5 files changed, 368 insertions(+), 49 deletions(-) create mode 100644 tests/test_hub_agent_entry_price.py create mode 100644 tests/test_trend_hub_enrich.py diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index f71f1f0..8b35169 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -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, } ) diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index b3461fb..e75b212 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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 ` ${esc(o.label || o.type || "委托")} ${fmt(o.amount, 4)} @@ -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 ? `` : ""; - 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 `
委托单 ${orderTotal} @@ -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 `
${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)} @@ -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 ? ` · 补仓 ${esc(done)}/${esc(total)}` : ` · 补仓 ${esc(done)} 次`; + 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 `
+
#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}
+
均价 ${esc(avg)} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)} · 状态 ${esc(t.status || "active")}
+
`; + }) + .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( `移动保本:${beOn ? "开" : "关"}` ); + if (trendPlan && trendPlan.id) { + meta.push(`趋势回调${trendAddSummaryHtml(trendPlan, tickMap)}`); + } const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); return `
@@ -1000,9 +1114,9 @@
${meta.map((m) => `${m}`).join("")}
-
成交价${entry != null ? fmt(entry, 4) : "—"}
-
止损${sl != null && sl !== "" ? fmt(sl, 4) : "—"}
-
止盈${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmt(tp, 4) : "—"}
+
开仓价${fmtEntryPrice(pos, tickMap)}
+
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
+
止盈${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmtSymbolPrice(tp, symbol, tickMap) : "—"}
盈亏比${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}
张数${fmt(pos.contracts, 4)}
浮盈亏${pnlText}
@@ -1014,9 +1128,9 @@
交易所止盈止损
- ${renderExTpslRows(exchangeId, symbol, cond)} + ${renderExTpslRows(exchangeId, symbol, cond, tickMap)}
- ${renderOrdersCollapse(exchangeId, symbol, cond, reg)} + ${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
`; } @@ -1055,43 +1169,32 @@ return `
${cards}
`; } - function renderOrderMonitorSection(orders) { + function renderOrderMonitorSection(orders, tickMap) { if (!orders || !orders.length) return ""; return orders - .map( - (o) => `
+ .map((o) => { + const sym = o.exchange_symbol || o.symbol || ""; + return `
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}
-
触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
-
` - ) +
触发 ${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 || "下单监控")}
+
`; + }) .join(""); } - function renderTrendSection(trends) { - if (!trends || !trends.length) return ""; - return trends - .map( - (t) => `
-
#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}
-
SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)} · 状态 ${esc(t.status || "active")}
-
` - ) - .join(""); - } - - function renderRollSection(rolls) { + function renderRollSection(rolls, tickMap) { if (!rolls || !rolls.length) return ""; return rolls .map( (g) => `
组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}
-
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmt(g.current_stop_loss, 4)} · ${esc(g.status || "active")}
+
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}
` ) .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 `
- +
合约方向张数浮盈操作
+
合约方向开仓价张数浮盈操作
${symBeBadge} ${renderDirectionHtml(x.side)}${fmtEntryPrice(x, tickMap)} ${fmt(x.contracts, 4)} ${fmt(x.unrealized_pnl, 2)} @@ -1122,11 +1226,12 @@
- ${renderOrdersCollapse(exchangeId, x.symbol, cond, reg)} + ${renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap)}
`; } function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { + const tickMap = buildPriceTickMap(row); let inner = `
余额
${fmt(ag.balance_usdt, 2)} U
浮盈合计
${fmt(ag.total_unrealized_pnl, 2)}
@@ -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 += `
下单监控 · ${orders.length}
`; orders.forEach((o) => { - inner += `
${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmt(o.trigger_price, 4)}
`; + const sym = o.exchange_symbol || o.symbol || ""; + inner += `
${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)}
`; }); } if ((row.capabilities || []).includes("key")) { @@ -1183,7 +1290,10 @@ if ((row.capabilities || []).includes("trend") && trends.length) { inner += `
趋势回调 · ${trends.length}
`; trends.forEach((t) => { - inner += `
#${t.id} ${esc(t.symbol)} ${renderDirectionHtml(t.direction)} · SL ${t.stop_loss} · TP ${t.take_profit}
`; + 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 += `
#${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)}
`; }); } 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; } diff --git a/strategy_trend_register.py b/strategy_trend_register.py index b0327da..3990ad9 100644 --- a/strategy_trend_register.py +++ b/strategy_trend_register.py @@ -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: diff --git a/tests/test_hub_agent_entry_price.py b/tests/test_hub_agent_entry_price.py new file mode 100644 index 0000000..6102b51 --- /dev/null +++ b/tests/test_hub_agent_entry_price.py @@ -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() diff --git a/tests/test_trend_hub_enrich.py b/tests/test_trend_hub_enrich.py new file mode 100644 index 0000000..a2c30a0 --- /dev/null +++ b/tests/test_trend_hub_enrich.py @@ -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()