fix: hide hub close/entrust controls for intraday discipline profile

Expose intraday_discipline via hub meta and block manual close/tpsl APIs; Gate instance card no longer shows 全平/平仓/委托.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-06 02:26:22 +08:00
parent a268a93027
commit 4904a86e03
8 changed files with 119 additions and 22 deletions
+2
View File
@@ -178,6 +178,7 @@ from lib.trade.entry_model_lib import (
build_intraday_entry_reason_options,
build_trend_div_entry_reason_options,
enrich_entry_model_display,
hub_meta_entry_context,
migrate_entry_model_columns,
order_entry_template_context,
parse_manual_order_style_fields,
@@ -9687,6 +9688,7 @@ def _hub_meta_bundle():
"btc_leverage": BTC_LEVERAGE,
"alt_leverage": ALT_LEVERAGE,
"trade_policy": trade_policy_template_context(TRADE_POLICY),
**hub_meta_entry_context(TRADE_POLICY),
}
+2
View File
@@ -177,6 +177,7 @@ from lib.trade.entry_model_lib import (
build_intraday_entry_reason_options,
build_trend_div_entry_reason_options,
enrich_entry_model_display,
hub_meta_entry_context,
migrate_entry_model_columns,
order_entry_template_context,
parse_manual_order_style_fields,
@@ -9522,6 +9523,7 @@ def _hub_meta_bundle():
"btc_leverage": BTC_LEVERAGE,
"alt_leverage": ALT_LEVERAGE,
"trade_policy": trade_policy_template_context(TRADE_POLICY),
**hub_meta_entry_context(TRADE_POLICY),
}
+2
View File
@@ -176,6 +176,7 @@ from lib.trade.entry_model_lib import (
build_intraday_entry_reason_options,
build_trend_div_entry_reason_options,
enrich_entry_model_display,
hub_meta_entry_context,
migrate_entry_model_columns,
order_entry_template_context,
parse_manual_order_style_fields,
@@ -9065,6 +9066,7 @@ def _hub_meta_bundle():
"btc_leverage": BTC_LEVERAGE,
"alt_leverage": ALT_LEVERAGE,
"trade_policy": trade_policy_template_context(TRADE_POLICY),
**hub_meta_entry_context(TRADE_POLICY),
}
+1 -1
View File
@@ -263,7 +263,7 @@ TRADING_DAY_RESET_HOUR=8
|----|------|
| 开仓类型 | 界面 **`假破` / `结构突破`**code`liquidity_false_break` / `structure_breakout`);**无** trend/swing 手选 |
| 写入字段 | `trade_records.entry_model` / 复盘下拉同两项 |
| 隐藏操作 | 日内 profile 下 **隐藏** 平仓、委托、移动保本(**实例页 + 中控**,共用 lib 判断 |
| 隐藏操作 | 日内 profile 下 **隐藏** 平仓、委托、移动保本(**实例页 + 中控**,`intraday_discipline` / `order_entry_profile=intraday` |
| 隐藏表单项 | 不展示 1h/2h/4h 时间平仓、移动保本勾选(避免与 §9.3 混用) |
| 后端可选 | 严格模式下拒绝 `del_order` / 改委托 API |
+10
View File
@@ -207,6 +207,7 @@ def order_entry_template_context(policy: TradePolicy) -> dict:
opts = entry_model_options()
return {
"order_entry_profile": profile,
"intraday_discipline": profile == PROFILE_INTRADAY,
"entry_model_options": [
{"code": o.code, "label": o.label, "trade_style": o.trade_style, "help": o.help}
for o in opts
@@ -215,6 +216,15 @@ def order_entry_template_context(policy: TradePolicy) -> dict:
}
def hub_meta_entry_context(policy: TradePolicy) -> dict:
"""供 /api/hub/meta:中控按 profile 隐藏平仓/委托等。"""
profile = order_entry_profile(policy)
return {
"order_entry_profile": profile,
"intraday_discipline": profile == PROFILE_INTRADAY,
}
def migrate_entry_model_columns(conn) -> None:
for table in ("order_monitors", "trade_records"):
try:
+34
View File
@@ -2050,6 +2050,27 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict
)
_INTRADAY_CLOSE_BLOCK_MSG = (
"日内账户禁止中控手动平仓/改委托,请等待计划止损/止盈或整点强制清仓"
)
def _meta_intraday_discipline(meta: dict | None) -> bool:
if not isinstance(meta, dict):
return False
if meta.get("intraday_discipline") is True:
return True
return meta.get("order_entry_profile") == "intraday"
async def _fetch_exchange_intraday_discipline(
client: httpx.AsyncClient, ex: dict
) -> bool:
data = await _fetch_flask_json(client, ex, "/api/hub/meta")
meta = (data or {}).get("meta") if isinstance(data, dict) else None
return _meta_intraday_discipline(meta if isinstance(meta, dict) else None)
async def _fetch_exchange_flask_bundle(
client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None
) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]:
@@ -2428,6 +2449,8 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody):
raise HTTPException(status_code=400, detail="side 须为 long 或 short")
url = f"{ex['agent_url'].rstrip('/')}/emergency/close-position"
async with httpx.AsyncClient() as client:
if await _fetch_exchange_intraday_discipline(client, ex):
raise HTTPException(status_code=403, detail=_INTRADAY_CLOSE_BLOCK_MSG)
r = await client.post(
url,
headers=_agent_headers(),
@@ -2464,6 +2487,8 @@ async def api_place_tpsl(exchange_id: str, body: PlaceTpslBody):
raise HTTPException(status_code=404, detail="账户未启用")
url = f"{ex['agent_url'].rstrip('/')}/orders/place-tpsl"
async with httpx.AsyncClient() as client:
if await _fetch_exchange_intraday_discipline(client, ex):
raise HTTPException(status_code=403, detail=_INTRADAY_CLOSE_BLOCK_MSG)
r = await client.post(
url,
headers=_agent_headers(),
@@ -2521,6 +2546,8 @@ async def api_close_exchange(exchange_id: str):
raise HTTPException(status_code=404, detail="账户未启用")
url = f"{ex['agent_url'].rstrip('/')}/emergency/close-all"
async with httpx.AsyncClient() as client:
if await _fetch_exchange_intraday_discipline(client, ex):
raise HTTPException(status_code=403, detail=_INTRADAY_CLOSE_BLOCK_MSG)
r = await client.post(url, headers=_agent_headers(), timeout=120.0)
try:
body = r.json()
@@ -2557,6 +2584,13 @@ async def api_close_all(body: CloseAllBody | None = Body(default=None)):
async with httpx.AsyncClient() as client:
async def one(ex: dict):
if await _fetch_exchange_intraday_discipline(client, ex):
return {
"id": ex["id"],
"name": ex["name"],
"skipped": True,
"reason": _INTRADAY_CLOSE_BLOCK_MSG,
}
url = f"{ex['agent_url'].rstrip('/')}/emergency/close-all"
try:
r = await client.post(url, headers=_agent_headers(), timeout=120.0)
+57 -20
View File
@@ -2804,6 +2804,24 @@
);
}
function isIntradayDisciplineRow(row) {
const m = row && row.meta;
if (!m || typeof m !== "object") return false;
if (m.intraday_discipline === true) return true;
return m.order_entry_profile === "intraday";
}
function forceCloseHeadBadgeHtml(state) {
if (!state || !state.enabled) return "";
return forceCloseSymbolBadgeHtml({
force_close_enabled: true,
force_close_label: state.label || "强制清仓",
force_close_countdown: state.countdown || "--:--:--",
force_close_at_ms: state.next_at_ms,
force_close_active: state.active,
});
}
function renderTrendDcaTable(t, tickMap) {
const levels = resolveTrendDcaLevels(t);
if (!levels.length) return "";
@@ -2979,7 +2997,7 @@
</div>`;
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) {
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap, intradayDiscipline) {
const symbol = pos.symbol || "";
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const side = (pos.side || "long").toLowerCase();
@@ -2999,6 +3017,7 @@
const tp = tpsl.tp;
const tpMonitored = tpsl.tp_monitored;
const isTrend = isTrendContext(mo, trendPlan);
const intraday = !!intradayDiscipline;
const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan);
const beSecured = isBreakevenSecured(side, entry, mo, cond, pos);
const upnl = resolveTrendFloatingPnl(pos, trendPlan);
@@ -3044,29 +3063,34 @@
if (riskLine) meta.push(riskLine);
const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl);
if (latestRiskLine) meta.push(latestRiskLine);
const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true;
meta.push(
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
);
if (!intraday) {
const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true;
meta.push(
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
);
}
} else {
meta.push("来源: 交易所持仓");
meta.push("风格: —");
meta.push(`<span class="pos-meta-off">移动保本:关</span>`);
if (!intraday) meta.push(`<span class="pos-meta-off">移动保本:关</span>`);
}
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
const fcSymBadge = !isTrend && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : "";
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
const headActions = intraday
? ""
: `<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn btn-place-tpsl" 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="pos-close-btn btn-close-pos" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
</div>`;
return `<div class="pos-card hub-pos-card">
<div class="pos-card-head">
<div class="pos-card-symbol">
<button type="button" class="btn-open-market sym-link pos-symbol-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)"><strong>${esc(symbol)}</strong></button>${tcSymBadge}${fcSymBadge}${symBeBadge}
<span class="pos-side-badge ${sideCls}">${sideCn}</span>
</div>
<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn btn-place-tpsl" 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="pos-close-btn btn-close-pos" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
</div>
${headActions}
</div>
<div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div>
<div class="pos-grid">
@@ -3218,7 +3242,10 @@
!isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
const fcBadge =
!isTrendContext(mo, trendPlan) && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : "";
const actionCell = `<div class="pos-action-group">
const intraday = !!options.intradayDiscipline;
const actionCell = intraday
? ""
: `<div class="pos-action-group">
<button type="button" class="btn-place-tpsl btn-sm 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 btn-sm danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
</div>`;
@@ -3329,7 +3356,8 @@
.join("")}</div>`;
}
function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap) {
function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap, intradayDiscipline) {
const intraday = !!intradayDiscipline;
const rows = positions
.map((p) =>
renderPositionTableRow(
@@ -3339,7 +3367,7 @@
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side),
tickMap,
{ compact: true }
{ compact: true, intradayDiscipline: intraday }
)
)
.join("");
@@ -3362,6 +3390,7 @@
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
const tickMap = buildPriceTickMap(row);
const intraday = isIntradayDisciplineRow(row);
let inner = renderAccountStatRow(row, ag);
inner += `<div class="section-title">交易所持仓 · ${pos.length} 仓</div>`;
if (pos.length) {
@@ -3371,13 +3400,16 @@
pos,
orders,
trends,
tickMap
tickMap,
intraday
);
} else {
inner += '<div class="empty-hint">无持仓</div>';
}
inner += renderCardStrategyStats(row, hm, flaskOk);
inner += `<div class="card-expand-hint">点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓</div>`;
inner += intraday
? `<div class="card-expand-hint">日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}</div>`
: `<div class="card-expand-hint">点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓</div>`;
return inner;
}
@@ -3396,9 +3428,11 @@
kmap[k.id] = k;
});
const flaskOpen = row.flask_url_browser || row.flask_url;
const intraday = isIntradayDisciplineRow(row);
const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : "";
let html = `<div class="fs-head">
<div>
<h2 class="fs-title">${esc(row.name)}</h2>
<h2 class="fs-title">${esc(row.name)}${fcHeadBadge ? " " + fcHeadBadge : ""}</h2>
<div class="fs-sub">${esc(flaskOpen || "")}</div>
</div>
<div class="fs-head-actions">
@@ -3407,7 +3441,7 @@
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>` : ""}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`}
</div>
</div>`;
if (!row.http_ok || ag.ok === false) {
@@ -3427,7 +3461,8 @@
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side),
tickMap
tickMap,
intraday
);
});
} else {
@@ -3740,12 +3775,14 @@
const openReview = flaskOpen
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
: "";
const intraday = isIntradayDisciplineRow(row);
const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : "";
return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
<div class="card-head card-expand-zone" title="点击放大全屏">
<div>
<div class="card-title-row">
<span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span>
<div class="card-title"><span>${esc(row.name)}</span>${formatRiskStatusBadge(hm.risk_status)}</div>
<div class="card-title"><span>${esc(row.name)}</span>${fcHeadBadge}${formatRiskStatusBadge(hm.risk_status)}</div>
</div>
<div class="card-sub">${esc(flaskOpen || "")}</div>
</div>
@@ -3754,7 +3791,7 @@
${openTrade}
${openKey}
${openReview}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`}
</div>
</div>
<div class="card-body">${inner}</div>
+11 -1
View File
@@ -6,6 +6,7 @@ from lib.trade.entry_model_lib import (
ENTRY_MODEL_SMALL_DIV,
build_trend_div_entry_reason_options,
entry_model_label,
hub_meta_entry_context,
is_intraday_trading_profile,
parse_manual_order_style_fields,
resolve_trade_record_entry_reason,
@@ -54,7 +55,16 @@ class TestEntryModelLib(unittest.TestCase):
self.assertEqual(code, ENTRY_MODEL_SMALL_DIV)
self.assertEqual(style, "swing")
def test_parse_intraday_uses_trade_style(self):
def test_hub_meta_intraday(self):
policy = load_trade_policy(
{
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
}
)
ctx = hub_meta_entry_context(policy)
self.assertTrue(ctx["intraday_discipline"])
self.assertEqual(ctx["order_entry_profile"], "intraday")
policy = TradePolicy(False, "both", True, ("BTC", "ETH"))
style, code, err = parse_manual_order_style_fields(policy, {"trade_style": "swing"})
self.assertIsNone(err)