Add trailing BE to SL/TP dialog and speed up position refresh.

Use modal for monitor upsert with trailing checkbox, refresh CTP tick every second, and push full snapshot after orders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 09:32:56 +08:00
parent d366344b0f
commit fd2dba22fd
4 changed files with 144 additions and 21 deletions
+19 -7
View File
@@ -1045,11 +1045,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
tp = float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None tp = float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None
holding = _holding_duration(open_time, now_iso) if open_time else "" holding = _holding_duration(open_time, now_iso) if open_time else ""
if (mark is None or float(mark or 0) <= 0) and not fast and ctp_status(mode).get("connected"): if ctp_status(mode).get("connected"):
live_mark = ctp_get_tick_price(mode, sym) live_mark = ctp_get_tick_price(mode, sym)
if live_mark and live_mark > 0: if live_mark and live_mark > 0:
mark = live_mark mark = live_mark
if (mark is None or float(mark or 0) <= 0) and not fast and codes: elif (mark is None or float(mark or 0) <= 0) and not fast and codes:
mark = fetch_price( mark = fetch_price(
sym, sym,
codes.get("market_code", ""), codes.get("market_code", ""),
@@ -1780,6 +1780,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return jsonify({"ok": False, "error": "缺少品种代码"}), 400 return jsonify({"ok": False, "error": "缺少品种代码"}), 400
if sl is None and tp is None: if sl is None and tp is None:
return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400 return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400
trailing_on = bool(d.get("trailing_be"))
if trailing_on and sl is None:
return jsonify({"ok": False, "error": "移动保本须填写止损价"}), 400
if trailing_on:
tp = None
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
conn = get_db() conn = get_db()
try: try:
@@ -1801,7 +1806,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
break break
if not has_pos: if not has_pos:
return jsonify({"ok": False, "error": "未找到对应持仓"}), 400 return jsonify({"ok": False, "error": "未找到对应持仓"}), 400
trailing_be = 1 if d.get("trailing_be") else ( trailing_be = 1 if trailing_on else (
int(mon.get("trailing_be") or 0) if mon else 0 int(mon.get("trailing_be") or 0) if mon else 0
) )
mid = _upsert_open_monitor( mid = _upsert_open_monitor(
@@ -1814,8 +1819,15 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
tp=tp, tp=tp,
trailing_be=trailing_be, trailing_be=trailing_be,
) )
if trailing_on and sl is not None:
conn.execute(
"""UPDATE trade_order_monitors SET
take_profit=NULL, initial_stop_loss=?, trailing_r_locked=0
WHERE id=?""",
(sl, mid),
)
conn.commit() conn.commit()
_push_position_snapshot_async() _push_position_snapshot_async(fast=False)
return jsonify({ return jsonify({
"ok": True, "ok": True,
"monitor_id": mid, "monitor_id": mid,
@@ -2419,7 +2431,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}" f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}"
) )
conn.close() conn.close()
_push_position_snapshot_async() _push_position_snapshot_async(fast=False)
return jsonify({ return jsonify({
"ok": True, "ok": True,
"result": result, "result": result,
@@ -3196,8 +3208,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
start_position_worker( start_position_worker(
refresh_fn=_position_worker_refresh, refresh_fn=_position_worker_refresh,
interval=2, interval=1,
idle_interval=5, idle_interval=3,
) )
_bootstrap_trading_runtime() _bootstrap_trading_runtime()
start_ctp_fee_worker( start_ctp_fee_worker(
+7
View File
@@ -116,6 +116,13 @@
.pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)} .pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)}
.pos-order-btn:disabled:not(.pos-order-done){cursor:wait} .pos-order-btn:disabled:not(.pos-order-done){cursor:wait}
.sl-tp-modal{max-width:420px;width:100%}
.sl-tp-modal-fields{display:flex;flex-direction:column;gap:.75rem;margin-bottom:1rem}
.sl-tp-modal-fields .trade-field{margin:0}
.sl-tp-modal-trailing{margin-top:.15rem}
.sl-tp-modal-actions{display:flex;gap:.5rem;justify-content:flex-end}
.sl-tp-modal-actions .btn-secondary,.sl-tp-modal-actions .btn-primary{width:auto;min-width:5rem;padding:.45rem 1rem;font-size:.85rem}
@media (min-width:768px) and (max-width:1100px){ @media (min-width:768px) and (max-width:1100px){
.trade-split .card{min-height:420px} .trade-split .card{min-height:420px}
.trade-form-line.line-3{grid-template-columns:1fr 1fr} .trade-form-line.line-3{grid-template-columns:1fr 1fr}
+93 -14
View File
@@ -1061,16 +1061,18 @@
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
var closeAllowed = row.close_allowed !== false && isTradingSession; var closeAllowed = row.close_allowed !== false && isTradingSession;
var slTpBtn = (!row.stop_loss && !row.take_profit && row.can_close) ? var slTpBtn = (!row.stop_loss && !row.take_profit && !row.trailing_be && row.can_close) ?
'<button type="button" class="pos-dismiss-btn pos-sl-btn" data-sl-tp="' + '<button type="button" class="pos-dismiss-btn pos-sl-btn" data-sl-tp="' +
encodeURIComponent(JSON.stringify({ encodeURIComponent(JSON.stringify({
symbol_code: row.symbol_code, direction: row.direction, symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null,
trailing_be: !!row.trailing_be
})) + '">设置止盈止损</button>' : ''; })) + '">设置止盈止损</button>' : '';
var editPayload = encodeURIComponent(JSON.stringify({ var editPayload = encodeURIComponent(JSON.stringify({
symbol_code: row.symbol_code, direction: row.direction, symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null, lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null,
stop_loss: row.stop_loss, take_profit: row.take_profit stop_loss: row.stop_loss, take_profit: row.take_profit,
trailing_be: !!row.trailing_be
})); }));
var entrustBtn = row.can_close ? var entrustBtn = row.can_close ?
'<button type="button" class="pos-order-btn pos-entrust-btn" data-edit-sl-tp="' + editPayload + '">委托</button>' : ''; '<button type="button" class="pos-order-btn pos-entrust-btn" data-edit-sl-tp="' + editPayload + '">委托</button>' : '';
@@ -1172,16 +1174,66 @@
}); });
} }
function promptStopTakeProfit(payload, btn, btnLabel) { var slTpModalState = { payload: null, btn: null, btnLabel: '设置止盈止损' };
btnLabel = btnLabel || '设置止盈止损';
var slDefault = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : ''; function syncSlTpModalTrailingUi() {
var tpDefault = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : ''; var trailingEl = document.getElementById('sl-tp-modal-trailing');
var slRaw = prompt('止损价(可留空)', slDefault); var tpWrap = document.getElementById('sl-tp-modal-tp-wrap');
if (slRaw === null) return; var hint = document.getElementById('sl-tp-modal-trailing-hint');
var tpRaw = prompt('止盈价(可留空)', tpDefault); var on = !!(trailingEl && trailingEl.checked);
if (tpRaw === null) return; if (tpWrap) tpWrap.hidden = on;
var sl = slRaw.trim() ? parseFloat(slRaw) : null; if (hint) hint.hidden = !on;
var tp = tpRaw.trim() ? parseFloat(tpRaw) : null; if (on) {
var tpInput = document.getElementById('sl-tp-modal-tp');
if (tpInput) tpInput.value = '';
}
}
function closeSlTpModal() {
var mask = document.getElementById('sl-tp-modal');
if (mask) mask.classList.remove('show');
slTpModalState.payload = null;
slTpModalState.btn = null;
}
function openSlTpModal(payload, btn, btnLabel) {
var mask = document.getElementById('sl-tp-modal');
var title = document.getElementById('sl-tp-modal-title');
var slInput = document.getElementById('sl-tp-modal-sl');
var tpInput = document.getElementById('sl-tp-modal-tp');
var trailingEl = document.getElementById('sl-tp-modal-trailing');
if (!mask || !slInput) return;
slTpModalState.payload = payload;
slTpModalState.btn = btn || null;
slTpModalState.btnLabel = btnLabel || '设置止盈止损';
if (title) title.textContent = slTpModalState.btnLabel;
slInput.value = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : '';
if (tpInput) {
tpInput.value = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : '';
}
if (trailingEl) trailingEl.checked = !!payload.trailing_be;
syncSlTpModalTrailingUi();
mask.classList.add('show');
slInput.focus();
}
function saveSlTpModal() {
var payload = slTpModalState.payload;
if (!payload) return;
var btn = slTpModalState.btn;
var btnLabel = slTpModalState.btnLabel;
var slInput = document.getElementById('sl-tp-modal-sl');
var tpInput = document.getElementById('sl-tp-modal-tp');
var trailingEl = document.getElementById('sl-tp-modal-trailing');
var trailingOn = !!(trailingEl && trailingEl.checked);
var slRaw = slInput && slInput.value ? slInput.value.trim() : '';
var tpRaw = trailingOn ? '' : (tpInput && tpInput.value ? tpInput.value.trim() : '');
var sl = slRaw ? parseFloat(slRaw) : null;
var tp = tpRaw ? parseFloat(tpRaw) : null;
if (trailingOn && (sl == null || isNaN(sl))) {
alert('移动保本须填写止损价');
return;
}
if (sl == null && tp == null) { if (sl == null && tp == null) {
alert('请至少填写止损或止盈'); alert('请至少填写止损或止盈');
return; return;
@@ -1190,6 +1242,8 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = '保存中…'; btn.textContent = '保存中…';
} }
var saveBtn = document.getElementById('sl-tp-modal-save');
if (saveBtn) saveBtn.disabled = true;
fetch('/api/trading/monitor/upsert', { fetch('/api/trading/monitor/upsert', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1201,7 +1255,8 @@
entry_price: payload.entry_price, entry_price: payload.entry_price,
monitor_id: payload.monitor_id || null, monitor_id: payload.monitor_id || null,
stop_loss: sl, stop_loss: sl,
take_profit: tp take_profit: tp,
trailing_be: trailingOn
}) })
}) })
.then(function (r) { .then(function (r) {
@@ -1214,6 +1269,7 @@
}) })
.then(function (d) { .then(function (d) {
if (!d.ok) throw new Error(d.error || '保存失败'); if (!d.ok) throw new Error(d.error || '保存失败');
closeSlTpModal();
pollPositions(); pollPositions();
}) })
.catch(function (e) { .catch(function (e) {
@@ -1224,9 +1280,31 @@
btn.disabled = false; btn.disabled = false;
btn.textContent = btnLabel; btn.textContent = btnLabel;
} }
})
.finally(function () {
if (saveBtn) saveBtn.disabled = false;
}); });
} }
function bindSlTpModal() {
var mask = document.getElementById('sl-tp-modal');
var trailingEl = document.getElementById('sl-tp-modal-trailing');
var cancelBtn = document.getElementById('sl-tp-modal-cancel');
var saveBtn = document.getElementById('sl-tp-modal-save');
if (trailingEl) trailingEl.addEventListener('change', syncSlTpModalTrailingUi);
if (cancelBtn) cancelBtn.addEventListener('click', closeSlTpModal);
if (saveBtn) saveBtn.addEventListener('click', saveSlTpModal);
if (mask) {
mask.addEventListener('click', function (e) {
if (e.target === mask) closeSlTpModal();
});
}
}
function promptStopTakeProfit(payload, btn, btnLabel) {
openSlTpModal(payload, btn, btnLabel || '设置止盈止损');
}
function bindSlTpButtons(root) { function bindSlTpButtons(root) {
if (!root) return; if (!root) return;
root.querySelectorAll('[data-sl-tp]').forEach(function (btn) { root.querySelectorAll('[data-sl-tp]').forEach(function (btn) {
@@ -1754,6 +1832,7 @@
} }
pollPositions(); pollPositions();
connectPositionStream(); connectPositionStream();
bindSlTpModal();
initCtpOnLoad(); initCtpOnLoad();
connectRecommendStream(); connectRecommendStream();
initRecommendSortControls(); initRecommendSortControls();
+25
View File
@@ -269,6 +269,31 @@
</div> </div>
</div> </div>
</div> </div>
<div id="sl-tp-modal" class="modal-mask" role="dialog" aria-labelledby="sl-tp-modal-title">
<div class="modal-box sl-tp-modal">
<h3 id="sl-tp-modal-title">设置止盈止损</h3>
<div class="sl-tp-modal-fields">
<div class="trade-field">
<label class="text-label" for="sl-tp-modal-sl">止损</label>
<input type="number" id="sl-tp-modal-sl" step="any" placeholder="必填(移动保本须填止损)">
</div>
<div class="trade-field" id="sl-tp-modal-tp-wrap">
<label class="text-label" for="sl-tp-modal-tp">止盈</label>
<input type="number" id="sl-tp-modal-tp" step="any" placeholder="可留空">
</div>
<label class="trailing-be-toggle sl-tp-modal-trailing">
<input type="checkbox" id="sl-tp-modal-trailing">
<span>移动保本</span>
</label>
<p class="hint" id="sl-tp-modal-trailing-hint" hidden>开启后不设固定止盈;达 1R 后止损移至开仓价 ± 缓冲跳</p>
</div>
<div class="sl-tp-modal-actions">
<button type="button" class="btn-secondary" id="sl-tp-modal-cancel">取消</button>
<button type="button" class="btn-primary" id="sl-tp-modal-save">保存</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script type="application/json" id="trade-page-data">{{ { <script type="application/json" id="trade-page-data">{{ {