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:
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, """);
|
||||
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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user