63beda3c71
Co-authored-by: Cursor <cursoragent@cursor.com>
946 lines
39 KiB
JavaScript
946 lines
39 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 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 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 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 {
|
|
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 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) {
|
|
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 = 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 = '填写止损后自动计算';
|
|
checkLotsLimit();
|
|
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 = '填写止损后自动计算';
|
|
checkLotsLimit();
|
|
scheduleQuote();
|
|
}).catch(function () {
|
|
lotsCalc.placeholder = '计算失败';
|
|
});
|
|
}
|
|
|
|
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 (isRiskMode() && lots <= 0) {
|
|
showOrderMsg('请填写止损,系统将自动计算手数', false);
|
|
return;
|
|
}
|
|
if (!isRiskMode() && 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 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 netClass = row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : '');
|
|
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
|
|
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
|
|
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 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" 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>' + riskMeta +
|
|
(row.sync_pending ? ' · <span class="text-muted">同步柜台中…</span>' : '') +
|
|
' · 浮盈' +
|
|
(slTpBtn ? ' · ' + slTpBtn : '') +
|
|
(row.sl_order_active ? ' · <span class="text-profit">止损监控中</span>' : '') +
|
|
(row.tp_order_active ? ' · <span class="text-profit">止盈监控中</span>' : '') +
|
|
(row.trailing_be ? ' · <span class="text-accent">移动保本' +
|
|
(row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '</span>' : '') + '</div>' +
|
|
'<div class="pos-metrics">' +
|
|
'<div class="cell"><label>持仓均价</label><div>' + 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>止损</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"><label>占用保证金</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>预估手续费</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
|
|
'<div class="cell ' + netClass + '"><label>扣费后</label><div>' +
|
|
(row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + ' 元' : '--') + '</div></div>' +
|
|
'</div>' + buildPendingHtml(row.pending_orders) +
|
|
'<div class="pos-footer">' +
|
|
'<span>' + row.lots + ' 手</span>' +
|
|
'<span>开仓 ' + (openT || '--') + '</span>' +
|
|
'<span>持仓 ' + (row.holding_duration || '--') + '</span>' +
|
|
'<span>保证金 ' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</span>' +
|
|
'<span>仓位 ' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</span>' +
|
|
'</div></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) {
|
|
var slRaw = prompt('止损价(可留空)', '');
|
|
if (slRaw === null) return;
|
|
var tpRaw = prompt('止盈价(可留空)', '');
|
|
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 = '设置止盈止损';
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
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) {
|
|
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 : '—') + '</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 (lotsInput) lotsInput.addEventListener('input', function () {
|
|
scheduleQuote();
|
|
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');
|
|
var cached = loadPosCache();
|
|
if (cached) {
|
|
applyPositionsData(cached);
|
|
}
|
|
pollPositions();
|
|
connectPositionStream();
|
|
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();
|
|
});
|
|
})();
|