Files
qihuo/static/js/trade.js
T
dekun aea9aca472 feat: 品种推荐与下单显示主力合约
推荐列表展示当前主力代码;下单品种支持中文/代码搜索并按交易所分组选择主力合约。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 12:56:06 +08:00

473 lines
20 KiB
JavaScript

(function () {
var sizingMode = window.TRADE_SIZING_MODE || 'risk';
var list = document.getElementById('position-live-list');
var recommendList = document.getElementById('recommend-list');
var symInput = document.getElementById('trade-symbol');
var dirSelect = document.getElementById('trade-direction');
var lotsInput = document.getElementById('trade-lots');
var lotsCalc = document.getElementById('trade-lots-calc');
var priceInput = document.getElementById('trade-price');
var slInput = document.getElementById('trade-sl');
var tpInput = document.getElementById('trade-tp');
var marketHint = document.getElementById('market-hint');
var metricsHint = document.getElementById('trade-metrics-hint');
var pollTimer = null;
var recommendSource = null;
var quoteTimer = null;
var calcTimer = null;
var lastQuotePrice = null;
var priceType = 'limit';
var lastCtpReconnectAt = 0;
var ctpReconnecting = false;
function runWhenReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
function fmtNum(v, digits) {
if (v === null || v === undefined) return '--';
return Number(v).toFixed(digits === undefined ? 2 : digits);
}
function selectedSymbol() {
var codeEl = document.getElementById('trade-symbol-code');
var code = codeEl && codeEl.value ? codeEl.value.trim() : '';
if (code) return code;
return (symInput && symInput.value || '').trim();
}
function isRiskMode() {
return sizingMode === 'risk';
}
function effectiveLots() {
if (isRiskMode()) {
var v = parseInt(lotsCalc && lotsCalc.value, 10);
return v > 0 ? v : 0;
}
return parseInt(lotsInput && lotsInput.value, 10) || 1;
}
function entryPrice() {
if (priceType === 'market') return lastQuotePrice;
return parseFloat(priceInput && priceInput.value) || 0;
}
function setPriceType(type) {
priceType = type === 'market' ? 'market' : 'limit';
document.querySelectorAll('.price-tab').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-type') === priceType);
});
if (priceInput) {
priceInput.disabled = priceType === 'market';
if (priceType === 'market' && lastQuotePrice) priceInput.value = lastQuotePrice;
}
if (marketHint) marketHint.hidden = priceType !== 'market';
}
function updateCtpBadge(connected) {
var ctpBadge = document.getElementById('ctp-badge');
var btnConnect = document.getElementById('btn-ctp-connect');
if (ctpBadge) {
ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned');
}
if (btnConnect && connected) {
btnConnect.textContent = '重连 CTP';
}
}
function refreshQuote() {
var sym = selectedSymbol();
var lots = isRiskMode() ? (effectiveLots() || 1) : (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;
lastQuotePrice = data.price;
if (priceType === 'market' && priceInput && data.price) {
priceInput.value = data.price;
} else if (priceInput && !priceInput.dataset.manual && data.price) {
priceInput.value = data.price;
}
if (metricsHint && data.metrics) {
var m = data.metrics;
metricsHint.innerHTML =
'<strong>' + (data.name || sym) + '</strong> 精度 ' + m.price_precision +
' 位 · 每跳 <strong class="text-accent">' + m.tick_value_total + '</strong> 元(' + lots + ' 手)';
}
scheduleAutoCalc();
}).catch(function () {});
}
function scheduleQuote() {
clearTimeout(quoteTimer);
quoteTimer = setTimeout(refreshQuote, 400);
}
function scheduleAutoCalc() {
if (!isRiskMode()) return;
clearTimeout(calcTimer);
calcTimer = setTimeout(autoCalcLots, 450);
}
function autoCalcLots() {
if (!isRiskMode() || !lotsCalc) return;
var sym = selectedSymbol();
var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0;
var sl = parseFloat(slInput && slInput.value) || 0;
if (!sym || !entry || !sl) {
lotsCalc.value = '';
lotsCalc.placeholder = '填写止损后自动计算';
return;
}
lotsCalc.placeholder = '计算中…';
fetch('/api/trade/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: sym,
direction: dirSelect ? dirSelect.value : 'long',
entry: entry,
price: entry,
stop_loss: sl,
take_profit: parseFloat(tpInput && tpInput.value) || 0
})
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) {
lotsCalc.value = '';
lotsCalc.placeholder = data.error || '无法计算';
return;
}
lotsCalc.value = data.lots;
lotsCalc.placeholder = '填写止损后自动计算';
scheduleQuote();
}).catch(function () {
lotsCalc.placeholder = '计算失败';
});
}
function tryAutoCtpReconnect() {
if (ctpReconnecting) return;
var now = Date.now();
if (now - lastCtpReconnectAt < 30000) return;
lastCtpReconnectAt = now;
ctpReconnecting = true;
fetch('/api/ctp/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto: true })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok && d.status && d.status.connected) {
updateCtpBadge(true);
var avail = document.getElementById('avail-display');
if (avail && d.account && d.account.available != null) {
avail.textContent = Number(d.account.available).toFixed(2);
}
pollPositions();
}
})
.catch(function () { /* ignore */ })
.finally(function () {
ctpReconnecting = false;
});
}
function showOrderMsg(text, ok) {
var el = document.getElementById('order-msg');
if (!el) return;
if (!text) {
el.hidden = true;
el.textContent = '';
el.className = 'trade-order-msg';
return;
}
el.hidden = false;
el.textContent = text;
el.className = 'trade-order-msg ' + (ok ? 'ok' : 'err');
}
function postOrder(offset) {
var sym = selectedSymbol();
if (!sym) { showOrderMsg('请选择品种', false); return; }
var direction = dirSelect ? dirSelect.value : 'long';
var price = entryPrice();
if (!price || price <= 0) {
showOrderMsg('无法获取有效价格,请先填写或刷新行情', false);
return;
}
var lots = effectiveLots();
if (offset === 'open') {
if (isRiskMode() && lots <= 0) {
showOrderMsg('请填写止损,系统将自动计算手数', false);
return;
}
if (!isRiskMode() && lots <= 0) {
showOrderMsg('请填写手数', false);
return;
}
}
var btnOpen = document.getElementById('btn-open');
if (btnOpen) {
btnOpen.disabled = true;
btnOpen.textContent = '开仓中…';
}
showOrderMsg('开仓中…', true);
var body = {
symbol: sym,
offset: offset,
direction: direction,
lots: lots,
price: price,
order_type: priceType,
stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null,
take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null
};
fetch('/api/trade/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) {
showOrderMsg(data.error || '下单失败', false);
return;
}
showOrderMsg('开仓成功 · ' + (data.lots || lots) + ' 手', true);
pollPositions();
refreshQuote();
setTimeout(function () { showOrderMsg(''); }, 4000);
}).catch(function () {
showOrderMsg('网络错误,请重试', false);
}).finally(function () {
if (btnOpen) {
btnOpen.disabled = false;
btnOpen.textContent = '开仓';
}
});
}
function buildPendingHtml(items) {
if (!items || !items.length) return '';
var rows = items.map(function (p) {
var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp');
return (
'<div class="pos-pending-item ' + cls + '">' +
'<span>' + (p.label || '挂单') + '</span>' +
'<span><strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手</span>' +
'</div>'
);
}).join('');
return '<div class="pos-pending-orders"><div class="pending-title">止盈止损挂单</div>' + rows + '</div>';
}
function buildPosCard(row) {
var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : '');
var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--';
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
var closePayload = encodeURIComponent(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
}));
var closeBtn = row.can_close ?
'<button type="button" class="pos-close-btn" data-close="' + closePayload + '">平仓</button>' : '';
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>' + closeBtn + '</div>' +
'<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong> · 柜台浮盈</div>' +
'<div class="pos-metrics">' +
'<div class="cell"><label>持仓均价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
'<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>' +
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
'</div>' + buildPendingHtml(row.pending_orders) +
'<div class="pos-footer"><span>' + row.lots + ' 手</span></div></div>'
);
}
function closePosition(payload, btn) {
function doClose(price) {
if (!price || price <= 0) { alert('无法获取现价'); return; }
if (!confirm('确认平仓 ' + payload.lots + ' 手?')) return;
if (btn) {
btn.disabled = true;
btn.textContent = '平仓中…';
}
fetch('/api/trading/close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({}, payload, { price: price }))
}).then(function (r) { return r.json(); }).then(function (d) {
if (!d.ok) {
alert(d.error || '平仓失败');
if (btn) {
btn.disabled = false;
btn.textContent = '平仓';
}
return;
}
if (btn) btn.textContent = '已平仓';
pollPositions();
}).catch(function () {
if (btn) {
btn.disabled = false;
btn.textContent = '平仓';
}
});
}
if (payload.mark_price > 0) {
doClose(payload.mark_price);
return;
}
fetch('/api/trade/quote?symbol=' + encodeURIComponent(payload.symbol_code) + '&lots=' + payload.lots)
.then(function (r) { return r.json(); })
.then(function (d) { doClose(d.price); });
}
function pollPositions() {
if (!list) return;
fetch('/api/trading/live')
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
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 connected = data.ctp_status && data.ctp_status.connected;
updateCtpBadge(!!connected);
var rows = data.rows || [];
if (!connected) {
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
tryAutoCtpReconnect();
return;
}
if (!rows.length) {
var pendingOnly = data.pending_orders || [];
if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">柜台暂无持仓</div>' +
pendingOnly.map(function (p) {
return (
'<div class="pos-pending-item ' +
(p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) +
'"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' +
'<span><strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手</span></div>'
);
}).join('');
} else {
list.innerHTML = '<div class="empty-hint">柜台暂无持仓。</div>';
}
return;
}
list.innerHTML = rows.map(buildPosCard).join('');
list.querySelectorAll('[data-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);
});
});
})
.catch(function () {
if (list.innerHTML.indexOf('pos-card') < 0) {
list.innerHTML = '<div class="empty-hint text-loss">持仓加载失败</div>';
}
});
}
function renderRecommendations(data) {
if (!recommendList || !data) return;
var recCap = document.getElementById('rec-capital');
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
var rows = data.rows || [];
if (!rows.length) {
recommendList.innerHTML = '<tr><td colspan="6" class="empty-hint">当前资金下暂无推荐品种</td></tr>';
return;
}
recommendList.innerHTML = rows.map(function (r) {
return (
'<tr class="rec-' + (r.status || '') + '">' +
'<td><strong>' + (r.name || '') + '</strong> <span class="text-accent">' + (r.main_code || r.ths || '') + '</span></td>' +
'<td>' + (r.exchange || '') + '</td>' +
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' +
'<td>' + (r.min_capital_one_lot != null ? r.min_capital_one_lot : '—') + '</td>' +
'<td><span class="badge ' + (r.status === 'ok' ? 'profit' : 'planned') + '">' + (r.status_label || '') + '</span></td>' +
'</tr>'
);
}).join('');
}
function connectRecommendStream() {
if (recommendSource) { recommendSource.close(); recommendSource = null; }
recommendSource = new EventSource('/api/recommend/stream');
recommendSource.addEventListener('recommend', function (ev) {
try { renderRecommendations(JSON.parse(ev.data)); } catch (e) { /* ignore */ }
});
recommendSource.onerror = function () {
if (recommendSource) { recommendSource.close(); recommendSource = null; }
setTimeout(connectRecommendStream, 5000);
};
}
document.querySelectorAll('.price-tab').forEach(function (btn) {
btn.addEventListener('click', function () {
setPriceType(btn.getAttribute('data-type'));
scheduleQuote();
});
});
if (symInput) {
symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); });
symInput.addEventListener('symbol-selected', function () {
scheduleQuote();
scheduleAutoCalc();
});
}
if (lotsInput) lotsInput.addEventListener('input', scheduleQuote);
if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc);
if (priceInput) {
priceInput.addEventListener('input', function () {
if (priceType === 'limit') priceInput.dataset.manual = '1';
scheduleAutoCalc();
});
}
var btnOpen = document.getElementById('btn-open');
if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); });
var btnConnect = document.getElementById('btn-ctp-connect');
if (btnConnect) {
btnConnect.addEventListener('click', function () {
btnConnect.disabled = true;
btnConnect.textContent = '连接中…';
fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) { alert(d.error || '连接失败'); return; }
updateCtpBadge(true);
pollPositions();
})
.finally(function () {
btnConnect.disabled = false;
btnConnect.textContent = '重连 CTP';
});
});
}
runWhenReady(function () {
setPriceType('limit');
pollPositions();
connectRecommendStream();
pollTimer = setInterval(pollPositions, 3000);
scheduleQuote();
});
})();