Files
qihuo/static/js/trade.js
T

1519 lines
63 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 = window.TRADE_SIZING_MODE || 'fixed';
if (sizingMode === 'risk') sizingMode = 'amount';
var list = document.getElementById('position-live-list');
var recommendList = document.getElementById('recommend-list');
var symInput = document.getElementById('trade-symbol');
var dirSelect = document.getElementById('trade-direction');
var lotsInput = document.getElementById('trade-lots');
var lotsCalc = document.getElementById('trade-lots-calc');
var priceInput = document.getElementById('trade-price');
var slInput = document.getElementById('trade-sl');
var tpInput = document.getElementById('trade-tp');
var marketHint = document.getElementById('market-hint');
var metricsHint = document.getElementById('trade-metrics-hint');
var recommendSource = null;
var positionSource = null;
var quoteTimer = null;
var calcTimer = null;
var lastQuotePrice = null;
var priceType = 'limit';
var lastCtpReconnectAt = 0;
var lastCtpUnreachableAt = 0;
var lastCtpLoginBanAt = 0;
var ctpReconnecting = false;
var ctpConnectInflight = false;
var isTradingSession = false;
var hasSlTpMonitoring = false;
var ctpConnected = false;
var positionsRendered = false;
var selectedMaxLots = null;
var recommendMaxByProduct = {};
var recommendMaxByCode = {};
var recRowsRaw = [];
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 = !!window.MARKET_NAV_ENABLED;
var productCategories = window.PRODUCT_CATEGORIES || [];
var POS_CACHE_KEY = 'qihuo_trading_live_v3';
function runWhenReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
function fmtNum(v, digits) {
if (v === null || v === undefined) return '--';
return Number(v).toFixed(digits === undefined ? 2 : digits);
}
function selectedSymbol() {
var codeEl = document.getElementById('trade-symbol-code');
var code = codeEl && codeEl.value ? codeEl.value.trim() : '';
if (code) return code;
return (symInput && symInput.value || '').trim();
}
function isFixedMode() {
return sizingMode === 'fixed';
}
function isAmountMode() {
return sizingMode === 'amount';
}
function effectiveLots() {
var v = parseInt(lotsCalc && lotsCalc.value, 10);
return v > 0 ? v : 0;
}
function updateRecommendMaxMaps(data) {
recommendMaxByProduct = {};
recommendMaxByCode = {};
(data && data.rows || []).forEach(function (r) {
if (!r || r.max_lots <= 0) return;
if (r.status !== 'ok' && r.status !== 'margin_ok') return;
if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots;
if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots;
});
checkLotsLimit();
}
function maxLotsForSymbol(sym) {
if (selectedMaxLots > 0) return selectedMaxLots;
var code = (sym || '').trim().toLowerCase();
if (!code) return 0;
if (recommendMaxByCode[code]) return recommendMaxByCode[code];
var m = code.match(/^([a-z]+)/i);
if (m && recommendMaxByProduct[m[1].toLowerCase()]) {
return recommendMaxByProduct[m[1].toLowerCase()];
}
return 0;
}
function checkLotsLimit() {
var warn = document.getElementById('lots-warn');
if (!warn) return;
var sym = selectedSymbol();
var maxLots = maxLotsForSymbol(sym);
var lots = effectiveLots();
if (maxLots > 0 && lots > maxLots) {
warn.hidden = false;
warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数';
} else {
warn.hidden = true;
warn.textContent = '';
}
}
function loadPosCache() {
try {
var raw = sessionStorage.getItem(POS_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function savePosCache(data) {
try {
if (!data || !data.rows || !data.rows.length) {
var prev = loadPosCache();
if (prev && prev.rows && prev.rows.length) return;
}
sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ }
}
function 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 applyPositionsData(data) {
if (!list || !data) return;
var cap = document.getElementById('cap-display');
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
var connected = data.ctp_status && data.ctp_status.connected;
var connecting = data.ctp_status && data.ctp_status.connecting;
var cooldownSec = (data.ctp_status && data.ctp_status.login_cooldown_sec) || 0;
if (cooldownSec > 0) connecting = false;
ctpConnected = !!connected;
isTradingSession = !!data.trading_session;
syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting });
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();
}
}
var riskBadge = document.getElementById('risk-badge');
if (riskBadge && data.risk_status) {
riskBadge.textContent = data.risk_status.status_label || '';
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
}
var rows = data.rows || [];
var seenKeys = {};
rows = rows.filter(function (row) {
var k = row.key || ((row.symbol_code || '') + ':' + (row.direction || ''));
if (seenKeys[k]) return false;
seenKeys[k] = true;
return true;
});
hasSlTpMonitoring = rows.some(function (row) {
return row.stop_loss != null || row.take_profit != null;
});
updateSessionUi();
savePosCache(data);
positionsRendered = true;
if (!rows.length) {
if (!connected) {
if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
return;
}
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;
}
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
tryAutoCtpReconnect();
return;
}
var pendingOnly = data.pending_orders || [];
if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">暂无持仓</div>' +
pendingOnly.map(function (p) {
var cancelAllowed = p.cancel_allowed !== false && isTradingSession;
var actionBtn = '';
if (p.monitor_id) {
actionBtn = '<button type="button" class="pos-dismiss-btn' +
(cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-monitor-id="' + p.monitor_id + '" data-pending-cancel="1">撤单</button>';
} else if (p.order_id && p.source === 'ctp') {
actionBtn = '<button type="button" class="pos-dismiss-btn' +
(cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-cancel-order="' + encodeURIComponent(p.order_id) + '">撤单</button>';
}
return (
'<div class="pos-pending-item ' +
(p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) +
'"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' +
'<span class="pos-pending-right"><strong>' + fmtNum(p.price) + '</strong> · ' +
(p.lots || 1) + ' 手' + actionBtn + '</span></div>'
);
}).join('');
bindPendingDismiss(list);
bindCancelOrderButtons(list);
} else {
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
}
return;
}
if (!connected) {
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 calcRR(direction, entry, sl, tp) {
entry = parseFloat(entry);
sl = parseFloat(sl);
tp = parseFloat(tp);
if (!entry || !sl || !tp) return null;
var risk, reward;
if (direction === 'long') {
risk = entry - sl;
reward = tp - entry;
} else if (direction === 'short') {
risk = sl - entry;
reward = entry - tp;
} else {
return null;
}
if (risk <= 0 || reward <= 0) return null;
return (reward / risk).toFixed(2);
}
function updateRRDisplay() {
var el = document.getElementById('trade-rr-hint');
if (!el) return;
var dir = dirSelect ? dirSelect.value : 'long';
var entry = entryPrice();
var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0;
var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0;
var lots = effectiveLots();
var parts = [];
var rr = calcRR(dir, entry, sl, tp);
if (rr) parts.push('盈亏比 ' + rr + ':1');
if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) {
if (lastPreviewMetrics.risk_amount != null) {
parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元');
}
if (lastPreviewMetrics.reward_amount != null && tp > 0) {
parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元');
}
}
if (parts.length) {
el.textContent = parts.join(' · ');
el.hidden = false;
} else {
el.textContent = '';
el.hidden = true;
}
}
var lastPreviewMetrics = null;
function setPriceType(type) {
priceType = type === 'market' ? 'market' : 'limit';
document.querySelectorAll('.price-tab').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-type') === priceType);
});
if (priceInput) {
priceInput.disabled = priceType === 'market';
if (priceType === 'market' && lastQuotePrice) priceInput.value = lastQuotePrice;
}
if (marketHint) marketHint.hidden = priceType !== 'market';
updateRRDisplay();
}
function 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 updateCtpBadge(connected, connecting) {
var ctpBadge = document.getElementById('ctp-badge');
var btnConnect = document.getElementById('btn-ctp-connect');
if (ctpBadge) {
if (connecting) {
ctpBadge.textContent = 'CTP 连接中';
ctpBadge.className = 'badge planned';
} else {
ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned');
}
}
if (btnConnect) {
if (connecting) {
btnConnect.textContent = '连接中…';
btnConnect.disabled = true;
} else {
btnConnect.disabled = false;
btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP';
}
}
}
function waitForCtpConnected(maxMs) {
var deadline = Date.now() + (maxMs || 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 && 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.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 = parseFloat(tpInput && tpInput.value) || 0;
if (isFixedMode()) {
var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1;
lotsCalc.value = String(fixedLots);
if (lotsInput) lotsInput.value = String(fixedLots);
if (!sym || !entry) {
lastPreviewMetrics = null;
updateRRDisplay();
checkLotsLimit();
return;
}
} else if (isAmountMode()) {
if (!sym || !entry || !sl) {
lotsCalc.value = '';
lotsCalc.placeholder = '填写止损后自动计算';
lastPreviewMetrics = null;
updateRRDisplay();
checkLotsLimit();
return;
}
lotsCalc.placeholder = '计算中…';
} else {
return;
}
fetch('/api/trade/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: sym,
direction: dirSelect ? dirSelect.value : 'long',
entry: entry,
price: entry,
stop_loss: sl,
take_profit: tp
})
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) {
if (isAmountMode()) {
lotsCalc.value = '';
lotsCalc.placeholder = data.error || '无法计算';
}
lastPreviewMetrics = null;
updateRRDisplay();
checkLotsLimit();
return;
}
lotsCalc.value = String(data.lots || '');
if (lotsInput) lotsInput.value = String(data.lots || '');
lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—';
lastPreviewMetrics = data.metrics || null;
updateRRDisplay();
checkLotsLimit();
scheduleQuote();
}).catch(function () {
if (isAmountMode()) lotsCalc.placeholder = '计算失败';
lastPreviewMetrics = null;
updateRRDisplay();
});
}
function tryAutoCtpReconnect() {
if (ctpReconnecting || 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');
if (offset === 'open') {
if (!isTradingSession) {
showOrderMsg('不在交易时间段', false);
return;
}
var trailingOn = !!(trailingBeEl && trailingBeEl.checked);
if (trailingOn && !(slInput && slInput.value)) {
showOrderMsg('开启移动保本须填写止损价', false);
return;
}
if (isAmountMode() && lots <= 0) {
showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false);
return;
}
if (isFixedMode() && lots <= 0) {
showOrderMsg('手数无效,请检查系统设置中的固定手数', false);
return;
}
if (lots <= 0) {
showOrderMsg('请填写有效手数', false);
return;
}
var maxLots = maxLotsForSymbol(sym);
if (maxLots > 0 && lots > maxLots) {
showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false);
return;
}
}
var btnOpen = document.getElementById('btn-open');
if (btnOpen) {
btnOpen.disabled = true;
btnOpen.textContent = '开仓中…';
}
showOrderMsg('开仓中…', true);
var body = {
symbol: sym,
offset: offset,
direction: direction,
lots: lots,
price: price,
order_type: priceType,
stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null,
take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null,
trailing_be: !!(trailingBeEl && trailingBeEl.checked)
};
fetch('/api/trade/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) {
showOrderMsg(data.error || '下单失败', false);
return;
}
var msg = data.message || (
data.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.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 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 = row.can_cancel_order ?
'<button type="button" class="pos-close-btn' + (cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-cancel-open="' + row.monitor_id + '">撤单</button>' : '';
var metaLine =
'状态 <strong class="text-accent">挂单中</strong>' +
' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' +
(row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
' · ' + slTpStatusHtml(row) +
' · 移动保本 ' + trailingStatusHtml(row) +
' · <span class="text-muted">约 ' + remainMin + ' 分钟内未成交自动撤单</span>';
return (
'<div class="pos-card is-pending">' +
'<div class="pos-card-head"><div><div class="title">' + row.symbol +
' <span class="badge dir">' + dirBadge + '</span>' +
' <span class="badge pending">挂单中</span></div>' +
'<div class="text-muted" style="font-size:.72rem">' + (row.symbol_code || '') + '</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.can_close) ?
'<button type="button" class="pos-dismiss-btn pos-sl-btn" data-sl-tp="' +
encodeURIComponent(JSON.stringify({
symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null
})) + '">设置止盈止损</button>' : '';
var editPayload = encodeURIComponent(JSON.stringify({
symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null,
stop_loss: row.stop_loss, take_profit: row.take_profit
}));
var entrustBtn = row.can_close ?
'<button type="button" class="pos-order-btn pos-entrust-btn" data-edit-sl-tp="' + editPayload + '">委托</button>' : '';
var orderBtn = '';
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) {
orderBtn = '<button type="button" class="pos-order-btn" data-place-orders="' + row.monitor_id + '">清理旧挂单</button>';
}
var closePayload = encodeURIComponent(JSON.stringify({
source: row.source, symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, mark_price: row.mark_price, monitor_id: row.monitor_id || null
}));
var closeBtn = row.can_close ?
'<button type="button" class="pos-close-btn' + (closeAllowed ? '' : ' is-session-off') + '" ' +
(closeAllowed ? '' : 'disabled title="不在交易时间段"') +
' data-close="' + closePayload + '">平仓</button>' : '';
var actionBtns = (entrustBtn || orderBtn || closeBtn) ?
'<div class="pos-card-actions">' + entrustBtn + orderBtn + closeBtn + '</div>' : '';
var metaLine =
'来源 <strong>' + (row.source_label || 'CTP') + '</strong>' +
(row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
' · 止损金额 <strong class="text-loss">' +
(row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '</strong>' +
' · 盈利金额 <strong class="text-profit">' +
(row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '</strong>' +
' · ' + slTpStatusHtml(row) +
' · 移动保本 ' + trailingStatusHtml(row) +
(slTpBtn ? ' · ' + slTpBtn : '') +
(row.sync_pending ? ' · <span class="text-muted">同步柜台中…</span>' : '');
var feeLabel = row.fee_source === 'ctp' ? '手续费(柜台)' : '手续费';
var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金';
var openLabel = '开仓';
return (
'<div class="pos-card">' +
'<div class="pos-card-head"><div><div class="title">' + row.symbol + ' <span class="badge dir">' + dirBadge + '</span></div>' +
'<div class="text-muted" style="font-size:.72rem">' + (row.symbol_code || '') + '</div></div>' +
actionBtns + '</div>' +
'<div class="pos-card-meta pos-card-meta-line">' + metaLine + '</div>' +
'<div class="pos-metrics">' +
'<div class="cell"><label>手数</label><div><strong>' + row.lots + ' 手</strong></div></div>' +
'<div class="cell"><label>均价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
'<div class="cell"><label>' + marginLabel + '</label><div>' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</div></div>' +
'<div class="cell"><label>仓位占比</label><div>' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</div></div>' +
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
'<div class="cell"><label>' + feeLabel + '</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
'<div class="cell"><label>' + 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);
});
});
}
function promptStopTakeProfit(payload, btn, btnLabel) {
btnLabel = btnLabel || '设置止盈止损';
var slDefault = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : '';
var tpDefault = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : '';
var slRaw = prompt('止损价(可留空)', slDefault);
if (slRaw === null) return;
var tpRaw = prompt('止盈价(可留空)', tpDefault);
if (tpRaw === null) return;
var sl = slRaw.trim() ? parseFloat(slRaw) : null;
var tp = tpRaw.trim() ? parseFloat(tpRaw) : null;
if (sl == null && tp == null) {
alert('请至少填写止损或止盈');
return;
}
if (btn) {
btn.disabled = true;
btn.textContent = '保存中…';
}
fetch('/api/trading/monitor/upsert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
symbol_code: payload.symbol_code,
direction: payload.direction,
lots: payload.lots,
entry_price: payload.entry_price,
monitor_id: payload.monitor_id || null,
stop_loss: sl,
take_profit: tp
})
})
.then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.error || ('HTTP ' + r.status));
});
}
return r.json();
})
.then(function (d) {
if (!d.ok) throw new Error(d.error || '保存失败');
pollPositions();
})
.catch(function (e) {
var msg = e.message || '保存失败';
if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行';
alert(msg);
if (btn) {
btn.disabled = false;
btn.textContent = btnLabel;
}
});
}
function bindSlTpButtons(root) {
if (!root) return;
root.querySelectorAll('[data-sl-tp]').forEach(function (btn) {
btn.addEventListener('click', function () {
promptStopTakeProfit(
JSON.parse(decodeURIComponent(btn.getAttribute('data-sl-tp'))), btn, '设置止盈止损'
);
});
});
root.querySelectorAll('[data-edit-sl-tp]').forEach(function (btn) {
btn.addEventListener('click', function () {
promptStopTakeProfit(
JSON.parse(decodeURIComponent(btn.getAttribute('data-edit-sl-tp'))), btn, '委托'
);
});
});
}
function closePosition(payload, btn) {
if (!isTradingSession) {
alert('不在交易时间段,无法平仓');
return;
}
function doClose(price) {
if (!price || price <= 0) { alert('无法获取现价'); return; }
if (!confirm('确认平仓 ' + payload.lots + ' 手?')) return;
if (btn) {
btn.disabled = true;
btn.textContent = '平仓中…';
}
fetch('/api/trading/close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({}, payload, { price: price }))
}).then(function (r) { return r.json(); }).then(function (d) {
if (!d.ok) {
alert(d.error || '平仓失败');
if (btn) {
btn.disabled = false;
btn.textContent = '平仓';
}
return;
}
if (btn) btn.textContent = '已平仓';
pollPositions();
}).catch(function () {
if (btn) {
btn.disabled = false;
btn.textContent = '平仓';
}
});
}
if (payload.mark_price > 0) {
doClose(payload.mark_price);
return;
}
fetch('/api/trade/quote?symbol=' + encodeURIComponent(payload.symbol_code) + '&lots=' + payload.lots)
.then(function (r) { return r.json(); })
.then(function (d) { doClose(d.price); });
}
function pollPositions() {
if (!list) return;
fetch('/api/trading/live')
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (data) {
applyPositionsData(data);
})
.catch(function () {
if (!positionsRendered && list.innerHTML.indexOf('pos-card') < 0) {
list.innerHTML = '<div class="empty-hint text-loss">持仓加载失败</div>';
}
});
}
function connectPositionStream() {
if (positionSource) {
positionSource.close();
positionSource = null;
}
positionSource = new EventSource('/api/trading/stream');
positionSource.addEventListener('positions', function (ev) {
try {
applyPositionsData(JSON.parse(ev.data));
} catch (e) { /* ignore */ }
});
positionSource.onerror = function () {
if (positionSource) {
positionSource.close();
positionSource = null;
}
setTimeout(connectPositionStream, 3000);
};
}
function 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) {
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 (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 || '';
if (marketNavEnabled && r.main_code) {
var href = '/market?symbol=' + encodeURIComponent(r.main_code);
return (
'<td><a href="' + href + '" class="rec-market-link" title="查看 K 线">' +
'<strong' + nameCls + '>' + name + '</strong> ' +
'<span class="text-accent">' + r.main_code + '</span></a></td>'
);
}
return (
'<td><strong' + nameCls + '>' + name + '</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);
renderRecommendRows(sorted);
}
function renderRecommendations(data) {
if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
var recCap = document.getElementById('rec-capital');
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
var recUpdated = document.getElementById('rec-updated');
if (recUpdated && data.updated_at) {
recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at;
}
var rows = data.rows || [];
recRowsRaw = rows.slice();
if (!rows.length) {
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
updateRecStats([], []);
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));
}
function connectRecommendStream() {
if (recommendSource) { recommendSource.close(); recommendSource = null; }
recommendSource = new EventSource('/api/recommend/stream');
recommendSource.addEventListener('recommend', function (ev) {
try { renderRecommendations(JSON.parse(ev.data)); } catch (e) { /* ignore */ }
});
recommendSource.onerror = function () {
if (recommendSource) { recommendSource.close(); recommendSource = null; }
setTimeout(connectRecommendStream, 5000);
};
}
document.querySelectorAll('.price-tab').forEach(function (btn) {
btn.addEventListener('click', function () {
setPriceType(btn.getAttribute('data-type'));
scheduleQuote();
});
});
if (symInput) {
symInput.addEventListener('input', function () {
selectedMaxLots = null;
scheduleQuote();
scheduleAutoCalc();
checkLotsLimit();
});
symInput.addEventListener('symbol-selected', function (ev) {
var item = ev.detail || {};
selectedMaxLots = item.max_lots > 0 ? item.max_lots : null;
scheduleQuote();
scheduleAutoCalc();
checkLotsLimit();
});
}
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
if (slInput) {
slInput.addEventListener('input', function () {
scheduleAutoCalc();
updateRRDisplay();
});
}
if (tpInput) {
tpInput.addEventListener('input', function () {
scheduleAutoCalc();
updateRRDisplay();
});
}
if (dirSelect) dirSelect.addEventListener('change', function () {
scheduleAutoCalc();
updateRRDisplay();
});
if (priceInput) {
priceInput.addEventListener('input', function () {
if (priceType === 'limit') priceInput.dataset.manual = '1';
scheduleAutoCalc();
updateRRDisplay();
});
}
var btnOpen = document.getElementById('btn-open');
if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); });
var btnConnect = document.getElementById('btn-ctp-connect');
if (btnConnect) {
btnConnect.addEventListener('click', function () {
requestCtpConnect(true);
});
}
function initCtpOnLoad() {
fetch('/api/ctp/status')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.status || {};
syncCtpBadgeFromStatus(st);
if (st.last_error) showCtpError(st.last_error);
if (st.connected) pollPositions();
})
.catch(function () {});
}
runWhenReady(function () {
setPriceType('limit');
if (isFixedMode() && lotsCalc) {
lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1);
if (lotsInput) lotsInput.value = lotsCalc.value;
}
var cached = loadPosCache();
if (cached) {
if (cached.ctp_status) {
cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false });
}
applyPositionsData(cached);
}
pollPositions();
connectPositionStream();
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 () {});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && !positionSource) {
connectPositionStream();
}
});
updateSessionUi();
updateRRDisplay();
scheduleQuote();
scheduleAutoCalc();
});
})();