/* 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 posFastPollTimer = null;
var posFastPollCount = 0;
var lastPosRowCount = 0;
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);
if (cfg.capital != null) {
var capEl = document.getElementById('cap-display');
if (capEl) capEl.textContent = Number(cfg.capital).toFixed(2);
}
if (cfg.bootstrap_live && (cfg.bootstrap_live.rows || cfg.bootstrap_live.capital != null)) {
window.__BOOTSTRAP_LIVE__ = cfg.bootstrap_live;
}
} 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 hasRows = data.rows && data.rows.length;
var hasOrders = data.active_orders && data.active_orders.length;
if (!hasRows && !hasOrders && data.capital == null) {
return;
}
if (!hasRows && lastPosRowCount > 0) {
var prev = loadPosCache();
if (prev && prev.rows && prev.rows.length) {
data = Object.assign({}, data, { rows: prev.rows });
}
}
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 = '
' + hint + '
';
return;
}
orderList.innerHTML = '暂无委托。
';
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 stopPosFastPoll() {
if (posFastPollTimer) {
clearInterval(posFastPollTimer);
posFastPollTimer = null;
}
posFastPollCount = 0;
}
function startPosFastPoll() {
if (posFastPollTimer) return;
posFastPollCount = 0;
posFastPollTimer = setInterval(function () {
pollPositions();
posFastPollCount += 1;
if (posFastPollCount >= 120) stopPosFastPoll();
}, 1000);
}
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 avail = document.getElementById('avail-display');
if (avail && data.account_available != null) {
avail.textContent = Number(data.account_available).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 = 'CTP 连接中,请稍候…
';
startPosFastPoll();
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 = '' + err + '
';
return;
}
if (!ctpAutoConnectEnabled) {
var offHint = (data.ctp_status && data.ctp_status.disabled_hint) ||
'CTP 自动连接已关闭,请在系统设置中开启';
list.innerHTML = '' + offHint + '
';
return;
}
list.innerHTML = 'CTP 未连接,后台自动连接中…
';
if (ctpAutoConnectEnabled) refreshCtpStatusPassive();
return;
}
var syncing = data.sync_state === 'syncing';
var hadPos = lastPosRowCount > 0 || !!list.querySelector('.pos-card');
var marginOpen = Number(data.margin_used) > 0;
var riskActive = data.risk_status && Number(data.risk_status.active_count) > 0;
if (syncing || hadPos || marginOpen || riskActive) {
if (syncBadge) {
syncBadge.hidden = false;
syncBadge.textContent = data.sync_label || '持仓同步中…';
syncBadge.className = 'sync-badge text-accent';
}
startPosFastPoll();
return;
}
list.innerHTML = '暂无持仓。
';
lastPosRowCount = 0;
syncPositionListScroll(0);
return;
}
lastPosRowCount = rows.length;
stopPosFastPoll();
list.innerHTML = rows.map(buildPosCard).join('');
syncPositionListScroll(rows.length);
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 || '') +
' · 距开盘 ' + fmtCountdown(c.secs_to_open - elapsed) + '';
} else if (c.in_session) {
if (c.secs_to_break != null) {
html += ' · 距' + (c.break_label || '休盘') + ' ' +
fmtCountdown(c.secs_to_break - elapsed) + '';
}
if (c.secs_to_close != null) {
html += ' · 距' + (c.close_label || '收盘') + ' ' +
fmtCountdown(c.secs_to_close - elapsed) + '';
}
}
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);
pollPositions();
return new Promise(function (resolve) {
setTimeout(function () { resolve(tick()); }, 800);
});
}
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 =
'' + (data.name || sym) + ' 精度 ' + m.price_precision +
' 位 · 每跳 ' + m.tick_value_total + ' 元(' + 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();
});
}
/** 只读 CTP 状态;连接由 qihuo-ctp 后台 worker 负责,前端不发起 connect。 */
function refreshCtpStatusPassive() {
if (ctpConnected || ctpConnecting) return;
var now = Date.now();
if (now - lastCtpReconnectAt < 8000) return;
lastCtpReconnectAt = now;
fetch('/api/ctp/status')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.status || {};
syncCtpBadgeFromStatus(st);
if (st.connected) {
showCtpError('');
pollPositions();
startPosFastPoll();
} else if (st.connecting) {
updateCtpBadge(false, true);
startPosFastPoll();
} else if (st.last_error) {
showCtpError(st.last_error);
} else if (st.disabled_hint) {
showCtpError(st.disabled_hint);
}
})
.catch(function () {});
}
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 ?
'' : '';
return (
'' +
'' + (p.label || '挂单') + '' +
'' +
'' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手' +
dismissBtn +
'' +
'
'
);
}).join('');
return '';
}
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('止损监控中');
} else if (row.stop_loss != null) {
parts.push('止损已设');
}
if (!row.trailing_be) {
if (row.tp_order_active || row.tp_monitoring) {
parts.push('止盈监控中');
} else if (row.take_profit != null) {
parts.push('止盈已设');
}
}
if (!parts.length) return '未设置';
return parts.join(' · ');
}
function trailingStatusHtml(row) {
if (row.trailing_be) {
return '已开启' +
(row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '';
}
return '未开启';
}
function syncPositionListScroll(count) {
if (!list) return;
var n = count != null ? count : list.querySelectorAll('.pos-card[data-pos-key]').length;
var isDesktopTablet = !document.documentElement.classList.contains('layout-phone')
&& document.documentElement.dataset.layout !== 'phone'
&& document.documentElement.dataset.mobile !== '1';
list.classList.toggle('pos-list-many', isDesktopTablet && n > 3);
}
function breakevenBadgeHtml(row) {
if (row.breakeven_locked) {
return ' 已保本';
}
if ((row.trailing_r_locked || 0) >= 1) {
return ' 已保本';
}
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) || entry <= 0) return '';
var tick = Number(row.tick_size) || Math.max(Math.abs(entry) * 1e-6, 0.01);
var beMult = 2;
var dir = (row.direction || 'long').toString().toLowerCase();
var expectedBe = dir === 'short' ? entry - beMult * tick : entry + beMult * tick;
var tol = beMult * tick + tick * 0.05;
if (Math.abs(sl - expectedBe) <= tol) {
return ' 已保本';
}
var buf = tick * Math.max(2, beMult);
if (Math.abs(sl - entry) > buf + tick) return '';
if (dir === 'short' ? sl <= entry + tick * 0.05 : sl >= entry - tick * 0.05) {
return ' 已保本';
}
return '';
}
function posSymbolTitleHtml(row, extraBadges) {
extraBadges = extraBadges || '';
var name = row.symbol_name || row.symbol || '';
var code = row.symbol_code || '';
var mainBadge = row.symbol_is_main ? ' 主力' : '';
var beBadge = breakevenBadgeHtml(row);
var inner = name + mainBadge;
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
inner += ' ' + code + '' + beBadge;
} else if (!name && code) {
inner = '' + code + '' + beBadge;
} else {
inner += beBadge;
}
if (marketNavEnabled && code) {
var href = '/market?symbol=' + encodeURIComponent(code) + '&period=15m';
inner = '' + inner + '';
}
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 = '';
} else if (row.order_id || row.vt_order_id) {
cancelBtn = '';
}
}
var pendingLabel = row.source_label || '挂单中';
var isCloseOrder = pendingLabel.indexOf('平仓') >= 0;
var metaLine =
'状态 ' + pendingLabel + '' +
' · 委托价 ' + fmtNum(orderPx) + '' +
(!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') +
(row.stop_loss != null || (!row.trailing_be && row.take_profit != null) ? ' · ' + slTpStatusHtml(row) : '') +
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
(!isCloseOrder ? ' · 约 ' + remainMin + ' 分钟内未成交自动撤单' : '');
return (
'' +
'
' + posSymbolTitleHtml(row,
' ' + dirBadge + '' +
' ' + (isCloseOrder ? '平仓委托' : '挂单中') + '') + '
' +
'
' + posSymbolSubHtml(row) + '
' +
'
' + cancelBtn + '
' +
'
' + metaLine + '
' +
'
' +
'
' +
'
' + fmtNum(orderPx) + '
' +
'
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' +
'
' +
'
' + (openT || '--') + '
' +
'
' +
buildPendingHtml(row.pending_orders) +
'
'
);
}
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) ?
'' : '';
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 ?
'' : '';
var orderBtn = '';
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) {
orderBtn = '';
}
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 ?
'' : '';
var actionBtns = (entrustBtn || orderBtn || closeBtn) ?
'' + entrustBtn + orderBtn + closeBtn + '
' : '';
var metaLine =
'来源 ' + (row.source_label || 'CTP') + '' +
(!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') +
' · 止损金额 ' +
(row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '' +
(!row.trailing_be ?
(' · 盈利金额 ' +
(row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '') : '') +
' · ' + slTpStatusHtml(row) +
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
(slTpBtn ? ' · ' + slTpBtn : '') +
(function () {
if (row.order_state === 'pending' || !row.monitor_id) return '';
if (ctpConnecting) {
return ' · CTP 连接中…';
}
if (row.sync_pending) {
return ' · 同步柜台中…';
}
return '';
}());
var feeLabel = row.fee_source === 'ctp' ? '已扣手续费(柜台)' : '已扣手续费';
var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金';
var openLabel = '开仓';
var rowKey = row.key || row.position_key || '';
return (
'' +
'
' + posSymbolTitleHtml(row,
' ' + dirBadge + '') + '
' +
'
' + posSymbolSubHtml(row) + '
' +
actionBtns + '
' +
'
' + metaLine + '
' +
'
' +
'
' +
'
' + fmtNum(row.entry_price) + '
' +
'
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' +
'
' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '
' +
'
' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '
' +
'
' +
'
' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '
' +
'
' + (openT || '--') + '
' +
'
' + (row.holding_duration || '--') + '
' +
'
' + buildPendingHtml(row.pending_orders) +
'
'
);
}
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 = '持仓加载失败
';
}
});
}
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(
'持仓占用 ' + fmtNum(meta.margin_used) + ' 元,' +
'剩余额度 ' + fmtNum(meta.margin_budget_remaining) + ' 元'
);
}
if (recIndustryFilter) {
parts.push('筛选 ' + shown + ' / 共 ' + total + ' 个品种');
} else {
parts.push('共 ' + total + ' 个品种');
}
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 '' + txt + '';
}
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 '' + prefix + label + '';
}
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 '' + label + '';
}
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' ? ' (柜台)' : '';
}
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 ? ' 夜盘' : '';
if (marketNavEnabled && r.main_code) {
var href = '/market?symbol=' + encodeURIComponent(r.main_code) + '&period=d';
return (
'' +
'' + name + nightTag + ' ' +
'' + r.main_code + ' | '
);
}
return (
'' + name + nightTag + ' ' +
'' + code + ' | '
);
}
function renderRecommendRows(rows) {
if (!recommendList) return;
if (!rows.length) {
var emptyMsg = recIndustryFilter
? '当前行业下暂无推荐品种'
: '当前资金下暂无推荐品种(每日后台刷新)';
recommendList.innerHTML = '| ' + emptyMsg + ' |
';
return;
}
recommendList.innerHTML = rows.map(function (r) {
var rowCls = 'rec-' + (r.status || '');
if (r.trend_transition) rowCls += ' rec-trend-break';
return (
'' +
recSymbolCellHtml(r) +
'| ' + (r.exchange || '') + ' | ' +
'' + (r.category || '—') + ' | ' +
'' + trendBadgeHtml(r) + ' | ' +
'' + gapBadgeHtml(r) + ' | ' +
'' + (r.price != null ? r.price : '—') + ' | ' +
'' + (r.prev_close != null ? r.prev_close : '—') + ' | ' +
'' + (r.today_open != null ? r.today_open : '—') + ' | ' +
'' + changeCellHtml(r) + ' | ' +
'' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + ' | ' +
'' + fmtRecVolume(r.volume) + ' | ' +
'' + fmtRecTurnover(r.turnover) + ' | ' +
'' + fmtRecNum(r.mult) + recSpecSuffix(r) + ' | ' +
'' + fmtRecNum(r.tick_size) + recSpecSuffix(r) + ' | ' +
'' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' (柜台)' : '') : '—') + ' | ' +
'' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + ' | ' +
'' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + ' | ' +
'' + (r.status_label || '') + ' | ' +
'
'
);
}).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 = '| 当前资金下暂无推荐品种(每日后台刷新) |
';
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();
startPosFastPoll();
} else if (st.connecting) {
startPosFastPoll();
waitForCtpConnected(90000);
} else if (ctpAutoConnectEnabled && !(st.login_cooldown_sec > 0)) {
refreshCtpStatusPassive();
startPosFastPoll();
}
})
.catch(function () {});
}
function cleanupTradePage() {
stopPosFastPoll();
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 bootData = window.__BOOTSTRAP_LIVE__;
if (bootData) {
applyPositionsData(bootData);
savePosCache(bootData);
} else {
var cached = loadPosCache();
if (cached && ((cached.rows && cached.rows.length) || cached.capital != null)) {
if (cached.ctp_status) {
cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false });
}
applyPositionsData(cached);
}
}
pollPositions();
connectPositionStream();
bindSlTpModal();
initCtpOnLoad();
updateSessionUi();
updateRRDisplay();
setTimeout(function () {
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 () {});
scheduleQuote();
scheduleAutoCalc();
}, 400);
}
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);
})();