Files
qihuo/static/js/trade.js
T

1034 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
var sizingMode = window.TRADE_SIZING_MODE || 'fixed';
if (sizingMode === 'risk') sizingMode = 'amount';
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 recommendSource = null;
var positionSource = null;
var quoteTimer = null;
var calcTimer = null;
var lastQuotePrice = null;
var priceType = 'limit';
var lastCtpReconnectAt = 0;
var ctpReconnecting = false;
var isTradingSession = false;
var hasSlTpMonitoring = false;
var ctpConnected = false;
var positionsRendered = false;
var selectedMaxLots = null;
var recommendMaxByProduct = {};
var recommendMaxByCode = {};
var POS_CACHE_KEY = 'qihuo_trading_live_v2';
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 isFixedMode() {
return sizingMode === 'fixed';
}
function isAmountMode() {
return sizingMode === 'amount';
}
function effectiveLots() {
var v = parseInt(lotsCalc && lotsCalc.value, 10);
return v > 0 ? v : 0;
}
function updateRecommendMaxMaps(data) {
recommendMaxByProduct = {};
recommendMaxByCode = {};
(data && data.rows || []).forEach(function (r) {
if (!r || r.max_lots <= 0) return;
if (r.status !== 'ok' && r.status !== 'margin_ok') return;
if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots;
if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots;
});
checkLotsLimit();
}
function maxLotsForSymbol(sym) {
if (selectedMaxLots > 0) return selectedMaxLots;
var code = (sym || '').trim().toLowerCase();
if (!code) return 0;
if (recommendMaxByCode[code]) return recommendMaxByCode[code];
var m = code.match(/^([a-z]+)/i);
if (m && recommendMaxByProduct[m[1].toLowerCase()]) {
return recommendMaxByProduct[m[1].toLowerCase()];
}
return 0;
}
function checkLotsLimit() {
var warn = document.getElementById('lots-warn');
if (!warn) return;
var sym = selectedSymbol();
var maxLots = maxLotsForSymbol(sym);
var lots = effectiveLots();
if (maxLots > 0 && lots > maxLots) {
warn.hidden = false;
warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数';
} else {
warn.hidden = true;
warn.textContent = '';
}
}
function loadPosCache() {
try {
var raw = sessionStorage.getItem(POS_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function savePosCache(data) {
try {
if (!data || !data.rows || !data.rows.length) {
var prev = loadPosCache();
if (prev && prev.rows && prev.rows.length) return;
}
sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ }
}
function applyPositionsData(data) {
if (!list || !data) return;
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;
var connecting = data.ctp_status && data.ctp_status.connecting;
ctpConnected = !!connected;
isTradingSession = !!data.trading_session;
updateCtpBadge(!!connected, !!connecting);
var riskBadge = document.getElementById('risk-badge');
if (riskBadge && data.risk_status) {
riskBadge.textContent = data.risk_status.status_label || '';
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
}
var rows = data.rows || [];
var seenKeys = {};
rows = rows.filter(function (row) {
var k = row.key || ((row.symbol_code || '') + ':' + (row.direction || ''));
if (seenKeys[k]) return false;
seenKeys[k] = true;
return true;
});
hasSlTpMonitoring = rows.some(function (row) {
return row.stop_loss != null || row.take_profit != null;
});
updateSessionUi();
savePosCache(data);
positionsRendered = true;
if (!rows.length) {
if (!connected) {
if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
return;
}
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
tryAutoCtpReconnect();
return;
}
var pendingOnly = data.pending_orders || [];
if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">暂无持仓</div>' +
pendingOnly.map(function (p) {
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
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 class="pos-pending-right"><strong>' + fmtNum(p.price) + '</strong> · ' +
(p.lots || 1) + ' 手' + dismissBtn + '</span></div>'
);
}).join('');
bindPendingDismiss(list);
} else {
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
}
return;
}
if (!connected) {
tryAutoCtpReconnect();
}
list.innerHTML = rows.map(buildPosCard).join('');
bindPendingDismiss(list);
bindSlTpButtons(list);
bindPlaceOrderButtons(list);
list.querySelectorAll('[data-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);
});
});
}
function schedulePositionPoll() {
/* 持仓改由后台 SSE 推送,保留空函数兼容旧调用 */
}
function updateSessionUi() {
var btnOpen = document.getElementById('btn-open');
var sessionHint = document.getElementById('session-hint');
if (btnOpen) {
btnOpen.disabled = !isTradingSession;
btnOpen.classList.toggle('btn-session-off', !isTradingSession);
}
if (sessionHint) {
sessionHint.hidden = !!isTradingSession;
}
}
function entryPrice() {
if (priceType === 'market') return lastQuotePrice;
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 lots = effectiveLots();
var parts = [];
var rr = calcRR(dir, entry, sl, tp);
if (rr) parts.push('盈亏比 ' + rr + ':1');
if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) {
if (lastPreviewMetrics.risk_amount != null) {
parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元');
}
if (lastPreviewMetrics.reward_amount != null && tp > 0) {
parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元');
}
}
if (parts.length) {
el.textContent = parts.join(' · ');
el.hidden = false;
} else {
el.textContent = '';
el.hidden = true;
}
}
var lastPreviewMetrics = null;
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';
updateRRDisplay();
}
function updateCtpBadge(connected, connecting) {
var ctpBadge = document.getElementById('ctp-badge');
var btnConnect = document.getElementById('btn-ctp-connect');
if (ctpBadge) {
if (connecting) {
ctpBadge.textContent = 'CTP 连接中';
ctpBadge.className = 'badge planned';
} else {
ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned');
}
}
if (btnConnect) {
if (connecting) {
btnConnect.textContent = '连接中…';
btnConnect.disabled = true;
} else {
btnConnect.disabled = false;
btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP';
}
}
}
function waitForCtpConnected(maxMs) {
var deadline = Date.now() + (maxMs || 35000);
function tick() {
return fetch('/api/ctp/status')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.status || {};
if (st.connected) {
updateCtpBadge(true, false);
if (d.account && d.account.available != null) {
var avail = document.getElementById('avail-display');
if (avail) avail.textContent = Number(d.account.available).toFixed(2);
}
pollPositions();
return true;
}
if (st.connecting && Date.now() < deadline) {
updateCtpBadge(false, true);
return new Promise(function (resolve) {
setTimeout(function () { resolve(tick()); }, 2000);
});
}
updateCtpBadge(false, false);
if (st.last_error) {
var hint = document.querySelector('.ctp-install-hint');
if (hint) hint.textContent = st.last_error;
}
return false;
})
.catch(function () { updateCtpBadge(false, false); return false; });
}
return tick();
}
function requestCtpConnect(force) {
updateCtpBadge(false, true);
return fetch('/api/ctp/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: !!force, auto: !force })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.status && d.status.connected) {
updateCtpBadge(true, false);
pollPositions();
return d;
}
if (d.connecting || (d.status && d.status.connecting)) {
return waitForCtpConnected(35000).then(function (ok) {
if (!ok && d.error) alert(d.error);
else if (!ok && d.status && d.status.last_error) alert(d.status.last_error);
return d;
});
}
if (!d.ok) {
updateCtpBadge(false, false);
alert(d.error || (d.status && d.status.last_error) || '连接失败');
}
return d;
})
.catch(function () {
updateCtpBadge(false, false);
});
}
function refreshQuote() {
var sym = selectedSymbol();
var lots = effectiveLots() || (isFixedMode() ? (window.TRADE_FIXED_LOTS || 1) : 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() {
clearTimeout(calcTimer);
calcTimer = setTimeout(autoCalcLots, 450);
}
function autoCalcLots() {
if (!lotsCalc) return;
var sym = selectedSymbol();
var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0;
var sl = parseFloat(slInput && slInput.value) || 0;
var tp = parseFloat(tpInput && tpInput.value) || 0;
if (isFixedMode()) {
var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1;
lotsCalc.value = String(fixedLots);
if (lotsInput) lotsInput.value = String(fixedLots);
if (!sym || !entry) {
lastPreviewMetrics = null;
updateRRDisplay();
checkLotsLimit();
return;
}
} else if (isAmountMode()) {
if (!sym || !entry || !sl) {
lotsCalc.value = '';
lotsCalc.placeholder = '填写止损后自动计算';
lastPreviewMetrics = null;
updateRRDisplay();
checkLotsLimit();
return;
}
lotsCalc.placeholder = '计算中…';
} else {
return;
}
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: tp
})
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) {
if (isAmountMode()) {
lotsCalc.value = '';
lotsCalc.placeholder = data.error || '无法计算';
}
lastPreviewMetrics = null;
updateRRDisplay();
checkLotsLimit();
return;
}
lotsCalc.value = String(data.lots || '');
if (lotsInput) lotsInput.value = String(data.lots || '');
lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—';
lastPreviewMetrics = data.metrics || null;
updateRRDisplay();
checkLotsLimit();
scheduleQuote();
}).catch(function () {
if (isAmountMode()) lotsCalc.placeholder = '计算失败';
lastPreviewMetrics = null;
updateRRDisplay();
});
}
function tryAutoCtpReconnect() {
if (ctpReconnecting) return;
var now = Date.now();
if (now - lastCtpReconnectAt < 60000) return;
lastCtpReconnectAt = now;
ctpReconnecting = true;
requestCtpConnect(false).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();
var trailingBeEl = document.getElementById('trailing-be');
if (offset === 'open') {
if (!isTradingSession) {
showOrderMsg('不在交易时间段', false);
return;
}
var trailingOn = !!(trailingBeEl && trailingBeEl.checked);
if (trailingOn && !(slInput && slInput.value)) {
showOrderMsg('开启移动保本须填写止损价', false);
return;
}
if (isAmountMode() && lots <= 0) {
showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false);
return;
}
if (isFixedMode() && lots <= 0) {
showOrderMsg('手数无效,请检查系统设置中的固定手数', false);
return;
}
if (lots <= 0) {
showOrderMsg('请填写有效手数', false);
return;
}
var maxLots = maxLotsForSymbol(sym);
if (maxLots > 0 && lots > maxLots) {
showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', 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,
trailing_be: !!(trailingBeEl && trailingBeEl.checked)
};
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;
}
var msg = data.message || ('开仓成功 · ' + (data.lots || lots) + ' 手');
showOrderMsg(msg, true);
pollPositions();
refreshQuote();
setTimeout(function () { showOrderMsg(''); }, 4000);
}).catch(function () {
showOrderMsg('网络错误,请重试', false);
}).finally(function () {
if (btnOpen) {
btnOpen.textContent = '开仓';
updateSessionUi();
}
});
}
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');
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
return (
'<div class="pos-pending-item ' + cls + '">' +
'<span>' + (p.label || '挂单') + '</span>' +
'<span class="pos-pending-right">' +
'<strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手' +
dismissBtn +
'</span>' +
'</div>'
);
}).join('');
return '<div class="pos-pending-orders"><div class="pending-title">止盈止损监控</div>' + rows + '</div>';
}
function dismissMonitor(monitorId, btn) {
if (!monitorId) return;
if (!confirm('取消该本地止盈止损监控?(不影响柜台委托)')) return;
if (btn) {
btn.disabled = true;
btn.textContent = '取消中…';
}
fetch('/api/trading/monitor/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monitor_id: monitorId })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) throw new Error(d.error || '取消失败');
pollPositions();
})
.catch(function (e) {
alert(e.message || '取消失败');
if (btn) {
btn.disabled = false;
btn.textContent = '取消';
}
});
}
function bindPendingDismiss(root) {
if (!root) return;
root.querySelectorAll('[data-monitor-id]').forEach(function (btn) {
btn.addEventListener('click', function () {
dismissMonitor(parseInt(btn.getAttribute('data-monitor-id'), 10), btn);
});
});
}
function slTpStatusHtml(row) {
var parts = [];
if (row.sl_order_active || row.sl_monitoring) {
parts.push('<span class="text-profit">止损监控中</span>');
} else if (row.stop_loss != null) {
parts.push('<span class="text-muted">止损已设</span>');
}
if (row.tp_order_active || row.tp_monitoring) {
parts.push('<span class="text-profit">止盈监控中</span>');
} else if (row.take_profit != null) {
parts.push('<span class="text-muted">止盈已设</span>');
}
if (!parts.length) return '<span class="text-muted">未设置</span>';
return parts.join(' · ');
}
function trailingStatusHtml(row) {
if (row.trailing_be) {
return '<span class="text-accent">已开启' +
(row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R' : '') + '</span>';
}
return '<span class="text-muted">未开启</span>';
}
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 openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
var closeAllowed = row.close_allowed !== false && isTradingSession;
var slTpBtn = (!row.stop_loss && !row.take_profit && row.can_close) ?
'<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, monitor_id: row.monitor_id || null
})) + '">设置止盈止损</button>' : '';
var editPayload = encodeURIComponent(JSON.stringify({
symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null,
stop_loss: row.stop_loss, take_profit: row.take_profit
}));
var entrustBtn = row.can_close ?
'<button type="button" class="pos-order-btn pos-entrust-btn" data-edit-sl-tp="' + editPayload + '">委托</button>' : '';
var orderBtn = '';
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) {
orderBtn = '<button type="button" class="pos-order-btn" data-place-orders="' + row.monitor_id + '">清理旧挂单</button>';
}
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' + (closeAllowed ? '' : ' is-session-off') + '" ' +
(closeAllowed ? '' : 'disabled title="不在交易时间段"') +
' data-close="' + closePayload + '">平仓</button>' : '';
var actionBtns = (entrustBtn || orderBtn || closeBtn) ?
'<div class="pos-card-actions">' + entrustBtn + orderBtn + closeBtn + '</div>' : '';
var metaLine =
'来源 <strong>' + (row.source_label || 'CTP') + '</strong>' +
(row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
' · 止损 <strong>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</strong>' +
' · 止盈 <strong>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</strong>' +
' · ' + slTpStatusHtml(row) +
' · 移动保本 ' + trailingStatusHtml(row) +
(slTpBtn ? ' · ' + slTpBtn : '') +
(row.sync_pending ? ' · <span class="text-muted">同步柜台中…</span>' : '');
var feeLabel = row.fee_source === 'ctp' ? '手续费(柜台)' : '手续费';
var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金';
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 pos-card-meta-line">' + metaLine + '</div>' +
'<div class="pos-metrics">' +
'<div class="cell"><label>手数 / 均价</label><div><strong>' + row.lots + ' 手</strong> @ ' + fmtNum(row.entry_price) + '</div></div>' +
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
'<div class="cell"><label>' + marginLabel + '</label><div>' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</div></div>' +
'<div class="cell"><label>仓位占比</label><div>' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</div></div>' +
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
'<div class="cell"><label>' + feeLabel + '</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
'<div class="cell"><label>开仓</label><div>' + (openT || '--') + '</div></div>' +
'<div class="cell"><label>持仓</label><div>' + (row.holding_duration || '--') + '</div></div>' +
'</div>' + buildPendingHtml(row.pending_orders) +
'</div>'
);
}
function placeMonitorOrders(monitorId, btn) {
if (!monitorId) return;
if (!confirm('清理该持仓在柜台残留的旧版止盈/止损挂单?')) return;
if (btn) {
btn.disabled = true;
btn.textContent = '委托中…';
}
fetch('/api/trading/monitor/place-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monitor_id: monitorId })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) throw new Error(d.error || d.message || '委托失败');
var msg = d.message || '委托已提交';
if (d.skipped && d.skipped.length) msg += '\n' + d.skipped.join('\n');
alert(msg);
pollPositions();
})
.catch(function (e) {
alert(e.message || '委托失败');
if (btn) {
btn.disabled = false;
btn.textContent = '委托';
}
});
}
function bindPlaceOrderButtons(root) {
if (!root) return;
root.querySelectorAll('[data-place-orders]').forEach(function (btn) {
btn.addEventListener('click', function () {
placeMonitorOrders(parseInt(btn.getAttribute('data-place-orders'), 10), btn);
});
});
}
function promptStopTakeProfit(payload, btn, btnLabel) {
btnLabel = btnLabel || '设置止盈止损';
var slDefault = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : '';
var tpDefault = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : '';
var slRaw = prompt('止损价(可留空)', slDefault);
if (slRaw === null) return;
var tpRaw = prompt('止盈价(可留空)', tpDefault);
if (tpRaw === null) return;
var sl = slRaw.trim() ? parseFloat(slRaw) : null;
var tp = tpRaw.trim() ? parseFloat(tpRaw) : null;
if (sl == null && tp == null) {
alert('请至少填写止损或止盈');
return;
}
if (btn) {
btn.disabled = true;
btn.textContent = '保存中…';
}
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) {
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) {
var msg = e.message || '保存失败';
if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行';
alert(msg);
if (btn) {
btn.disabled = false;
btn.textContent = btnLabel;
}
});
}
function bindSlTpButtons(root) {
if (!root) return;
root.querySelectorAll('[data-sl-tp]').forEach(function (btn) {
btn.addEventListener('click', function () {
promptStopTakeProfit(
JSON.parse(decodeURIComponent(btn.getAttribute('data-sl-tp'))), btn, '设置止盈止损'
);
});
});
root.querySelectorAll('[data-edit-sl-tp]').forEach(function (btn) {
btn.addEventListener('click', function () {
promptStopTakeProfit(
JSON.parse(decodeURIComponent(btn.getAttribute('data-edit-sl-tp'))), btn, '委托'
);
});
});
}
function closePosition(payload, btn) {
if (!isTradingSession) {
alert('不在交易时间段,无法平仓');
return;
}
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) {
applyPositionsData(data);
})
.catch(function () {
if (!positionsRendered && list.innerHTML.indexOf('pos-card') < 0) {
list.innerHTML = '<div class="empty-hint text-loss">持仓加载失败</div>';
}
});
}
function connectPositionStream() {
if (positionSource) {
positionSource.close();
positionSource = null;
}
positionSource = new EventSource('/api/trading/stream');
positionSource.addEventListener('positions', function (ev) {
try {
applyPositionsData(JSON.parse(ev.data));
} catch (e) { /* ignore */ }
});
positionSource.onerror = function () {
if (positionSource) {
positionSource.close();
positionSource = null;
}
setTimeout(connectPositionStream, 3000);
};
}
function renderRecommendations(data) {
if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
var recCap = document.getElementById('rec-capital');
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
var recUpdated = document.getElementById('rec-updated');
if (recUpdated && data.updated_at) {
recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at;
}
var rows = data.rows || [];
if (!rows.length) {
recommendList.innerHTML = '<tr><td colspan="9" 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.ref_stop_loss != null ? r.ref_stop_loss : '—') + '</td>' +
'<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' +
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' <span class="text-muted">(柜台)</span>' : '') : '—') + '</td>' +
'<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' +
'<td>' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '</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 () {
selectedMaxLots = null;
scheduleQuote();
scheduleAutoCalc();
checkLotsLimit();
});
symInput.addEventListener('symbol-selected', function (ev) {
var item = ev.detail || {};
selectedMaxLots = item.max_lots > 0 ? item.max_lots : null;
scheduleQuote();
scheduleAutoCalc();
checkLotsLimit();
});
}
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
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();
});
}
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 () {
requestCtpConnect(true);
});
}
runWhenReady(function () {
setPriceType('limit');
if (isFixedMode() && lotsCalc) {
lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1);
if (lotsInput) lotsInput.value = lotsCalc.value;
}
var cached = loadPosCache();
if (cached) {
applyPositionsData(cached);
}
pollPositions();
connectPositionStream();
requestCtpConnect(false);
connectRecommendStream();
fetch('/api/recommend/list')
.then(function (r) { return r.json(); })
.then(function (data) { if (data.ok) renderRecommendations(data); })
.catch(function () {});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && !positionSource) {
connectPositionStream();
}
});
updateSessionUi();
updateRRDisplay();
scheduleQuote();
scheduleAutoCalc();
});
})();