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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-27 23:57:11 +08:00
parent 4f4c4bb9fc
commit e18d5feb72
12 changed files with 220 additions and 48 deletions
+3 -2
View File
@@ -791,11 +791,12 @@ def api_symbol_search():
q = request.args.get("q", "") q = request.args.get("q", "")
conn = get_db() conn = get_db()
try: 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) capital = get_account_capital(conn, get_setting)
ctp_connected = is_ctp_connected(get_setting)
finally: finally:
conn.close() 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") @app.route("/api/symbols/mains")
+5 -2
View File
@@ -45,9 +45,10 @@
### 期货下单 ### 期货下单
- 品种联想(仅列出可开仓品种或全部主力,取决于计仓模式 - 品种联想(可开仓品种表与下拉一致;小账户或 CTP 未连接时仅四品种,见 [TRADING.md](./TRADING.md)
- 方向、手数(固定手数 / 固定金额计仓) - 方向、手数(固定手数 / 固定金额计仓)
- 限价 / 市价(FAK)、止盈、止损 - 限价 / 市价(FAK)、止盈、止损
- **移动保本**(默认关闭):开启后隐藏止盈与盈亏比,仅填止损;由移动止损监控平仓,不设固定止盈
- 非交易时段禁止报单 - 非交易时段禁止报单
### 当前持仓 ### 当前持仓
@@ -60,6 +61,8 @@
### 可开仓品种 ### 可开仓品种
- 按当前权益与保证金上限筛选可开品种,养成开仓纪律、限制仓位 - 按当前权益与保证金上限筛选可开品种,养成开仓纪律、限制仓位
- **权益 ≤20 万** 或 **CTP 未连接** 时,仅展示并可交易:玉米、豆粕、甲醇、螺纹钢(SimNow/实盘一致)
- **夜盘时段** 仅显示有夜盘品种,并标注「夜盘」
- **行业分类**、走势(多头/空头/震荡/转多/转空)、跳空、昨日成交量(手)、成交额 - **行业分类**、走势(多头/空头/震荡/转多/转空)、跳空、昨日成交量(手)、成交额
- 支持行业筛选与多字段排序 - 支持行业筛选与多字段排序
- 每日后台刷新缓存 - 每日后台刷新缓存
@@ -172,7 +175,7 @@
| 导航显示 | 开关可选菜单项 | | 导航显示 | 开关可选菜单项 |
| 交易模式 | SimNow / 实盘 CTP | | 交易模式 | SimNow / 实盘 CTP |
| 计仓模式 | 固定手数、固定金额 | | 计仓模式 | 固定手数、固定金额 |
| 保证金上限、移动保本、挂单超时 | 见表单说明 | | 保证金上限、移动保本缓冲、挂单超时 | 保证金上限默认 30%;移动保本缓冲为达 1R 后止损相对开仓价的跳数(默认 2 跳) |
| CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env` | | CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env` |
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 | | 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
| 企业微信 Webhook | 计划/关键位推送 | | 企业微信 Webhook | 计划/关键位推送 |
+54 -2
View File
@@ -7,7 +7,7 @@
| 区域 | 说明 | | 区域 | 说明 |
|------|------| |------|------|
| 顶栏 | 交易模式、CTP 状态、权益/可用、连接 CTP | | 顶栏 | 交易模式、CTP 状态、权益/可用、连接 CTP |
| 期货下单 | 限价/市价报单、止盈止损、以损定仓/固定手数 | | 期货下单 | 限价/市价报单、止盈/止损、移动保本、以损定仓/固定手数 |
| 当前持仓 | CTP 持仓卡片、挂单中、撤单、平仓 | | 当前持仓 | CTP 持仓卡片、挂单中、撤单、平仓 |
| 可开仓品种 | 按权益与保证金上限筛选、行业分类、走势/跳空/成交量排序 | | 可开仓品种 | 按权益与保证金上限筛选、行业分类、走势/跳空/成交量排序 |
@@ -36,6 +36,56 @@
- 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金) - 最大手数 = 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 ## 首次使用 SimNow
+14 -6
View File
@@ -30,7 +30,7 @@ from position_sizing import (
) )
from product_recommend import ( from product_recommend import (
assert_product_allowed_for_capital, assert_product_allowed_for_capital,
is_small_account, should_apply_small_account_scope,
small_account_scope_hint, small_account_scope_hint,
SMALL_ACCOUNT_SCOPE_LABEL, SMALL_ACCOUNT_SCOPE_LABEL,
) )
@@ -96,6 +96,7 @@ from trading_context import (
get_sizing_mode, get_sizing_mode,
get_trailing_be_tick_buffer, get_trailing_be_tick_buffer,
get_trading_mode, get_trading_mode,
is_ctp_connected,
trading_mode_label, trading_mode_label,
) )
from ctp_symbol import ths_to_vnpy_symbol 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) rec_cache = _recommend_payload(conn)
if rec_cache.get("needs_refresh"): if rec_cache.get("needs_refresh"):
_schedule_recommend_refresh() _schedule_recommend_refresh()
ctp_connected = is_ctp_connected(get_setting)
return render_template( return render_template(
"trade.html", "trade.html",
trading_mode=mode, 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_rows=rec_cache.get("rows") or [],
recommend_updated_at=rec_cache.get("updated_at"), recommend_updated_at=rec_cache.get("updated_at"),
night_session=is_night_trading_session(), night_session=is_night_trading_session(),
small_account_scope=is_small_account(capital), small_account_scope=should_apply_small_account_scope(
small_account_scope_hint=small_account_scope_hint(), capital, ctp_connected=ctp_connected,
),
small_account_scope_hint=small_account_scope_hint(ctp_connected=ctp_connected),
product_categories=PRODUCT_CATEGORIES, product_categories=PRODUCT_CATEGORIES,
) )
finally: finally:
@@ -2156,7 +2160,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if err: if err:
conn.close() conn.close()
return jsonify({"ok": False, "error": err}), 403 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: if scope_err:
conn.close() conn.close()
return jsonify({"ok": False, "error": scope_err}), 403 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"): if offset.startswith("open"):
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
sl = d.get("stop_loss") sl = d.get("stop_loss")
tp = d.get("take_profit")
trailing_be = 1 if d.get("trailing_be") else 0 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") open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
vt_order_id = str(result.get("order_id") or "") vt_order_id = str(result.get("order_id") or "")
mid = _upsert_open_monitor( mid = _upsert_open_monitor(
@@ -2515,7 +2521,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
conn.close() conn.close()
return jsonify({"ok": False, "error": err}), 403 return jsonify({"ok": False, "error": err}), 403
capital = _capital(conn) 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: if scope_err:
conn.close() conn.close()
return jsonify({"ok": False, "error": scope_err}), 403 return jsonify({"ok": False, "error": scope_err}), 403
+48 -6
View File
@@ -24,8 +24,13 @@ SMALL_ACCOUNT_PRODUCT_THS = frozenset({"c", "m", "MA", "rb"})
SMALL_ACCOUNT_SCOPE_LABEL = "玉米、豆粕、甲醇、螺纹钢" 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) 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}" 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}" 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: def normalize_product_ths(ths: str) -> str:
import re import re
s = (ths or "").strip() 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} 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。""" """小账户品种白名单校验;通过返回 None。"""
if not is_small_account(capital): if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return None return None
if product_in_small_account_whitelist(ths): if product_in_small_account_whitelist(ths):
return None return None
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000) 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}" return f"权益 {wan} 万以下仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
def filter_products_for_capital(products: list[dict], capital: float) -> list[dict]: def filter_products_for_capital(
if not is_small_account(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 list(products)
return [p for p in products if product_in_small_account_whitelist(p)] 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, default_stop_ticks: int = 20,
reward_risk_ratio: float = 2.0, reward_risk_ratio: float = 2.0,
trading_mode: str = "simulation", trading_mode: str = "simulation",
ctp_connected: bool = True,
) -> dict: ) -> dict:
"""评估单品种在当前资金下是否可交易。""" """评估单品种在当前资金下是否可交易。"""
ths = product.get("ths") or "" ths = product.get("ths") or ""
@@ -117,7 +157,7 @@ def assess_product_for_capital(
cap = float(capital or 0) cap = float(capital or 0)
margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.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 { return {
"ths": ths, "ths": ths,
"name": name, "name": name,
@@ -210,6 +250,7 @@ def list_product_recommendations(
*, *,
max_margin_pct: float = 30.0, max_margin_pct: float = 30.0,
trading_mode: str = "simulation", trading_mode: str = "simulation",
ctp_connected: bool = True,
) -> list[dict]: ) -> list[dict]:
"""扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}""" """扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
@@ -222,6 +263,7 @@ def list_product_recommendations(
product, capital, price, product, capital, price,
max_margin_pct=max_margin_pct, max_margin_pct=max_margin_pct,
trading_mode=trading_mode, trading_mode=trading_mode,
ctp_connected=ctp_connected,
) )
main_code = (quote.get("ths_code") or "").strip() main_code = (quote.get("ths_code") or "").strip()
row["main_code"] = main_code row["main_code"] = main_code
+23 -2
View File
@@ -14,7 +14,11 @@ from typing import Callable, Optional
from contract_specs import get_contract_spec from contract_specs import get_contract_spec
from fee_specs import ensure_fee_rates_schema 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 recommend_trend import sort_recommend_by_trend
from symbols import product_category from symbols import product_category
@@ -104,6 +108,15 @@ def recommend_cache_needs_refresh(
return False 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( def enrich_recommend_rows(
rows: list[dict], rows: list[dict],
capital: float, capital: float,
@@ -213,8 +226,13 @@ def refresh_recommend_cache(
"""后台拉行情、筛选并写入数据库。""" """后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn) ensure_recommend_tables(conn)
ensure_fee_rates_schema(conn) ensure_fee_rates_schema(conn)
ctp_connected = _ctp_connected_for_mode(trading_mode)
all_rows = list_product_recommendations( 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) rows = filter_affordable_recommendations(all_rows)
if not rows and float(capital or 0) > 0: if not rows and float(capital or 0) > 0:
@@ -289,6 +307,9 @@ def recommend_payload(
rows = enrich_recommend_rows( rows = enrich_recommend_rows(
rows, cap, max_margin_pct=pct, trading_mode=trading_mode, 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 = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots)
rows = sort_recommend_by_trend(rows) rows = sort_recommend_by_trend(rows)
payload["rows"] = rows payload["rows"] = rows
+2 -2
View File
@@ -732,7 +732,7 @@ def check_sl_tp_on_tick(
pass pass
reason = None 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" reason = "take_profit"
elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick):
reason = "stop_loss" reason = "stop_loss"
@@ -813,7 +813,7 @@ def check_monitors_locally(
pass pass
reason = None 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" reason = "take_profit"
elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick):
reason = "stop_loss" reason = "stop_loss"
+2
View File
@@ -48,6 +48,8 @@
.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} .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{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-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} .trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0}
.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center} .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} .trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem}
+45 -18
View File
@@ -313,6 +313,22 @@
return parseFloat(priceInput && priceInput.value) || 0; 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) { function calcRR(direction, entry, sl, tp) {
entry = parseFloat(entry); entry = parseFloat(entry);
sl = parseFloat(sl); sl = parseFloat(sl);
@@ -335,19 +351,22 @@
function updateRRDisplay() { function updateRRDisplay() {
var el = document.getElementById('trade-rr-hint'); var el = document.getElementById('trade-rr-hint');
if (!el) return; if (!el) return;
var trailingOn = isTrailingBeOn();
var dir = dirSelect ? dirSelect.value : 'long'; var dir = dirSelect ? dirSelect.value : 'long';
var entry = entryPrice(); var entry = entryPrice();
var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; 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 lots = effectiveLots();
var parts = []; var parts = [];
var rr = calcRR(dir, entry, sl, tp); if (!trailingOn) {
if (rr) parts.push('盈亏比 ' + rr + ':1'); var rr = calcRR(dir, entry, sl, tp);
if (rr) parts.push('盈亏比 ' + rr + ':1');
}
if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) { if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) {
if (lastPreviewMetrics.risk_amount != null) { if (lastPreviewMetrics.risk_amount != null) {
parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元'); 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) + ' 元'); parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元');
} }
} }
@@ -572,7 +591,7 @@
var sym = selectedSymbol(); var sym = selectedSymbol();
var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0;
var sl = parseFloat(slInput && slInput.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()) { if (isFixedMode()) {
var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1; var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1;
lotsCalc.value = String(fixedLots); lotsCalc.value = String(fixedLots);
@@ -671,12 +690,12 @@
} }
var lots = effectiveLots(); var lots = effectiveLots();
var trailingBeEl = document.getElementById('trailing-be'); var trailingBeEl = document.getElementById('trailing-be');
var trailingOn = !!(trailingBeEl && trailingBeEl.checked);
if (offset === 'open') { if (offset === 'open') {
if (!isTradingSession) { if (!isTradingSession) {
showOrderMsg('不在交易时间段', false); showOrderMsg('不在交易时间段', false);
return; return;
} }
var trailingOn = !!(trailingBeEl && trailingBeEl.checked);
if (trailingOn && !(slInput && slInput.value)) { if (trailingOn && !(slInput && slInput.value)) {
showOrderMsg('开启移动保本须填写止损价', false); showOrderMsg('开启移动保本须填写止损价', false);
return; return;
@@ -713,8 +732,8 @@
price: price, price: price,
order_type: priceType, order_type: priceType,
stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null, stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null,
take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null, take_profit: (trailingOn || !(tpInput && tpInput.value)) ? null : parseFloat(tpInput.value),
trailing_be: !!(trailingBeEl && trailingBeEl.checked) trailing_be: trailingOn
}; };
fetch('/api/trade/order', { fetch('/api/trade/order', {
method: 'POST', method: 'POST',
@@ -861,10 +880,12 @@
} else if (row.stop_loss != null) { } else if (row.stop_loss != null) {
parts.push('<span class="text-muted">止损已设</span>'); parts.push('<span class="text-muted">止损已设</span>');
} }
if (row.tp_order_active || row.tp_monitoring) { if (!row.trailing_be) {
parts.push('<span class="text-profit">止盈监控中</span>'); if (row.tp_order_active || row.tp_monitoring) {
} else if (row.take_profit != null) { parts.push('<span class="text-profit">止盈监控中</span>');
parts.push('<span class="text-muted">止盈已设</span>'); } else if (row.take_profit != null) {
parts.push('<span class="text-muted">止盈已设</span>');
}
} }
if (!parts.length) return '<span class="text-muted">未设置</span>'; if (!parts.length) return '<span class="text-muted">未设置</span>';
return parts.join(' · '); return parts.join(' · ');
@@ -926,8 +947,8 @@
var metaLine = var metaLine =
'状态 <strong class="text-accent">' + pendingLabel + '</strong>' + '状态 <strong class="text-accent">' + pendingLabel + '</strong>' +
' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' + ' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' +
(row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') + (!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
(row.stop_loss != null || row.take_profit != null ? ' · ' + slTpStatusHtml(row) : '') + (row.stop_loss != null || (!row.trailing_be && row.take_profit != null) ? ' · ' + slTpStatusHtml(row) : '') +
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') + (row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
(!isCloseOrder ? ' · <span class="text-muted">约 ' + remainMin + ' 分钟内未成交自动撤单</span>' : ''); (!isCloseOrder ? ' · <span class="text-muted">约 ' + remainMin + ' 分钟内未成交自动撤单</span>' : '');
return ( return (
@@ -988,13 +1009,14 @@
'<div class="pos-card-actions">' + entrustBtn + orderBtn + closeBtn + '</div>' : ''; '<div class="pos-card-actions">' + entrustBtn + orderBtn + closeBtn + '</div>' : '';
var metaLine = var metaLine =
'来源 <strong>' + (row.source_label || 'CTP') + '</strong>' + '来源 <strong>' + (row.source_label || 'CTP') + '</strong>' +
(row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') + (!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
' · 止损金额 <strong class="text-loss">' + ' · 止损金额 <strong class="text-loss">' +
(row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '</strong>' + (row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '</strong>' +
' · 盈利金额 <strong class="text-profit">' + (!row.trailing_be ?
(row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '</strong>' + (' · 盈利金额 <strong class="text-profit">' +
(row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '</strong>') : '') +
' · ' + slTpStatusHtml(row) + ' · ' + slTpStatusHtml(row) +
' · 移动保本 ' + trailingStatusHtml(row) + (row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
(slTpBtn ? ' · ' + slTpBtn : '') + (slTpBtn ? ' · ' + slTpBtn : '') +
(function () { (function () {
if (row.order_state === 'pending' || !row.monitor_id) return ''; if (row.order_state === 'pending' || !row.monitor_id) return '';
@@ -1565,6 +1587,11 @@
scheduleAutoCalc(); scheduleAutoCalc();
updateRRDisplay(); updateRRDisplay();
}); });
var trailingBeEl = document.getElementById('trailing-be');
if (trailingBeEl) {
trailingBeEl.addEventListener('change', updateTrailingBeUi);
updateTrailingBeUi();
}
if (priceInput) { if (priceInput) {
priceInput.addEventListener('input', function () { priceInput.addEventListener('input', function () {
if (priceType === 'limit') priceInput.dataset.manual = '1'; if (priceType === 'limit') priceInput.dataset.manual = '1';
+10 -6
View File
@@ -461,19 +461,21 @@ def _match_score(product: dict, q_lower: str) -> int:
return 10 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() q = query.strip()
if not q: if not q:
return [] return []
q_lower = q.lower() q_lower = q.lower()
from market_sessions import is_night_trading_session 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() night_only = is_night_trading_session()
product_pool = PRODUCTS product_pool = PRODUCTS
if capital is not None and is_small_account(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) product_pool = filter_products_for_capital(
PRODUCTS, capital, ctp_connected=ctp_connected,
)
with _main_index_lock: with _main_index_lock:
index = dict(_main_index) index = dict(_main_index)
index_ready = bool(index) index_ready = bool(index)
@@ -498,8 +500,10 @@ def search_symbols(query: str, *, capital: float | None = None) -> list:
codes = ths_to_codes(q) codes = ths_to_codes(q)
if codes: if codes:
product = _product_for_contract_code(codes["ths_code"]) product = _product_for_contract_code(codes["ths_code"])
if capital is not None and is_small_account(capital): if capital is not None and should_apply_small_account_scope(
from product_recommend import is_small_account, product_in_small_account_whitelist 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): if not product or not product_in_small_account_whitelist(product):
return results return results
raw = fetch_raw_for_volume(codes["sina_code"]) raw = fetch_raw_for_volume(codes["sina_code"])
+3 -2
View File
@@ -77,7 +77,7 @@
<input type="number" id="trade-price" step="any" placeholder="限价"> <input type="number" id="trade-price" step="any" placeholder="限价">
<p class="hint market-hint" id="market-hint" hidden>市价以 FAK 即时成交报单(非限价挂单)</p> <p class="hint market-hint" id="market-hint" hidden>市价以 FAK 即时成交报单(非限价挂单)</p>
</div> </div>
<div class="trade-field"> <div class="trade-field" id="field-tp">
<label class="text-label">止盈</label> <label class="text-label">止盈</label>
<input type="number" id="trade-tp" step="any"> <input type="number" id="trade-tp" step="any">
</div> </div>
@@ -90,9 +90,10 @@
<div class="trade-action-row"> <div class="trade-action-row">
<label class="trailing-be-toggle"> <label class="trailing-be-toggle">
<input type="checkbox" id="trailing-be" checked> <input type="checkbox" id="trailing-be">
<span>移动保本</span> <span>移动保本</span>
</label> </label>
<p class="hint trailing-be-hint" id="trailing-be-hint" hidden>已开启:仅监控止损,达 1R 后移动保本平仓</p>
<span class="hint trade-rr-hint" id="trade-rr-hint" hidden></span> <span class="hint trade-rr-hint" id="trade-rr-hint" hidden></span>
<button type="button" class="btn-primary btn-open" id="btn-open">开仓</button> <button type="button" class="btn-primary btn-open" id="btn-open">开仓</button>
<p class="hint session-hint text-muted" id="session-hint" hidden>不在交易时间段</p> <p class="hint session-hint text-muted" id="session-hint" hidden>不在交易时间段</p>
+11
View File
@@ -91,5 +91,16 @@ def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
return 0.0 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: def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘" return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"