合并期货下单与持仓监控为统一界面,移除手工录入。

策略与 CTP 自动同步持仓,新增 /api/trading/live 聚合展示与平仓接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 10:18:00 +08:00
parent 6e423eebfb
commit 7b8a660309
8 changed files with 396 additions and 233 deletions
+5 -16
View File
@@ -1,20 +1,9 @@
.trade-page{max-width:720px;margin:0 auto}
.trade-page{max-width:960px;margin:0 auto}
.trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1rem}
.trade-order-card{padding:1.25rem}
.trade-tabs{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem}
.trade-tabs span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem}
.trade-tabs a{color:var(--text-muted);text-decoration:none}
.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem}
.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)}
.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:1rem 0}
.trade-btn{border:none;border-radius:8px;padding:.75rem .35rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.15rem;color:#fff;font-weight:600}
.trade-btn .btn-price{font-size:1.1rem}
.trade-btn .btn-label{font-size:.85rem}
.trade-btn .btn-sub{font-size:.68rem;opacity:.85;font-weight:400}
.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)}
.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)}
.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)}
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border)}
.trade-subnav{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem}
.trade-subnav span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem}
.trade-subnav a{color:var(--text-muted);text-decoration:none}
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border);margin-top:1rem}
.trade-footer strong{color:var(--accent)}
.rec-blocked td{opacity:.55}
.rec-ok td:first-child{font-weight:600}
+133 -97
View File
@@ -1,95 +1,142 @@
(function () {
var symInput = document.getElementById('trade-symbol');
var lotsInput = document.getElementById('trade-lots');
var priceInput = document.getElementById('trade-price');
var footer = document.getElementById('trade-footer');
var slInput = document.getElementById('trade-sl');
var tpInput = document.getElementById('trade-tp');
var debounceTimer;
var list = document.getElementById('position-live-list');
var pollTimer = null;
function selectedSymbol() {
return (symInput && symInput.value || '').trim();
function fmtNum(v, digits) {
if (v === null || v === undefined) return '--';
return Number(v).toFixed(digits === undefined ? 2 : digits);
}
function refreshQuote() {
var sym = selectedSymbol();
var lots = lotsInput ? lotsInput.value : '1';
if (!sym) return;
fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots))
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) return;
if (priceInput && !priceInput.dataset.manual && data.price) {
priceInput.value = data.price;
}
var px = data.price != null ? data.price : '—';
['px-long', 'px-short'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = px;
});
var ml = document.getElementById('max-long');
var ms = document.getElementById('max-short');
if (ml) ml.textContent = '≤' + (data.max_open_long || '—');
if (ms) ms.textContent = '≤' + (data.max_open_short || '—');
document.getElementById('pos-long').textContent = '≤' + (data.pos_long || 0);
document.getElementById('pos-short').textContent = '≤' + (data.pos_short || 0);
if (footer && data.metrics) {
var m = data.metrics;
footer.innerHTML =
'<p><strong>' + (data.name || sym) + '</strong> ' + (data.footer_text || '') + '</p>' +
'<p>价格精度 <strong>' + m.price_precision + '</strong> 位 · ' +
'最小变动 <strong>' + m.tick_size + '</strong> · ' +
'每跳 <strong>' + m.tick_value_per_lot + '</strong> 元/手 · ' +
'当前 <strong>' + lots + '</strong> 手每跳合计 <strong class="text-accent">' + m.tick_value_total + '</strong> 元</p>' +
(m.margin_total ? '<p class="text-muted">预估保证金约 ' + m.margin_total + ' 元</p>' : '');
}
}).catch(function () {});
function buildPosCard(row) {
var pnlClass = '';
if (row.float_pnl > 0) pnlClass = 'pnl-pos';
if (row.float_pnl < 0) pnlClass = 'pnl-neg';
var pnlText = '--';
if (row.float_pnl != null) {
var sign = row.float_pnl >= 0 ? '+' : '';
pnlText = sign + fmtNum(row.float_pnl) + '元';
if (row.float_pct != null) {
pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)';
}
}
var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--';
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
var closeBtn = '';
if (row.close_url) {
closeBtn =
'<form method="post" action="' + row.close_url + '" style="display:inline" onsubmit="return confirm(\'确认平仓?\')">' +
'<button type="submit" class="btn-del pos-del">平仓</button></form>';
} else if (row.can_close) {
closeBtn =
'<button type="button" class="btn-del pos-del" data-close=\'' + JSON.stringify({
source: row.source,
symbol_code: row.symbol_code,
direction: row.direction,
lots: row.lots,
mark_price: row.mark_price,
monitor_id: row.monitor_id || null
}) + '\'>平仓</button>';
}
var metaParts = ['来源 <strong>' + (row.source_label || row.source) + '</strong>'];
if (row.risk_pct != null) {
metaParts.push('风险 <strong>' + fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元</strong>');
}
if (row.tick_value_total != null) {
metaParts.push('每跳 <strong>' + fmtNum(row.tick_value_total) + '元</strong>');
}
var slTp =
'<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' +
'<div class="cell"><label>止盈</label><div>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</div></div>';
var footerParts = ['张数 ' + row.lots];
if (row.margin != null) footerParts.push('保证金 ' + fmtNum(row.margin) + '元');
if (row.position_pct != null) footerParts.push('仓位占比 ' + fmtNum(row.position_pct) + '%');
if (openT) footerParts.push('开仓 ' + openT);
if (row.holding_duration) footerParts.push('持仓 ' + row.holding_duration);
if (row.est_fee != null) footerParts.push('手续费(估) ' + fmtNum(row.est_fee) + '元');
return (
'<div class="pos-card" data-key="' + (row.key || '') + '">' +
'<div class="pos-card-head">' +
'<div><div class="title">' + row.symbol + ' <span class="badge dir">' + dirBadge + '</span></div>' +
(row.symbol_code && row.symbol_code !== row.symbol ? '<div class="text-muted" style="font-size:.72rem;margin-top:.15rem">' + row.symbol_code + '</div>' : '') +
'</div>' + closeBtn + '</div>' +
'<div class="pos-card-meta">' + metaParts.join(' · ') + '</div>' +
'<div class="pos-metrics">' +
'<div class="cell"><label>成交价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
slTp +
'<div class="cell"><label>盈亏比</label><div>' + rr + '</div></div>' +
'<div class="cell"><label>标记价</label><div>' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '</div></div>' +
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
(row.est_fee != null ?
'<div class="cell"><label>预估手续费</label><div>' + fmtNum(row.est_fee) + '元</div></div>' +
'<div class="cell ' + (row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : '')) + '">' +
'<label>扣费后</label><div>' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '</div></div>'
: '') +
'</div>' +
'<div class="pos-footer">' + footerParts.map(function (s) { return '<span>' + s + '</span>'; }).join('') + '</div>' +
'</div>'
);
}
function scheduleRefresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshQuote, 400);
}
if (symInput) symInput.addEventListener('input', scheduleRefresh);
if (lotsInput) lotsInput.addEventListener('input', scheduleRefresh);
if (priceInput) {
priceInput.addEventListener('input', function () {
priceInput.dataset.manual = '1';
});
}
function postOrder(offset, direction) {
var sym = selectedSymbol();
if (!sym) { alert('请选择品种'); return; }
var body = {
symbol: sym,
offset: offset,
direction: direction,
lots: parseInt(lotsInput.value, 10) || 1,
price: parseFloat(priceInput.value) || 0,
stop_loss: slInput ? parseFloat(slInput.value) : null,
take_profit: tpInput ? parseFloat(tpInput.value) : null
};
fetch('/api/trade/order', {
function closePosition(payload) {
var price = payload.mark_price;
if (!price || price <= 0) {
alert('无法获取现价,请稍后重试');
return;
}
if (!confirm('确认以 ' + price + ' 限价平仓 ' + payload.lots + ' 手?')) return;
fetch('/api/trading/close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) { alert(data.error || '下单失败'); return; }
alert('已提交 ' + (data.lots || '') + ' 手');
location.reload();
});
body: JSON.stringify({
source: payload.source,
symbol_code: payload.symbol_code,
direction: payload.direction,
lots: payload.lots,
price: price,
monitor_id: payload.monitor_id
})
}).then(function (r) { return r.json(); }).then(function (d) {
if (!d.ok) { alert(d.error || '平仓失败'); return; }
pollPositions();
}).catch(function () { alert('平仓请求失败'); });
}
var btnLong = document.getElementById('btn-open-long');
var btnShort = document.getElementById('btn-open-short');
var btnCloseL = document.getElementById('btn-close-long');
var btnCloseS = document.getElementById('btn-close-short');
if (btnLong) btnLong.addEventListener('click', function () { postOrder('open', 'long'); });
if (btnShort) btnShort.addEventListener('click', function () { postOrder('open', 'short'); });
if (btnCloseL) btnCloseL.addEventListener('click', function () { postOrder('close', 'long'); });
if (btnCloseS) btnCloseS.addEventListener('click', function () { postOrder('close', 'short'); });
function pollPositions() {
if (!list) return;
fetch('/api/trading/live')
.then(function (r) { return r.json(); })
.then(function (data) {
var cap = document.getElementById('cap-display');
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
var ctpBadge = document.getElementById('ctp-badge');
if (ctpBadge && data.ctp_status) {
ctpBadge.textContent = data.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (data.ctp_status.connected ? 'profit' : 'planned');
}
var rows = data.rows || [];
if (!rows.length) {
list.innerHTML = '<div class="empty-hint">暂无持仓。请先在「策略交易」开仓,或连接 CTP 同步柜台持仓。</div>';
return;
}
list.innerHTML = rows.map(buildPosCard).join('');
list.querySelectorAll('[data-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
closePosition(JSON.parse(btn.getAttribute('data-close')));
});
});
})
.catch(function () {
if (list.innerHTML.indexOf('pos-card') < 0) {
list.innerHTML = '<div class="empty-hint text-loss">加载失败,请刷新页面</div>';
}
});
}
var btnConnect = document.getElementById('btn-ctp-connect');
if (btnConnect) {
@@ -109,19 +156,8 @@
});
}
setInterval(function () {
fetch('/api/account_snapshot').then(function (r) { return r.json(); }).then(function (d) {
var cap = document.getElementById('cap-display');
if (cap && d.capital != null) cap.textContent = Number(d.capital).toFixed(2);
var badge = document.getElementById('risk-badge');
if (badge && d.risk_status) badge.textContent = d.risk_status.status_label;
var ctpBadge = document.getElementById('ctp-badge');
if (ctpBadge && d.ctp_status) {
ctpBadge.textContent = d.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (d.ctp_status.connected ? 'profit' : 'planned');
}
}).catch(function () {});
}, 5000);
scheduleRefresh();
document.addEventListener('DOMContentLoaded', function () {
pollPositions();
pollTimer = setInterval(pollPositions, 2000);
});
})();