feat: 盈亏比与亏损额度展示,市价FAK报单,修复止盈止损保存失败

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 14:33:05 +08:00
parent 367f32dd82
commit 63beda3c71
5 changed files with 125 additions and 88 deletions
+31 -69
View File
@@ -531,6 +531,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"current_price": mark,
"margin": pos_metrics.get("margin"),
"position_pct": pos_metrics.get("position_pct"),
"risk_amount": pos_metrics.get("risk_amount") if sl is not None else None,
"risk_pct": pos_metrics.get("risk_pct") if sl is not None else None,
"rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None,
"float_pnl": float_pnl,
"est_fee": fee_info["total_fee"],
"est_fee_open": fee_info["open_fee"],
@@ -818,7 +821,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
@app.route("/api/trading/monitor/upsert", methods=["POST"])
@login_required
def api_trading_monitor_upsert():
"""为已有 CTP 持仓补充/更新本地止盈止损监控。"""
"""为已有持仓补充/更新本地止盈止损监控。"""
d = request.get_json(silent=True) or {}
sym = (d.get("symbol_code") or d.get("symbol") or "").strip()
direction = (d.get("direction") or "long").strip().lower()
@@ -834,10 +837,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if sl is None and tp is None:
return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
has_pos = False
for p in _ctp_positions(mode):
conn = get_db()
try:
init_strategy_tables(conn)
mon = _find_active_monitor(conn, sym, direction)
has_pos = bool(mon)
ths_sym = sym
if ctp_status(mode).get("connected"):
for p in _ctp_positions(mode, refresh_if_empty=False):
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
@@ -846,75 +853,30 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
has_pos = True
lots = int(p.get("lots") or lots)
entry = float(p.get("avg_price") or entry or 0)
sym = (p.get("symbol") or sym).strip()
ths_sym = _ctp_pos_to_ths_code(p) or sym
break
if not has_pos:
return jsonify({"ok": False, "error": "柜台无对应持仓"}), 400
conn = get_db()
try:
init_strategy_tables(conn)
mon = None
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active'"
).fetchall():
row = dict(r)
if row.get("direction") != direction:
continue
if _match_ctp_symbol(sym, row.get("symbol") or ""):
mon = row
break
codes = ths_to_codes(sym)
now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if "trailing_be" in d:
trailing_be = 1 if d.get("trailing_be") else 0
elif mon:
trailing_be = int(mon.get("trailing_be") or 0)
else:
trailing_be = 0
ensure_monitor_order_columns(conn)
if mon:
initial_sl = mon.get("initial_stop_loss")
if sl is not None and initial_sl is None:
initial_sl = sl
conn.execute(
"""UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=?,
initial_stop_loss=?, trailing_be=?
WHERE id=?""",
(
sl, tp, lots, entry or mon.get("entry_price"),
initial_sl, trailing_be,
mon["id"],
),
return jsonify({"ok": False, "error": "未找到对应持仓"}), 400
trailing_be = 1 if d.get("trailing_be") else (
int(mon.get("trailing_be") or 0) if mon else 0
)
mid = mon["id"]
else:
conn.execute(
"""INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, initial_stop_loss, trailing_be,
open_time, monitor_type, status
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""",
(
sym,
codes.get("name", sym) if codes else sym,
codes.get("market_code", "") if codes else "",
direction,
lots,
entry,
sl,
tp,
sl,
trailing_be,
now_s,
"manual",
),
mid = _upsert_open_monitor(
conn,
sym=ths_sym,
direction=direction,
lots=lots,
price=entry,
sl=sl,
tp=tp,
trailing_be=trailing_be,
)
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.commit()
mon_row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
return jsonify({"ok": True, "monitor_id": mid, "message": "止盈止损已保存,程序本地监控"})
_push_position_snapshot_async()
return jsonify({
"ok": True,
"monitor_id": mid,
"message": "止盈止损已保存,程序本地监控",
})
finally:
conn.close()
+1
View File
@@ -36,6 +36,7 @@
.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}
.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}
.trade-order-msg.ok{color:var(--profit)}
+78 -7
View File
@@ -208,6 +208,43 @@
return parseFloat(priceInput && priceInput.value) || 0;
}
function calcRR(direction, entry, sl, tp) {
entry = parseFloat(entry);
sl = parseFloat(sl);
tp = parseFloat(tp);
if (!entry || !sl || !tp) return null;
var risk, reward;
if (direction === 'long') {
risk = entry - sl;
reward = tp - entry;
} else if (direction === 'short') {
risk = sl - entry;
reward = entry - tp;
} else {
return null;
}
if (risk <= 0 || reward <= 0) return null;
return (reward / risk).toFixed(2);
}
function updateRRDisplay() {
var el = document.getElementById('trade-rr-hint');
if (!el) return;
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 rr = calcRR(dir, entry, sl, tp);
if (rr) {
el.textContent = '盈亏比 ' + rr + ':1';
el.hidden = false;
} else {
el.textContent = '';
el.hidden = true;
}
}
function setPriceType(type) {
priceType = type === 'market' ? 'market' : 'limit';
document.querySelectorAll('.price-tab').forEach(function (btn) {
@@ -218,6 +255,7 @@
if (priceType === 'market' && lastQuotePrice) priceInput.value = lastQuotePrice;
}
if (marketHint) marketHint.hidden = priceType !== 'market';
updateRRDisplay();
}
function updateCtpBadge(connected, connecting) {
@@ -547,7 +585,7 @@
'<button type="button" class="pos-dismiss-btn pos-sl-btn" data-sl-tp="' +
encodeURIComponent(JSON.stringify({
symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null
})) + '">设置止盈止损</button>' : '';
var orderBtn = '';
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) {
@@ -561,12 +599,19 @@
'<button type="button" class="pos-close-btn" data-close="' + closePayload + '">平仓</button>' : '';
var actionBtns = (orderBtn || closeBtn) ?
'<div class="pos-card-actions">' + orderBtn + closeBtn + '</div>' : '';
var riskMeta = '';
if (row.rr_ratio != null) {
riskMeta += ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>';
}
if (row.risk_amount != null) {
riskMeta += ' · 亏损额度 <strong>' + fmtNum(row.risk_amount) + ' 元</strong>';
}
return (
'<div class="pos-card">' +
'<div class="pos-card-head"><div><div class="title">' + row.symbol + ' <span class="badge dir">' + dirBadge + '</span></div>' +
'<div class="text-muted" style="font-size:.72rem">' + (row.symbol_code || '') + '</div></div>' +
actionBtns + '</div>' +
'<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong>' +
'<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong>' + riskMeta +
(row.sync_pending ? ' · <span class="text-muted">同步柜台中…</span>' : '') +
' · 浮盈' +
(slTpBtn ? ' · ' + slTpBtn : '') +
@@ -652,22 +697,33 @@
fetch('/api/trading/monitor/upsert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
symbol_code: payload.symbol_code,
direction: payload.direction,
lots: payload.lots,
entry_price: payload.entry_price,
monitor_id: payload.monitor_id || null,
stop_loss: sl,
take_profit: tp
})
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.error || ('HTTP ' + r.status));
});
}
return r.json();
})
.then(function (d) {
if (!d.ok) throw new Error(d.error || '保存失败');
pollPositions();
})
.catch(function (e) {
alert(e.message || '保存失败');
var msg = e.message || '保存失败';
if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行';
alert(msg);
if (btn) {
btn.disabled = false;
btn.textContent = '设置止盈止损';
@@ -830,13 +886,27 @@
checkLotsLimit();
});
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc);
if (slInput) {
slInput.addEventListener('input', function () {
scheduleAutoCalc();
updateRRDisplay();
});
}
if (tpInput) {
tpInput.addEventListener('input', function () {
scheduleAutoCalc();
updateRRDisplay();
});
}
if (dirSelect) dirSelect.addEventListener('change', function () {
scheduleAutoCalc();
updateRRDisplay();
});
if (priceInput) {
priceInput.addEventListener('input', function () {
if (priceType === 'limit') priceInput.dataset.manual = '1';
scheduleAutoCalc();
updateRRDisplay();
});
}
@@ -869,6 +939,7 @@
}
});
updateSessionUi();
updateRRDisplay();
scheduleQuote();
});
})();
+2 -1
View File
@@ -67,7 +67,7 @@
<button type="button" class="price-tab" data-type="market">市价</button>
</div>
<input type="number" id="trade-price" step="any" placeholder="限价">
<p class="hint market-hint" id="market-hint" hidden>市价将按最新行情价报单</p>
<p class="hint market-hint" id="market-hint" hidden>市价以 FAK 即时成交报单(非限价挂单)</p>
</div>
<div class="trade-field">
<label class="text-label">止盈</label>
@@ -85,6 +85,7 @@
<input type="checkbox" id="trailing-be" checked>
<span>移动保本</span>
</label>
<span class="hint trade-rr-hint" id="trade-rr-hint" hidden></span>
<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="trade-order-msg" id="order-msg" hidden></p>
+4 -2
View File
@@ -825,10 +825,11 @@ class CtpBridge:
raise ValueError(f"未知开平: {offset}")
use_market = (order_type or "limit").lower() == "market"
ot = OrderType.LIMIT
if use_market:
ot = OrderType.FAK
price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price)
else:
ot = OrderType.LIMIT
price = round_to_tick(float(price), tick)
if price <= 0:
raise ValueError("委托价格无效,请检查行情或手动填写价格")
@@ -929,7 +930,8 @@ def ctp_get_account(mode: str) -> dict[str, Any]:
def ctp_list_positions(mode: str, *, refresh_if_empty: bool = True) -> list[dict[str, Any]]:
b = get_bridge()
b.ensure_connected(mode)
if b.connected_mode != mode or not b.ping():
return []
return b.list_positions(refresh_if_empty=refresh_if_empty)