feat: 盈亏比与亏损额度展示,市价FAK报单,修复止盈止损保存失败
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+31
-69
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user