From e18d5feb72e8bd2d3d2371ac1d8322a2e3f4afd2 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 27 Jun 2026 23:57:11 +0800 Subject: [PATCH] Apply 200k scope when CTP offline; trailing breakeven order UX. When SimNow or live CTP is disconnected, default to the four-product whitelist regardless of reference capital. Trailing breakeven defaults off; when enabled hide take-profit and risk-reward, monitor exits via trailing stop only. Document both behaviors in TRADING.md and FEATURES.md. Co-authored-by: Cursor --- app.py | 5 ++-- docs/FEATURES.md | 7 +++-- docs/TRADING.md | 56 +++++++++++++++++++++++++++++++++++++-- install_trading.py | 20 +++++++++----- product_recommend.py | 54 ++++++++++++++++++++++++++++++++----- recommend_store.py | 25 ++++++++++++++++-- sl_tp_guard.py | 4 +-- static/css/trade.css | 2 ++ static/js/trade.js | 63 +++++++++++++++++++++++++++++++------------- symbols.py | 16 ++++++----- templates/trade.html | 5 ++-- trading_context.py | 11 ++++++++ 12 files changed, 220 insertions(+), 48 deletions(-) diff --git a/app.py b/app.py index b32ab09..022bb13 100644 --- a/app.py +++ b/app.py @@ -791,11 +791,12 @@ def api_symbol_search(): q = request.args.get("q", "") conn = get_db() try: - from trading_context import get_account_capital + from trading_context import get_account_capital, is_ctp_connected capital = get_account_capital(conn, get_setting) + ctp_connected = is_ctp_connected(get_setting) finally: conn.close() - return jsonify(search_symbols(q, capital=capital)) + return jsonify(search_symbols(q, capital=capital, ctp_connected=ctp_connected)) @app.route("/api/symbols/mains") diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d4f07a0..c0f3395 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -45,9 +45,10 @@ ### 期货下单 -- 品种联想(仅列出可开仓品种或全部主力,取决于计仓模式) +- 品种联想(可开仓品种表与下拉一致;小账户或 CTP 未连接时仅四品种,见 [TRADING.md](./TRADING.md)) - 方向、手数(固定手数 / 固定金额计仓) - 限价 / 市价(FAK)、止盈、止损 +- **移动保本**(默认关闭):开启后隐藏止盈与盈亏比,仅填止损;由移动止损监控平仓,不设固定止盈 - 非交易时段禁止报单 ### 当前持仓 @@ -60,6 +61,8 @@ ### 可开仓品种 - 按当前权益与保证金上限筛选可开品种,养成开仓纪律、限制仓位 +- **权益 ≤20 万** 或 **CTP 未连接** 时,仅展示并可交易:玉米、豆粕、甲醇、螺纹钢(SimNow/实盘一致) +- **夜盘时段** 仅显示有夜盘品种,并标注「夜盘」 - **行业分类**、走势(多头/空头/震荡/转多/转空)、跳空、昨日成交量(手)、成交额 - 支持行业筛选与多字段排序 - 每日后台刷新缓存 @@ -172,7 +175,7 @@ | 导航显示 | 开关可选菜单项 | | 交易模式 | SimNow / 实盘 CTP | | 计仓模式 | 固定手数、固定金额 | -| 保证金上限、移动保本、挂单超时 | 见表单说明 | +| 保证金上限、移动保本缓冲、挂单超时 | 保证金上限默认 30%;移动保本缓冲为达 1R 后止损相对开仓价的跳数(默认 2 跳) | | CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) | | 参考资金 | CTP 未连接时用于可开仓筛选与估算 | | 企业微信 Webhook | 计划/关键位推送 | diff --git a/docs/TRADING.md b/docs/TRADING.md index 986350e..e814496 100644 --- a/docs/TRADING.md +++ b/docs/TRADING.md @@ -7,7 +7,7 @@ | 区域 | 说明 | |------|------| | 顶栏 | 交易模式、CTP 状态、权益/可用、连接 CTP | -| 期货下单 | 限价/市价报单、止盈止损、以损定仓/固定手数 | +| 期货下单 | 限价/市价报单、止盈/止损、移动保本、以损定仓/固定手数 | | 当前持仓 | CTP 持仓卡片、挂单中、撤单、平仓 | | 可开仓品种 | 按权益与保证金上限筛选、行业分类、走势/跳空/成交量排序 | @@ -36,6 +36,56 @@ - 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金) - 展示近一周日线走势、跳空、昨日成交量(手)、成交额 - 可按 **行业** 筛选,支持多字段排序 +- **夜盘时段**:品种下拉与可开仓表仅显示有夜盘交易的品种,并带「夜盘」标记 + +### 小账户品种范围(≤20 万) + +权益 **不超过 20 万元** 时,系统限制可浏览、可搜索、可报单的品种为以下 **4 个**: + +| 品种 | 代码 | +|------|------| +| 玉米 | `c` | +| 豆粕 | `m` | +| 甲醇 | `MA` | +| 螺纹钢 | `rb` | + +适用范围: + +- 可开仓品种表 +- 期货下单品种联想 / 下拉 +- 开仓报单校验(含趋势策略首仓) + +**SimNow 与实盘规则一致**:**CTP 未连接** 时,无论系统设置中的参考资金是否大于 20 万,均 **默认按 20 万以下四品种范围** 展示与校验;连接 CTP 后改用柜台权益判断是否启用上述白名单。 + +页面会提示:「未连接 CTP,默认按 20 万以下账户:仅显示并可交易 玉米、豆粕、甲醇、螺纹钢」。 + +## 期货下单 · 止盈止损与移动保本 + +本地止盈止损由程序监控持仓,触发后市价平仓(与 CTP 柜台委托/持仓数据独立)。 + +### 默认模式(移动保本关闭) + +- 可同时填写 **止盈**、**止损** +- 填写入场价与止损/止盈后,表单下方显示 **盈亏比**、止损金额、止盈金额(如有) +- 本地监控:价格触及止盈或止损即平仓 + +### 移动保本(可选,默认关闭) + +勾选 **移动保本** 后: + +| 表单项 | 行为 | +|--------|------| +| 止盈 | **隐藏**,不提交止盈价 | +| 盈亏比 / 止盈金额 | **不显示** | +| 止损 | **必填**,仅保留止损输入 | + +平仓逻辑: + +- **不再** 按固定止盈价监控 +- 程序按 **移动止损** 管理出场:浮盈达 **1R** 后止损移至开仓价 ± N 跳(保本);达 **2R** 移至 1R,依次类推(N 见系统设置「移动保本缓冲」) +- 开启移动保本 **必须填写止损价**,否则无法开仓 + +持仓卡片在开启移动保本时同样 **不展示盈亏比、盈利金额、止盈状态**,仅保留止损与移动保本进度(如已锁 N R)。 ## 策略交易 @@ -48,7 +98,9 @@ ## 参考资金 -系统设置中的「参考资金」仅在 **CTP 未连接** 时用于可开仓品种筛选与以损定仓估算;连接后自动改用柜台权益。 +系统设置中的「参考资金」在 **CTP 未连接** 时用于以损定仓估算;连接后自动改用柜台权益。 + +可开仓品种与品种白名单:**未连接 CTP 时一律按 20 万以下四品种范围**(见上文);连接后若柜台权益 ≤20 万,同样仅上述四品种。 ## 首次使用 SimNow diff --git a/install_trading.py b/install_trading.py index 17d439c..d6cb5fa 100644 --- a/install_trading.py +++ b/install_trading.py @@ -30,7 +30,7 @@ from position_sizing import ( ) from product_recommend import ( assert_product_allowed_for_capital, - is_small_account, + should_apply_small_account_scope, small_account_scope_hint, SMALL_ACCOUNT_SCOPE_LABEL, ) @@ -96,6 +96,7 @@ from trading_context import ( get_sizing_mode, get_trailing_be_tick_buffer, get_trading_mode, + is_ctp_connected, trading_mode_label, ) from ctp_symbol import ths_to_vnpy_symbol @@ -1629,6 +1630,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se rec_cache = _recommend_payload(conn) if rec_cache.get("needs_refresh"): _schedule_recommend_refresh() + ctp_connected = is_ctp_connected(get_setting) return render_template( "trade.html", trading_mode=mode, @@ -1651,8 +1653,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se recommend_rows=rec_cache.get("rows") or [], recommend_updated_at=rec_cache.get("updated_at"), night_session=is_night_trading_session(), - small_account_scope=is_small_account(capital), - small_account_scope_hint=small_account_scope_hint(), + small_account_scope=should_apply_small_account_scope( + capital, ctp_connected=ctp_connected, + ), + small_account_scope_hint=small_account_scope_hint(ctp_connected=ctp_connected), product_categories=PRODUCT_CATEGORIES, ) finally: @@ -2156,7 +2160,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if err: conn.close() return jsonify({"ok": False, "error": err}), 403 - scope_err = assert_product_allowed_for_capital(sym, _capital(conn)) + scope_err = assert_product_allowed_for_capital( + sym, _capital(conn), ctp_connected=is_ctp_connected(get_setting), + ) if scope_err: conn.close() return jsonify({"ok": False, "error": scope_err}), 403 @@ -2220,8 +2226,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if offset.startswith("open"): from zoneinfo import ZoneInfo sl = d.get("stop_loss") - tp = d.get("take_profit") trailing_be = 1 if d.get("trailing_be") else 0 + tp = None if trailing_be else d.get("take_profit") open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") vt_order_id = str(result.get("order_id") or "") mid = _upsert_open_monitor( @@ -2515,7 +2521,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn.close() return jsonify({"ok": False, "error": err}), 403 capital = _capital(conn) - scope_err = assert_product_allowed_for_capital(sym, capital) + scope_err = assert_product_allowed_for_capital( + sym, capital, ctp_connected=is_ctp_connected(get_setting), + ) if scope_err: conn.close() return jsonify({"ok": False, "error": scope_err}), 403 diff --git a/product_recommend.py b/product_recommend.py index eaae3d3..91122e9 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -24,8 +24,13 @@ SMALL_ACCOUNT_PRODUCT_THS = frozenset({"c", "m", "MA", "rb"}) SMALL_ACCOUNT_SCOPE_LABEL = "玉米、豆粕、甲醇、螺纹钢" -def small_account_scope_hint() -> str: +def small_account_scope_hint(*, ctp_connected: bool = True) -> str: wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000) + if not ctp_connected: + return ( + f"未连接 CTP,默认按 {wan} 万以下账户:" + f"仅显示并可交易 {SMALL_ACCOUNT_SCOPE_LABEL}" + ) return f"权益 {wan} 万以下仅显示并可交易:{SMALL_ACCOUNT_SCOPE_LABEL}" @@ -34,6 +39,28 @@ def small_account_scope_status_label() -> str: return f"权益{wan}万以下限{SMALL_ACCOUNT_SCOPE_LABEL}" +def should_apply_small_account_scope( + capital: float, + *, + ctp_connected: bool, +) -> bool: + """SimNow/实盘一致:未连接 CTP 时默认按 20 万以下四品种范围。""" + if not ctp_connected: + return True + return is_small_account(capital) + + +def filter_rows_for_account_scope( + rows: list[dict], + capital: float, + *, + ctp_connected: bool, +) -> list[dict]: + if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected): + return rows + return [r for r in rows if product_in_small_account_whitelist(r.get("ths") or "")] + + def normalize_product_ths(ths: str) -> str: import re s = (ths or "").strip() @@ -60,18 +87,30 @@ def product_in_small_account_whitelist(ths_or_product) -> bool: return upper in {x.upper() for x in SMALL_ACCOUNT_PRODUCT_THS} -def assert_product_allowed_for_capital(ths: str, capital: float) -> Optional[str]: +def assert_product_allowed_for_capital( + ths: str, + capital: float, + *, + ctp_connected: bool = True, +) -> Optional[str]: """小账户品种白名单校验;通过返回 None。""" - if not is_small_account(capital): + if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected): return None if product_in_small_account_whitelist(ths): return None wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000) + if not ctp_connected: + return f"未连接 CTP,仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}" return f"权益 {wan} 万以下仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}" -def filter_products_for_capital(products: list[dict], capital: float) -> list[dict]: - if not is_small_account(capital): +def filter_products_for_capital( + products: list[dict], + capital: float, + *, + ctp_connected: bool = True, +) -> list[dict]: + if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected): return list(products) return [p for p in products if product_in_small_account_whitelist(p)] @@ -103,6 +142,7 @@ def assess_product_for_capital( default_stop_ticks: int = 20, reward_risk_ratio: float = 2.0, trading_mode: str = "simulation", + ctp_connected: bool = True, ) -> dict: """评估单品种在当前资金下是否可交易。""" ths = product.get("ths") or "" @@ -117,7 +157,7 @@ def assess_product_for_capital( cap = float(capital or 0) margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) - if is_small_account(cap) and not product_in_small_account_whitelist(product): + if should_apply_small_account_scope(cap, ctp_connected=ctp_connected) and not product_in_small_account_whitelist(product): return { "ths": ths, "name": name, @@ -210,6 +250,7 @@ def list_product_recommendations( *, max_margin_pct: float = 30.0, trading_mode: str = "simulation", + ctp_connected: bool = True, ) -> list[dict]: """扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}""" @@ -222,6 +263,7 @@ def list_product_recommendations( product, capital, price, max_margin_pct=max_margin_pct, trading_mode=trading_mode, + ctp_connected=ctp_connected, ) main_code = (quote.get("ths_code") or "").strip() row["main_code"] = main_code diff --git a/recommend_store.py b/recommend_store.py index 133ec34..ff8d158 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -14,7 +14,11 @@ from typing import Callable, Optional from contract_specs import get_contract_spec from fee_specs import ensure_fee_rates_schema -from product_recommend import _attach_turnover, list_product_recommendations +from product_recommend import ( + _attach_turnover, + filter_rows_for_account_scope, + list_product_recommendations, +) from recommend_trend import sort_recommend_by_trend from symbols import product_category @@ -104,6 +108,15 @@ def recommend_cache_needs_refresh( return False +def _ctp_connected_for_mode(trading_mode: str) -> bool: + try: + from vnpy_bridge import ctp_status + + return bool(ctp_status(trading_mode).get("connected")) + except Exception: + return False + + def enrich_recommend_rows( rows: list[dict], capital: float, @@ -213,8 +226,13 @@ def refresh_recommend_cache( """后台拉行情、筛选并写入数据库。""" ensure_recommend_tables(conn) ensure_fee_rates_schema(conn) + ctp_connected = _ctp_connected_for_mode(trading_mode) all_rows = list_product_recommendations( - capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode, + capital, + quote_fn, + max_margin_pct=max_margin_pct, + trading_mode=trading_mode, + ctp_connected=ctp_connected, ) rows = filter_affordable_recommendations(all_rows) if not rows and float(capital or 0) > 0: @@ -289,6 +307,9 @@ def recommend_payload( rows = enrich_recommend_rows( rows, cap, max_margin_pct=pct, trading_mode=trading_mode, ) + rows = filter_rows_for_account_scope( + rows, cap, ctp_connected=_ctp_connected_for_mode(trading_mode), + ) rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots) rows = sort_recommend_by_trend(rows) payload["rows"] = rows diff --git a/sl_tp_guard.py b/sl_tp_guard.py index f6e1d34..d0e072e 100644 --- a/sl_tp_guard.py +++ b/sl_tp_guard.py @@ -732,7 +732,7 @@ def check_sl_tp_on_tick( pass reason = None - if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): + if tp_f is not None and not mon.get("trailing_be") and _tp_triggered(direction, tp_f, mark, tick): reason = "take_profit" elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): reason = "stop_loss" @@ -813,7 +813,7 @@ def check_monitors_locally( pass reason = None - if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): + if tp_f is not None and not mon.get("trailing_be") and _tp_triggered(direction, tp_f, mark, tick): reason = "take_profit" elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): reason = "stop_loss" diff --git a/static/css/trade.css b/static/css/trade.css index b61fc00..170126d 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -48,6 +48,8 @@ .trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} .trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none} .trailing-be-toggle input{width:auto;margin:0} +.trailing-be-hint{font-size:.72rem;margin:0;color:var(--text-muted)} +.trade-form-line.line-3 #field-tp.is-hidden{display:none} .trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0} .session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center} .trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem} diff --git a/static/js/trade.js b/static/js/trade.js index 20c2b8d..dd450ce 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -313,6 +313,22 @@ return parseFloat(priceInput && priceInput.value) || 0; } + function isTrailingBeOn() { + var el = document.getElementById('trailing-be'); + return !!(el && el.checked); + } + + function updateTrailingBeUi() { + var on = isTrailingBeOn(); + var tpField = document.getElementById('field-tp'); + var hint = document.getElementById('trailing-be-hint'); + if (tpField) tpField.classList.toggle('is-hidden', on); + if (hint) hint.hidden = !on; + if (on && tpInput) tpInput.value = ''; + updateRRDisplay(); + scheduleAutoCalc(); + } + function calcRR(direction, entry, sl, tp) { entry = parseFloat(entry); sl = parseFloat(sl); @@ -335,19 +351,22 @@ function updateRRDisplay() { var el = document.getElementById('trade-rr-hint'); if (!el) return; + var trailingOn = isTrailingBeOn(); var dir = dirSelect ? dirSelect.value : 'long'; var entry = entryPrice(); var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; - var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0; + var tp = trailingOn ? 0 : (tpInput && tpInput.value ? parseFloat(tpInput.value) : 0); var lots = effectiveLots(); var parts = []; - var rr = calcRR(dir, entry, sl, tp); - if (rr) parts.push('盈亏比 ' + rr + ':1'); + if (!trailingOn) { + var rr = calcRR(dir, entry, sl, tp); + if (rr) parts.push('盈亏比 ' + rr + ':1'); + } if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) { if (lastPreviewMetrics.risk_amount != null) { parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元'); } - if (lastPreviewMetrics.reward_amount != null && tp > 0) { + if (!trailingOn && lastPreviewMetrics.reward_amount != null && tp > 0) { parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元'); } } @@ -572,7 +591,7 @@ var sym = selectedSymbol(); var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var sl = parseFloat(slInput && slInput.value) || 0; - var tp = parseFloat(tpInput && tpInput.value) || 0; + var tp = isTrailingBeOn() ? 0 : (parseFloat(tpInput && tpInput.value) || 0); if (isFixedMode()) { var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1; lotsCalc.value = String(fixedLots); @@ -671,12 +690,12 @@ } var lots = effectiveLots(); var trailingBeEl = document.getElementById('trailing-be'); + var trailingOn = !!(trailingBeEl && trailingBeEl.checked); if (offset === 'open') { if (!isTradingSession) { showOrderMsg('不在交易时间段', false); return; } - var trailingOn = !!(trailingBeEl && trailingBeEl.checked); if (trailingOn && !(slInput && slInput.value)) { showOrderMsg('开启移动保本须填写止损价', false); return; @@ -713,8 +732,8 @@ price: price, order_type: priceType, stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null, - take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null, - trailing_be: !!(trailingBeEl && trailingBeEl.checked) + take_profit: (trailingOn || !(tpInput && tpInput.value)) ? null : parseFloat(tpInput.value), + trailing_be: trailingOn }; fetch('/api/trade/order', { method: 'POST', @@ -861,10 +880,12 @@ } else if (row.stop_loss != null) { parts.push('止损已设'); } - if (row.tp_order_active || row.tp_monitoring) { - parts.push('止盈监控中'); - } else if (row.take_profit != null) { - parts.push('止盈已设'); + if (!row.trailing_be) { + if (row.tp_order_active || row.tp_monitoring) { + parts.push('止盈监控中'); + } else if (row.take_profit != null) { + parts.push('止盈已设'); + } } if (!parts.length) return '未设置'; return parts.join(' · '); @@ -926,8 +947,8 @@ var metaLine = '状态 ' + pendingLabel + '' + ' · 委托价 ' + fmtNum(orderPx) + '' + - (row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + - (row.stop_loss != null || row.take_profit != null ? ' · ' + slTpStatusHtml(row) : '') + + (!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + + (row.stop_loss != null || (!row.trailing_be && row.take_profit != null) ? ' · ' + slTpStatusHtml(row) : '') + (row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') + (!isCloseOrder ? ' · 约 ' + remainMin + ' 分钟内未成交自动撤单' : ''); return ( @@ -988,13 +1009,14 @@ '
' + entrustBtn + orderBtn + closeBtn + '
' : ''; var metaLine = '来源 ' + (row.source_label || 'CTP') + '' + - (row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + + (!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + ' · 止损金额 ' + (row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '' + - ' · 盈利金额 ' + - (row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '' + + (!row.trailing_be ? + (' · 盈利金额 ' + + (row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '') : '') + ' · ' + slTpStatusHtml(row) + - ' · 移动保本 ' + trailingStatusHtml(row) + + (row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') + (slTpBtn ? ' · ' + slTpBtn : '') + (function () { if (row.order_state === 'pending' || !row.monitor_id) return ''; @@ -1565,6 +1587,11 @@ scheduleAutoCalc(); updateRRDisplay(); }); + var trailingBeEl = document.getElementById('trailing-be'); + if (trailingBeEl) { + trailingBeEl.addEventListener('change', updateTrailingBeUi); + updateTrailingBeUi(); + } if (priceInput) { priceInput.addEventListener('input', function () { if (priceType === 'limit') priceInput.dataset.manual = '1'; diff --git a/symbols.py b/symbols.py index ebe2381..2ad0db1 100644 --- a/symbols.py +++ b/symbols.py @@ -461,19 +461,21 @@ def _match_score(product: dict, q_lower: str) -> int: return 10 -def search_symbols(query: str, *, capital: float | None = None) -> list: +def search_symbols(query: str, *, capital: float | None = None, ctp_connected: bool = True) -> list: q = query.strip() if not q: return [] q_lower = q.lower() from market_sessions import is_night_trading_session - from product_recommend import filter_products_for_capital, is_small_account + from product_recommend import filter_products_for_capital, should_apply_small_account_scope night_only = is_night_trading_session() product_pool = PRODUCTS - if capital is not None and is_small_account(capital): - product_pool = filter_products_for_capital(PRODUCTS, capital) + if capital is not None and should_apply_small_account_scope(capital, ctp_connected=ctp_connected): + product_pool = filter_products_for_capital( + PRODUCTS, capital, ctp_connected=ctp_connected, + ) with _main_index_lock: index = dict(_main_index) index_ready = bool(index) @@ -498,8 +500,10 @@ def search_symbols(query: str, *, capital: float | None = None) -> list: codes = ths_to_codes(q) if codes: product = _product_for_contract_code(codes["ths_code"]) - if capital is not None and is_small_account(capital): - from product_recommend import is_small_account, product_in_small_account_whitelist + if capital is not None and should_apply_small_account_scope( + capital, ctp_connected=ctp_connected, + ): + from product_recommend import product_in_small_account_whitelist if not product or not product_in_small_account_whitelist(product): return results raw = fetch_raw_for_volume(codes["sina_code"]) diff --git a/templates/trade.html b/templates/trade.html index cc8110b..d77a116 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -77,7 +77,7 @@ -
+
@@ -90,9 +90,10 @@
+ diff --git a/trading_context.py b/trading_context.py index 88f2b0d..7dd1e70 100644 --- a/trading_context.py +++ b/trading_context.py @@ -91,5 +91,16 @@ def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float: return 0.0 +def is_ctp_connected(get_setting: Callable[[str, str], str]) -> bool: + """当前交易模式(SimNow / 实盘)是否已连接 CTP。""" + try: + from vnpy_bridge import ctp_status + + mode = get_trading_mode(get_setting) + return bool(ctp_status(mode).get("connected")) + except Exception: + return False + + def trading_mode_label(get_setting: Callable[[str, str], str]) -> str: return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"