Files
qihuo/static/js/trade.js
T

1949 lines
81 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.
/* Copyright (c) 2025-2026 马建军. All rights reserved.
* 专有软件 — 未经授权禁止复制、传播、转售。
* 详见 LICENSE.zh-CN.txt
*/
(function () {
var sizingMode = 'fixed';
var list = document.getElementById('position-live-list');
var orderList = document.getElementById('order-live-list');
var syncBadge = document.getElementById('sync-badge');
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 lastCtpUnreachableAt = 0;
var lastCtpLoginBanAt = 0;
var ctpReconnecting = false;
var ctpConnectInflight = false;
var isTradingSession = false;
var hasSlTpMonitoring = false;
var ctpConnected = false;
var ctpConnecting = false;
var ctpAutoConnectEnabled = true;
var positionsRendered = false;
var selectedMaxLots = null;
var recommendMaxByProduct = {};
var recommendMaxByCode = {};
var recRowsRaw = [];
var recMeta = {};
var recSortKey = 'trend';
var recSortDesc = true;
var recIndustryFilter = '';
var REC_SORT_CACHE = 'qihuo_rec_sort_v2';
var REC_INDUSTRY_CACHE = 'qihuo_rec_industry_v1';
var REC_COLSPAN = 18;
var marketNavEnabled = false;
var productCategories = [];
var POS_CACHE_KEY = 'qihuo_trading_live_v5';
function loadTradeConfig() {
var el = document.getElementById('trade-page-data');
if (!el) return;
try {
var cfg = JSON.parse(el.textContent);
sizingMode = cfg.sizing_mode || 'fixed';
if (sizingMode === 'risk') sizingMode = 'amount';
ctpAutoConnectEnabled = cfg.ctp_auto_connect !== false;
marketNavEnabled = !!cfg.market_nav_enabled;
productCategories = cfg.product_categories || [];
window.TRADE_FIXED_LOTS = cfg.fixed_lots;
window.TRADE_FIXED_AMOUNT = cfg.fixed_amount;
window.__RECOMMEND_ROWS__ = cfg.recommend_rows || [];
if (cfg.session_clock) applySessionClock(cfg.session_clock);
} catch (e) { /* ignore */ }
}
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 (lastSizingInfo && lastSizingInfo.capped_by === 'margin' && lastSizingInfo.lots_by_risk > lots) {
warn.hidden = false;
warn.textContent = '以损定仓 ' + lastSizingInfo.lots_by_risk + ' 手,保证金上限 ' +
(lastSizingInfo.max_margin_pct || '') + '% 收紧为 ' + lots + ' 手';
return;
}
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) return;
var connected = data.ctp_status && data.ctp_status.connected;
if (!connected) {
sessionStorage.removeItem(POS_CACHE_KEY);
return;
}
if (!data.rows || !data.rows.length) {
if (!data.active_orders || !data.active_orders.length) {
sessionStorage.removeItem(POS_CACHE_KEY);
return;
}
}
sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ }
}
function showCtpError(msg) {
var hint = document.querySelector('.ctp-install-hint');
if (hint) hint.textContent = msg || '';
}
function isCtpLoginBanError(msg) {
return !!(msg && (
msg.indexOf('登录被禁止') >= 0 ||
msg.indexOf('连续登录失败') >= 0 ||
msg.indexOf('登录冷却') >= 0 ||
msg.indexOf('错误码 75') >= 0
));
}
function isCtpUnreachableError(msg) {
return !!(msg && (msg.indexOf('不可达') >= 0 || msg.indexOf('Connection refused') >= 0 || msg.indexOf('timed out') >= 0));
}
function applyActiveOrders(orders, data) {
if (!orderList) return;
orders = orders || [];
if (!orders.length) {
var connected = data && data.ctp_status && data.ctp_status.connected;
if (!connected) {
var hint = (data && data.ctp_status && data.ctp_status.disabled_hint) ||
'CTP 未连接,委托以柜台为准';
orderList.innerHTML = '<div class="empty-hint text-muted">' + hint + '</div>';
return;
}
orderList.innerHTML = '<div class="empty-hint">暂无委托。</div>';
return;
}
orderList.innerHTML = orders.map(buildPendingOrderCard).join('');
bindPendingDismiss(orderList);
bindCancelOpenButtons(orderList);
bindCancelOrderButtons(orderList);
}
function findPosCardByKey(key) {
if (!list || !key) return null;
var cards = list.querySelectorAll('.pos-card[data-pos-key]');
for (var i = 0; i < cards.length; i++) {
if (cards[i].getAttribute('data-pos-key') === key) return cards[i];
}
return null;
}
function applyPositionQuotes(data) {
if (!data || !data.quotes || !list) return;
data.quotes.forEach(function (q) {
var card = findPosCardByKey(q.key || q.position_key);
if (!card) return;
var markEl = card.querySelector('.pos-q-mark');
var pnlEl = card.querySelector('.pos-q-pnl');
var pnlWrap = card.querySelector('.pos-q-pnl-wrap');
if (markEl && q.mark_price != null) markEl.textContent = fmtNum(q.mark_price);
if (pnlEl && q.float_pnl != null) {
var pnl = q.float_pnl;
pnlEl.textContent = (pnl >= 0 ? '+' : '') + fmtNum(pnl) + ' 元';
if (pnlWrap) {
pnlWrap.classList.remove('pnl-pos', 'pnl-neg');
if (pnl > 0) pnlWrap.classList.add('pnl-pos');
else if (pnl < 0) pnlWrap.classList.add('pnl-neg');
}
}
var closeBtn = card.querySelector('[data-close]');
if (closeBtn && q.mark_price != null) {
try {
var payload = JSON.parse(decodeURIComponent(closeBtn.getAttribute('data-close')));
payload.mark_price = q.mark_price;
closeBtn.setAttribute('data-close', encodeURIComponent(JSON.stringify(payload)));
} catch (e) { /* ignore */ }
}
});
}
function applyPositionsData(data) {
if (!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;
var cooldownSec = (data.ctp_status && data.ctp_status.login_cooldown_sec) || 0;
if (cooldownSec > 0) connecting = false;
ctpConnected = !!connected;
ctpConnecting = !!connecting;
isTradingSession = !!data.trading_session;
if (data.session_clock) applySessionClock(data.session_clock);
syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting });
if (data.ctp_status && typeof data.ctp_status.auto_connect_enabled === 'boolean') {
ctpAutoConnectEnabled = data.ctp_status.auto_connect_enabled;
updateCtpConnectButtonState();
}
if (syncBadge) {
if (data.sync_label && connected) {
syncBadge.hidden = false;
syncBadge.textContent = data.sync_label;
syncBadge.className = 'sync-badge ' + (data.sync_state === 'syncing' ? 'text-accent' : 'text-muted');
} else {
syncBadge.hidden = true;
syncBadge.textContent = '';
}
}
if (!connected && !connecting && data.ctp_status && data.ctp_status.last_error) {
showCtpError(data.ctp_status.last_error);
if (isCtpLoginBanError(data.ctp_status.last_error)) {
lastCtpLoginBanAt = Date.now();
} else if (isCtpUnreachableError(data.ctp_status.last_error)) {
lastCtpUnreachableAt = Date.now();
}
} else if (!connected && data.ctp_status && data.ctp_status.disabled_hint) {
showCtpError(data.ctp_status.disabled_hint);
}
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');
}
applyActiveOrders(data.active_orders || [], data);
if (!list) return;
var rows = (data.rows || []).filter(function (row) {
return row.order_state !== 'pending';
});
var seenKeys = {};
rows = rows.filter(function (row) {
var k = row.key || row.position_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;
}
if (cooldownSec > 0 || (data.ctp_status && data.ctp_status.last_error)) {
var err = (data.ctp_status && data.ctp_status.last_error) || 'CTP 未连接';
list.innerHTML = '<div class="empty-hint text-loss">' + err + '</div>';
return;
}
if (!ctpAutoConnectEnabled) {
var offHint = (data.ctp_status && data.ctp_status.disabled_hint) ||
'CTP 自动连接已关闭,请在系统设置中开启';
list.innerHTML = '<div class="empty-hint text-muted">' + offHint + '</div>';
return;
}
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
if (ctpAutoConnectEnabled) tryAutoCtpReconnect();
return;
}
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
return;
}
if (!connected && ctpAutoConnectEnabled) {
tryAutoCtpReconnect();
}
list.innerHTML = rows.map(buildPosCard).join('');
bindPendingDismiss(list);
bindCancelOpenButtons(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 isTrailingBeOn() {
var el = document.getElementById('trailing-be');
return !!(el && el.checked);
}
function updateTrailingBeUi() {
var on = isTrailingBeOn();
var tpField = document.getElementById('field-tp');
var hint = document.getElementById('trailing-be-hint');
if (tpField) tpField.classList.toggle('is-hidden', on);
if (hint) hint.hidden = !on;
if (on && tpInput) tpInput.value = '';
updateRRDisplay();
scheduleAutoCalc();
}
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 trailingOn = isTrailingBeOn();
var dir = dirSelect ? dirSelect.value : 'long';
var entry = entryPrice();
var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0;
var tp = trailingOn ? 0 : (tpInput && tpInput.value ? parseFloat(tpInput.value) : 0);
var lots = effectiveLots();
var parts = [];
if (!trailingOn) {
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 (!trailingOn && 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;
var lastSizingInfo = null;
var sessionClockBase = null;
var sessionClockTickTimer = null;
function fmtCountdown(secs) {
secs = Math.max(0, parseInt(secs, 10) || 0);
var h = Math.floor(secs / 3600);
var m = Math.floor((secs % 3600) / 60);
var s = secs % 60;
if (h > 0) return h + '小时' + (m < 10 ? '0' : '') + m + '分' + (s < 10 ? '0' : '') + s + '秒';
if (m > 0) return m + '分' + (s < 10 ? '0' : '') + s + '秒';
return s + '秒';
}
function fmtClockNow(d) {
var mo = d.getMonth() + 1;
var da = d.getDate();
var pad = function (n) { return n < 10 ? '0' + n : String(n); };
return pad(mo) + '-' + pad(da) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
function tickSessionClock() {
var base = sessionClockBase;
if (!base || !base.clock) return;
var c = base.clock;
var elapsed = Math.floor((Date.now() - base.at) / 1000);
var nowEl = document.getElementById('clock-now');
if (nowEl && c.now) {
var t = new Date(String(c.now).replace(/-/g, '/'));
if (!isNaN(t.getTime())) {
t.setSeconds(t.getSeconds() + elapsed);
nowEl.textContent = fmtClockNow(t);
}
}
var statusEl = document.getElementById('clock-status');
if (statusEl) {
statusEl.textContent = c.status_label || (c.in_session ? '交易时间段' : '非交易时间段');
statusEl.className = c.in_session ? 'text-profit' : 'text-muted';
}
var detail = document.getElementById('clock-detail');
if (!detail) return;
var html = '';
if (!c.in_session && c.secs_to_open != null) {
html = ' · 下次' + (c.next_open_label || '开盘') + ' ' + (c.next_open_at || '') +
' · 距开盘 <strong>' + fmtCountdown(c.secs_to_open - elapsed) + '</strong>';
} else if (c.in_session) {
if (c.secs_to_break != null) {
html += ' · 距' + (c.break_label || '休盘') + ' <strong>' +
fmtCountdown(c.secs_to_break - elapsed) + '</strong>';
}
if (c.secs_to_close != null) {
html += ' · 距' + (c.close_label || '收盘') + ' <strong>' +
fmtCountdown(c.secs_to_close - elapsed) + '</strong>';
}
}
detail.innerHTML = html;
}
function applySessionClock(clock) {
if (!clock) return;
sessionClockBase = { clock: clock, at: Date.now() };
tickSessionClock();
if (sessionClockTickTimer) clearInterval(sessionClockTickTimer);
sessionClockTickTimer = setInterval(tickSessionClock, 1000);
}
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 syncCtpBadgeFromStatus(st) {
if (!st) return;
var connected = !!st.connected;
var connecting = !!st.connecting;
if ((st.login_cooldown_sec || 0) > 0) {
connecting = false;
}
updateCtpBadge(connected, connecting);
}
function updateCtpConnectButtonState() {
var btnConnect = document.getElementById('btn-ctp-connect');
var hint = document.getElementById('ctp-auto-hint');
if (hint) {
hint.textContent = ctpAutoConnectEnabled
? '断线自动重连 · 开盘前 30 分钟自动连接'
: '自动连接已关闭 · 开盘前 30 分钟仍会按计划连接';
}
if (btnConnect && !ctpAutoConnectEnabled) {
btnConnect.disabled = true;
btnConnect.title = '请先在系统设置 → CTP 连接 中开启自动连接';
}
}
function updateCtpBadge(connected, connecting) {
var ctpBadge = document.getElementById('ctp-badge');
var btnConnect = document.getElementById('btn-ctp-connect');
if (ctpBadge) {
if (!ctpAutoConnectEnabled && !connected) {
ctpBadge.textContent = 'CTP 已关闭';
ctpBadge.className = 'badge planned';
} else if (connecting) {
ctpBadge.textContent = 'CTP 连接中';
ctpBadge.className = 'badge planned';
} else {
ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned');
}
}
if (btnConnect) {
if (!ctpAutoConnectEnabled) {
btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP';
btnConnect.disabled = true;
btnConnect.title = '请先在系统设置 → CTP 连接 中开启自动连接';
} else if (connecting) {
btnConnect.textContent = '连接中…';
btnConnect.disabled = true;
btnConnect.title = '';
} else {
btnConnect.disabled = false;
btnConnect.title = '';
btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP';
}
}
}
function waitForCtpConnected(maxMs) {
var deadline = Date.now() + (maxMs || 70000);
function tick() {
return fetch('/api/ctp/status')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.status || {};
if (st.connected) {
syncCtpBadgeFromStatus(st);
showCtpError('');
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.login_cooldown_sec || 0) > 0) {
syncCtpBadgeFromStatus(st);
if (st.last_error) showCtpError(st.last_error);
return false;
}
if (st.connecting && Date.now() < deadline) {
syncCtpBadgeFromStatus(st);
return new Promise(function (resolve) {
setTimeout(function () { resolve(tick()); }, 2000);
});
}
syncCtpBadgeFromStatus(st);
if (st.last_error) {
showCtpError(st.last_error);
if (isCtpLoginBanError(st.last_error)) {
lastCtpLoginBanAt = Date.now();
} else if (isCtpUnreachableError(st.last_error)) {
lastCtpUnreachableAt = Date.now();
}
}
return false;
})
.catch(function () { updateCtpBadge(false, false); return false; });
}
return tick();
}
function requestCtpConnect(force) {
if (!force && !ctpAutoConnectEnabled) {
showCtpError('CTP 自动连接已关闭,请在系统设置中开启');
return Promise.resolve({ ok: false, disabled: true });
}
if (!force && ctpConnectInflight) {
return Promise.resolve({});
}
ctpConnectInflight = 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) {
var st = d.status || {};
if (st.connected) {
syncCtpBadgeFromStatus(st);
showCtpError('');
pollPositions();
return d;
}
if ((st.login_cooldown_sec || 0) > 0 || d.cooldown) {
syncCtpBadgeFromStatus(st);
showCtpError(st.last_error || d.error || 'CTP 登录冷却中');
return d;
}
if (d.connecting || st.connecting) {
updateCtpBadge(false, true);
return waitForCtpConnected(70000).then(function (ok) {
if (!ok && d.error) showCtpError(d.error);
else if (!ok && st.last_error) showCtpError(st.last_error);
return d;
});
}
if (d.disabled || st.auto_connect_enabled === false) {
ctpAutoConnectEnabled = false;
updateCtpConnectButtonState();
syncCtpBadgeFromStatus(st);
showCtpError(st.disabled_hint || d.error || 'CTP 自动连接已关闭');
return d;
}
if (!d.ok) {
syncCtpBadgeFromStatus(st);
var err = d.error || st.last_error || '连接失败';
showCtpError(err);
}
return d;
})
.catch(function () {
updateCtpBadge(false, false);
})
.finally(function () {
ctpConnectInflight = 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 = isTrailingBeOn() ? 0 : (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 (!r.ok && data && !data.error) data.error = '请求失败 ' + r.status;
return data;
});
}).then(function (data) {
if (!data.ok) {
if (isAmountMode()) {
lotsCalc.value = '';
lotsCalc.placeholder = data.error || '无法计算';
}
lastPreviewMetrics = null;
lastSizingInfo = null;
updateRRDisplay();
checkLotsLimit();
return;
}
lotsCalc.value = String(data.lots || '');
if (lotsInput) lotsInput.value = String(data.lots || '');
lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—';
lastPreviewMetrics = data.metrics || null;
lastSizingInfo = data.sizing_info || null;
updateRRDisplay();
checkLotsLimit();
scheduleQuote();
}).catch(function () {
if (isAmountMode()) lotsCalc.placeholder = '计算失败';
lastPreviewMetrics = null;
lastSizingInfo = null;
updateRRDisplay();
});
}
function tryAutoCtpReconnect() {
if (!ctpAutoConnectEnabled) return;
if (ctpReconnecting || ctpConnectInflight) return;
var now = Date.now();
if (now - lastCtpReconnectAt < 60000) return;
if (lastCtpLoginBanAt && now - lastCtpLoginBanAt < 2700000) return;
if (lastCtpUnreachableAt && now - lastCtpUnreachableAt < 300000) 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');
var trailingOn = !!(trailingBeEl && trailingBeEl.checked);
if (offset === 'open') {
if (!isTradingSession) {
showOrderMsg('不在交易时间段', false);
return;
}
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: (trailingOn || !(tpInput && tpInput.value)) ? null : parseFloat(tpInput.value),
trailing_be: trailingOn
};
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.filled ? ('开仓成功 · ' + (data.lots || lots) + ' 手') :
('委托已提交 · ' + (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, opts) {
opts = opts || {};
if (!monitorId) return;
var isPending = !!opts.pending;
if (isPending && !isTradingSession) {
alert('不在交易时间段,无法撤单');
return;
}
var confirmMsg = isPending
? '撤销该开仓委托?(将向柜台发送撤单)'
: '取消该本地止盈止损监控?(不影响柜台委托)';
if (!confirm(confirmMsg)) return;
if (btn) {
btn.disabled = true;
btn.textContent = '取消中…';
}
var url = isPending ? '/api/trading/monitor/cancel-open' : '/api/trading/monitor/dismiss';
fetch(url, {
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 = isPending ? '撤单' : '取消';
}
});
}
function bindCancelOpenButtons(root) {
if (!root) return;
root.querySelectorAll('[data-cancel-open]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!isTradingSession) {
alert('不在交易时间段,无法撤单');
return;
}
dismissMonitor(parseInt(btn.getAttribute('data-cancel-open'), 10), btn, { pending: true });
});
});
}
function bindPendingDismiss(root) {
if (!root) return;
root.querySelectorAll('[data-monitor-id]').forEach(function (btn) {
btn.addEventListener('click', function () {
var isPendingCancel = btn.getAttribute('data-pending-cancel') === '1';
dismissMonitor(
parseInt(btn.getAttribute('data-monitor-id'), 10),
btn,
isPendingCancel ? { pending: true } : {}
);
});
});
}
function bindCancelOrderButtons(root) {
if (!root) return;
root.querySelectorAll('[data-cancel-order]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!isTradingSession) {
alert('不在交易时间段,无法撤单');
return;
}
var orderId = decodeURIComponent(btn.getAttribute('data-cancel-order') || '');
if (!orderId) return;
if (!confirm('撤销该柜台委托?')) return;
btn.disabled = true;
btn.textContent = '撤单中…';
fetch('/api/trading/order/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId })
}).then(function (r) { return r.json(); }).then(function (d) {
if (!d.ok) throw new Error(d.error || d.message || '撤单失败');
pollPositions();
}).catch(function (e) {
alert(e.message || '撤单失败');
btn.disabled = false;
btn.textContent = '撤单';
});
});
});
}
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.trailing_be) {
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 posSymbolTitleHtml(row, extraBadges) {
extraBadges = extraBadges || '';
var name = row.symbol_name || row.symbol || '';
var code = row.symbol_code || '';
var mainBadge = row.symbol_is_main ? ' <span class="badge planned pos-main-badge">主力</span>' : '';
var beBadge = (function () {
if (row.breakeven_locked) return ' <span class="badge profit dash-be-badge">已保本</span>';
if ((row.trailing_r_locked || 0) >= 1) return ' <span class="badge profit dash-be-badge">已保本</span>';
if (row.stop_loss == null || row.entry_price == null) return '';
var entry = Number(row.entry_price);
var sl = Number(row.stop_loss);
if (isNaN(entry) || isNaN(sl)) return '';
var tick = Number(row.tick_size) || Math.max(Math.abs(entry) * 1e-6, 0.01);
var buf = tick * 2.5;
var dir = (row.direction || 'long').toString().toLowerCase();
if (Math.abs(sl - entry) > buf + tick) return '';
if (dir === 'short' ? sl <= entry + tick * 0.05 : sl >= entry - tick * 0.05) {
return ' <span class="badge profit dash-be-badge">已保本</span>';
}
return '';
}());
var inner = name + mainBadge + beBadge;
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
inner += ' <span class="text-accent">' + code + '</span>';
} else if (!name && code) {
inner = '<span class="text-accent">' + code + '</span>';
}
if (marketNavEnabled && code) {
var href = '/market?symbol=' + encodeURIComponent(code) + '&period=15m';
inner = '<a href="' + href + '" class="pos-market-link" title="查看 15 分 K 线">' + inner + '</a>';
}
return inner + extraBadges;
}
function posSymbolSubHtml(row) {
if (row.symbol_exchange) return row.symbol_exchange;
return row.symbol_code || '';
}
function buildPendingOrderCard(row) {
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
var orderPx = row.order_price != null ? row.order_price : row.entry_price;
var remainMin = row.pending_timeout_min != null
? row.pending_timeout_min
: (row.auto_cancel_sec != null ? Math.max(1, Math.ceil(row.auto_cancel_sec / 60)) : 5);
var cancelAllowed = row.cancel_allowed !== false && isTradingSession;
var cancelBtn = '';
if (row.can_cancel_order) {
if (row.monitor_id) {
cancelBtn = '<button type="button" class="pos-close-btn' + (cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-cancel-open="' + row.monitor_id + '">撤单</button>';
} else if (row.order_id || row.vt_order_id) {
cancelBtn = '<button type="button" class="pos-close-btn' + (cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-cancel-order="' + encodeURIComponent(row.vt_order_id || row.order_id) + '">撤单</button>';
}
}
var pendingLabel = row.source_label || '挂单中';
var isCloseOrder = pendingLabel.indexOf('平仓') >= 0;
var metaLine =
'状态 <strong class="text-accent">' + pendingLabel + '</strong>' +
' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' +
(!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
(row.stop_loss != null || (!row.trailing_be && row.take_profit != null) ? ' · ' + slTpStatusHtml(row) : '') +
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
(!isCloseOrder ? ' · <span class="text-muted">约 ' + remainMin + ' 分钟内未成交自动撤单</span>' : '');
return (
'<div class="pos-card is-pending">' +
'<div class="pos-card-head"><div><div class="title">' + posSymbolTitleHtml(row,
' <span class="badge dir">' + dirBadge + '</span>' +
' <span class="badge pending">' + (isCloseOrder ? '平仓委托' : '挂单中') + '</span>') + '</div>' +
'<div class="text-muted pos-symbol-sub">' + posSymbolSubHtml(row) + '</div></div>' +
'<div class="pos-card-actions">' + cancelBtn + '</div></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></div></div>' +
'<div class="cell"><label>委托价</label><div>' + fmtNum(orderPx) + '</div></div>' +
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
'<div class="cell pnl-pending"><label>状态</label><div class="text-accent">等待成交</div></div>' +
'<div class="cell"><label>提交时间</label><div>' + (openT || '--') + '</div></div>' +
'</div>' +
buildPendingHtml(row.pending_orders) +
'</div>'
);
}
function buildPosCard(row) {
if (row.order_state === 'pending') {
return buildPendingOrderCard(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.trailing_be && 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,
trailing_be: !!row.trailing_be
})) + '">设置止盈止损</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,
trailing_be: !!row.trailing_be
}));
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.trailing_be && row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
' · 止损金额 <strong class="text-loss">' +
(row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '</strong>' +
(!row.trailing_be ?
(' · 盈利金额 <strong class="text-profit">' +
(row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '</strong>') : '') +
' · ' + slTpStatusHtml(row) +
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
(slTpBtn ? ' · ' + slTpBtn : '') +
(function () {
if (row.order_state === 'pending' || !row.monitor_id) return '';
if (ctpConnecting) {
return ' · <span class="text-muted">CTP 连接中…</span>';
}
if (row.sync_pending) {
return ' · <span class="text-muted">同步柜台中…</span>';
}
return '';
}());
var feeLabel = row.fee_source === 'ctp' ? '已扣手续费(柜台)' : '已扣手续费';
var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金';
var openLabel = '开仓';
var rowKey = row.key || row.position_key || '';
return (
'<div class="pos-card" data-pos-key="' + rowKey + '">' +
'<div class="pos-card-head"><div><div class="title">' + posSymbolTitleHtml(row,
' <span class="badge dir">' + dirBadge + '</span>') + '</div>' +
'<div class="text-muted pos-symbol-sub">' + posSymbolSubHtml(row) + '</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></div></div>' +
'<div class="cell"><label>均价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
'<div class="cell"><label>当前价格</label><div class="pos-q-mark">' + (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 pos-q-pnl-wrap ' + pnlClass + '"><label>浮盈亏</label><div class="pos-q-pnl">' + pnlText + '</div></div>' +
'<div class="cell"><label>' + feeLabel + '</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
'<div class="cell"><label>' + openLabel + '</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);
});
});
}
var slTpModalState = { payload: null, btn: null, btnLabel: '设置止盈止损' };
function syncSlTpModalTrailingUi() {
var trailingEl = document.getElementById('sl-tp-modal-trailing');
var tpWrap = document.getElementById('sl-tp-modal-tp-wrap');
var hint = document.getElementById('sl-tp-modal-trailing-hint');
var on = !!(trailingEl && trailingEl.checked);
if (tpWrap) tpWrap.hidden = on;
if (hint) hint.hidden = !on;
if (on) {
var tpInput = document.getElementById('sl-tp-modal-tp');
if (tpInput) tpInput.value = '';
}
}
function closeSlTpModal() {
var mask = document.getElementById('sl-tp-modal');
if (mask) mask.classList.remove('show');
slTpModalState.payload = null;
slTpModalState.btn = null;
}
function openSlTpModal(payload, btn, btnLabel) {
var mask = document.getElementById('sl-tp-modal');
var title = document.getElementById('sl-tp-modal-title');
var slInput = document.getElementById('sl-tp-modal-sl');
var tpInput = document.getElementById('sl-tp-modal-tp');
var trailingEl = document.getElementById('sl-tp-modal-trailing');
if (!mask || !slInput) return;
slTpModalState.payload = payload;
slTpModalState.btn = btn || null;
slTpModalState.btnLabel = btnLabel || '设置止盈止损';
if (title) title.textContent = slTpModalState.btnLabel;
slInput.value = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : '';
if (tpInput) {
tpInput.value = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : '';
}
if (trailingEl) trailingEl.checked = !!payload.trailing_be;
syncSlTpModalTrailingUi();
mask.classList.add('show');
slInput.focus();
}
function saveSlTpModal() {
var payload = slTpModalState.payload;
if (!payload) return;
var btn = slTpModalState.btn;
var btnLabel = slTpModalState.btnLabel;
var slInput = document.getElementById('sl-tp-modal-sl');
var tpInput = document.getElementById('sl-tp-modal-tp');
var trailingEl = document.getElementById('sl-tp-modal-trailing');
var trailingOn = !!(trailingEl && trailingEl.checked);
var slRaw = slInput && slInput.value ? slInput.value.trim() : '';
var tpRaw = trailingOn ? '' : (tpInput && tpInput.value ? tpInput.value.trim() : '');
var sl = slRaw ? parseFloat(slRaw) : null;
var tp = tpRaw ? parseFloat(tpRaw) : null;
if (trailingOn && (sl == null || isNaN(sl))) {
alert('移动保本须填写止损价');
return;
}
if (sl == null && tp == null) {
alert('请至少填写止损或止盈');
return;
}
if (btn) {
btn.disabled = true;
btn.textContent = '保存中…';
}
var saveBtn = document.getElementById('sl-tp-modal-save');
if (saveBtn) saveBtn.disabled = true;
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,
trailing_be: trailingOn
})
})
.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 || '保存失败');
closeSlTpModal();
pollPositions();
})
.catch(function (e) {
var msg = e.message || '保存失败';
if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行';
alert(msg);
if (btn) {
btn.disabled = false;
btn.textContent = btnLabel;
}
})
.finally(function () {
if (saveBtn) saveBtn.disabled = false;
});
}
function bindSlTpModal() {
var mask = document.getElementById('sl-tp-modal');
var trailingEl = document.getElementById('sl-tp-modal-trailing');
var cancelBtn = document.getElementById('sl-tp-modal-cancel');
var saveBtn = document.getElementById('sl-tp-modal-save');
if (trailingEl) trailingEl.addEventListener('change', syncSlTpModalTrailingUi);
if (cancelBtn) cancelBtn.addEventListener('click', closeSlTpModal);
if (saveBtn) saveBtn.addEventListener('click', saveSlTpModal);
if (mask) {
mask.addEventListener('click', function (e) {
if (e.target === mask) closeSlTpModal();
});
}
}
function promptStopTakeProfit(payload, btn, btnLabel) {
openSlTpModal(payload, btn, 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.addEventListener('position_quotes', function (ev) {
try {
applyPositionQuotes(JSON.parse(ev.data));
} catch (e) { /* ignore */ }
});
positionSource.onerror = function () {
if (positionSource) {
positionSource.close();
positionSource = null;
}
setTimeout(connectPositionStream, 3000);
};
}
function loadRecSortPrefs() {
try {
var raw = sessionStorage.getItem(REC_SORT_CACHE);
if (!raw) return;
var p = JSON.parse(raw);
if (p.key) recSortKey = p.key;
if (typeof p.desc === 'boolean') recSortDesc = p.desc;
} catch (e) { /* ignore */ }
try {
var ind = sessionStorage.getItem(REC_INDUSTRY_CACHE);
if (ind != null) recIndustryFilter = ind;
} catch (e2) { /* ignore */ }
}
function saveRecSortPrefs() {
try {
sessionStorage.setItem(REC_SORT_CACHE, JSON.stringify({ key: recSortKey, desc: recSortDesc }));
} catch (e) { /* ignore */ }
}
function saveRecIndustryPref() {
try {
sessionStorage.setItem(REC_INDUSTRY_CACHE, recIndustryFilter || '');
} catch (e) { /* ignore */ }
}
function syncRecSortUi() {
var sel = document.getElementById('rec-sort-key');
var btn = document.getElementById('rec-sort-dir');
var indSel = document.getElementById('rec-industry-filter');
if (sel) sel.value = recSortKey;
if (btn) btn.textContent = recSortDesc ? '↓' : '↑';
if (indSel) indSel.value = recIndustryFilter || '';
}
function filterRecommendRows(rows) {
if (!recIndustryFilter) return (rows || []).slice();
return (rows || []).filter(function (r) {
return (r.category || '') === recIndustryFilter;
});
}
function countByCategory(rows) {
var counts = {};
(rows || []).forEach(function (r) {
var cat = r.category || '其他';
counts[cat] = (counts[cat] || 0) + 1;
});
return counts;
}
function updateRecStats(allRows, visibleRows, meta) {
var el = document.getElementById('rec-stats');
if (!el) return;
var total = (allRows || []).length;
var shown = (visibleRows || []).length;
if (!total) {
el.textContent = '';
return;
}
var parts = [];
if (meta && meta.margin_used > 0) {
parts.push(
'持仓占用 <strong>' + fmtNum(meta.margin_used) + '</strong> 元,' +
'剩余额度 <strong class="text-accent">' + fmtNum(meta.margin_budget_remaining) + '</strong> 元'
);
}
if (recIndustryFilter) {
parts.push('筛选 <strong>' + shown + '</strong> / 共 ' + total + ' 个品种');
} else {
parts.push('共 <strong>' + total + '</strong> 个品种');
}
var order = productCategories.length ? productCategories.slice() : [];
var counts = countByCategory(recIndustryFilter ? visibleRows : allRows);
Object.keys(counts).forEach(function (k) {
if (order.indexOf(k) < 0) order.push(k);
});
var breakdown = order.filter(function (cat) { return counts[cat]; }).map(function (cat) {
return cat + ' ' + counts[cat];
});
if (breakdown.length) parts.push(breakdown.join(' · '));
el.innerHTML = parts.join(' · ');
}
var TREND_SORT_RANK = { break_long: 0, break_short: 0, long: 1, short: 2, range: 3, '': 9 };
var GAP_SORT_RANK = { up: 2, down: 1, none: 0, '': -1 };
function sortRecommendRows(rows) {
var list = (rows || []).slice();
var key = recSortKey || 'trend';
var desc = recSortDesc;
list.sort(function (a, b) {
var av, bv, as, bs;
if (key === 'gap') {
av = GAP_SORT_RANK[a.gap || ''] !== undefined ? GAP_SORT_RANK[a.gap || ''] : -1;
bv = GAP_SORT_RANK[b.gap || ''] !== undefined ? GAP_SORT_RANK[b.gap || ''] : -1;
as = Math.abs(Number(a.gap_pct) || 0);
bs = Math.abs(Number(b.gap_pct) || 0);
} else if (key === 'volume') {
av = Number(a.volume) || 0;
bv = Number(b.volume) || 0;
as = bs = 0;
} else if (key === 'amplitude') {
av = Number(a.yesterday_amplitude_pct) || 0;
bv = Number(b.yesterday_amplitude_pct) || 0;
as = bs = 0;
} else {
av = TREND_SORT_RANK[a.trend || ''] !== undefined ? TREND_SORT_RANK[a.trend || ''] : 9;
bv = TREND_SORT_RANK[b.trend || ''] !== undefined ? TREND_SORT_RANK[b.trend || ''] : 9;
as = Number(a.max_lots) || 0;
bs = Number(b.max_lots) || 0;
}
if (av !== bv) return desc ? bv - av : av - bv;
if (as !== bs) return desc ? bs - as : as - bs;
return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN');
});
return list;
}
function fmtRecVolume(v) {
if (v === null || v === undefined) return '—';
var n = Number(v);
if (!isFinite(n)) return '—';
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return String(Math.round(n));
}
function fmtRecTurnover(v) {
if (v === null || v === undefined) return '—';
var n = Number(v);
if (!isFinite(n)) return '—';
if (n >= 1e8) return (n / 1e8).toFixed(2) + '亿';
if (n >= 1e4) return (n / 1e4).toFixed(1) + '万';
return String(Math.round(n));
}
function changeCellHtml(r) {
if (r.yesterday_change == null) return '—';
var ch = Number(r.yesterday_change);
var cls = ch > 0 ? 'rec-change-up' : (ch < 0 ? 'rec-change-down' : '');
var txt = (ch > 0 ? '+' : '') + ch;
if (r.yesterday_change_pct != null) {
var pct = Number(r.yesterday_change_pct);
txt += ' (' + (pct > 0 ? '+' : '') + pct + '%)';
}
return '<span class="' + cls + '">' + txt + '</span>';
}
function trendBadgeHtml(r) {
var label = r.trend_label || '';
if (!label || label === '—') return '—';
var cls = 'planned';
if (r.trend === 'break_long' || r.trend === 'break_short') cls = 'break';
else if (r.trend === 'long') cls = 'profit';
else if (r.trend === 'short') cls = 'loss';
var title = '';
if (r.trend_overlap_pct != null) title = ' title="近3日重叠 ' + r.trend_overlap_pct + '%"';
var prefix = r.trend_transition ? '★ ' : '';
return '<span class="badge trend-badge ' + cls + '"' + title + '>' + prefix + label + '</span>';
}
function gapBadgeHtml(r) {
var label = r.gap_label || '';
if (!label || label === '—') return '—';
var cls = 'planned';
if (r.gap === 'up') cls = 'profit';
else if (r.gap === 'down') cls = 'loss';
var title = '';
if (r.gap_pct != null && r.gap !== 'none') {
title = ' title="跳空 ' + (Number(r.gap_pct) > 0 ? '+' : '') + r.gap_pct + '%"';
}
return '<span class="badge gap-badge ' + cls + '"' + title + '>' + label + '</span>';
}
function fmtRecNum(v) {
if (v == null || v === '') return '—';
var n = Number(v);
if (!isFinite(n)) return '—';
return String(n);
}
function recSpecSuffix(r) {
return r.spec_source === 'ctp' ? ' <span class="text-muted">(柜台)</span>' : '';
}
function recSymbolCellHtml(r) {
var code = r.main_code || r.ths || '';
var nameCls = r.trend_transition ? ' class="trend-name"' : '';
var name = r.name || '';
var nightTag = r.has_night_session ? ' <span class="night-session-tag">夜盘</span>' : '';
if (marketNavEnabled && r.main_code) {
var href = '/market?symbol=' + encodeURIComponent(r.main_code) + '&period=d';
return (
'<td><a href="' + href + '" class="rec-market-link" title="查看日线 K 线">' +
'<strong' + nameCls + '>' + name + nightTag + '</strong> ' +
'<span class="text-accent">' + r.main_code + '</span></a></td>'
);
}
return (
'<td><strong' + nameCls + '>' + name + nightTag + '</strong> ' +
'<span class="text-accent">' + code + '</span></td>'
);
}
function renderRecommendRows(rows) {
if (!recommendList) return;
if (!rows.length) {
var emptyMsg = recIndustryFilter
? '当前行业下暂无推荐品种'
: '当前资金下暂无推荐品种(每日后台刷新)';
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">' + emptyMsg + '</td></tr>';
return;
}
recommendList.innerHTML = rows.map(function (r) {
var rowCls = 'rec-' + (r.status || '');
if (r.trend_transition) rowCls += ' rec-trend-break';
return (
'<tr class="' + rowCls + '">' +
recSymbolCellHtml(r) +
'<td>' + (r.exchange || '') + '</td>' +
'<td>' + (r.category || '—') + '</td>' +
'<td>' + trendBadgeHtml(r) + '</td>' +
'<td>' + gapBadgeHtml(r) + '</td>' +
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
'<td>' + (r.prev_close != null ? r.prev_close : '—') + '</td>' +
'<td>' + (r.today_open != null ? r.today_open : '—') + '</td>' +
'<td>' + changeCellHtml(r) + '</td>' +
'<td>' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '</td>' +
'<td>' + fmtRecVolume(r.volume) + '</td>' +
'<td>' + fmtRecTurnover(r.turnover) + '</td>' +
'<td>' + fmtRecNum(r.mult) + recSpecSuffix(r) + '</td>' +
'<td>' + fmtRecNum(r.tick_size) + recSpecSuffix(r) + '</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 renderRecommendTable() {
var filtered = filterRecommendRows(recRowsRaw);
var sorted = sortRecommendRows(filtered);
updateRecStats(recRowsRaw, sorted, recMeta);
renderRecommendRows(sorted);
}
function renderRecommendations(data) {
if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
recMeta = {
margin_used: data.margin_used || 0,
margin_budget_remaining: data.margin_budget_remaining,
margin_budget_total: data.margin_budget_total
};
var recCap = document.getElementById('rec-capital');
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
var recMarginHint = document.getElementById('rec-margin-hint');
if (recMarginHint) {
if (recMeta.margin_used > 0) {
recMarginHint.textContent = ' · 已扣持仓占用 ' + fmtNum(recMeta.margin_used) + ' 元';
} else {
recMarginHint.textContent = '';
}
}
var recUpdated = document.getElementById('rec-updated');
if (recUpdated && data.updated_at) {
recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at;
}
var rows = data.rows || [];
recRowsRaw = rows.slice();
if (!rows.length) {
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
updateRecStats([], [], recMeta);
return;
}
renderRecommendTable();
}
function initRecommendSortControls() {
loadRecSortPrefs();
syncRecSortUi();
var sel = document.getElementById('rec-sort-key');
var btn = document.getElementById('rec-sort-dir');
var indSel = document.getElementById('rec-industry-filter');
if (indSel) {
indSel.addEventListener('change', function () {
recIndustryFilter = indSel.value || '';
saveRecIndustryPref();
renderRecommendTable();
});
}
if (sel) {
sel.addEventListener('change', function () {
recSortKey = sel.value || 'trend';
saveRecSortPrefs();
renderRecommendTable();
});
}
if (btn) {
btn.addEventListener('click', function () {
recSortDesc = !recSortDesc;
saveRecSortPrefs();
syncRecSortUi();
renderRecommendTable();
});
}
if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw), recMeta);
}
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();
});
var trailingBeEl = document.getElementById('trailing-be');
if (trailingBeEl) {
trailingBeEl.addEventListener('change', updateTrailingBeUi);
updateTrailingBeUi();
}
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);
});
}
function initCtpOnLoad() {
fetch('/api/ctp/status')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.status || {};
if (typeof st.auto_connect_enabled === 'boolean') {
ctpAutoConnectEnabled = st.auto_connect_enabled;
}
updateCtpConnectButtonState();
syncCtpBadgeFromStatus(st);
if (st.disabled_hint) {
showCtpError(st.disabled_hint);
} else if (st.last_error) {
showCtpError(st.last_error);
}
if (st.connected) pollPositions();
})
.catch(function () {});
}
function cleanupTradePage() {
if (sessionClockTickTimer) {
clearInterval(sessionClockTickTimer);
sessionClockTickTimer = null;
}
if (positionSource) {
positionSource.close();
positionSource = null;
}
if (recommendSource) {
recommendSource.close();
recommendSource = null;
}
if (quoteTimer) {
clearTimeout(quoteTimer);
quoteTimer = null;
}
if (calcTimer) {
clearTimeout(calcTimer);
calcTimer = null;
}
}
function bootTradePage() {
loadTradeConfig();
if (!list && !orderList) return;
updateCtpConnectButtonState();
setPriceType('limit');
if (isFixedMode() && lotsCalc) {
lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1);
if (lotsInput) lotsInput.value = lotsCalc.value;
}
var cached = loadPosCache();
if (cached) {
if (cached.ctp_status) {
cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false });
}
if (cached.ctp_status && cached.ctp_status.connected) {
applyPositionsData(cached);
}
}
pollPositions();
connectPositionStream();
bindSlTpModal();
initCtpOnLoad();
connectRecommendStream();
initRecommendSortControls();
if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) {
recRowsRaw = window.__RECOMMEND_ROWS__.slice();
renderRecommendTable();
}
fetch('/api/recommend/list')
.then(function (r) { return r.json(); })
.then(function (data) { if (data.ok) renderRecommendations(data); })
.catch(function () {});
updateSessionUi();
updateRRDisplay();
scheduleQuote();
scheduleAutoCalc();
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && list && !positionSource) {
connectPositionStream();
}
});
function startTradePage() {
if (!document.querySelector('.trade-page')) return;
bootTradePage();
}
if (window.qihuoPageBoot) window.qihuoPageBoot(startTradePage, '.trade-page');
else if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', startTradePage);
else startTradePage();
window.addEventListener('pagehide', cleanupTradePage);
})();