// ──────────────────────────────────────────── // 유틸 // ──────────────────────────────────────────── const $ = id => document.getElementById(id); /** 서버/로컬 휴장일 YYYY-MM-DD 집합 — dashInitDate 등 조기 호출보다 먼저 선언(TDZ 방지) */ let _krHolidays = new Set(); const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR'); /** 원화 가격·평가금 — 소수점 없이 정수 (KIS 주식 호가 단위) */ const fmtWon = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; return Math.round(Number(n)).toLocaleString('ko-KR'); }; /** 원화 금액·손익 — 소수점 없이 정수 + '원' (대시보드·요약 카드) */ const fmtKrw = n => { const w = fmtWon(n); return w === '—' ? w : w + '원'; }; /** 달러 금액 — $1,234.56 */ const fmtUsd = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; const v = Number(n); const sign = v < 0 ? '-' : ''; return sign + '$' + Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); }; /** 표시용 환율 (캐시/폴백). 외부 HTTP 안 탐. */ let _displayFxRate = 0; function setDisplayFxRate(r) { const v = Number(r); if (Number.isFinite(v) && v > 0) _displayFxRate = v; } function getDisplayFxRate(fallback) { const f = Number(fallback); if (Number.isFinite(f) && f > 0) return f; if (_displayFxRate > 0) return _displayFxRate; return 0; } /** 해외 탭: $USD + (원 · 환율) — fxRate 없으면 원만 생략 */ const fmtUsdWithKrw = (usd, fxRate) => { const u = fmtUsd(usd); if (u === '—') return u; const r = getDisplayFxRate(fxRate); if (!Number.isFinite(r) || r <= 0) return u; const krw = Math.round(Number(usd) * r); return `${u} (${fmtWon(krw)}원 · ${r.toFixed(2)})`; }; /** 요약 카드용 — HTML 없이 텍스트 ($ + 원) */ const fmtUsdWithKrwText = (usd, fxRate) => { const u = fmtUsd(usd); if (u === '—') return u; const r = getDisplayFxRate(fxRate); if (!Number.isFinite(r) || r <= 0) return u; return `${u} (${fmtWon(Math.round(Number(usd) * r))}원)`; }; /** 수익률(%) — 거래내역·가상체결 테이블 공용 */ const fmtPct = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; const v = Number(n); return (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; }; /** 틱 분봉커버 문구 — 구독구간(주) + 전체(참고). REST 웜업 봉 때문에 전체가 낮아 보임. */ function fmtTickCoverageLabel(tickMeta) { const m = tickMeta || {}; const sub = m.tick_bar_coverage_pct_subscribed != null ? m.tick_bar_coverage_pct_subscribed : m.tick_bar_coverage_pct; const all = m.tick_bar_coverage_pct_all; const friend = m.tick_bar_coverage_pct_friend; const codes = (m.tick_codes_with_data != null && m.tick_codes_total != null) ? `${m.tick_codes_with_data}/${m.tick_codes_total}종` : ''; let s = `구독구간 ${sub != null ? sub : '—'}%`; if (m.tick_bar_coverage_pct_traded != null) { const tn = m.tick_codes_traded != null ? `${m.tick_codes_traded}종` : ''; s += ` · 거래종목 ${m.tick_bar_coverage_pct_traded}%` + (tn ? `(${tn})` : ''); } if (all != null) s += ` · 전체 ${all}%`; if (friend != null && friend !== all) s += ` · 틱종목 ${friend}%`; if (codes) s += ` (${codes})`; return s; } /** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (API가 최신순이어도 프론트에서 재정렬) */ function tradeExitSortKey(t) { const keys = ['exit_time', 'sell_time', 'sell_date', 'buy_time', 'entry_time', 'buy_date']; for (const k of keys) { const v = t && t[k]; if (v != null && String(v).trim()) { return String(v).replace(/[-: T]/g, '').slice(0, 14); } } return ''; } function tradesNewestFirst(trades) { return [...(trades || [])].sort((a, b) => tradeExitSortKey(b).localeCompare(tradeExitSortKey(a))); } /** 가상 거래 내역 — 전략 공통 렌더·정렬 */ const _virtualTradesCache = {}; const _virtualTradesSort = {}; const _virtualTradesOpts = {}; function fmtTradeTime(raw) { const s = String(raw || '').trim(); if (!s) return ''; // ISO 형식('2026-06-26 09:30:25') — 초(SS)까지 표시. 날짜만(<=10자)이면 그대로. if (s.includes('-') && (s.includes(':') || s.length <= 10)) { return s.length <= 10 ? s : s.slice(0, 19); } const d = s.replace(/[-: T]/g, ''); if (d.length >= 14) { return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' ' + d.slice(8, 10) + ':' + d.slice(10, 12) + ':' + d.slice(12, 14); } if (d.length >= 12) { return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' ' + d.slice(8, 10) + ':' + d.slice(10, 12) + ':00'; } if (d.length >= 8) return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8); return s; } function _tradeTimeSortKey(raw) { const s = String(raw || '').trim(); if (!s) return ''; return s.replace(/[-: T]/g, '').slice(0, 14); } function normalizeVirtualTrade(t, meta) { meta = meta || {}; const isUsd = String(meta.currency || '').toUpperCase() === 'USD'; const roundPx = v => { const n = Number(v); if (!Number.isFinite(n)) return 0; return isUsd ? Math.round(n * 10000) / 10000 : Math.round(n); }; const roundMoney = v => { const n = Number(v); if (!Number.isFinite(n)) return 0; return isUsd ? Math.round(n * 100) / 100 : Math.round(n); }; const ep = roundPx(t.buy_price ?? t.avg_price ?? t.entry ?? 0); const xp = roundPx(t.sell_price ?? t.exit_price ?? t.exit ?? 0); const qty = Number(t.qty || 0) || (ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0); const pnl = roundMoney(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0); let rateNum = Number(t.profit_rate); if (!Number.isFinite(rateNum) && ep > 0) rateNum = (xp - ep) / ep * 100; if (!Number.isFinite(rateNum)) rateNum = 0; const isOpen = !!t.is_open; const buyRaw = t.buy_time || t.entry_time || t.buy_date || ''; const sellRaw = isOpen ? '' : (t.sell_time || t.exit_time || t.sell_date || ''); const code = t.code || meta.code || ''; const name = t.name || meta.name || ''; const fxRate = (() => { const r = Number(t.fx_rate); if (Number.isFinite(r) && r > 0) return r; const ymd = String(t.fx_ymd || sellRaw || buyRaw || '').slice(0, 10); const map = meta.fxRates || {}; const m = Number(map[ymd]); if (Number.isFinite(m) && m > 0) return m; return getDisplayFxRate(meta.fxRate); })(); const pnlKrw = (() => { const k = Number(t.pnl_krw); if (Number.isFinite(k)) return Math.round(k); if (Number.isFinite(fxRate) && fxRate > 0) return Math.round(pnl * fxRate); return NaN; })(); return { code, name, buyRaw, sellRaw, buyKey: _tradeTimeSortKey(buyRaw), sellKey: isOpen ? _tradeTimeSortKey(buyRaw) : _tradeTimeSortKey(sellRaw), ep, xp, qty, pnl, pnlKrw, fxRate, isUsd, isOpen, rateNum, hold: t.hold_min ?? t.hold_days ?? t.hold_minutes ?? 0, reason: t.sell_reason || t.reason || '', rsi: t.rsi_entry, entrySource: t.entry_source_label || t.entry_source || '', exitSource: t.exit_source_label || t.exit_source || '', debugTick: t.debug_tick || '', parityException: !!(t.parity_exception) || _detectParityExceptionCauses(t).length > 0, parityCause: t.parity_cause || _detectParityExceptionCauses(t).join('+'), cumPnl: (() => { const v = Number(t.cum_pnl); if (!Number.isFinite(v)) return NaN; return isUsd ? Math.round(v * 100) / 100 : Math.round(v); })(), cumReturnPct: Number(t.cum_return_pct ?? NaN), entryOb: t.entry_ob || null, exitOb: t.exit_ob || null, accountLabel: t.account_label || ( t.is_mock == null || t.is_mock === '' ? '' : (Number(t.is_mock) === 1 ? '모의' : '실전') ), isMock: t.is_mock, }; } function _detectParityExceptionCauses(t) { const causes = []; const es = String(t.entry_source || '').toLowerCase(); const xs = String(t.exit_source || '').toLowerCase(); const reason = String(t.sell_reason || t.reason || ''); const el = String(t.entry_source_label || t.entrySource || ''); const xl = String(t.exit_source_label || t.exitSource || ''); if (['ohlc_low', 'ohlc_fallback', 'ohlc_high', 'ohlc_intrabar', 'ohlc_bar'].includes(es) || /진입OHLC|OHLC 폴백|OHLC high/i.test(el)) { causes.push('진입OHLC'); } if (['ohlc_bar', 'ohlc_fallback'].includes(xs) || /청산OHLC|^OHLC$/i.test(xl)) { causes.push('청산OHLC'); } if (['eod_flat', 'wallclock_eod'].includes(xs) || /틱공백EOD/i.test(xl)) { causes.push('틱공백EOD'); } if (/ghost_purge/i.test(reason)) causes.push('ghost_purge'); return causes; } function sortVirtualTradeRows(rows, mode) { const list = [...rows]; const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey)); const cmpBuy = (a, b) => String(a.buyKey).localeCompare(String(b.buyKey)); switch (mode) { case 'exit_asc': return list.sort(cmpExit); case 'buy_desc': return list.sort((a, b) => cmpBuy(b, a)); case 'buy_asc': return list.sort(cmpBuy); case 'pnl_desc': return list.sort((a, b) => b.pnl - a.pnl); case 'pnl_asc': return list.sort((a, b) => a.pnl - b.pnl); case 'rate_desc': return list.sort((a, b) => b.rateNum - a.rateNum); case 'rate_asc': return list.sort((a, b) => a.rateNum - b.rateNum); case 'exit_desc': default: return list.sort((a, b) => cmpExit(b, a)); } } /** 실매·백테 거래내역 공통 컨텍스트: 한도 · 장중 누적 최고 · 최종 누적 */ function fillTradePnLContext(elOrId, opts) { opts = opts || {}; const el = typeof elOrId === 'string' ? $(elOrId) : elOrId; if (!el) return; const tb = Number(opts.totalBudget || 0); const peak = Number(opts.peakCum || 0); const peakAt = opts.peakAt ? fmtTradeTime(opts.peakAt) : ''; const label = opts.label || '거래내역'; const isUsd = String(opts.currency || '').toUpperCase() === 'USD'; const fx = Number(opts.fxRate || 0); const fmtMoney = (n) => isUsd ? fmtUsdWithKrwText(n, fx) : (fmtWon(n) + '원'); let line = `📋 ${label} · 한도 ${tb > 0 ? (isUsd ? fmtUsd(tb) : fmtWon(tb) + '원') : '—'}`; if (peak > 0 && peakAt) { line += ` | 장중 누적 최고 +${fmtMoney(peak)} (${peakAt})`; } if (opts.totalPnl != null && Number.isFinite(Number(opts.totalPnl))) { const pnlHtml = isUsd ? fmtUsdWithKrw(opts.totalPnl, fx > 0 ? fx : (Number(opts.totalPnlKrw) ? (Number(opts.totalPnl) ? Number(opts.totalPnlKrw) / Number(opts.totalPnl) : fx) : fx)) : fmtKrw(opts.totalPnl); // 원 환산 금액이 별도로 있으면 그걸로 병기 if (isUsd && opts.totalPnlKrw != null && Number.isFinite(Number(opts.totalPnlKrw))) { line += ` | 최종 누적 ${fmtUsd(opts.totalPnl)} (${fmtWon(opts.totalPnlKrw)}원)`; } else { line += ` | 최종 누적 ${pnlHtml}`; } } el.innerHTML = line; el.style.display = 'block'; } function _syncTradeSortButtons(tbodyId, mode) { document.querySelectorAll(`.trade-sort-bar[data-tbody="${tbodyId}"] .trade-sort-btn`).forEach(btn => { btn.classList.toggle('active', btn.dataset.sort === mode); }); } function attachCumPnlClient(trades, totalBudget) { const out = (trades || []).map(t => Object.assign({}, t)); if (!out.length) return out; if (out.every(t => Number.isFinite(Number(t.cum_pnl)))) return out; const ordered = [...out].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); let cum = 0; const tb = Number(totalBudget || 0); for (const t of ordered) { if (t.is_open) continue; cum += Number(t.pnl ?? t.realized_pnl ?? 0); t.cum_pnl = Math.round(cum); t.cum_return_pct = tb > 0 ? Math.round((cum / tb) * 10000) / 100 : 0; } return out; } /** KR 6자리 종목코드 */ function _normKrStockCode(code) { const d = String(code || '').replace(/\D/g, ''); if (!d) return ''; return d.length >= 6 ? d.slice(-6) : d.padStart(6, '0'); } function _tradeExtLinksHtml(r) { if (r.isUsd || !r.code) return ''; const c = _normKrStockCode(r.code); return `
N N(M)
`; } /** 호가셀 — 녹/노랑/빨강 뱃지 + 쉬운 말 (실매·가상 공용). 임계는 enrich lim_* 만 사용. */ function _obEscAttr(s) { return String(s || '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/${rest}`; } const cls = ok ? 'ob-metric-ok' : 'ob-metric-bad'; return `${ok ? okHead : badHead}` + ` ${rest}`; } function _entryObCellHtml(ob) { if (!ob || ob.matched === false) { return '기록없음' + '
체결≠호가저장'; } const verdict = String(ob.verdict || ob.reject_code || ''); const rej = String(ob.reject_code || '').trim(); const nearOnly = !!ob.near_only; const limSpread = _obNum(ob.lim_spread_pct); const limRatio = _obNum(ob.lim_ratio); const sprN = _obNum(ob.spread_pct); const ratioN = _obNum(ob.mid_ratio != null ? ob.mid_ratio : ob.ratio); const bidN = _obNum(ob.bid_qty_l3); const askN = _obNum(ob.ask_qty_l3); const dsec = _obNum(ob.delta_sec); const badSpread = Number.isFinite(sprN) && Number.isFinite(limSpread) && limSpread > 0 && sprN > limSpread; const badM = Number.isFinite(ratioN) && Number.isFinite(limRatio) && ratioN < limRatio; const isPass = verdict === 'PASS'; const isBody = verdict === 'BODY' || (!verdict && !rej); const isReject = !!(rej && rej !== 'PASS') || (!!verdict && verdict !== 'PASS' && verdict !== 'BODY'); let badgeCls = 'badge-flat'; let badgeTxt = nearOnly ? '근처숫자' : '숫자만'; let badgeTip = '호가 숫자만 있어요. 통과/탈락 도장은 없을 수 있어요.'; if (isReject) { badgeCls = 'badge-loss'; if (verdict.indexOf('스프레드') >= 0 || verdict.indexOf('스프') >= 0) { badgeTxt = nearOnly ? '간격큼·근처' : '간격큼'; badgeTip = '사는 값과 파는 값 사이가 너무 멀어요.'; } else if (verdict.indexOf('수급') >= 0 || verdict.indexOf('지지') >= 0) { badgeTxt = nearOnly ? '살사람적음·근처' : '살사람적음'; badgeTip = '사겠다는 대기보다 팔겠다는 대기가 많아요.'; } else if (verdict.indexOf('얇') >= 0) { badgeTxt = '살대기부족'; badgeTip = '사겠다고 걸어둔 주식이 너무 적어요.'; } else if (verdict.indexOf('매도벽') >= 0 || verdict.indexOf('돌파매도') >= 0) { badgeTxt = '팔대기많음'; badgeTip = '팔겠다고 걸어둔 주식이 너무 많아요. 위로 가기 힘들 수 있어요.'; } else { badgeTxt = '필터걸림'; badgeTip = (ob.reject_msg || verdict) + ' — 그 순간 호가 기준으로는 탈락입니다.'; } } else if (badSpread) { badgeCls = 'badge-loss'; badgeTxt = nearOnly ? '간격큼·근처' : '간격큼'; badgeTip = '사는 값과 파는 값 사이가 기준보다 멀어요.'; } else if (badM) { badgeCls = 'badge-loss'; badgeTxt = nearOnly ? '살힘약함·근처' : '살힘약함'; badgeTip = '사겠다는 대기(위 3칸)가 팔겠다는 대기보다 약해요.'; } else if (isPass || (!isReject && Number.isFinite(sprN) && Number.isFinite(ratioN) && !badSpread && !badM)) { badgeCls = 'badge-win'; badgeTxt = nearOnly ? '괜찮음·근처' : '괜찮음'; badgeTip = isPass ? '간격·대기량이 기준 안이에요. (호가필터를 켜도 통과했을 거래)' : '판정 도장은 없고, 숫자만 보면 기준 안이에요.'; } if (nearOnly && Number.isFinite(dsec)) { badgeTip += `\n(매수시각과 약 ${Math.round(Math.abs(dsec) / 60)}분 차이 — 근처 기록)`; } const lines = []; if (Number.isFinite(sprN)) { const limS = Number.isFinite(limSpread) && limSpread > 0 ? limSpread.toFixed(2) + '%까지' : ''; const ok = Number.isFinite(limSpread) && limSpread > 0 ? !badSpread : null; lines.push(_obMetricLine(ok, '가격간격 OK', '가격간격 큼', sprN.toFixed(2) + '%' + (limS ? ` (${limS})` : ''))); } if (Number.isFinite(ratioN)) { const limR = Number.isFinite(limRatio) ? limRatio.toFixed(2) + ' 이상' : ''; const ok = Number.isFinite(limRatio) ? !badM : null; lines.push(_obMetricLine(ok, '살 힘 OK', '살 힘 약함', ratioN.toFixed(2) + (limR ? ` (${limR})` : ''))); } if (Number.isFinite(bidN) || Number.isFinite(askN)) { const b = Number.isFinite(bidN) ? bidN.toLocaleString() : '—'; const a = Number.isFinite(askN) ? askN.toLocaleString() : '—'; lines.push(`사겠음 ${b}주 · 팔겠음 ${a}주`); } if (Number.isFinite(dsec)) { const dS = `${dsec >= 0 ? '+' : ''}${Math.round(dsec)}s`; lines.push(`시차 ${dS}${ob.source ? ' · ' + _obEscAttr(ob.source) : ''}`); } const tip = [ badgeTip, ob.reject_msg || '', '가격간격 = 바로 살 수 있는 값과 바로 팔 수 있는 값의 차이(%)', '살 힘 = 사겠다고 걸어둔 양 ÷ 팔겠다고 걸어둔 양 (위 3칸 합, 배수)', ob.snap_time ? `snap ${ob.snap_time}` : '', '※ 빨간 뱃지여도 당시 필터 스킵(만료 구멍)이면 실제 매수는 됐을 수 있어요', '※ 체결 성공 ≠ 호가 사진이 꼭 있다는 뜻은 아님', ].filter(Boolean).join('\n'); return `${badgeTxt}` + `
${lines.join('
')}
`; } function _exitObCellHtml(ob, isOpen) { if (isOpen) { return '보유중'; } if (!ob || ob.matched === false) { return '기록없음'; } const nearOnly = !!ob.near_only; const limSpread = _obNum(ob.lim_spread_pct); const limOr = _obNum(ob.lim_or_ratio); const sprN = _obNum(ob.spread_pct); const orN = _obNum(ob.whale_ratio != null ? ob.whale_ratio : ob.or_ratio); const bidN = _obNum(ob.total_bid_qty); const askN = _obNum(ob.total_ask_qty); const dsec = _obNum(ob.delta_sec); const badSpread = Number.isFinite(sprN) && Number.isFinite(limSpread) && limSpread > 0 && sprN > limSpread; const badOr = Number.isFinite(orN) && Number.isFinite(limOr) && orN < limOr; let badgeCls = 'badge-flat'; let badgeTxt = nearOnly ? '근처숫자' : '숫자만'; let badgeTip = '팔 때 호가 숫자예요. (매수 필터 통과/탈락 도장이 아님)'; if (badOr) { badgeCls = 'badge-loss'; badgeTxt = nearOnly ? '수급약함·근처' : '수급약함'; badgeTip = '사겠다는 총대기 ÷ 팔겠다는 총대기가 작아요(OR).'; } else if (badSpread) { badgeCls = 'badge-loss'; badgeTxt = nearOnly ? '간격큼·근처' : '간격큼'; badgeTip = '팔 때 사·팔 가격 간격이 컸어요.'; } else if (Number.isFinite(orN) && Number.isFinite(limOr) && !badOr) { badgeCls = 'badge-win'; badgeTxt = nearOnly ? '수급OK·근처' : '수급OK'; badgeTip = '총사겠음÷총팔겠음(OR)이 표시 기준 이상이에요.'; } if (nearOnly && Number.isFinite(dsec)) { badgeTip += `\n(매도시각과 약 ${Math.round(Math.abs(dsec) / 60)}분 차이 — 근처 기록)`; } const lines = []; if (Number.isFinite(orN)) { const limTxt = Number.isFinite(limOr) ? `, 기준 ${limOr.toFixed(2)}` : ''; const ok = Number.isFinite(limOr) ? !badOr : null; lines.push(_obMetricLine(ok, 'OR OK', 'OR 약함', orN.toFixed(2) + ` (총사겠음÷총팔겠음${limTxt})`)); } if (Number.isFinite(sprN)) { const ok = Number.isFinite(limSpread) && limSpread > 0 ? !badSpread : null; lines.push(_obMetricLine(ok, '가격간격 OK', '가격간격 큼', sprN.toFixed(2) + '%')); } if (Number.isFinite(bidN) || Number.isFinite(askN)) { const b = Number.isFinite(bidN) ? bidN.toLocaleString() : '—'; const a = Number.isFinite(askN) ? askN.toLocaleString() : '—'; lines.push(`총사겠음 ${b} · 총팔겠음 ${a}`); } if (Number.isFinite(dsec)) { const dS = `${dsec >= 0 ? '+' : ''}${Math.round(dsec)}s`; lines.push(`시차 ${dS}${ob.source ? ' · ' + _obEscAttr(ob.source) : ''}`); } const tip = [ badgeTip, 'OR = 호가창 전체 사겠음 ÷ 팔겠음', '가격간격 = 매수1↔매도1 차이(%)', ob.snap_time ? `snap ${ob.snap_time}` : '', '※ 매수호가 칸과 다른 칸 — 살 때 vs 팔 때', ].filter(Boolean).join('\n'); return `${badgeTxt}` + `
${lines.join('
')}
`; } function _obMwCellHtml(ob) { return _entryObCellHtml(ob); } function renderVirtualTrades(tbodyId, trades, opts) { opts = opts || {}; let src = trades || []; if (opts.showCumulative) { src = attachCumPnlClient(src, opts.totalBudget); } _virtualTradesCache[tbodyId] = src; _virtualTradesOpts[tbodyId] = opts; if (!_virtualTradesSort[tbodyId]) _virtualTradesSort[tbodyId] = 'exit_desc'; const mode = _virtualTradesSort[tbodyId]; const meta = Object.assign({}, opts.meta || {}, { currency: opts.currency || (opts.meta && opts.meta.currency) || '', fxRates: opts.fxRates || (opts.meta && opts.meta.fxRates) || {}, }); const isUsd = String(meta.currency || '').toUpperCase() === 'USD'; const defaultFx = getDisplayFxRate(meta.fxRate || opts.fxRate); const fmtPx = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n); const fmtPnlCell = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n); const rows = sortVirtualTradeRows( (_virtualTradesCache[tbodyId] || []).map(t => normalizeVirtualTrade(t, Object.assign({}, meta, { fxRate: defaultFx }))), mode, ); const tbody = $(tbodyId); if (!tbody) return; tbody.innerHTML = ''; rows.forEach(r => { const pnlCls = r.pnl > 0 ? 'text-pnl-pos' : (r.pnl < 0 ? 'text-pnl-neg' : ''); const rateBadge = r.isOpen ? 'badge-flat' : (r.rateNum > 0 ? 'badge-win' : (r.rateNum < 0 ? 'badge-loss' : 'badge-flat')); const nameCell = (r.name || r.code) ? `${r.name || r.code}
${r.code || ''}` : (r.code || '-'); const linksHtml = _tradeExtLinksHtml(r); const reasonHtml = `${r.reason || '-'}`; const debugHtml = r.debugTick ? `${r.debugTick}` : (r.entrySource || r.exitSource ? `${r.entrySource || '-'}→${r.exitSource || '-'}` : '-'); const rowFx = getDisplayFxRate(r.fxRate || defaultFx); const cumPnlHtml = Number.isFinite(r.cumPnl) ? `${fmtPnlCell(r.cumPnl, rowFx)}` : '-'; const cumPctHtml = Number.isFinite(r.cumReturnPct) ? `${fmtPct(r.cumReturnPct)}` : '-'; const sellCell = r.isOpen ? '보유중' : `${fmtTradeTime(r.sellRaw)}`; const exitPxCell = r.isOpen ? `${fmtPx(r.xp, rowFx)} 현재가` : fmtPx(r.xp, rowFx); const pnlCell = r.isOpen ? `${fmtPnlCell(r.pnl, rowFx)} 평가` : fmtPnlCell(r.pnl, rowFx); const isParityEx = !!r.parityException; const rowCls = [ r.isOpen ? 'trade-row-open' : '', isParityEx ? 'trade-row-parity-ex' : '', ].filter(Boolean).join(' '); const causeBadge = isParityEx ? `${r.parityCause || '예외'}` : ''; const rowStyle = r.isOpen && !isParityEx ? ' style="background:rgba(210,153,34,.06)"' : ''; const liveAttrs = r.isOpen && r.code && r.ep > 0 && r.qty > 0 ? ` data-live-code="${String(r.code).replace(/"/g, '')}" data-live-buy="${r.ep}" data-live-qty="${r.qty}" data-live-usd="${r.isUsd ? '1' : '0'}"` : ''; let html = ``; if (opts.showAccount) { const al = r.accountLabel || '미상'; const ab = al === '모의' ? 'badge-flat' : (al === '실전' ? 'badge-win' : 'badge-loss'); html += `${al}`; } html += ` ${causeBadge}${nameCell}${linksHtml} ${fmtTradeTime(r.buyRaw)} ${sellCell} ${fmtPx(r.ep, rowFx)} ${exitPxCell} ${r.qty} ${pnlCell} ${fmtPct(r.rateNum)}`; if (opts.showCumulative) { html += `${cumPnlHtml}${cumPctHtml}`; } html += `${r.hold}`; if (opts.showDebug) html += `${debugHtml}`; html += `${_entryObCellHtml(r.entryOb)}`; html += `${_exitObCellHtml(r.exitOb, r.isOpen)}`; html += `${reasonHtml}`; if (opts.showRsi) html += `${r.rsi != null && r.rsi !== '' ? r.rsi : '-'}`; html += ''; tbody.insertAdjacentHTML('beforeend', html); }); _syncTradeSortButtons(tbodyId, mode); if (rows.some(r => r.isOpen)) { liveQuoteStartFromDom(tbody); } } function setVirtualTradesSort(tbodyId, mode) { _virtualTradesSort[tbodyId] = mode; if (_virtualTradesCache[tbodyId]) { renderVirtualTrades(tbodyId, _virtualTradesCache[tbodyId], _virtualTradesOpts[tbodyId] || {}); } } document.addEventListener('click', e => { const btn = e.target.closest('.trade-sort-btn'); if (!btn) return; const bar = btn.closest('.trade-sort-bar'); if (!bar || !bar.dataset.tbody) return; setVirtualTradesSort(bar.dataset.tbody, btn.dataset.sort || 'exit_desc'); }); const colorPnl = (el, val) => { if (!el) return; el.classList.remove('green','red'); if (val > 0) el.classList.add('green'); else if (val < 0) el.classList.add('red'); }; // 차트 인스턴스 저장 (재생성용) const charts = {}; function destroyChart(id) { if (charts[id]) { charts[id].destroy(); delete charts[id]; } } const CHART_COLORS = { blue: '#58a6ff', green: '#3fb950', red: '#f85149', yellow: '#d29922', purple:'#bc8cff', orange:'#f0883e', }; function lineChart(id, labels, data, label, color) { destroyChart(id); const el = $(id); if (!el) return; const ctx = el.getContext('2d'); if (!ctx) return; charts[id] = new Chart(ctx, { type: 'line', data: { labels, datasets: [{ label, data, borderColor: color || CHART_COLORS.blue, borderWidth: 2, pointRadius: data.length > 100 ? 0 : 3, fill: { target: 'origin', above: 'rgba(63,185,80,0.08)', below: 'rgba(248,81,73,0.08)' }, tension: 0.3, }] }, options: { responsive: true, animation: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } }, scales: { x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { color:'#21262d' } }, y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } }, } } }); } function barChart(id, labels, data, colors) { destroyChart(id); const el = $(id); if (!el) return; const ctx = el.getContext('2d'); if (!ctx) return; charts[id] = new Chart(ctx, { type: 'bar', data: { labels, datasets: [{ data, backgroundColor: colors || data.map(v => v >= 0 ? 'rgba(63,185,80,0.7)' : 'rgba(248,81,73,0.7)'), }] }, options: { responsive: true, animation: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } }, scales: { x: { ticks: { color: '#8b949e', maxTicksLimit: 14 }, grid: { color:'#21262d' } }, y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } }, } } }); } function doughnutChart(id, labels, data) { destroyChart(id); const el = $(id); if (!el) return; const ctx = el.getContext('2d'); if (!ctx) return; const palette = [CHART_COLORS.green, CHART_COLORS.red, CHART_COLORS.yellow, CHART_COLORS.purple, CHART_COLORS.orange, CHART_COLORS.blue]; charts[id] = new Chart(ctx, { type: 'doughnut', data: { labels, datasets: [{ data, backgroundColor: palette, borderWidth: 0 }] }, options: { responsive: true, animation: false, plugins: { legend: { position: 'right', labels: { color:'#8b949e', font:{ size:11 } } } } } }); } // ──────────────────────────────────────────── // 탭 전환 (새로고침해도 마지막 탭 유지) // ──────────────────────────────────────────── const BT_TAB_KEY = 'kis_bt_active_tab'; const BT_TAB_NAMES = [ 'actual', 'dashboard', 'portfolio', 'backtest', 'tail', 'dbband', 'breakout', 'range_break', 'momentum', 'us_momentum', 'optuna', 'holding', 'updownbox', 'updow', 'envhist', 'dart', 'liveconfig', 'feedstats', ]; function btShowTab(tab, opts) { opts = opts || {}; if (!BT_TAB_NAMES.includes(tab)) tab = 'actual'; const el = document.querySelector('[data-tab="' + tab + '"]'); if (!el) return; document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active')); el.classList.add('active'); $('tab-actual').style.display = tab === 'actual' ? '' : 'none'; $('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none'; $('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none'; $('tab-backtest').style.display = tab === 'backtest' ? '' : 'none'; $('tab-tail').style.display = tab === 'tail' ? '' : 'none'; $('tab-dbband').style.display = tab === 'dbband' ? '' : 'none'; $('tab-breakout').style.display = tab === 'breakout' ? '' : 'none'; $('tab-range_break').style.display = tab === 'range_break' ? '' : 'none'; $('tab-momentum').style.display = tab === 'momentum' ? '' : 'none'; $('tab-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none'; $('tab-optuna').style.display = tab === 'optuna' ? '' : 'none'; $('tab-holding').style.display = tab === 'holding' ? '' : 'none'; $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none'; $('tab-updow').style.display = tab === 'updow' ? '' : 'none'; const _ehEl = $('tab-envhist'); if (_ehEl) _ehEl.style.display = tab === 'envhist' ? '' : 'none'; $('tab-dart').style.display = tab === 'dart' ? '' : 'none'; $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none'; const _fsEl = $('tab-feedstats'); if (_fsEl) _fsEl.style.display = tab === 'feedstats' ? '' : 'none'; try { localStorage.setItem(BT_TAB_KEY, tab); } catch (e) { /* ignore */ } if (!opts.fromHash) { try { if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab); } catch (e) { /* ignore */ } } if (tab === 'dashboard') loadDashboard(); if (tab === 'liveconfig') lcOnTabShow(); if (tab === 'feedstats') fsOnTabShow(); if (tab === 'portfolio') pfLoad(true); if (tab === 'holding') hdLoadStocks(); if (tab === 'updow') permLoad(); if (tab === 'envhist') envHistLoad(); if (tab === 'us_momentum') { updateUsMomFxHint(); usmomLoadStocks(false); usmomOptSyncDatesFromMain(); if (typeof _usmomOptJobId !== 'undefined' && _usmomOptJobId) usmomStockOptunaStartPolling(); } if (tab === 'dart') dartOnTabShow(); if (tab === 'updownbox') ubxOnTabShow(); if (tab === 'dbband') dbLoadStocks(); if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors(); if (tab === 'optuna' && typeof optunaOnTabShow === 'function') optunaOnTabShow(); // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지) if (tab === 'portfolio') { /* pfRenderTable 에서 start */ } else if (tab === 'actual') { const at = $('act_tbody'); if (at && at.querySelector('[data-live-code]') && typeof liveQuoteStartFromDom === 'function') { liveQuoteStartFromDom(at); } else if (typeof liveQuoteStop === 'function') { liveQuoteStop(); } } else if (typeof liveQuoteStop === 'function') { liveQuoteStop(); } } function btRestoreTab() { let tab = (location.hash || '').replace(/^#/, ''); if (!BT_TAB_NAMES.includes(tab)) { try { tab = localStorage.getItem(BT_TAB_KEY) || ''; } catch (e) { tab = ''; } } if (!BT_TAB_NAMES.includes(tab)) tab = 'actual'; btShowTab(tab); } document.querySelectorAll('[data-tab]').forEach(el => { el.addEventListener('click', e => { e.preventDefault(); btShowTab(el.dataset.tab); }); }); window.addEventListener('hashchange', () => { const tab = (location.hash || '').replace(/^#/, ''); if (BT_TAB_NAMES.includes(tab)) btShowTab(tab, { fromHash: true }); }); // ════════════════════════════════════════════════════════════════ // 수집통계 (일자별 틱·호가) // ════════════════════════════════════════════════════════════════ function fsFmtN(n) { const x = Number(n) || 0; return x.toLocaleString('ko-KR'); } function fsNcCell(o) { o = o || {}; return fsFmtN(o.n) + ' / ' + fsFmtN(o.codes); } function fsOnTabShow() { const el = $('fs_date'); if (el && !el.value) { try { if (typeof tradingDayKst === 'string' && tradingDayKst) el.value = tradingDayKst; else { const d = new Date(); el.value = d.toISOString().slice(0, 10); } } catch (e) { /* ignore */ } } fsLoad(false); } async function fsLoad(heavy) { const dayEl = $('fs_date'); const day = (dayEl && dayEl.value) ? dayEl.value : ''; const useHeavy = !!heavy; if ($('fs_meta')) { $('fs_meta').textContent = useHeavy ? '상세 집계 중…(교집합·느릴 수 있음)' : '조회 중…(폴백나이 포함, 십수 초 가능)'; } try { const qs = []; if (day) qs.push('date=' + encodeURIComponent(day)); if (useHeavy) qs.push('heavy=1'); const r = await fetch('/api/feed_collect_stats?' + qs.join('&')); const j = await r.json(); if (!j.ok) throw new Error(j.error || 'fail'); if (dayEl && j.day) dayEl.value = j.day; if ($('fs_meta')) { $('fs_meta').textContent = (j.day || '') + ' 기준 · 당일만 · ' + (j.heavy ? '상세(교집합)' : '조회(폴백나이 포함)') + ' · API ok'; } const env = j.env || {}; if ($('fs_env')) { const bits = [ 'LIVE_TICK=' + (env.LIVE_TICK_PROVIDER || '-'), 'LIVE_OB=' + (env.LIVE_OB_PROVIDER || '-'), 'OB_MODE=' + (env.WS_ORDERBOOK_SAVE_MODE || '-'), 'FALLBACK_AGE=' + (env.LIVE_FEED_FALLBACK_MAX_AGE_SEC != null ? env.LIVE_FEED_FALLBACK_MAX_AGE_SEC : '-'), 'OB_TICK_AGE=' + (env.WS_ORDERBOOK_TICK_MAX_AGE_SEC != null ? env.WS_ORDERBOOK_TICK_MAX_AGE_SEC : '-'), 'KIS_OB_SAVE=' + env.WS_ORDERBOOK_SAVE_KIS, 'KW_OB_SAVE=' + env.WS_ORDERBOOK_SAVE_KIWOOM, 'LS_OB_SAVE=' + env.LS_WS_ORDERBOOK_SAVE, 'LS_OB_MODE=' + (env.LS_WS_ORDERBOOK_SAVE_MODE || '-'), 'LS_TICK_SAVE=' + env.LS_WS_TICK_SAVE, ]; $('fs_env').innerHTML = bits.map(b => '' + b + '').join(''); } const notes = j.notes || []; if ($('fs_notes')) { $('fs_notes').innerHTML = notes.length ? notes.map(n => '
' + n + '
').join('') : '
특이 노트 없음
'; } function fsRatio(x) { if (x == null || x === '') return '—'; return Number(x).toFixed(3); } function fsPct(x) { if (x == null || x === '') return '—'; return Number(x).toFixed(1) + '%'; } function fsVendorNote(v) { const tn = Number(v.tick_n) || 0; const on = Number(v.ob_n) || 0; const r = v.ob_per_tick; if (tn === 0 && on === 0) return '데이터 없음'; if (tn === 0 && on > 0) return '틱0·호가만 (LS SAVE/모드 확인)'; if (on === 0 && tn > 0) return '호가0 (세션·틱동기·Bye)'; if (r != null && r >= 0.85) return '틱동기 양호'; if (r != null && r < 0.05) return '호가 거의 없음'; return '틱동기 부분'; } const vm = j.vendor_matrix || {}; const vTb = $('fs_vendor_tbody'); if (vTb) { const vendors = vm.vendors || []; const labels = { kis: '한투 (kis)', kiwoom: '키움 (kiwoom)', ls: 'LS (ls)' }; if (!vendors.length) { vTb.innerHTML = '없음'; } else { vTb.innerHTML = vendors.map(v => { const hot = (Number(v.tick_n) > 0 && Number(v.ob_n) === 0) || (Number(v.tick_n) === 0 && Number(v.ob_n) === 0 && (v.vendor === 'ls')); const st = hot ? ' style="background:rgba(220,53,69,.10)"' : ''; const name = labels[v.vendor] || v.vendor; return '' + name + '' + '' + fsFmtN(v.tick_n) + '' + '' + fsFmtN(v.tick_codes) + '' + '' + fsFmtN(v.ob_n) + '' + '' + fsFmtN(v.ob_codes) + '' + '' + fsRatio(v.ob_per_tick) + '' + '' + fsPct(v.ob_per_tick_pct) + '' + '' + fsVendorNote(v) + ''; }).join(''); } } if ($('fs_vendor_foot')) { $('fs_vendor_foot').textContent = 'filter_eval(진입스냅) ' + fsFmtN(vm.filter_eval_n) + '행 / ' + fsFmtN(vm.filter_eval_codes) + '종목 · MODE=tick 이면 호가/틱≈1 이 이상적, 0이면 그 벤더 호가DB 구멍'; } const age = j.age_cut || {}; if ($('fs_age_note')) { $('fs_age_note').textContent = age.note || ('나이=' + (age.age_sec != null ? age.age_sec : 3) + 's · 자사%=총행대비 · 분배%=usable비중 · 간택%=체인1순위'); } const aTb = $('fs_age_tbody'); if (aTb) { const arows = age.rows || []; if (!arows.length) { aTb.innerHTML = '집계 없음(recv_ts·시각 파싱 확인)'; } else { aTb.innerHTML = arows.map(a => { const hot = (Number(a.fail_pct) || 0) >= 20 || (Number(a.pick_pct) || 0) >= 70 || (a.source === 'Σ(틱)' && (a.empty_minutes || 0) > 0); const st = hot ? ' style="background:rgba(220,53,69,.10)"' : ''; const emp = (a.source === 'Σ(틱)') ? fsFmtN(a.empty_minutes || age.empty_minutes_all_fail || 0) : '—'; const pickN = (a.pick_n == null) ? '—' : fsFmtN(a.pick_n); const pickP = (a.pick_pct == null) ? '—' : fsPct(a.pick_pct); const share = (a.usable_share_pct == null) ? '—' : fsPct(a.usable_share_pct); return '' + (a.channel || '') + '' + (a.source || '') + '' + '' + fsFmtN(a.total) + '' + '' + fsFmtN(a.pass_n) + '' + '' + fsFmtN(a.fail_n) + '' + '' + fsFmtN(a.unknown_n) + '' + '' + fsFmtN(a.usable_n) + '' + '' + fsPct(a.usable_pct) + '' + '' + share + '' + '' + pickN + '' + '' + pickP + '' + '' + fsPct(a.fail_pct) + '' + '' + emp + ''; }).join(''); } } const gap = j.disconnect || {}; if ($('fs_gap_note')) { $('fs_gap_note').textContent = gap.note || ('끊김=종목별 recv_ts 공백 · soft=' + (gap.soft_sec != null ? gap.soft_sec : 10) + 's · hard=' + (gap.hard_sec != null ? gap.hard_sec : 60) + 's'); } const gTb = $('fs_gap_tbody'); if (gTb) { const grows = gap.rows || []; const labels = { kis: '한투 (kis)', kiwoom: '키움 (kiwoom)', ls: 'LS (ls)' }; if (!grows.length) { gTb.innerHTML = '끊김 집계 없음'; } else { gTb.innerHTML = grows.map(g => { const hot = (Number(g.hard_n) || 0) >= 500 || (Number(g.hard_pct) || 0) >= 5; const st = hot ? ' style="background:rgba(220,53,69,.10)"' : ''; const name = labels[g.vendor] || g.vendor; return '' + name + '' + '' + fsFmtN(g.pair_n) + '' + '' + fsFmtN(g.soft_n) + '' + '' + fsPct(g.soft_pct) + '' + '' + fsFmtN(g.hard_n) + '' + '' + fsPct(g.hard_pct) + '' + '' + (g.avg_soft_gap_sec != null ? g.avg_soft_gap_sec : '—') + '' + '' + (g.max_gap_sec != null ? fsFmtN(g.max_gap_sec) : '—') + ''; }).join(''); } } const s = j.summary || {}; const tk = s.ticks || {}; const ob = s.orderbook || {}; function fillRows(tbodyId, rows, emptyMsg) { const tb = $(tbodyId); if (!tb) return; rows = rows || []; if (!rows.length) { tb.innerHTML = '' + (emptyMsg || '0') + ''; return; } tb.innerHTML = rows.map(r => '' + (r.key || '') + '' + fsFmtN(r.n) + '' + fsFmtN(r.codes) + '' ).join(''); } fillRows('fs_ticks_tbody', j.ticks_by_source, '틱 없음'); fillRows('fs_ob_tbody', j.orderbook_by_source, '호가 없음'); fillRows('fs_fe_tbody', j.filter_eval_by_strategy, 'filter_eval 없음'); const covTb = $('fs_cov_tbody'); if (covTb) { const cov = j.coverage || []; if (!cov.length) { covTb.innerHTML = '커버리지 없음'; } else { covTb.innerHTML = cov.map(c => { const noPct = (c.tick_codes_no_ob_pct == null) ? '—' : (c.tick_codes_no_ob_pct + '%'); const sync = (c.ob_per_tick_row_pct == null) ? '-' : (c.ob_per_tick_row_pct + '%'); const miss = (c.missing_ob_row_pct == null) ? '-' : (c.missing_ob_row_pct + '%'); const noOb = (c.tick_codes_no_ob == null) ? '—' : fsFmtN(c.tick_codes_no_ob); const hot = (c.tick_codes_no_ob || 0) > 0 || (c.missing_ob_row_pct || 0) >= 20; const style = hot ? ' style="background:rgba(220,53,69,.12)"' : ''; return '' + (c.vendor || '') + '' + fsFmtN(c.tick_n) + '' + fsFmtN(c.tick_codes) + '' + fsFmtN(c.ob_n) + '' + fsFmtN(c.ob_codes) + '' + noOb + '' + noPct + '' + sync + '' + miss + ''; }).join(''); } } const ov = j.overlap || {}; if ($('fs_overlap')) { if (ov.deferred) { $('fs_overlap').textContent = '벤더 교집합: 상세 집계 후 표시'; } else { $('fs_overlap').textContent = '틱 교집합 ' + fsFmtN(ov.both_tick_codes) + ' · KIS만 ' + fsFmtN(ov.kis_only_tick_codes) + ' · 키움만 ' + fsFmtN(ov.kiwoom_only_tick_codes) + ' | 호가 교집합 ' + fsFmtN(ov.both_ob_codes) + ' · KIS만 ' + fsFmtN(ov.kis_only_ob_codes) + ' · 키움만 ' + fsFmtN(ov.kiwoom_only_ob_codes); } } const lsTb = $('fs_ls_tbody'); if (lsTb) { const rows = [ { key: 'ls_ws_orderbook', ...(ob.ls_ws_orderbook || {}) }, { key: 'ls_ws_ticks', ...(tk.ls_table || {}) }, { key: 'ls_ws_candles', ...(s.ls_candles || {}) }, { key: 'kis_ws_orderbook', ...(ob.kis_ws_orderbook || {}) }, { key: 'ws_ticks ls mirror', ...(tk.ls_mirror || {}) }, ]; lsTb.innerHTML = rows.map(r => '' + r.key + '' + fsFmtN(r.n) + '' + fsFmtN(r.codes) + '' ).join(''); } } catch (e) { if ($('fs_meta')) $('fs_meta').textContent = '오류: ' + (e.message || e); } } // ════════════════════════════════════════════════════════════════ // DART 수주 공시 // ════════════════════════════════════════════════════════════════ let _dartPollTimer = null; function dartOnTabShow() { dartLoad(); if (_dartPollTimer) clearInterval(_dartPollTimer); _dartPollTimer = setInterval(() => { const t = document.querySelector('[data-tab].active'); if (t && t.dataset.tab === 'dart') dartLoad(true); }, 10000); } async function dartLoad(quiet) { try { const r = await fetch('/api/dart/disclosures?limit=80'); const j = await r.json(); if (!j.ok) throw new Error(j.error || 'fail'); const f = j.flags || {}; if ($('dart_scan_en')) $('dart_scan_en').checked = !!f.scan; if ($('dart_sub_en')) $('dart_sub_en').checked = !!f.subscribe; if ($('dart_trade_en')) $('dart_trade_en').checked = !!f.trade; if ($('dart_strat_en')) $('dart_strat_en').checked = !!f.strategy; if ($('dart_watch_max') && f.watch_max != null) $('dart_watch_max').value = f.watch_max; if ($('dart_watch_ttl') && f.watch_ttl_hours != null) $('dart_watch_ttl').value = f.watch_ttl_hours; if ($('dart_quality_en')) $('dart_quality_en').checked = f.quality_filter !== false; if ($('dart_min_sales') && f.min_sales_pct != null) $('dart_min_sales').value = f.min_sales_pct; if ($('dart_require_theme')) $('dart_require_theme').checked = f.require_theme !== false; const wb = $('dart_watch_tbody'); if (wb) { const w = j.watch || []; wb.innerHTML = w.length ? w.map(x => ` ${x.stock_code||''}${x.corp_name||''} ${(x.report_nm||'').slice(0,40)} ${x.added_at||''}${x.expires_at||''} `).join('') : '워치 없음 (구독 스위치 OFF 또는 공시 대기)'; } const tb = $('dart_disc_tbody'); if (tb) { const rows = j.rows || []; tb.innerHTML = rows.length ? rows.map(x => { const ok = x.filter_ok == null ? null : Number(x.filter_ok) === 1; const badge = ok === true ? '통과' : ok === false ? `${(x.filter_reason||'제외').slice(0,24)}` : '-'; const pct = (x.sales_pct != null && x.sales_pct !== '') ? Number(x.sales_pct).toFixed(1) : '-'; return ` ${x.first_seen_at||''} ${x.stock_code||''}${x.corp_name||''} ${pct} ${badge} ${(x.report_nm||'').trim()} ${x.url ? `원문` : ''} `; }).join('') : '공시 없음'; } } catch (e) { if (!quiet) console.error('dartLoad', e); } } async function dartSaveConfig() { const body = { DART_SCAN_ENABLED: $('dart_scan_en') && $('dart_scan_en').checked ? 'true' : 'false', DART_SUBSCRIBE_ENABLED: $('dart_sub_en') && $('dart_sub_en').checked ? 'true' : 'false', DART_TRADE_ENABLED: $('dart_trade_en') && $('dart_trade_en').checked ? 'true' : 'false', STRATEGY_DART_ENABLED: $('dart_strat_en') && $('dart_strat_en').checked ? 'true' : 'false', DART_WATCH_MAX: $('dart_watch_max') ? String($('dart_watch_max').value || 15) : '15', DART_WATCH_TTL_HOURS: $('dart_watch_ttl') ? String($('dart_watch_ttl').value || 24) : '24', DART_QUALITY_FILTER_ENABLED: $('dart_quality_en') && $('dart_quality_en').checked ? 'true' : 'false', DART_MIN_SALES_PCT: $('dart_min_sales') ? String($('dart_min_sales').value || 5) : '5', DART_REQUIRE_THEME: $('dart_require_theme') && $('dart_require_theme').checked ? 'true' : 'false', }; const r = await fetch('/api/dart/config', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); const j = await r.json(); alert(j.ok ? '저장됨: ' + (j.saved||[]).join(', ') : ('실패: ' + (j.error||''))); dartLoad(); } async function dartRunBacktest() { const s = $('dart_bt_start') && $('dart_bt_start').value; const e = $('dart_bt_end') && $('dart_bt_end').value; if (!s || !e) { alert('시작/종료일 필요'); return; } $('dart_bt_out').textContent = '실행 중…'; try { const r = await fetch(`/api/backtest/dart?start=${encodeURIComponent(s)}&end=${encodeURIComponent(e)}`); const j = await r.json(); if (!j.ok) throw new Error(j.error || 'fail'); $('dart_bt_out').textContent = `이벤트 ${j.events} · 거래 ${j.trade_count} · 승률 ${Number(j.win_rate||0).toFixed(1)}% · PnL ${fmtKrw(j.total_pnl)}\n` + (j.trades||[]).slice(0, 20).map(t => `${t.code} ${t.entry_time}→${t.exit_time} ${t.reason} pnl=${Math.round(t.pnl||0)}` ).join('\n'); } catch (err) { $('dart_bt_out').textContent = '오류: ' + err; } } // ════════════════════════════════════════════════════════════════ // 영구구독 관리 (permanent_subscriptions: KR 국내WS / US 해외WS) // ════════════════════════════════════════════════════════════════ let _permRefreshTimer = null; let _permRefreshSec = 15; function permOnMarket() { // 시장 선택 시 거래소 기본값 자동 보정 (KR→KRX, US→NASD) const mk = $('perm_market') ? $('perm_market').value : 'KR'; if ($('perm_exch')) $('perm_exch').value = (mk === 'US') ? 'NASD' : 'KRX'; } function permFmtQuoteTime(t) { const s = String(t || ''); if (s.length >= 12) { return s.slice(4, 6) + '/' + s.slice(6, 8) + ' ' + s.slice(8, 10) + ':' + s.slice(10, 12); } if (s.length >= 8) return s.slice(0, 4) + '-' + s.slice(4, 6) + '-' + s.slice(6, 8); return s; } function permFmtPriceCell(x, fxRate) { const px = Number(x.price); if (!(px > 0)) { return '
시세대기'; } const isUs = String(x.market_type || '').toUpperCase() === 'US'; const fx = getDisplayFxRate(fxRate != null ? fxRate : x.fx_rate); let main; if (isUs) { const krw = (x.price_krw != null && Number(x.price_krw) > 0) ? Number(x.price_krw) : (fx > 0 ? Math.round(px * fx) : 0); main = '$' + px.toFixed(4) + ''; if (krw > 0) { main += '
(' + Math.round(krw).toLocaleString('ko-KR') + '원' + (fx > 0 ? (' · ' + fx.toFixed(2)) : '') + ')'; } } else { main = '' + Math.round(px).toLocaleString('ko-KR') + ''; } let chgHtml = ''; const chg = x.chg_pct; if (chg != null && chg !== '' && Number.isFinite(Number(chg))) { const v = Number(chg); const cls = v >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; chgHtml = '
' + (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; } const t = (x.price_src === 'tick' && x.tick_time) ? x.tick_time : (x.candle_time || x.tick_time || ''); const src = (x.price_src === 'tick') ? 'tick' : (x.price_src === 'candle' ? '봉' : ''); const meta = permFmtQuoteTime(t) + (src ? (' ·' + src) : ''); return main + chgHtml + (meta ? ('
' + meta + '') : ''); } function permEnsureAutoRefresh(sec) { const s = Math.max(5, Number(sec) || _permRefreshSec || 15); _permRefreshSec = s; if (_permRefreshTimer) clearInterval(_permRefreshTimer); _permRefreshTimer = setInterval(function () { const tab = $('tab-updow'); if (tab && tab.style.display !== 'none') permLoad({ quiet: true }); }, s * 1000); } async function permLoad(opt) { const quiet = !!(opt && opt.quiet); const tb = $('perm_tbody'); if (!tb) return; try { const url = quiet ? '/api/permanent_subs?light=1' : '/api/permanent_subs'; const r = await fetch(url); const j = await r.json(); const rows = (j && j.rows) || []; const baseTf = Number((j && j.base_tf_min) || 1) || 1; if (j && j.fx_rate) setDisplayFxRate(j.fx_rate); if (j && j.refresh_sec) permEnsureAutoRefresh(j.refresh_sec); const fx = getDisplayFxRate(j && j.fx_rate); if ($('perm_master_en') && typeof j.master_enabled !== 'undefined') { $('perm_master_en').checked = !!j.master_enabled; } if (!rows.length) { tb.innerHTML = '등록된 영구구독 없음'; return; } tb.innerHTML = rows.map(function (x) { const onBadge = x.enabled ? 'ON' : 'OFF'; const mkBadge = (String(x.market_type).toUpperCase() === 'US') ? 'US' : 'KR'; const nm = (x.name || '').trim(); const code = x.code || ''; const title = nm && nm !== code ? ('' + code + '
' + nm + '') : ('' + code + ''); const editPayload = JSON.stringify({ code: x.code, market_type: x.market_type, exchange: x.exchange, symbol: x.symbol, note: x.note || '', }); const ap = (x.alert_price != null && Number(x.alert_price) > 0) ? Number(x.alert_price) : ''; const aside = (String(x.alert_side || 'gte').toLowerCase() === 'lte') ? 'lte' : 'gte'; const armed = Number(x.alert_armed) === 1; const fired = (x.alert_fired_at || '').toString().slice(0, 16); let alertCell = '
' + '' + '' + ''; if (ap !== '' && !armed) { alertCell += ''; } if (ap !== '') { alertCell += ''; } alertCell += '
'; if (ap !== '') { alertCell += '
' + (armed ? '감시중' : '발화대기') + (fired ? (' · ' + fired) : '') + '
'; } return '' + '' + title + '' + '' + permFmtPriceCell(x, fx) + '' + '' + alertCell + '' + '' + mkBadge + '' + '' + (x.exchange || '') + '' + '' + baseTf + '분' + '' + (x.ws_tr_key || '') + '' + '' + onBadge + '' + '' + (x.note || '') + '' + ' ' + (String(x.market_type).toUpperCase() === 'KR' ? ' ' : '') + '' + ''; }).join(''); } catch (e) { if (!quiet) { tb.innerHTML = '로드 실패: ' + e + ''; } } } async function permSaveAlert(code) { const c = String(code || '').trim().toUpperCase(); if (!c) return; const apEl = $('perm_ap_' + c); const asEl = $('perm_as_' + c); const payload = { code: c, action: 'set', alert_price: apEl ? apEl.value : '', alert_side: asEl ? asEl.value : 'gte', }; try { const r = await fetch('/api/permanent_subs/alert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const j = await r.json(); if (!j.ok) { alert('알람 저장 실패: ' + (j.error || '')); return; } permLoad({ quiet: true }); } catch (e) { alert('알람 저장 오류: ' + e); } } async function permRearmAlert(code) { try { const r = await fetch('/api/permanent_subs/alert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code, action: 'rearm' }), }); const j = await r.json(); if (!j.ok) { alert('재무장 실패: ' + (j.error || '')); return; } permLoad({ quiet: true }); } catch (e) { alert('재무장 오류: ' + e); } } async function permClearAlert(code) { try { const r = await fetch('/api/permanent_subs/alert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code, action: 'clear' }), }); const j = await r.json(); if (!j.ok) { alert('해제 실패: ' + (j.error || '')); return; } permLoad({ quiet: true }); } catch (e) { alert('해제 오류: ' + e); } } // ──────────────────────────────────────────── // 전략별 env 변경 이력 // ──────────────────────────────────────────── let _envHistData = null; async function envHistLoad() { const root = $('envhist_root'); if (!root) return; root.innerHTML = '
로딩…
'; try { const r = await fetch('/api/env/strategy_history?limit=3'); const j = await r.json(); if (!j.ok) { root.innerHTML = '
로드 실패: ' + (j.error || '') + '
'; return; } _envHistData = j.strategies || []; envHistRender(); } catch (e) { root.innerHTML = '
로드 오류: ' + e + '
'; } } function envHistEsc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); } function envHistRender() { const root = $('envhist_root'); if (!root) return; const rows = _envHistData || []; const changedOnly = $('envhist_changed_only') ? !!$('envhist_changed_only').checked : true; if (!rows.length) { root.innerHTML = '
이력 없음
'; return; } root.innerHTML = rows.map(function (st) { const snaps = st.snapshots || []; const n = snaps.length; if (!n) { return '
' + envHistEsc(st.label) + ' ' + '(' + envHistEsc(st.table) + ') 스냅샷 없음
'; } let keys; if (changedOnly) { keys = (st.changed_keys || []).slice(); if (!keys.length) { return '
' + '
' + envHistEsc(st.label) + '' + '' + n + '스냅 · 변경0
' + '
최근 ' + n + '건 사이에 값 변화 없음' + ' (' + snaps.map(function (s) { return envHistEsc(s.created_at); }).join(' → ') + ')
'; } } else { const set = {}; snaps.forEach(function (s) { Object.keys(s.values || {}).forEach(function (k) { set[k] = 1; }); }); keys = Object.keys(set).sort(); } const head = '키' + snaps.map(function (s, i) { return '#' + (i + 1) + '
' + envHistEsc(s.created_at) + '
id=' + s.id + '
'; }).join('') + ''; const body = keys.map(function (k) { const vals = snaps.map(function (s) { return (s.values && s.values[k] != null) ? String(s.values[k]) : ''; }); const tds = vals.map(function (v, i) { const prev = i > 0 ? vals[i - 1] : v; const diff = i > 0 && v !== prev; const style = diff ? 'color:#c62828;font-weight:700;background:rgba(198,40,40,.08)' : ''; return '' + (v === '' ? '' : envHistEsc(v)) + ''; }).join(''); return '' + envHistEsc(k) + '' + tds + ''; }).join(''); return '
' + '
' + '' + envHistEsc(st.label) + ' ' + envHistEsc(st.table) + '' + '' + keys.length + '키' + (changedOnly ? ' 변경' : '') + ' · ' + n + '스냅
' + '
' + '' + '' + head + '' + body + '
'; }).join(''); } function permEdit(jsonStr) { let x; try { x = JSON.parse(jsonStr); } catch (e) { return; } if ($('perm_code')) $('perm_code').value = x.code || ''; if ($('perm_market')) $('perm_market').value = String(x.market_type || 'KR').toUpperCase(); if ($('perm_exch')) $('perm_exch').value = x.exchange || 'KRX'; if ($('perm_symbol')) $('perm_symbol').value = x.symbol || ''; if ($('perm_note')) $('perm_note').value = x.note || ''; permOnMarket(); } async function permSave() { const code = ($('perm_code') ? $('perm_code').value : '').trim().toUpperCase(); if (!code) { alert('코드를 입력하세요'); return; } const payload = { code: code, market_type: $('perm_market') ? $('perm_market').value : 'KR', exchange: $('perm_exch') ? $('perm_exch').value : '', symbol: $('perm_symbol') ? $('perm_symbol').value.trim().toUpperCase() : '', enabled: true, note: $('perm_note') ? $('perm_note').value : '', }; try { const r = await fetch('/api/permanent_subs/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const j = await r.json(); if (!j.ok) { alert('저장 실패: ' + (j.error || '')); return; } if ($('perm_code')) $('perm_code').value = ''; if ($('perm_symbol')) $('perm_symbol').value = ''; if ($('perm_note')) $('perm_note').value = ''; permLoad(); } catch (e) { alert('저장 오류: ' + e); } } async function permDelete(code) { if (!confirm(code + ' 영구구독을 삭제할까요?')) return; try { const r = await fetch('/api/permanent_subs/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code }), }); const j = await r.json(); if (!j.ok && j.error) { alert('삭제 실패: ' + j.error); } permLoad(); } catch (e) { alert('삭제 오류: ' + e); } } async function permSaveMaster() { const en = $('perm_master_en') ? !!$('perm_master_en').checked : true; try { const r = await fetch('/api/permanent_subs/master', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: en }), }); const j = await r.json(); if (!j.ok) { alert('마스터 저장 실패: ' + (j.error || '')); return; } permLoad({ quiet: true }); } catch (e) { alert('마스터 저장 오류: ' + e); } } async function permFillCandles(code) { const c = String(code || '').trim(); const msg = c ? (c + ' 확정봉을 키움 REST로 ls_ws_candles 구멍만 채울까요?') : 'enabled KR 전종목 확정봉을 키움 REST로 ls_ws_candles 구멍만 채울까요? (ws_candles 미사용)'; if (!confirm(msg)) return; try { const r = await fetch('/api/permanent_subs/fill_candles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: c }), }); const j = await r.json(); if (!j.ok) { alert('확정봉 가져오기 실패: ' + (j.error || '')); return; } const rows = (j.codes || []).map(function (x) { return x.code + ': +' + (x.inserted || 0) + (x.error ? (' (' + x.error + ')') : ''); }); alert('완료 ' + (j.ok_n || 0) + '종목\n' + rows.join('\n')); permLoad(); } catch (e) { alert('확정봉 오류: ' + e); } } // ──────────────────────────────────────────── // 오늘 운영 대시보드 // ──────────────────────────────────────────── function dashFmtKrw(n) { return fmtKrw(n); } function dashFmtPct(n) { const v = Number(n) || 0; return (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; } function dashPnlCls(n) { return Number(n) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; } function dashInitDate() { const el = $('dash_date'); if (!el) return; if (!el.value) { el.value = kstTradingDayIso(_krHolidays); } else { el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays); } } /** 모의/실전 필터 — select value 비어 있으면 API 기본(현재 KIS_MOCK) */ function accountQueryParam(selectId) { const el = $(selectId); if (!el || !el.value) return ''; return '&account=' + encodeURIComponent(el.value); } function accountBadgeHtml(label) { const al = label || '미상'; const ab = al === '모의' ? 'badge-flat' : (al === '실전' ? 'badge-win' : 'badge-loss'); return `${al}`; } async function loadDashboard() { dashInitDate(); const day = $('dash_date').value; showSpinner(true); try { const r = await fetch( '/api/actual/dashboard?date=' + encodeURIComponent(day) + accountQueryParam('dash_account') ); const d = await r.json(); if (!d.ok) { alert(d.error || '대시보드 조회 실패'); return; } const tot = d.totals || {}; const acctLbl = d.account_label || ''; $('dash_meta').textContent = '기준일 ' + d.date + (acctLbl ? ' · 계좌 ' + acctLbl : '') + ' · 갱신 ' + (d.as_of || ''); $('dash_card_turnover').textContent = dashFmtKrw(tot.turnover_krw); $('dash_card_turnover_sub').textContent = '매수 ' + dashFmtKrw(tot.buy_turnover_krw) + ' · 매도 ' + dashFmtKrw(tot.sell_turnover_krw); $('dash_card_budget').textContent = dashFmtPct(tot.budget_usage_pct); $('dash_card_budget_sub').textContent = '피크 ' + dashFmtKrw(tot.budget_used_krw) + ' · 현재 ' + dashFmtKrw(tot.budget_now_krw || 0) + ' / 한도 ' + dashFmtKrw(tot.budget_limit_krw); $('dash_card_pnl').textContent = dashFmtKrw(tot.realized_pnl_krw); $('dash_card_pnl').className = 'stat-value ' + dashPnlCls(tot.realized_pnl_krw); $('dash_card_ret').textContent = dashFmtPct(tot.return_pct); $('dash_card_ret').className = 'stat-value ' + dashPnlCls(tot.return_pct); const tbody = $('dash_tbody'); tbody.innerHTML = ''; const dashRows = (d.strategies || []).filter((row) => row.strategy_id !== 'UPDOW'); const brokerLabel = (b) => (b === 'kis' ? '한투(KIS)' : (b === 'kiwoom' ? '키움' : (b || '—'))); const brokerSrcCls = (b) => { if (b === 'kis') return 'dash-univ-src-kis'; if (b === 'kiwoom') return 'dash-univ-src-kiwoom'; return ''; }; let prevBroker = ''; dashRows.forEach(row => { const sid = row.strategy_id; const broker = row.broker || row.source || ''; if (broker !== prevBroker) { tbody.insertAdjacentHTML('beforeend', ` ${brokerLabel(broker)} `); prevBroker = broker; } const isLive = !!row.is_live_source; const onBadge = isLive ? (row.enabled ? 'ON' : 'OFF') : ''; const pnlCls = dashPnlCls(row.realized_pnl_krw); const retCls = dashPnlCls(row.return_pct); const trCls = !isLive ? 'dash-ops-inactive' : ''; const mute = !isLive; const fmtOrDash = (v, fn) => (mute ? '—' : fn(v)); const liveTag = isLive ? ' 실매' : ''; tbody.insertAdjacentHTML('beforeend', ` ${row.label || sid}
${sid}${liveTag} ${onBadge} ${fmtOrDash(row.buy_turnover_krw, dashFmtKrw)} ${fmtOrDash(row.sell_turnover_krw, dashFmtKrw)} ${fmtOrDash(row.turnover_krw, dashFmtKrw)} ${mute ? '—' : row.closed_trades} ${mute ? '—' : row.open_positions} ${fmtOrDash(row.realized_pnl_krw, dashFmtKrw)} ${fmtOrDash(row.budget_limit_krw, dashFmtKrw)} ${fmtOrDash(row.budget_used_krw, dashFmtKrw)} ${fmtOrDash(row.budget_now_krw || 0, dashFmtKrw)} ${fmtOrDash(row.budget_usage_pct, dashFmtPct)} ${fmtOrDash(row.return_pct, dashFmtPct)} ${isLive ? `` : ''} `); }); $('dash_ft_buy').textContent = dashFmtKrw(tot.buy_turnover_krw); $('dash_ft_sell').textContent = dashFmtKrw(tot.sell_turnover_krw); $('dash_ft_turn').textContent = dashFmtKrw(tot.turnover_krw); $('dash_ft_closed').textContent = String(tot.closed_trades); $('dash_ft_open').textContent = String(tot.open_positions); $('dash_ft_pnl').textContent = dashFmtKrw(tot.realized_pnl_krw); $('dash_ft_pnl').className = 'text-end ' + dashPnlCls(tot.realized_pnl_krw); $('dash_ft_limit').textContent = dashFmtKrw(tot.budget_limit_krw); $('dash_ft_used').textContent = dashFmtKrw(tot.budget_used_krw); $('dash_ft_now').textContent = dashFmtKrw(tot.budget_now_krw || 0); $('dash_ft_usage').textContent = dashFmtPct(tot.budget_usage_pct); $('dash_ft_ret').textContent = dashFmtPct(tot.return_pct); $('dash_ft_ret').className = 'text-end ' + dashPnlCls(tot.return_pct); const notes = d.notes || {}; $('dash_notes').textContent = (notes.strategy_source || '') + ' · ' + (notes.turnover || '') + ' · ' + (notes.budget_used || '') + ' · ' + (notes.budget_now || '') + ' · ' + (notes.return_pct || ''); // 조건검색 이력: 키움 target_candidates_history 원툴 const univBody = $('dash_univ_tbody'); if (univBody) { univBody.innerHTML = ''; const univRows = (d.universe_history || []) .filter((row) => row.strategy_id !== 'UPDOW'); univRows.forEach(row => { const sid = row.strategy_id || ''; const liveSrc = row.live_universe_source || '—'; const lastAt = row.last_at || '—'; const firstAt = row.first_at || '—'; const liveBadge = row.is_live_source ? ' 실매' : ''; univBody.insertAdjacentHTML('beforeend', ` ${row.label || sid}
${sid}${liveBadge} ${liveSrc} ${Number(row.slots || 0).toLocaleString()} ${Number(row.codes || 0).toLocaleString()} ${Number(row.rows || 0).toLocaleString()} ${firstAt} ${lastAt} `); }); const meta = $('dash_univ_meta'); if (meta) { meta.textContent = ''; } const un = $('dash_univ_notes'); if (un) { un.textContent = (notes.universe_history || '') + ' · 키움=target_candidates_history'; } } } catch (e) { alert('대시보드 오류: ' + e); } finally { showSpinner(false); } } function staleNoteFromUniv(rows) { const lsStale = (rows || []).filter(r => r.source === 'ls' && r.stale); if (!lsStale.length) return ''; return ' · ⚠ LS 이력이 장시작 전에서 끊김 — 장중 적재 점검 필요'; } function dashDrill(strategyId) { const radio = document.querySelector('input[name=act_strategy][value="' + strategyId + '"]'); if (radio) radio.checked = true; const day = $('dash_date').value; if (day) { $('act_start').value = day; $('act_end').value = day; } const dashAcct = $('dash_account'); const actAcct = $('act_account'); if (dashAcct && actAcct) actAcct.value = dashAcct.value; btShowTab('actual'); loadActual(); } function dashGoActual() { dashDrill(document.querySelector('input[name=act_strategy]:checked')?.value || 'SHORT'); } dashInitDate(); // ──────────────────────────────────────────── // 보유중 라이브 시세 (ws_ticks SELECT via API — 매매 비접촉) // ──────────────────────────────────────────── let _liveQuoteES = null; let _liveQuotePoll = null; let _liveQuotePrevPx = {}; let _liveQuoteCodesKey = ''; function liveQuoteStop() { if (_liveQuoteES) { try { _liveQuoteES.close(); } catch (e) { /* ignore */ } _liveQuoteES = null; } if (_liveQuotePoll) { clearInterval(_liveQuotePoll); _liveQuotePoll = null; } _liveQuoteCodesKey = ''; } function liveQuoteCollectCodes(root) { const scope = root || document; const codes = []; const seen = new Set(); scope.querySelectorAll('[data-live-code]').forEach(el => { const c = String(el.getAttribute('data-live-code') || '').trim(); if (!c || seen.has(c)) return; seen.add(c); codes.push(c); }); return codes; } function liveQuoteFlash(el, dir) { if (!el) return; el.classList.remove('live-quote-flash-up', 'live-quote-flash-down'); void el.offsetWidth; el.classList.add(dir > 0 ? 'live-quote-flash-up' : 'live-quote-flash-down'); } function liveQuoteFmtPx(px, isUsd) { const n = Number(px); if (!Number.isFinite(n) || n <= 0) return '—'; if (isUsd) return fmtUsdWithKrw(n, getDisplayFxRate()); return fmtWon(n); } function liveQuoteFmtPnl(pnl, isUsd) { if (!Number.isFinite(pnl)) return '—'; if (isUsd) return fmtUsdWithKrw(pnl, getDisplayFxRate()); return fmtWon(pnl); } function liveQuoteFmtPctNum(pct) { if (!Number.isFinite(pct)) return '—'; const sign = pct >= 0 ? '+' : ''; return `${sign}${pct.toFixed(2)}%`; } function liveQuoteApplyPctBadge(el, pct) { if (!el) return; el.classList.remove('badge-win', 'badge-loss', 'badge-flat', 'text-pnl-pos', 'text-pnl-neg'); if (!Number.isFinite(pct)) { if (el.tagName === 'SPAN') el.classList.add('badge-flat'); return; } if (el.tagName === 'SPAN') { el.classList.add(pct > 0 ? 'badge-win' : (pct < 0 ? 'badge-loss' : 'badge-flat')); } else { el.classList.add(pct >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'); } } function liveQuoteApplyPayload(d) { if (!d || !d.ok || d.enabled === false) return; const quotes = d.quotes || {}; document.querySelectorAll('tr[data-live-code]').forEach(tr => { const code = String(tr.getAttribute('data-live-code') || '').trim(); const q = quotes[code]; if (!q || !(Number(q.price) > 0)) return; const px = Number(q.price); const buy = Number(tr.getAttribute('data-live-buy') || 0); const qty = Number(tr.getAttribute('data-live-qty') || 0); const isUsd = String(tr.getAttribute('data-live-usd') || '') === '1'; const prev = _liveQuotePrevPx[code]; const pxEl = tr.querySelector('[data-live-cell="px"]'); const pnlEl = tr.querySelector('[data-live-cell="pnl"]'); const pctEl = tr.querySelector('[data-live-cell="pct"]'); const dayEl = tr.querySelector('[data-live-cell="day"]'); const evalEl = tr.querySelector('[data-live-cell="eval"]'); if (pxEl) { pxEl.innerHTML = liveQuoteFmtPx(px, isUsd); if (prev != null && Number.isFinite(prev) && prev > 0 && px !== prev) { liveQuoteFlash(pxEl, px > prev ? 1 : -1); } pxEl.classList.toggle('live-quote-stale', !!q.stale); } const pnl = (buy > 0 && qty > 0) ? (px - buy) * qty : NaN; const buyPct = buy > 0 ? ((px - buy) / buy) * 100 : NaN; const dayPct = (q.day_chg_pct != null && Number.isFinite(Number(q.day_chg_pct))) ? Number(q.day_chg_pct) : NaN; if (pnlEl && Number.isFinite(pnl)) { const label = tr.classList.contains('trade-row-open') ? `${liveQuoteFmtPnl(pnl, isUsd)} 평가` : liveQuoteFmtPnl(pnl, isUsd); pnlEl.innerHTML = label; pnlEl.classList.toggle('text-pnl-pos', pnl > 0); pnlEl.classList.toggle('text-pnl-neg', pnl < 0); } // 당일 등락(전일종가 대비)=메인, 매수가 대비=보조 if (dayEl) { dayEl.textContent = liveQuoteFmtPctNum(dayPct); liveQuoteApplyPctBadge(dayEl, dayPct); if (prev != null && Number.isFinite(prev) && px !== prev) { liveQuoteFlash(dayEl, px > prev ? 1 : -1); } } if (pctEl) { if (dayEl) { pctEl.innerHTML = Number.isFinite(buyPct) ? `${liveQuoteFmtPctNum(buyPct)}` + `
매수대비
` : '—'; liveQuoteApplyPctBadge(pctEl, buyPct); } else { pctEl.innerHTML = Number.isFinite(dayPct) ? `
${liveQuoteFmtPctNum(dayPct)}
` + `
당일` + (Number.isFinite(buyPct) ? ` · 매수 ${liveQuoteFmtPctNum(buyPct)}` : '') + `
` : (Number.isFinite(buyPct) ? `${liveQuoteFmtPctNum(buyPct)}
매수대비
` : '—'); liveQuoteApplyPctBadge(pctEl, Number.isFinite(dayPct) ? dayPct : buyPct); if (prev != null && Number.isFinite(prev) && px !== prev) { liveQuoteFlash(pctEl, px > prev ? 1 : -1); } } } if (evalEl && qty > 0) { evalEl.innerHTML = liveQuoteFmtPx(px * qty, isUsd); } _liveQuotePrevPx[code] = px; }); } function liveQuoteStartFromDom(root) { const codes = liveQuoteCollectCodes(root); const key = codes.slice().sort().join(','); if (!codes.length) { liveQuoteStop(); return; } if (key === _liveQuoteCodesKey && (_liveQuoteES || _liveQuotePoll)) { return; } liveQuoteStop(); _liveQuoteCodesKey = key; const q = 'codes=' + encodeURIComponent(codes.join(',')); const url = '/api/portfolio/live_quotes_stream?' + q; if (typeof EventSource !== 'undefined') { try { _liveQuoteES = new EventSource(url); _liveQuoteES.onmessage = (ev) => { try { liveQuoteApplyPayload(JSON.parse(ev.data)); } catch (e) { /* ignore */ } }; _liveQuoteES.onerror = () => { if (_liveQuoteES) { try { _liveQuoteES.close(); } catch (e2) { /* ignore */ } _liveQuoteES = null; } if (!_liveQuotePoll) { _liveQuotePoll = setInterval(() => { fetch('/api/portfolio/live_quotes?' + q) .then(r => r.json()) .then(liveQuoteApplyPayload) .catch(() => {}); }, 500); } }; return; } catch (e) { /* fall through */ } } _liveQuotePoll = setInterval(() => { fetch('/api/portfolio/live_quotes?' + q) .then(r => r.json()) .then(liveQuoteApplyPayload) .catch(() => {}); }, 400); } // ──────────────────────────────────────────── // 보유·매도 (active_trades + OrderManager 시장가) // ──────────────────────────────────────────── // 보유·매도 정렬 상태 (다른 탭의 정렬바와 동일 UX, 데이터 스키마만 다름) let _pfItems = []; let _pfMeta = null; let _pfSort = 'pnl_desc'; /** 보유·매도 정렬 — 수익률/평가금/종목명/미등록 우선 */ function pfSortItems(items, mode) { const arr = [...(items || [])]; const num = v => (Number.isFinite(Number(v)) ? Number(v) : 0); switch (mode) { case 'pnl_asc': return arr.sort((a, b) => num(a.pnl_pct) - num(b.pnl_pct)); case 'eval_desc': return arr.sort((a, b) => num(b.eval_amt) - num(a.eval_amt)); case 'eval_asc': return arr.sort((a, b) => num(a.eval_amt) - num(b.eval_amt)); case 'name_asc': return arr.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ko')); case 'untracked_first': return arr.sort((a, b) => (b.untracked ? 1 : 0) - (a.untracked ? 1 : 0) || num(b.eval_amt) - num(a.eval_amt)); case 'pnl_desc': default: return arr.sort((a, b) => num(b.pnl_pct) - num(a.pnl_pct)); } } async function pfLoad(withBroker) { const stratEl = document.querySelector('input[name=pf_strategy]:checked'); const strat = stratEl ? stratEl.value : 'ALL'; const brokerQ = withBroker ? '&broker=1' : ''; showSpinner(true); try { const r = await fetch( '/api/portfolio/active?strategy=' + encodeURIComponent(strat) + brokerQ + accountQueryParam('pf_account') ); const d = await r.json(); if (!d.ok) { alert(d.error || '조회 실패'); return; } _pfItems = d.items || []; _pfMeta = { kis_mock: d.kis_mock, account_mode: d.account_mode, account_label: d.account_label, with_broker: d.with_broker, broker_codes: d.broker_codes, count: d.count, account: d.account || null, }; pfRenderAccountSummary(d.account, d.with_broker); pfRenderTable(); } catch (e) { alert('보유 조회 오류: ' + e); } finally { showSpinner(false); } } /** 보유·매도 — 매수시각 강조 (날짜 + 시각 분리) */ function pfFmtBuyDate(s) { if (!s) return ''; const t = String(s).trim().replace('T', ' '); const d = t.slice(0, 10); const tm = t.length >= 16 ? t.slice(11, 16) : (t.length > 10 ? t.slice(11) : ''); if (tm) { return `${d}
${tm}`; } return `${d}`; } /** 실계좌 대조 시 상단 — 입금액·예수금·평가금 */ function pfRenderAccountSummary(acct, withBroker) { const row = $('pf_account_row'); if (!row) return; if (!withBroker || !acct) { row.style.display = 'none'; return; } row.style.display = ''; const set = (id, v, suffix) => { const el = $(id); if (!el) return; el.textContent = (v != null && v !== '') ? fmtWon(v) + (suffix || '원') : '—'; }; set('pf_total_deposit', acct.total_deposit, '원'); set('pf_cash', acct.cash, '원'); set('pf_holdings_eval', acct.holdings_eval, '원'); set('pf_total_asset', acct.total_asset, '원'); } /** 캐시된 _pfItems 를 현재 정렬(_pfSort)로 렌더 (재조회 없이 정렬만 갱신) */ function pfRenderTable() { const d = _pfMeta || {}; const tbody = $('pf_tbody'); if (!tbody) return; tbody.innerHTML = ''; const items = pfSortItems(_pfItems, _pfSort); if ($('pf_empty')) $('pf_empty').style.display = items.length ? 'none' : ''; const mockLbl = d.account_label || (d.kis_mock ? '모의' : '실전'); const untracked = items.filter(x => x.untracked).length; const brNote = d.with_broker ? ` · 실계좌 ${d.broker_codes}종목` + (untracked ? ` (미등록 ${untracked})` : '') : ' · 「2. 실계좌 대조」필요'; if ($('pf_meta')) { $('pf_meta').textContent = `계좌 ${mockLbl} · 표시 ${d.count}건${brNote} · ${new Date().toLocaleString('ko-KR')}`; } // 정렬 버튼 active 동기화 document.querySelectorAll('#pf_sort_bar .pf-sort-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.sort === _pfSort); }); items.forEach(it => { const pnlCls = it.pnl_pct >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; let syncBadge = ''; if (d.with_broker) { if (it.untracked) { let lbl, cls; if (it.origin === 'holding') { lbl = '홀딩봇'; cls = 'badge-win'; } else if (it.origin === 'bot') { lbl = '봇고아'; cls = 'badge-loss'; } else { lbl = '수동(보호)'; cls = 'badge-flat'; } syncBadge = '' + lbl + ''; } else if (it.sync_ok) { syncBadge = 'OK'; } else { syncBadge = '주의'; } } const codeEsc = String(it.code).replace(/'/g, "\\'"); const stratEsc = String(it.strategy || '').replace(/'/g, "\\'"); const stratLbl = it.strategy ? '' + it.strategy + '' : ''; // 종목명이 코드와 동일하면 이름 중복 숨김 (코드만 표시) const nameDisplay = (it.name && it.name !== it.code) ? `${it.name}
${it.code}` : `${it.code}`; const sellBtn = it.can_sell ? `` : 'DB없음'; const isUs = String(it.strategy || '').toUpperCase() === 'US_MOMENTUM' || (!/^\d{6}$/.test(String(it.code || ''))); const buyRef = Number(it.broker_avg_price > 0 ? it.broker_avg_price : it.buy_price) || 0; const qtyRef = Number(it.broker_qty != null && it.broker_qty > 0 ? it.broker_qty : it.db_qty) || 0; const liveAttrs = (String(it.code || '') && buyRef > 0 && qtyRef > 0) ? ` data-live-code="${String(it.code).replace(/"/g, '')}" data-live-buy="${buyRef}" data-live-qty="${qtyRef}" data-live-usd="${isUs ? '1' : '0'}"` : ''; tbody.insertAdjacentHTML('beforeend', ` ${accountBadgeHtml(it.account_label)} ${stratLbl} ${nameDisplay} ${pfFmtBuyDate(it.buy_date)} ${it.db_qty} ${it.broker_qty == null ? '—' : it.broker_qty} ${syncBadge} ${it.buy_price > 0 ? fmtWon(it.buy_price) : '—'} ${it.broker_avg_price != null && it.broker_avg_price > 0 ? fmtWon(it.broker_avg_price) : '—'} ${fmtWon(it.current_price)} — ${it.pnl_pct >= 0 ? '+' : ''}${it.pnl_pct}% ${fmtWon(it.eval_amt)} ${sellBtn} `); }); liveQuoteStartFromDom(tbody); } // 보유·매도 정렬 버튼 (다른 탭과 동일 UX) — pf-sort-btn 클릭 시 재조회 없이 정렬만 갱신 document.addEventListener('click', e => { const btn = e.target.closest('#pf_sort_bar .pf-sort-btn'); if (!btn) return; _pfSort = btn.dataset.sort || 'pnl_desc'; pfRenderTable(); }); async function pfSell(code, strategy, name, qty) { const msg = `[${strategy}] ${name}(${code})\n` + `DB 보유 ${qty}주를 시장가 전량 매도합니다.\n` + `OrderManager 매도 경로로 DB(trade_history) 동기화됩니다.\n\n` + `HTS 에서 이미 팔았다면 실계좌 0주일 수 있으며, 그 경우 유령잔고 정리만 됩니다.\n계속할까요?`; if (!confirm(msg)) return; showSpinner(true); try { const r = await fetch('/api/portfolio/sell', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, strategy }), }); const d = await r.json(); if (d.ok) { alert( `매도 접수 완료\nODNO: ${d.ord_no || '-'}\n` + `체결 ${d.filled_qty || '-'}주 @ ${Number(d.filled_avg_price || 0).toLocaleString()}원` ); pfLoad(false); } else { alert('매도 실패: ' + (d.error || d.detail || 'unknown')); } } catch (e) { alert('매도 요청 오류: ' + e); } finally { showSpinner(false); } } async function pfReconcileOrphans() { const msg = `실잔고 기준으로 DB를 맞춥니다. 매도 주문은 나가지 않습니다.\n\n` + `· 고아(실잔고 O · DB X · 봇 BUY) → active_trades 에 등록\n` + `· 유령(DB O · 실잔고 0) → active_trades 행 삭제\n` + `· 수량/평단만 다름 → 실잔고 값으로 UPDATE\n\n` + `※ 수동매수 추정·MANUAL_HOLD_CODES 는 제외.\n계속할까요?`; if (!confirm(msg)) return; showSpinner(true); try { const r = await fetch('/api/portfolio/reconcile_orphans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); const d = await r.json(); if (d.ok) { let out = '잔고=DB 맞추기 완료' + `\n고아복구 ${d.reconciled_count || 0} · 수량/평단 ${d.synced_count || 0}` + ` · 유령삭제 ${d.ghost_purged_count || 0}` + (d.failed_count ? ` · 실패 ${d.failed_count}` : '') + `\n(제외: 수동 ${d.skipped_manual_count || 0} · 이미추적 ${d.skipped_tracked_count || 0} · 주문없음 ${d.skipped_no_order_count || 0})`; if ((d.reconciled || []).length) { out += '\n\n[복구]\n' + d.reconciled.slice(0, 12) .map(x => ` · ${x.name}(${x.code}) [${x.strategy}] ${x.qty}주`).join('\n'); } if ((d.ghost_purged || []).length) { out += '\n\n[유령삭제]\n' + d.ghost_purged.slice(0, 12) .map(x => ` · ${x.name || ''}(${x.code}) [${x.strategy || ''}]`).join('\n'); } if ((d.failed || []).length) { out += '\n\n[실패]\n' + d.failed.slice(0, 8) .map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n'); } alert(out); pfLoad(true); } else { alert('맞추기 실패: ' + (d.error || 'unknown')); } } catch (e) { alert('맞추기 요청 오류: ' + e); } finally { showSpinner(false); } } // ──────────────────────────────────────────── // 날짜 기본값 설정 (KST = 한국장 기준, 브라우저 타임존과 무관) // ──────────────────────────────────────────── function fmtKstDate(d) { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(d); } function kstTodayParts() { const iso = fmtKstDate(new Date()); const [y, m, day] = iso.split('-').map(x => parseInt(x, 10)); return { y, m, day, iso }; } function kstMonthStartIso() { const t = kstTodayParts(); return `${t.y}-${String(t.m).padStart(2, '0')}-01`; } function kstMonthAgoIso() { const t = kstTodayParts(); let idx = t.y * 12 + (t.m - 1) - 1; const ny = Math.floor(idx / 12); const nm = (idx % 12) + 1; const lastDay = new Date(ny, nm, 0).getDate(); const nd = Math.min(t.day, lastDay); return `${ny}-${String(nm).padStart(2, '0')}-${String(nd).padStart(2, '0')}`; } /** 현재 KST 시(0~23) */ function kstHourNow() { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Seoul', hour: 'numeric', hour12: false, }).formatToParts(new Date()); const h = parts.find(p => p.type === 'hour'); return h ? (parseInt(h.value, 10) % 24) : 12; } function kstYesterdayIso() { return fmtKstDate(new Date(Date.now() - 24 * 3600 * 1000)); } /** N일 전 KST 날짜 (백테 기본 시작일 등) */ function kstDaysAgoIso(n) { return fmtKstDate(new Date(Date.now() - n * 24 * 3600 * 1000)); } function setKrHolidays(list) { _krHolidays = new Set((list || []).map(String).filter(Boolean)); } /** iso 하루 전 (캘린더) */ function kstAddDaysIso(iso, deltaDays) { const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10)); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + deltaDays); const yy = dt.getUTCFullYear(); const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); const dd = String(dt.getUTCDate()).padStart(2, '0'); return `${yy}-${mm}-${dd}`; } function kstWeekdayMon0(iso) { const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10)); // UTC 정오로 파싱해 요일 드리프트 방지 return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)).getUTCDay(); // 0=일 … 6=토 } function kstIsTradingDayIso(iso, holidays) { if (!iso) return false; const wd = kstWeekdayMon0(iso); if (wd === 0 || wd === 6) return false; const hol = holidays || _krHolidays; return !(hol && hol.has(iso)); } /** 주말·휴장이면 이전 장운영일(YYYY-MM-DD) */ function kstClampToPrevTradingDayIso(iso, holidays, maxBack) { let cur = String(iso || '').slice(0, 10); if (!cur) return cur; const lim = Math.max(1, maxBack || 14); for (let i = 0; i <= lim; i++) { if (kstIsTradingDayIso(cur, holidays)) return cur; cur = kstAddDaysIso(cur, -1); } return cur; } /** 실거래 조회 기준일 — 장 시작 전이면 전날, 이후 주말/휴장 보정 */ function kstTradingDayIso(holidays) { const base = kstHourNow() < 9 ? kstYesterdayIso() : kstTodayParts().iso; return kstClampToPrevTradingDayIso(base, holidays); } function setDateVal(id, val) { const el = $(id); if (el && val) el.value = val; } /** 날짜 인풋: 주말/휴장 선택 시 이전 장운영일로 스냅 */ function bindKrTradingDayDateInputs() { document.querySelectorAll('input[type="date"]').forEach(el => { if (el.dataset.krTradingBound === '1') return; el.dataset.krTradingBound = '1'; el.addEventListener('change', function() { if (!this.value) return; const c = kstClampToPrevTradingDayIso(this.value, _krHolidays); if (c && c !== this.value) this.value = c; }); }); } function initDefaultDates(datesPayload) { const hol = (datesPayload && datesPayload.holidays) ? datesPayload.holidays : [..._krHolidays]; setKrHolidays(hol); const endKst = (datesPayload && datesPayload.end) ? datesPayload.end : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); const startKst = (datesPayload && datesPayload.start) ? datesPayload.start : kstClampToPrevTradingDayIso(kstAddDaysIso(endKst, -7), _krHolidays); // 실거래 분석: 단일 거래일 기본 조회 (장전·주말·휴장 → 이전 장운영일) const tradingDayKst = (datesPayload && datesPayload.trading_day) ? datesPayload.trading_day : kstTradingDayIso(_krHolidays); setDateVal('act_start', tradingDayKst); setDateVal('act_end', tradingDayKst); setDateVal('dash_date', tradingDayKst); setDateVal('fs_date', tradingDayKst); setDateVal('lc_date', tradingDayKst); // 백테 탭: 시작 ~ 종료 (둘 다 거래일) setDateVal('bt_start', startKst); setDateVal('bt_end', endKst); setDateVal('tl_start', startKst); setDateVal('tl_end', endKst); setDateVal('db_start', startKst); setDateVal('db_end', endKst); setDateVal('db_fetch_start', startKst); setDateVal('bo_start', startKst); setDateVal('bo_end', endKst); setDateVal('rb_start', startKst); setDateVal('rb_end', endKst); setDateVal('mom_start', startKst); setDateVal('mom_end', endKst); setDateVal('usmom_start', startKst); setDateVal('usmom_end', endKst); setDateVal('hd_start', tradingDayKst); setDateVal('hd_end', tradingDayKst); setDateVal('hd_fetch_start', tradingDayKst); setDateVal('ubx_start', startKst); setDateVal('ubx_end', endKst); const lr = $('lastRefresh'); if (lr) lr.textContent = '업데이트: ' + new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); bindKrTradingDayDateInputs(); } (function() { // 주말만이라도 즉시 보정 → /api/env/params 의 dates 로 휴장일 재보정 initDefaultDates(null); const hdFetch = $('hd_fetch_start'); if (hdFetch) { hdFetch.addEventListener('change', function() { if (this.value) setDateVal('hd_start', this.value); }); } // ── DB config_scalp / config_momentum / config_short → 스캘핑·꼬리·모멘텀 초기값 ── // DB 값이 있으면 반영, null이면 HTML에 하드코딩된 기본값 그대로 유지 fetch('/api/env/params') .then(r => r.json()) .then(d => { if (d && d.dates) initDefaultDates(d.dates); const set = (id, val) => { if (val !== null && val !== undefined) $(id).value = val; }; const setM = set; // 돌파·박스 탭 setM (함수 미정의 ReferenceError 방지) // 스캘핑·꼬리·모멘텀·돌파 — fill*FromApi (Optuna 적용 후 reload 와 동일 경로) try { fillScalpFormFromApi(d.scalp || {}); } catch (e) { console.warn('fillScalp', e); } try { fillTailFormFromApi(d.tail || {}); } catch (e) { console.warn('fillTail', e); } try { fillDbBandFormFromApi(d.dbband || {}); } catch (e) { console.warn('fillDbBand', e); } try { fillMomentumFormFromApi(d.momentum || {}); } catch (e) { console.warn('fillMomentum', e); } try { fillUsMomentumFormFromApi(d.us_momentum || d.momentum || {}); } catch (e) { console.warn('fillUsMom', e); } try { fillBreakoutFormFromApi(d.breakout || {}); const rb = d.range_break || {}; setM('rb_box_lb', rb.box_lookback_min); setM('rb_box_max_w', rb.box_max_width_pct); setM('rb_setup_vol', rb.setup_vol_max_mult); setM('rb_bear_min', rb.setup_bear_bars_min); setM('rb_vol_win', rb.vol_window); setM('rb_vol_mult', rb.vol_mult); setM('rb_sl', rb.sl_pct); setM('rb_tp', rb.tp_pct); setM('rb_trail', rb.trail_pct); if (rb.trail_arm_pct != null) setM('rb_trail_arm', rb.trail_arm_pct); setM('rb_shoulder_smin', rb.shoulder_min_high_pct); setM('rb_shoulder_scut', rb.shoulder_cut_pct); setM('rb_time_start', rb.time_start_hm); setM('rb_time_end', rb.time_end_hm); setM('rb_max_daily', rb.max_daily); setM('rb_cooldown', rb.cooldown_min); setM('rb_max_chg', rb.max_daily_chg); setM('rb_min_price', rb.min_price); setM('rb_max_loss', rb.max_loss_krw); if (rb.use_high_chase_filter !== undefined && $('rb_use_high_chase')) { $('rb_use_high_chase').checked = !!rb.use_high_chase_filter; } setM('rb_slot', rb.slot_money); if (rb.max_stocks != null) setM('rb_max_stocks', rb.max_stocks); if (rb.total_budget_krw != null) setM('rb_total_budget', rb.total_budget_krw); } catch (e) { console.warn('fillBreakout/range', e); } // 날짜(거래일) 확정 후 실거래 분석 1회 — 빈 날짜로 전체 조회·고스트 노출 방지 loadActual(); }) .catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 — 로컬 거래일로라도 조회 */ loadActual(); }); const defChk = $('bt_use_defense'); if (defChk) { defChk.addEventListener('change', saveScalpDefenseRealtime); } const macdChk = $('bt_use_macd'); if (macdChk) { macdChk.addEventListener('change', saveScalpMacdRealtime); } })(); // ──────────────────────────────────────────── // 실거래 분석 로드 // ──────────────────────────────────────────── function loadActual() { const strategy = document.querySelector('input[name=act_strategy]:checked').value; const start = $('act_start').value; const end = $('act_end').value; showSpinner(true); fetch( `/api/actual?strategy=${encodeURIComponent(strategy)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}` + accountQueryParam('act_account') ) .then(r => r.json()) .then(d => { showSpinner(false); renderActual(d); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function renderActual(d) { const s = d.summary; const p = d.params || {}; const filtered = Number((d.meta || {}).filtered_forced_rows || 0); const isUs = String(d.currency || p.market || '').toUpperCase() === 'USD' || String(document.querySelector('input[name=act_strategy]:checked')?.value || '').toUpperCase() === 'US_MOMENTUM'; const fxEnd = Number(s.fx_rate_end || 0) || getDisplayFxRate(); if (fxEnd > 0) setDisplayFxRate(fxEnd); $('a_total').textContent = fmt(s.total_trades) + '건'; $('a_winrate').textContent = s.win_rate + '%'; colorPnl($('a_winrate'), s.win_rate - 50); if (isUs) { if (s.total_pnl_krw != null && Number.isFinite(Number(s.total_pnl_krw))) { $('a_pnl').innerHTML = `${fmtUsd(s.total_pnl)} (${fmtWon(s.total_pnl_krw)}원)`; } else { $('a_pnl').innerHTML = fmtUsdWithKrw(s.total_pnl, fxEnd); } colorPnl($('a_pnl'), s.total_pnl); if (s.max_drawdown_krw != null && Number.isFinite(Number(s.max_drawdown_krw))) { $('a_mdd').innerHTML = `-${fmtUsd(s.max_drawdown)} (${fmtWon(s.max_drawdown_krw)}원)`; } else { $('a_mdd').textContent = '-' + fmtUsdWithKrwText(s.max_drawdown, fxEnd); } } else { $('a_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('a_pnl'), s.total_pnl); $('a_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; } $('a_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor; colorPnl($('a_pf'), s.profit_factor - 1); $('a_hold').textContent = s.avg_hold_min + '분'; // 운용한도 대비 수익률 — API summary에 없으면 params.total_budget_krw 로 계산 const aSign = (v) => (v > 0 ? '+' : ''); let botPct = s.bot_pct; let dailyAvg = s.daily_avg_pct; const tb = Number(p.total_budget_krw || 0); if ((botPct == null || botPct === '') && tb > 0) { botPct = Math.round((Number(s.total_pnl || 0) / tb) * 10000) / 100; } if ((dailyAvg == null || dailyAvg === '') && botPct != null) { const startEl = $('act_start'); const endEl = $('act_end'); let days = 1; try { if (startEl && endEl && startEl.value && endEl.value) { const t0 = new Date(startEl.value + 'T00:00:00'); const t1 = new Date(endEl.value + 'T00:00:00'); days = Math.max(1, Math.round((t1 - t0) / 86400000) + 1); } } catch (e) { days = 1; } dailyAvg = Math.round((Number(botPct) / days) * 1000) / 1000; } if ($('a_bot_pct')) { $('a_bot_pct').textContent = (botPct == null) ? '-' : (aSign(botPct) + botPct + '%'); if (botPct != null) colorPnl($('a_bot_pct'), botPct); } if ($('a_daily_avg_pct')) { $('a_daily_avg_pct').textContent = (dailyAvg == null) ? '-' : (aSign(dailyAvg) + dailyAvg + '%'); if (dailyAvg != null) colorPnl($('a_daily_avg_pct'), dailyAvg); } const openCnt = Number((d.meta || {}).open_count || 0); const closedCnt = Number((d.meta || {}).closed_count || 0); const hint = $('act_table_hint'); const acctLbl = d.account_label || (d.meta || {}).account_label || ''; if (hint) { hint.textContent = `(청산 ${closedCnt}건 · 보유 ${openCnt}건` + (acctLbl ? ` · 계좌 ${acctLbl}` : '') + ' — 상단 요약·차트는 청산만)'; } const actCtx = $('act_trade_context'); if (actCtx) { fillTradePnLContext(actCtx, { label: '실매 trade_history', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } const note = $('act_filter_note'); if (note) { const parts = []; if (openCnt > 0) { parts.push(`📌 보유중 ${openCnt}건 — 매수만 된 포지션(노란 행), 매도 후 trade_history 로 이동`); } if (filtered > 0) { parts.push(`⚠️ 집계 제외: 0원 강제정리 ${filtered}건`); } if (s.peak_cum_pnl > 0 && s.total_pnl < s.peak_cum_pnl) { parts.push( `💡 장중 누적이 +${fmtKrw(s.peak_cum_pnl)}까지 갔다가 이후 청산으로 ${fmtKrw(s.total_pnl)} — ` + `누적손익 열은 매도 완료 순 합계입니다.`, ); } const tickLive = d.tick_live || {}; if (tickLive.tick_bar_coverage_pct != null || tickLive.tick_bar_coverage_pct_traded != null) { const rows = tickLive.ws_tick_rows_loaded != null ? Number(tickLive.ws_tick_rows_loaded).toLocaleString() : '—'; parts.push( `📊 실매 틱DB | 틱 ${rows}건 · 분봉커버 ${fmtTickCoverageLabel(tickLive)}`, ); } if (parts.length) { note.style.display = ''; note.innerHTML = parts.join('
'); } else { note.style.display = 'none'; note.innerHTML = ''; } } if (filtered > 0) { console.log(`[실거래 분석] ghost_purge·강제정리 ${filtered}건 제외 후 집계`); } // 승/패 바 if (s.total_trades > 0) { $('act_winbar_card').style.display = ''; const wr = s.win_rate; $('a_win_label').textContent = `🟢 승 ${s.win_trades}건 (${wr}%)`; $('a_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`; $('a_ratio_g').style.width = wr + '%'; $('a_ratio_r').style.width = (100 - wr) + '%'; } // 누적 손익 곡선 const eqLabels = d.equity.map((e,i) => i % Math.max(1, Math.floor(d.equity.length/20)) === 0 ? e.date : ''); lineChart('a_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익'); // 매도이유 도넛 const rKeys = Object.keys(d.reasons); doughnutChart('a_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); // 종목별 손익 바 barChart('a_code_chart', d.top_codes.map(c => c.name || c.code), d.top_codes.map(c => c.pnl)); // 일별 바 barChart('a_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); renderVirtualTrades('act_tbody', d.trades || [], { showDebug: true, showCumulative: true, showAccount: true, totalBudget: Number(p.total_budget_krw || 0), currency: isUs ? 'USD' : '', fxRates: d.fx_rates || {}, fxRate: fxEnd, }); } // ──────────────────────────────────────────── // 돌파 백테스트 실행 // ──────────────────────────────────────────── function runBreakoutBacktest() { let start = $('bo_start')?.value || ''; let end = $('bo_end')?.value || ''; if (!start) { start = kstMonthStartIso(); setDateVal('bo_start', start); } if (!end) { end = kstTodayParts().iso; setDateVal('bo_end', end); } if (end && start && end < start) { alert('종료일이 시작일보다 앞섭니다. 날짜를 확인하세요.'); return; } const params = { start, end, lookback_min: $('bo_lookback').value, vol_window: $('bo_vol_win').value, vol_mult: $('bo_vol_mult').value, min_turnover_1m_pct: $('bo_min_turnover')?.value || '0.05', prev_chg_min: $('bo_prev_min').value, prev_chg_max: $('bo_prev_max').value, entry_mode: $('bo_entry_mode')?.value || 'intrabar', intrabar_slippage_pct: $('bo_intrabar_slip')?.value || '0', sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed', sl_pct: $('bo_sl').value, atr_period: $('bo_atr_period')?.value || '14', atr_sl_mult: $('bo_atr_sl_mult')?.value || '2.0', atr_sl_min_pct: $('bo_atr_sl_min')?.value || '0.8', atr_sl_max_pct: $('bo_atr_sl_max')?.value || '6.0', tp_pct: $('bo_tp').value, trail_pct: $('bo_trail').value, trail_arm_pct: $('bo_trail_arm')?.value || '0', shoulder_min_high_pct: $('bo_shoulder_smin').value, shoulder_cut_pct: $('bo_shoulder_scut').value, time_start_hm: $('bo_time_start').value, time_end_hm: $('bo_time_end').value, max_daily: $('bo_max_daily').value, cooldown_min: $('bo_cooldown').value, max_daily_chg: $('bo_max_chg').value, min_price: $('bo_min_price').value, confirm_margin_pct: $('bo_confirm_margin')?.value || '0', body_min_pct: $('bo_body_min')?.value || '0', ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '', use_ema_filter: $('bo_use_ema_f')?.checked ? 1 : 0, ema_fast_period: $('bo_ema_fast')?.value || '9', ema_slow_period: $('bo_ema_slow')?.value || '21', max_loss_krw: $('bo_max_loss').value, slot_money: $('bo_slot').value, max_stocks: $('bo_max_stocks')?.value || '20', total_budget_krw: $('bo_total_budget')?.value || '0', universe: $('bo_use_univ_history')?.checked ? 'history' : 'all', universe_history_source: univHistorySourceParam('bo_univ_history_source'), candle_source: $('bo_candle_source')?.value || '', tick_source: $('bo_tick_source')?.value || '', ob_source: $('bo_ob_source')?.value || '', ob_filter: $('bo_ob_filter')?.checked ? 1 : 0, pg_filter: $('bo_pg_filter')?.checked ? 1 : 0, max_spread_pct: $('bo_max_spread_pct')?.value, daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '', daily_trail_drop_pct: $('bo_daily_trail_drop')?.value || '0', daily_trail_arm_krw: $('bo_daily_trail_arm')?.value || '0', daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing', daily_profit_enabled: $('bo_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('bo_daily_risk_budget_enabled')?.checked ? 1 : 0, eod_enabled: $('bo_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', skip_hts_scan_dupes: $('bo_skip_hts_dupes')?.checked ? 1 : 0, env_timeline: envTimelineParam('bo_env_timeline'), }; const qs = new URLSearchParams(params).toString(); showSpinner(true); fetch('/api/backtest/breakout?' + qs) .then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }) .then(d => { showSpinner(false); renderBreakoutBacktest(d); }) .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); }); } function saveBreakoutConfig() { const body = { lookback_min: parseInt($('bo_lookback').value, 10), vol_window: parseInt($('bo_vol_win').value, 10), vol_mult: parseFloat($('bo_vol_mult').value), min_turnover_1m_pct: parseFloat($('bo_min_turnover')?.value || '0.05'), prev_chg_min: parseFloat($('bo_prev_min').value), prev_chg_max: parseFloat($('bo_prev_max').value), entry_mode: $('bo_entry_mode')?.value || 'intrabar', intrabar_slippage_pct: parseFloat($('bo_intrabar_slip')?.value || '0'), sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed', sl_pct: parseFloat($('bo_sl').value), atr_period: parseInt($('bo_atr_period')?.value || '14', 10), atr_sl_mult: parseFloat($('bo_atr_sl_mult')?.value || '2.0'), atr_sl_min_pct: parseFloat($('bo_atr_sl_min')?.value || '0.8'), atr_sl_max_pct: parseFloat($('bo_atr_sl_max')?.value || '6.0'), tp_pct: parseFloat($('bo_tp').value), trail_pct: parseFloat($('bo_trail').value), trail_arm_pct: parseFloat($('bo_trail_arm')?.value || '0'), shoulder_min_high_pct: parseFloat($('bo_shoulder_smin').value), shoulder_cut_pct: parseFloat($('bo_shoulder_scut').value), time_start_hm: parseInt($('bo_time_start').value, 10), time_end_hm: parseInt($('bo_time_end').value, 10), eod_enabled: !!($('bo_eod_enabled')?.checked), eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', max_hold_bars: parseInt($('bo_max_hold')?.value || '0', 10), max_daily: parseInt($('bo_max_daily').value, 10), cooldown_min: parseFloat($('bo_cooldown').value), max_daily_chg: parseFloat($('bo_max_chg').value), min_price: parseFloat($('bo_min_price').value), confirm_margin_pct: parseFloat($('bo_confirm_margin')?.value || '0'), body_min_pct: parseFloat($('bo_body_min')?.value || '0'), ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '', use_ema_filter: !!($('bo_use_ema_f')?.checked), ema_fast_period: parseInt($('bo_ema_fast')?.value || '9', 10), ema_slow_period: parseInt($('bo_ema_slow')?.value || '21', 10), max_loss_krw: parseInt($('bo_max_loss').value, 10), slot_money: parseInt($('bo_slot').value, 10), max_stocks: parseInt($('bo_max_stocks')?.value || '20', 10), total_budget_krw: parseInt($('bo_total_budget')?.value || '0', 10), ob_filter: !!($('bo_ob_filter')?.checked), pg_filter: !!($('bo_pg_filter')?.checked), daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing', daily_profit_enabled: !!($('bo_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('bo_daily_risk_budget_enabled')?.checked), skip_hts_scan_dupes: !!($('bo_skip_hts_dupes')?.checked), }; if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) return; fetch('/api/backtest/breakout/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } btAddPresetOption('bo_daily_trail_preset', $('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value); const byTbl = d.saved_by_table || {}; const tblLines = Object.entries(byTbl).map(([t, ks]) => ` ${t}: ${ks.join(', ')}`).join('\n'); alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`); }).catch(e => alert('오류: ' + e)); } function renderBreakoutBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음')); return; } if (!d.summary) { alert('백테스트 응답 오류 (summary 없음)'); return; } const s = d.summary; const p = d.params || {}; const boRa = $('bo_result_area'); if (boRa) boRa.style.display = 'block'; const boPb = $('bo_params_bar'); if (boPb) boPb.style.display = 'block'; const universeLabel = formatUniverseLabel(p); const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : (v != null ? v : '—')); const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : ''; const summaryLine = `${universeLabel} ` + `진입 ${(p.entry_mode || 'intrabar').toUpperCase()} | ` + `Lookback ${p.lookback_min}분 | 회전율≥${tr(p.min_turnover_1m_pct)}%` + (Number(p.vol_mult) > 0 ? ` · VolWin${p.vol_window}분×${tr(p.vol_mult)}배` : '') + ` | ` + `직전봉 ${tr(p.prev_chg_min)}~${tr(p.prev_chg_max)}% | ` + ((Number(p.confirm_margin_pct) > 0 || Number(p.body_min_pct) > 0) ? `가짜돌파필터(여유${tr(p.confirm_margin_pct||0)}%·몸통${tr(p.body_min_pct||0)}%) | ` : ``) + `EMA필터 ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` + `손절-${tr(p.sl_pct)}% 익절+${tr(p.tp_pct)}% 트레일${tr(p.trail_pct)}%` + (Number(p.trail_arm_pct) > 0 ? `(무장${tr(p.trail_arm_pct)}%)` : '') + ` | ` + `${p.time_window}${periodHint} | 일${p.max_daily}회 쿨다운${p.cooldown_min}분 | ` + `1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | 종목수 ${p.codes_analyzed || 0}개`; let barHtml = summaryLine; if (s.budget_warning) { barHtml += `
💰 ${s.budget_warning}
`; } const tickMeta = s.tick_backtest || {}; const buySrc = s.backtest_buy_source || ''; if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) { const covLabel = fmtTickCoverageLabel(tickMeta); const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—'; const srcLabel = buySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (buySrc === 'ohlc_fallback' ? 'OHLC high 폴백' : buySrc); const tickColor = buySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; barHtml += `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning ? `거래 0건 — ${s.budget_warning}` : '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (1회투자 > 총한도 이면 체결 불가)'; barHtml += `
⚠️ ${zmsg}
`; } if (boPb) boPb.innerHTML = barHtml; const boCtx = $('bo_trade_context'); if (boCtx) { fillTradePnLContext(boCtx, { label: '백테 BREAKOUT', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; setTxt('bo_total', (s.total_trades||0) + '건'); setTxt('bo_winrate', (s.win_rate||0) + '%'); colorPnl($('bo_winrate'), (s.win_rate||0) - 50); setTxt('bo_pnl', fmtKrw(s.total_pnl)); colorPnl($('bo_pnl'), s.total_pnl); setTxt('bo_pf', (s.profit_factor||0) >= 999 ? '∞' : String(s.profit_factor||0)); colorPnl($('bo_pf'), (s.profit_factor||0) - 1); setTxt('bo_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('bo_hold', (s.avg_hold_min||0) + '분'); const boSign = v => (v > 0 ? '+' : ''); setTxt('bo_bot_pct', boSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('bo_bot_pct'), s.bot_pct || 0); setTxt('bo_daily_avg_pct', boSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('bo_daily_avg_pct'), s.daily_avg_pct || 0); const winbar = $('bo_winbar_card'); if ((s.total_trades||0) > 0) { if (winbar) winbar.style.display = 'block'; const wr = s.win_rate||0; setTxt('bo_win_label', `🟢 승 ${s.win_trades||0}건 (${wr}%)`); setTxt('bo_loss_label', `🔴 패 ${s.loss_trades||0}건 (${(100-wr).toFixed(1)}%)`); const rg = $('bo_ratio_g'); if (rg) rg.style.width = wr + '%'; const rr = $('bo_ratio_r'); if (rr) rr.style.width = (100-wr) + '%'; } else if (winbar) { winbar.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('bo_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); } else { destroyChart('bo_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('bo_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('bo_reason_chart'); } if (d.daily && d.daily.length) { barChart('bo_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else { destroyChart('bo_daily_chart'); } } catch (chartErr) { console.error('bo chart render', chartErr); } renderVirtualTrades('bo_tbody', d.trades || [], { showDebug: true, showCumulative: true, totalBudget: Number(p.total_budget_krw || 0), }); if (boRa) boRa.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function runRangeBreakBacktest() { let start = $('rb_start')?.value || ''; let end = $('rb_end')?.value || ''; if (!start) { start = kstMonthStartIso(); setDateVal('rb_start', start); } if (!end) { end = kstTodayParts().iso; setDateVal('rb_end', end); } if (end && start && end < start) { alert('종료일이 시작일보다 앞섭니다.'); return; } const params = { start, end, box_lookback_min: $('rb_box_lb').value, box_max_width_pct: $('rb_box_max_w').value, setup_vol_max_mult: $('rb_setup_vol').value, setup_bear_bars_min: $('rb_bear_min').value, vol_window: $('rb_vol_win').value, vol_mult: $('rb_vol_mult').value, sl_pct: $('rb_sl').value, tp_pct: $('rb_tp').value, trail_pct: $('rb_trail').value, trail_arm_pct: $('rb_trail_arm')?.value || '0', shoulder_min_high_pct: $('rb_shoulder_smin').value, shoulder_cut_pct: $('rb_shoulder_scut').value, time_start_hm: $('rb_time_start').value, time_end_hm: $('rb_time_end').value, max_daily: $('rb_max_daily').value, cooldown_min: $('rb_cooldown').value, max_daily_chg: $('rb_max_chg').value, min_price: $('rb_min_price').value, max_loss_krw: $('rb_max_loss').value, slot_money: $('rb_slot').value, max_stocks: $('rb_max_stocks')?.value || '20', total_budget_krw: $('rb_total_budget')?.value || '0', use_high_chase_filter: $('rb_use_high_chase')?.checked ? 1 : 0, universe: $('rb_use_univ_history')?.checked ? 'history' : 'all', env_timeline: envTimelineParam('rb_env_timeline'), }; showSpinner(true); fetch('/api/backtest/range_break?' + new URLSearchParams(params).toString()) .then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }) .then(d => { showSpinner(false); renderRangeBreakBacktest(d); }) .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); }); } function saveRangeBreakConfig() { const body = { box_lookback_min: parseInt($('rb_box_lb').value, 10), box_max_width_pct: parseFloat($('rb_box_max_w').value), setup_vol_max_mult: parseFloat($('rb_setup_vol').value), setup_bear_bars_min: parseInt($('rb_bear_min').value, 10), vol_window: parseInt($('rb_vol_win').value, 10), vol_mult: parseFloat($('rb_vol_mult').value), sl_pct: parseFloat($('rb_sl').value), tp_pct: parseFloat($('rb_tp').value), trail_pct: parseFloat($('rb_trail').value), trail_arm_pct: parseFloat($('rb_trail_arm')?.value || '0'), shoulder_min_high_pct: parseFloat($('rb_shoulder_smin').value), shoulder_cut_pct: parseFloat($('rb_shoulder_scut').value), time_start_hm: parseInt($('rb_time_start').value, 10), time_end_hm: parseInt($('rb_time_end').value, 10), max_daily: parseInt($('rb_max_daily').value, 10), cooldown_min: parseFloat($('rb_cooldown').value), max_daily_chg: parseFloat($('rb_max_chg').value), min_price: parseFloat($('rb_min_price').value), max_loss_krw: parseInt($('rb_max_loss').value, 10), use_high_chase_filter: !!($('rb_use_high_chase')?.checked), slot_money: parseInt($('rb_slot').value, 10), max_stocks: parseInt($('rb_max_stocks')?.value || '20', 10), total_budget_krw: parseInt($('rb_total_budget')?.value || '0', 10), }; if (!confirm('💾 박스권돌파 봇(RANGE_BREAK_* env)에 저장할까요?')) return; fetch('/api/backtest/range_break/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } alert('✅ 저장 완료 (env_id: ' + d.env_id + ')'); }).catch(e => alert('오류: ' + e)); } function renderRangeBreakBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '응답 없음')); return; } if (!d.summary) { alert('summary 없음'); return; } const s = d.summary; const p = d.params || {}; const ra = $('rb_result_area'); if (ra) ra.style.display = 'block'; const pb = $('rb_params_bar'); if (pb) { pb.style.display = 'block'; pb.innerHTML = `박스 ${p.box_lookback_min}분 · 폭≤${p.box_max_width_pct}% · vol×${p.vol_mult} ` + `| ${p.time_window} | 손절-${p.sl_pct}% 익절+${p.tp_pct}% | 종목 ${p.codes_analyzed || 0}개`; } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; setTxt('rb_total', (s.total_trades || 0) + '건'); setTxt('rb_winrate', (s.win_rate || 0) + '%'); colorPnl($('rb_winrate'), (s.win_rate || 0) - 50); setTxt('rb_pnl', fmtKrw(s.total_pnl)); colorPnl($('rb_pnl'), s.total_pnl); setTxt('rb_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('rb_pf'), (s.profit_factor || 0) - 1); setTxt('rb_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('rb_hold', (s.avg_hold_min || 0) + '분'); const rbSign = (v) => (v > 0 ? '+' : ''); setTxt('rb_bot_pct', rbSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('rb_bot_pct'), s.bot_pct || 0); setTxt('rb_daily_avg_pct', rbSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('rb_daily_avg_pct'), s.daily_avg_pct || 0); if ((s.total_trades || 0) > 0 && $('rb_winbar_card')) { $('rb_winbar_card').style.display = ''; const wr = s.win_rate || 0; setTxt('rb_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); setTxt('rb_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); if ($('rb_ratio_g')) $('rb_ratio_g').style.width = wr + '%'; if ($('rb_ratio_r')) $('rb_ratio_r').style.width = (100 - wr) + '%'; } try { if (d.equity && d.equity.length) { lineChart('rb_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익', '#58a6ff'); } else destroyChart('rb_equity_chart'); const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) doughnutChart('rb_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); else destroyChart('rb_reason_chart'); if (d.daily && d.daily.length) { barChart('rb_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else destroyChart('rb_daily_chart'); } catch (e) { console.error('rb chart', e); } renderVirtualTrades('rb_tbody', d.trades || [], { showCumulative: true, totalBudget: Number(p.total_budget_krw || s.total_budget_krw || 0), }); let rbCtx = $('rb_trade_context'); if (!rbCtx) { const bar = document.querySelector('.trade-sort-bar[data-tbody="rb_tbody"]'); if (bar) { rbCtx = document.createElement('div'); rbCtx.id = 'rb_trade_context'; rbCtx.className = 'mb-2 p-2 rounded'; rbCtx.style.cssText = 'font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none'; bar.parentNode.insertBefore(rbCtx, bar); } } if (rbCtx) { fillTradePnLContext(rbCtx, { label: '백테 RANGE_BREAK', totalBudget: Number(p.total_budget_krw || s.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } if (ra) ra.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // - 백엔드 API: /api/backtest/momentum (momentum_engine 전용) // - 탐색결과: /api/backtest/momentum/search_results · apply_search // ============================================================================ function runMomentumBacktest() { try { showSpinner(true); let start = $('mom_start')?.value || ''; let end = $('mom_end')?.value || ''; if (!start) { start = kstMonthStartIso(); setDateVal('mom_start', start); } if (!end) { end = kstTodayParts().iso; setDateVal('mom_end', end); } if (end && start && end < start) { showSpinner(false); alert('종료일이 시작일보다 앞섭니다. 날짜를 확인하세요.'); return; } const v = id => { const el = $(id); return el ? el.value : ''; }; const slotN = parseFloat(v('mom_slot_money')) || 0; const budgetN = parseFloat(v('mom_total_budget')) || 0; if (budgetN > 0 && slotN > 0 && budgetN < slotN * 0.9) { showSpinner(false); alert( `⚠️ 총운용한도(${budgetN.toLocaleString()}원) < 1회투자(${slotN.toLocaleString()}원)\n` + '→ 1주도 살 수 없어 거래 0건입니다.\n\n' + '총운용한도를 올리거나 1회투자금을 줄이세요.' ); return; } const params = { start, end, sl_pct: v('mom_sl'), tp_pct: v('mom_tp'), tp_max_pct: v('mom_tp_max'), slot_money: v('mom_slot_money'), slots: v('mom_slots'), total_budget_krw: v('mom_total_budget') || '0', cooldown_min: v('mom_cooldown'), shoulder_min_high: v('mom_smin'), shoulder_cut_pct: v('mom_scut'), trail_pct: v('mom_trail'), trail_arm_pct: v('mom_trail_arm'), max_hold_bars: v('mom_max_hold'), ratchet_tiers: v('mom_ratchet'), time_start: v('mom_time_start'), time_end: 1530, max_daily: v('mom_max_daily'), high_chase_thr: v('mom_high_chase'), max_daily_chg: v('mom_max_daily_chg'), min_price: v('mom_min_price'), max_loss_krw: v('mom_max_loss_krw'), min_margin: v('mom_min_margin'), use_defense_filters: $('mom_use_defense')?.checked ? 1 : 0, ob_filter: $('mom_ob_filter')?.checked ? 1 : 0, pg_filter: $('mom_pg_filter')?.checked ? 1 : 0, max_spread_pct: v('mom_max_spread_pct'), use_high_chase_filter: $('mom_use_high_chase_f')?.checked ? 1 : 0, use_daily_range_filter: $('mom_use_daily_range')?.checked ? 1 : 0, use_ema_filter: $('mom_use_ema_f')?.checked ? 1 : 0, use_rsi_max_filter: $('mom_use_rsi_max_f')?.checked ? 1 : 0, pattern_breakout: $('mom_pat_breakout')?.checked ? 1 : 0, pattern_pullback: $('mom_pat_pullback')?.checked ? 1 : 0, chase_lookback_min: v('mom_chase_lookback') || '10', pullback_lookback_min: v('mom_pullback_lookback') || '15', pullback_min_pct: v('mom_pullback_min') || '0.3', pullback_max_pct: v('mom_pullback_max') || '3.0', setup_vol_max_mult: v('mom_setup_vol') || '0.8', setup_bear_bars_min: v('mom_setup_bear') || '1', ema_fast_period: v('mom_ema_fast') || '9', ema_slow_period: v('mom_ema_slow') || '21', mom_rsi_min: v('mom_rsi_min'), mom_rsi_max: v('mom_rsi_max'), mom_vol_mult: v('mom_vol_mult'), mom_vol_win: v('mom_vol_win'), e_min_chg_pct: v('mom_e_min_chg') || '0.2', mom_time_end: v('mom_time_end_buy'), mom_max_from_open_pct: v('mom_max_from_open') || '999', mom_min_from_open_pct: v('mom_min_from_open') || '-999', universe: $('mom_use_univ_history')?.checked ? 'history' : 'sim', universe_history_source: univHistorySourceParam('mom_univ_history_source'), candle_source: $('mom_candle_source')?.value || '', tick_source: $('mom_tick_source')?.value || '', ob_source: $('mom_ob_source')?.value || '', daily_trail_tiers: (v('mom_daily_trail_tiers') || '').trim(), daily_trail_drop_pct: v('mom_daily_trail_drop') || '0', daily_trail_arm_krw: v('mom_daily_trail_arm') || '0', daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing', daily_profit_enabled: $('mom_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('mom_daily_risk_budget_enabled')?.checked ? 1 : 0, eod_enabled: $('mom_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', backtest_skip_pre_subscribe: $('mom_skip_pre_sub')?.checked ? 1 : 0, env_timeline: envTimelineParam('mom_env_timeline'), }; const qs = new URLSearchParams(params).toString(); fetch('/api/backtest/momentum?' + qs) .then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }) .then(d => { showSpinner(false); renderMomentumBacktest(d); }) .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err + '\n\n※ 저장 후보 이력 체크 시 1~3분 걸릴 수 있습니다. 기간을 줄이거나 체크 해제 후 재시도하세요.'); }); } catch (err) { showSpinner(false); alert('백테스트 실행 오류: ' + err); } } /** 해외 모멘텀 백테 — /api/backtest/us_momentum (국내 momentum API 분기 사용 안 함) */ function runUsMomentumBacktest() { try { showSpinner(true); let start = $('usmom_start')?.value || ''; let end = $('usmom_end')?.value || ''; if (!start) { start = kstMonthStartIso(); setDateVal('usmom_start', start); } if (!end) { end = kstTodayParts().iso; setDateVal('usmom_end', end); } if (end && start && end < start) { showSpinner(false); alert('종료일이 시작일보다 앞섭니다.'); return; } const v = id => { const el = $(id); return el ? el.value : ''; }; const params = { start, end, sl_pct: v('usmom_sl'), tp_pct: v('usmom_tp'), tp_max_pct: v('usmom_tp_max'), slot_money: v('usmom_slot_money'), slots: v('usmom_slots'), total_budget_krw: v('usmom_total_budget') || '0', cooldown_min: v('usmom_cooldown'), shoulder_min_high: v('usmom_smin'), shoulder_cut_pct: v('usmom_scut'), trail_pct: v('usmom_trail') || '0', trail_arm_pct: v('usmom_trail_arm') || '0', max_hold_bars: v('usmom_max_hold') || '0', ratchet_tiers: ($('usmom_ratchet')?.value || '').trim(), time_start: v('usmom_time_start') || '2230', time_end: v('usmom_time_end') || '500', max_daily: v('usmom_max_daily') || '5', high_chase_thr: v('usmom_high_chase') || '0.96', max_daily_chg: v('usmom_max_daily_chg') || '20', min_price: v('usmom_min_price') || '1', max_loss_krw: v('usmom_max_loss') || '0', min_margin: v('usmom_min_margin') || '0.2', use_defense_filters: $('usmom_use_defense')?.checked ? 1 : 0, use_high_chase_filter: $('usmom_use_high_chase_f')?.checked ? 1 : 0, use_daily_range_filter: $('usmom_use_daily_range')?.checked ? 1 : 0, use_ema_filter: $('usmom_use_ema_f')?.checked ? 1 : 0, use_rsi_max_filter: $('usmom_use_rsi_max_f')?.checked ? 1 : 0, pattern_breakout: $('usmom_pat_breakout')?.checked ? 1 : 0, pattern_pullback: $('usmom_pat_pullback')?.checked ? 1 : 0, chase_lookback_min: v('usmom_chase_lookback') || '10', pullback_lookback_min: v('usmom_pullback_lookback') || '15', pullback_min_pct: v('usmom_pullback_min') || '0.3', pullback_max_pct: v('usmom_pullback_max') || '3.0', setup_vol_max_mult: v('usmom_setup_vol') || '0.8', setup_bear_bars_min: v('usmom_setup_bear') || '1', ema_fast_period: v('usmom_ema_fast') || '9', ema_slow_period: v('usmom_ema_slow') || '21', mom_rsi_min: v('usmom_rsi_min'), mom_rsi_max: v('usmom_rsi_max'), mom_vol_mult: v('usmom_vol_mult'), mom_vol_win: v('usmom_vol_win') || '5', mom_time_end: v('usmom_time_end') || '500', mom_max_from_open_pct: v('usmom_max_from_open') || '999', mom_min_from_open_pct: v('usmom_min_from_open') || '-999', eod_enabled: $('usmom_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('usmom_eod_hm') && $('usmom_eod_hm').value.trim()) || '05:00', backtest_skip_pre_subscribe: 0, daily_profit_enabled: $('usmom_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('usmom_daily_risk_budget_enabled')?.checked ? 1 : 0, daily_trail_tiers: ($('usmom_daily_trail_tiers')?.value || '').trim(), daily_profit_mode: ($('usmom_daily_profit_mode')?.value || 'trailing'), codes: ($('usmom_bt_codes')?.value || '').trim(), }; const qs = new URLSearchParams(params).toString(); fetch('/api/backtest/us_momentum?' + qs) .then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }) .then(d => { showSpinner(false); renderUsMomentumBacktest(d); }) .catch(err => { showSpinner(false); alert('해외 모멘텀 백테 오류: ' + err); }); } catch (err) { showSpinner(false); alert('해외 모멘텀 실행 오류: ' + err); } } function renderUsMomentumBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음')); return; } const s = d.summary || {}; const trades = d.trades || []; const p = d.params || {}; const fxRates = d.fx_rates || {}; const fxEnd = Number(s.fx_rate_end || 0) || getDisplayFxRate(); if (fxEnd > 0) setDisplayFxRate(fxEnd); const ra = $('usmom_result_area'); if (ra) ra.style.display = 'block'; const pb = $('usmom_params_bar'); if (pb) { pb.style.display = 'block'; const sess = p.time_window || `${p.time_start_hm ?? '?'}~${p.mom_time_end_hm ?? p.mom_time_end ?? p.time_end_hm ?? '?'}`; const slot = Number(p.slot_money || 0); const bud = Number(p.total_budget_krw || 0); const slotTxt = fxEnd > 0 ? fmtUsdWithKrwText(slot, fxEnd) : fmtUsd(slot); const budTxt = fxEnd > 0 ? fmtUsdWithKrwText(bud, fxEnd) : fmtUsd(bud); const fxNote = fxEnd > 0 ? ` · FX ${fxEnd.toFixed(2)}원/$` : ''; pb.innerHTML = `market=${p.market || 'US'} | 유니버스=${p.universe_source || 'permanent_us'} | ` + `종목 ${p.codes_analyzed || '—'} · 1회 ${slotTxt} · 한도 ${budTxt} · ` + `세션 ${sess}` + (p.max_hold_bars != null ? ` · max_hold=${p.max_hold_bars}` : '') + (p.start && p.end ? ` · ${p.start}~${p.end}` : '') + fxNote; } const ctx = $('usmom_trade_context'); if (ctx) { fillTradePnLContext(ctx, { label: '백테 US_MOMENTUM', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, currency: 'USD', fxRate: fxEnd, }); } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; const setHtml = (id, html) => { const el = $(id); if (el) el.innerHTML = html; }; setTxt('usmom_total', (s.total_trades || 0) + '건'); setTxt('usmom_winrate', (s.win_rate || 0) + '%'); colorPnl($('usmom_winrate'), (s.win_rate || 0) - 50); if (s.total_pnl_krw != null && Number.isFinite(Number(s.total_pnl_krw))) { setHtml('usmom_pnl', `${fmtUsd(s.total_pnl)} (${fmtWon(s.total_pnl_krw)}원)`); } else { setHtml('usmom_pnl', fmtUsdWithKrw(s.total_pnl, fxEnd)); } colorPnl($('usmom_pnl'), s.total_pnl); setTxt('usmom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('usmom_pf'), (s.profit_factor || 0) - 1); if (s.max_drawdown_krw != null && Number.isFinite(Number(s.max_drawdown_krw))) { setHtml('usmom_mdd', `-${fmtUsd(s.max_drawdown)} (${fmtWon(s.max_drawdown_krw)}원)`); } else { setTxt('usmom_mdd', '-' + fmtUsdWithKrwText(s.max_drawdown, fxEnd)); } setTxt('usmom_hold', (s.avg_hold_min || 0) + '분'); const usSign = v => (v > 0 ? '+' : ''); setTxt('usmom_bot_pct', usSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('usmom_bot_pct'), s.bot_pct || 0); setTxt('usmom_daily_avg_pct', usSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('usmom_daily_avg_pct'), s.daily_avg_pct || 0); const winbar = $('usmom_winbar_card'); if ((s.total_trades || 0) > 0) { if (winbar) winbar.style.display = 'block'; const wr = s.win_rate || 0; setTxt('usmom_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); setTxt('usmom_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); const rg = $('usmom_ratio_g'); if (rg) rg.style.width = wr + '%'; const rr = $('usmom_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%'; } else if (winbar) { winbar.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('usmom_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익($)', '#3fb950'); } else { destroyChart('usmom_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('usmom_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('usmom_reason_chart'); } if (d.daily && d.daily.length) { barChart('usmom_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else { destroyChart('usmom_daily_chart'); } } catch (chartErr) { console.error('usmom chart render', chartErr); } renderVirtualTrades('usmom_tbody', trades || [], { showDebug: true, showCumulative: true, totalBudget: Number(p.total_budget_krw || 0), currency: 'USD', fxRates, fxRate: fxEnd, }); if (ra) ra.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function usmomGoOptuna() { /* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */ document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = (cb.value === 'us_momentum'); }); document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; }); btShowTab('optuna'); } /** 해외 모멘텀 → 실거래 분석 탭 (국내 전략 탭과 동일: 전략탭에 실거래 UI 없음) */ function usmomActGoActualTab() { const radio = document.querySelector('input[name=act_strategy][value=US_MOMENTUM]'); if (radio) radio.checked = true; const day = ($('act_start')?.value || '').trim() || kstTradingDayIso(_krHolidays); setDateVal('act_start', day); setDateVal('act_end', day); btShowTab('actual'); loadActual(); } /** @deprecated 임베드 실거래/페이퍼 UI 제거 — 실거래 분석 탭으로 */ function loadUsMomActual() { usmomActGoActualTab(); } function loadUsMomPaper() { usmomActGoActualTab(); } let _usmomStockCache = []; function usmomLoadStocks(seed) { const q = seed ? '?seed=1' : ''; fetch('/api/us_momentum/stocks' + q) .then(r => r.json()) .then(d => { _usmomStockCache = d.stocks || []; const sel = $('usmom_stock_code'); const tb = $('usmom_stock_tbody'); if (sel) { const cur = sel.value; sel.innerHTML = _usmomStockCache.map(s => `` ).join('') || ''; if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur; } if (tb) { if (!_usmomStockCache.length) { tb.innerHTML = '영구구독 US 없음'; } else { tb.innerHTML = _usmomStockCache.map(s => ` ${s.code}${s.stock_group || ''}${s.exchange || ''} ${s.has_stock_cfg ? '✅' : '—'} ${s.enabled ? 'ON' : 'OFF'}` ).join(''); } } if (sel && sel.value) usmomOnStockSelect(); }) .catch(e => { const tb = $('usmom_stock_tbody'); if (tb) tb.innerHTML = `${e}`; }); } function usmomPickStock(code) { const sel = $('usmom_stock_code'); if (sel) sel.value = code; usmomOnStockSelect(); } function usmomOnStockSelect() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) return; const meta = (_usmomStockCache || []).find(s => s.code === code) || {}; if ($('usmom_stock_group')) $('usmom_stock_group').value = meta.stock_group || 'STOCK'; if ($('usmom_stock_exch')) $('usmom_stock_exch').value = meta.exchange || 'NASD'; const badge = $('usmom_stock_cfg_badge'); fetch('/api/us_momentum/stock_cfg?code=' + encodeURIComponent(code)) .then(r => r.json()) .then(d => { if (badge) { badge.textContent = d.has_stock_cfg ? `핀 있음 · ${code}` : `전역 폴백 · ${code}`; badge.style.color = d.has_stock_cfg ? 'var(--accent,#58a6ff)' : 'var(--muted)'; } const c = d.cfg || {}; const setN = (id, v) => { const el = $(id); if (el && v != null && v !== '') el.value = v; }; setN('usmom_sl', c.sl_pct); setN('usmom_tp', c.tp_pct); setN('usmom_tp_max', c.tp_max_pct); setN('usmom_smin', c.shoulder_min_high_pct); setN('usmom_scut', c.shoulder_cut_pct); setN('usmom_trail', c.trail_pct); setN('usmom_trail_arm', c.trail_arm_pct); setN('usmom_max_hold', c.max_hold_bars); if (c.ratchet_tiers != null && $('usmom_ratchet')) $('usmom_ratchet').value = c.ratchet_tiers; if (c.cooldown_sec != null && $('usmom_cooldown')) $('usmom_cooldown').value = String(Math.round(Number(c.cooldown_sec) / 60 * 10) / 10); setN('usmom_max_daily', c.max_daily); setN('usmom_slot_money', c.slot_money); setN('usmom_rsi_min', c.rsi_min); setN('usmom_rsi_max', c.rsi_max); setN('usmom_vol_mult', c.vol_mult); setN('usmom_vol_win', c.vol_win); setN('usmom_chase_lookback', c.chase_lookback_min); setN('usmom_pullback_lookback', c.pullback_lookback_min); setN('usmom_pullback_min', c.pullback_min_pct); setN('usmom_pullback_max', c.pullback_max_pct); if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code; }) .catch(e => console.warn('[usmom stock_cfg]', e)); } function usmomSaveStockCfg() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) { alert('종목을 선택하세요'); return; } const numOrNull = (id) => { const el = $(id); if (!el || el.value === '' || el.value == null) return null; const n = Number(el.value); return Number.isFinite(n) ? n : null; }; const body = { code, exchange: ($('usmom_stock_exch')?.value || 'NASD').trim(), symbol: code, name: code, stock_group: ($('usmom_stock_group')?.value || 'STOCK'), sl_pct: numOrNull('usmom_sl'), tp_pct: numOrNull('usmom_tp'), tp_max_pct: numOrNull('usmom_tp_max'), shoulder_min_high_pct: numOrNull('usmom_smin'), shoulder_cut_pct: numOrNull('usmom_scut'), trail_pct: numOrNull('usmom_trail'), trail_arm_pct: numOrNull('usmom_trail_arm'), ratchet_tiers: ($('usmom_ratchet')?.value || '').trim() || null, max_hold_bars: numOrNull('usmom_max_hold'), cooldown_sec: (() => { const m = numOrNull('usmom_cooldown'); return m == null ? null : Math.round(m * 60); })(), max_daily: numOrNull('usmom_max_daily'), slot_money: numOrNull('usmom_slot_money'), rsi_min: numOrNull('usmom_rsi_min'), rsi_max: numOrNull('usmom_rsi_max'), vol_mult: numOrNull('usmom_vol_mult'), vol_win: numOrNull('usmom_vol_win'), chase_lookback_min: numOrNull('usmom_chase_lookback'), pullback_lookback_min: numOrNull('usmom_pullback_lookback'), pullback_min_pct: numOrNull('usmom_pullback_min'), pullback_max_pct: numOrNull('usmom_pullback_max'), setup_vol_max_mult: numOrNull('usmom_setup_vol'), setup_bear_bars_min: numOrNull('usmom_setup_bear'), high_chase_thr: numOrNull('usmom_high_chase'), max_daily_chg: numOrNull('usmom_max_daily_chg'), min_price: numOrNull('usmom_min_price'), ema_fast_period: numOrNull('usmom_ema_fast'), ema_slow_period: numOrNull('usmom_ema_slow'), use_defense_filters: $('usmom_use_defense')?.checked ? 1 : 0, use_high_chase_filter: $('usmom_use_high_chase_f')?.checked ? 1 : 0, use_daily_range_filter: $('usmom_use_daily_range')?.checked ? 1 : 0, use_ema_filter: $('usmom_use_ema_f')?.checked ? 1 : 0, use_rsi_max_filter: $('usmom_use_rsi_max_f')?.checked ? 1 : 0, pattern_breakout: $('usmom_pat_breakout')?.checked ? 1 : 0, pattern_pullback: $('usmom_pat_pullback')?.checked ? 1 : 0, }; fetch('/api/us_momentum/stock_cfg', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); alert('✅ ' + code + ' 종목 파람 저장'); usmomLoadStocks(false); }) .catch(e => alert('종목 저장 실패: ' + e)); } function usmomClearStockCfg() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) return; if (!confirm(code + ' 종목 핀을 삭제하고 전역 US_MOMENTUM_* 로 복귀할까요?')) return; fetch('/api/us_momentum/stock_cfg?code=' + encodeURIComponent(code), { method: 'DELETE' }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); usmomLoadStocks(false); }) .catch(e => alert('삭제 실패: ' + e)); } const USMOM_OPT_LS_KEY = 'kis_usmom_stock_optuna_job'; let _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; let _usmomOptPollTimer = null; /** @type {{source:string, rank:number}|null} */ let _usmomOptSelected = null; window._usmomOptLastSummary = null; function usmomOptSetStatus(html, isErr) { const el = $('usmom_opt_status'); if (!el) return; el.innerHTML = html; el.style.color = isErr ? 'var(--danger, #f85149)' : 'var(--muted)'; } function usmomOptSyncSelectedButtons() { const has = !!( _usmomOptSelected && _usmomOptSelected.source && _usmomOptSelected.rank ); const fl = $('usmom_opt_btn_fill_sel'); const ap = $('usmom_opt_btn_apply_sel'); if (fl) fl.disabled = !has; if (ap) ap.disabled = !has; const sel = $('usmom_opt_detail_sel'); if (sel) { sel.textContent = has ? `${_usmomOptSelected.source} #${_usmomOptSelected.rank}` : '미선택'; } } function usmomOptSetButtons(running, done) { const st = $('usmom_opt_btn_start'); const sp = $('usmom_opt_btn_stop'); const ap = $('usmom_opt_btn_apply'); const fl = $('usmom_opt_btn_fill'); if (st) st.disabled = !!running; if (sp) sp.disabled = !running; if (ap) ap.disabled = !done; if (fl) fl.disabled = !done; const box = $('usmom_opt_results_box'); if (box) { if (done) { box.style.display = 'block'; box.dataset.forceShow = '1'; } else if (running) { box.style.display = 'none'; box.dataset.forceShow = ''; } } usmomOptSyncSelectedButtons(); } function usmomOptFmtUsdOrNum(v, digits) { const n = Number(v); if (!Number.isFinite(n)) return '—'; if (digits == null) return n.toLocaleString(undefined, { maximumFractionDigits: 2 }); return n.toFixed(digits); } function usmomOptRenderTopTable(tbodyId, rows, source) { const tb = $(tbodyId); if (!tb) return; if (!rows || !rows.length) { tb.innerHTML = `${source} 후보 없음`; return; } const src = source; tb.innerHTML = rows.map(r => { const rank = r.rank || 1; const sel = _usmomOptSelected && _usmomOptSelected.source === src && Number(_usmomOptSelected.rank) === Number(rank); const outline = sel ? 'outline:1px solid var(--accent)' : ''; return ` ${rank} #${r.optuna_trial_number ?? '—'} ${usmomOptFmtUsdOrNum(r.total_trades, 0)} ${usmomOptFmtUsdOrNum(r.win_rate, 1)}% ${usmomOptFmtUsdOrNum(r.pf, 2)} ${usmomOptFmtUsdOrNum(r.total_pnl)} `; }).join(''); } function usmomStockOptunaRenderResults(sum) { window._usmomOptLastSummary = sum || null; const box = $('usmom_opt_results_box'); if (!sum) { if (box) box.style.display = 'none'; return; } if (box) { box.style.display = 'block'; box.dataset.forceShow = '1'; } usmomOptRenderTopTable('usmom_opt_top5_learn', sum.top5_learn || [], 'learn'); usmomOptRenderTopTable('usmom_opt_top5_gated', sum.top5_gated || [], 'gated'); } function usmomOptSyncDatesFromMain() { const s = ($('usmom_start')?.value || '').trim(); const e = ($('usmom_end')?.value || '').trim(); if ($('usmom_opt_start') && s) $('usmom_opt_start').value = s; if ($('usmom_opt_end') && e) $('usmom_opt_end').value = e; } function usmomOptSyncDatesToMain() { const s = ($('usmom_opt_start')?.value || '').trim(); const e = ($('usmom_opt_end')?.value || '').trim(); if ($('usmom_start') && s) setDateVal('usmom_start', s); if ($('usmom_end') && e) setDateVal('usmom_end', e); } function usmomStockOptunaRender(job) { if (!job) return; const st = job.status || ''; const prog = job.progress || {}; const pct = Number(prog.pct || 0); const doneN = prog.trials_done ?? 0; const totN = prog.trials_total ?? job.trials ?? '—'; const fill = $('usmom_opt_fill'); if (fill) { fill.style.width = `${Math.min(100, Math.max(0, pct))}%`; fill.classList.toggle('is-done', st === 'done'); fill.classList.toggle('is-error', st === 'error'); } if ($('usmom_opt_prog_label')) { const met = optunaBestMetricsText(prog, job); $('usmom_opt_prog_label').textContent = `trial ${doneN} / ${totN} (${pct}%)` + (job.symbol ? ` · ${job.symbol}` : '') + (met ? ` · ${met}` : ''); } const log = job.log_path ? `
${job.log_path}` : ''; usmomOptSetStatus( `${job.label || '종목 Optuna'} · ${st}` + (job.error ? `
❌ ${job.error}` : '') + (job.result_json ? `
JSON: ${job.result_json}` : '') + log, st === 'error' ); const tail = $('usmom_opt_log_tail'); if (tail) { const t = (job.log_tail || '').trim(); if (t) { tail.textContent = t; tail.classList.remove('d-none'); } else if (st !== 'running') { tail.classList.add('d-none'); } } usmomOptSetButtons(st === 'running', st === 'done'); if (st === 'done' && job.result_summary) { usmomStockOptunaRenderResults(job.result_summary); } // 상단 네비 프로그레스도 동일 갱신 (Optuna 탭과 공유 바) if (typeof optunaSetNav === 'function') optunaSetNav(job); } async function usmomStockOptunaView(source, rank) { if (!_usmomOptJobId) { _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; } if (!_usmomOptJobId) { alert('종목 Optuna job 없음'); return; } const src = source || 'learn'; const rk = Number(rank) || 1; _usmomOptSelected = { source: src, rank: rk }; usmomOptSyncSelectedButtons(); const title = $('usmom_opt_detail_title'); const met = $('usmom_opt_detail_metrics'); const pre = $('usmom_opt_detail_params'); if (title) title.textContent = `로딩 ${src} #${rk}…`; if (met) met.textContent = '불러오는 중…'; try { const qs = new URLSearchParams({ job_id: _usmomOptJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { if (met) met.textContent = '❌ ' + (j.error || '실패'); if (pre) { pre.textContent = '—'; pre.classList.add('d-none'); } return; } const m = j.metrics || {}; if (title) { title.textContent = `${j.strategy || 'us_momentum'} · ${m.label || src} · trial ${ m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—' }`; } if (met) { met.innerHTML = `거래 ${usmomOptFmtUsdOrNum(m.total_trades, 0)} · ` + `승률 ${usmomOptFmtUsdOrNum(m.win_rate, 1)}% · ` + `PF ${usmomOptFmtUsdOrNum(m.pf, 2)} · ` + `PnL ${usmomOptFmtUsdOrNum(m.total_pnl)}` + (j.start && j.end ? ` · ${j.start}~${j.end}` : ''); } if (pre) { pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2); pre.classList.remove('d-none'); } window._optunaJobId = _usmomOptJobId; window._optunaCandidateCache = { strategy: j.strategy || 'us_momentum', start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, }; usmomStockOptunaRenderResults(window._usmomOptLastSummary); } catch (e) { if (met) met.textContent = '오류: ' + e; } } async function usmomStockOptunaFillSelected() { if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; } return usmomStockOptunaFillForm(_usmomOptSelected.source, _usmomOptSelected.rank); } async function usmomStockOptunaApplySelected() { if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; } return usmomStockOptunaApply(_usmomOptSelected.source, _usmomOptSelected.rank); } async function usmomStockOptunaStart() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) { alert('종목을 선택하세요'); return; } usmomOptSyncDatesFromMain(); let start = ($('usmom_opt_start')?.value || '').trim() || ($('usmom_start')?.value || '').trim(); let end = ($('usmom_opt_end')?.value || '').trim() || ($('usmom_end')?.value || '').trim(); if (!start || !end) { alert('시작·종료일을 입력하세요'); return; } usmomOptSyncDatesToMain(); if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code; const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100; let studyTrials = parseInt($('usmom_opt_study_trials')?.value, 10); if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null; const mode = ($('usmom_opt_mode')?.value || 'tpe').trim() || 'tpe'; const candleSrc = $('opt_candle_source')?.value || ''; const tickSrc = $('opt_tick_source')?.value || ''; const obSrc = $('opt_ob_source')?.value || ''; if (!confirm( `종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}` + (studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') + `\n` + `완료 후 「폼에 넣기」→백테 검증 · 「종목행에 적용」→ stock_config\n(전역 US_MOMENTUM_* 미적용)` )) return; try { const r = await fetch('/api/optuna/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ strategy: 'us_momentum', symbol: code, start, end, trials, mode, study_trials: studyTrials || undefined, candle_source: candleSrc, tick_source: tickSrc, ob_source: obSrc, }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; } _usmomOptJobId = j.job.job_id; localStorage.setItem(USMOM_OPT_LS_KEY, _usmomOptJobId); usmomStockOptunaRender(j.job); usmomStockOptunaStartPolling(); } catch (e) { alert('오류: ' + e); } } async function usmomStockOptunaStop() { if (!_usmomOptJobId) return; if (!confirm('종목 Optuna를 중지할까요?')) return; try { const r = await fetch('/api/optuna/stop/' + encodeURIComponent(_usmomOptJobId), { method: 'POST' }); const j = await r.json(); if (!j.ok) { alert(j.error || '중지 실패'); return; } usmomStockOptunaRender(j.job); if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; } } catch (e) { alert('오류: ' + e); } } async function usmomStockOptunaPollOnce() { if (!_usmomOptJobId) return; try { const r = await fetch('/api/optuna/status/' + encodeURIComponent(_usmomOptJobId)); const j = await r.json(); if (!j.ok) { usmomOptSetStatus('job 없음', true); _usmomOptJobId = ''; localStorage.removeItem(USMOM_OPT_LS_KEY); usmomOptSetButtons(false, false); const fill = $('usmom_opt_fill'); if (fill) { fill.style.width = '0%'; fill.classList.remove('is-done', 'is-error'); } if ($('usmom_opt_prog_label')) $('usmom_opt_prog_label').textContent = 'trial —'; return; } usmomStockOptunaRender(j.job); const st = j.job.status; if (st === 'done' || st === 'error') { if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; } } } catch (e) { usmomOptSetStatus('폴링 오류: ' + e, true); } } function usmomStockOptunaStartPolling() { if (_usmomOptPollTimer) clearInterval(_usmomOptPollTimer); usmomStockOptunaPollOnce(); _usmomOptPollTimer = setInterval(usmomStockOptunaPollOnce, 4000); } async function usmomStockOptunaFillForm(source, rank) { /* source/rank 없으면 학습1위(진행바 best). Top「폼」·선택 버튼에서 지정 가능. */ if (!_usmomOptJobId) { _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; } if (!_usmomOptJobId) { alert('완료된 종목 Optuna job 없음'); return; } const src = (source || 'learn').toString(); const rk = Number(rank) || 1; _usmomOptSelected = { source: src, rank: rk }; usmomOptSyncSelectedButtons(); const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase() || ($('usmom_bt_codes')?.value || '').trim().toUpperCase(); try { const qs = new URLSearchParams({ job_id: _usmomOptJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '후보 없음')); return; } window._optunaJobId = _usmomOptJobId; window._optunaCandidateCache = { strategy: j.strategy || 'us_momentum', start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, }; // 이미 🇺🇸탭 — tab.click 스킵 (핀 cfg 재로드로 덮어쓰기 방지) await optunaFillFormFromCandidate(src, rk); if (code && $('usmom_bt_codes')) $('usmom_bt_codes').value = code; if (j.start) { setDateVal('usmom_start', j.start); if ($('usmom_opt_start')) $('usmom_opt_start').value = j.start; } if (j.end) { setDateVal('usmom_end', j.end); if ($('usmom_opt_end')) $('usmom_opt_end').value = j.end; } } catch (e) { alert('폼에 넣기 오류: ' + e); } } async function usmomStockOptunaApply(source, rank) { if (!_usmomOptJobId) { _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; } if (!_usmomOptJobId) { alert('완료된 종목 Optuna job 없음'); return; } const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) { alert('종목 선택 필요'); return; } const src = (source || 'learn').toString(); const rk = Number(rank) || 1; _usmomOptSelected = { source: src, rank: rk }; usmomOptSyncSelectedButtons(); if (!confirm( `${code} ${src} #${rk} → us_momentum_stock_config?\n` + `(전역 US_MOMENTUM_* / 다단트레일 미변경)` )) return; try { const r = await fetch('/api/optuna/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: _usmomOptJobId, source: src, rank: rk, symbol: code, exchange: ($('usmom_stock_exch')?.value || 'NASD').trim(), stock_group: ($('usmom_stock_group')?.value || 'STOCK').trim(), }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; } alert( `✅ ${code} 종목행 적용 (${src} #${rk})\n` + `PnL=${j.metrics?.total_pnl} trades=${j.metrics?.total_trades}\n` + (j.apply_target || '') ); usmomLoadStocks(false); } catch (e) { alert('오류: ' + e); } } /** US_MOMENTUM_* 만 저장 — 국내 MOMENTUM_/SCALP_ 미오염 */ function saveUsMomentumConfig() { const body = { sl_pct: parseFloat($('usmom_sl')?.value), tp_pct: parseFloat($('usmom_tp')?.value), tp_max_pct: parseFloat($('usmom_tp_max')?.value), slot_money: parseInt($('usmom_slot_money')?.value || '0', 10) || 0, slots: parseInt($('usmom_slots')?.value || '4', 10) || 4, total_budget_krw: parseInt($('usmom_total_budget')?.value || '0', 10), cooldown_min: parseFloat($('usmom_cooldown')?.value || '0'), shoulder_min_high: parseFloat($('usmom_smin')?.value), shoulder_cut_pct: parseFloat($('usmom_scut')?.value), trail_pct: parseFloat($('usmom_trail')?.value || '0'), trail_arm_pct: parseFloat($('usmom_trail_arm')?.value || '0'), max_hold_bars: parseInt($('usmom_max_hold')?.value || '0', 10), ratchet_tiers: ($('usmom_ratchet')?.value || '').trim(), time_start: parseInt($('usmom_time_start')?.value || '2230', 10), time_end: parseInt($('usmom_time_end')?.value || '500', 10), sell_time_end: parseInt($('usmom_sell_time_end')?.value || '630', 10), max_daily: parseInt($('usmom_max_daily')?.value || '5', 10), high_chase_thr: parseFloat($('usmom_high_chase')?.value || '0.96'), max_daily_chg: parseFloat($('usmom_max_daily_chg')?.value || '20'), min_price: parseFloat($('usmom_min_price')?.value || '1'), max_loss_krw: parseInt($('usmom_max_loss')?.value || '0', 10), min_margin: parseFloat($('usmom_min_margin')?.value || '0.2'), use_defense_filters: !!($('usmom_use_defense')?.checked), use_high_chase_filter: !!($('usmom_use_high_chase_f')?.checked), use_daily_range_filter: !!($('usmom_use_daily_range')?.checked), use_ema_filter: !!($('usmom_use_ema_f')?.checked), use_rsi_max_filter: !!($('usmom_use_rsi_max_f')?.checked), pattern_breakout: !!($('usmom_pat_breakout')?.checked), pattern_pullback: !!($('usmom_pat_pullback')?.checked), chase_lookback_min: parseInt($('usmom_chase_lookback')?.value || '10', 10), pullback_lookback_min: parseInt($('usmom_pullback_lookback')?.value || '15', 10), pullback_min_pct: parseFloat($('usmom_pullback_min')?.value || '0.3'), pullback_max_pct: parseFloat($('usmom_pullback_max')?.value || '3.0'), setup_vol_max_mult: parseFloat($('usmom_setup_vol')?.value || '0.8'), setup_bear_bars_min: parseInt($('usmom_setup_bear')?.value || '1', 10), ema_fast_period: parseInt($('usmom_ema_fast')?.value || '9', 10), ema_slow_period: parseInt($('usmom_ema_slow')?.value || '21', 10), mom_rsi_min: parseFloat($('usmom_rsi_min')?.value), mom_rsi_max: parseFloat($('usmom_rsi_max')?.value), mom_vol_mult: parseFloat($('usmom_vol_mult')?.value), mom_vol_win: parseInt($('usmom_vol_win')?.value || '5', 10), mom_max_from_open_pct: parseFloat($('usmom_max_from_open')?.value || '999'), mom_min_from_open_pct: parseFloat($('usmom_min_from_open')?.value || '-999'), daily_trail_tiers: ($('usmom_daily_trail_tiers')?.value || '').trim(), daily_profit_mode: ($('usmom_daily_profit_mode')?.value || 'trailing'), daily_profit_enabled: !!($('usmom_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('usmom_daily_risk_budget_enabled')?.checked), eod_enabled: !!($('usmom_eod_enabled')?.checked), eod_hm: ($('usmom_eod_hm')?.value || '').trim() || '05:00', }; fetch('/api/backtest/us_momentum/save_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); alert('✅ 해외 모멘텀 설정 저장 (' + (d.saved_keys || []).length + '키 · US_MOMENTUM_* only)'); }) .catch(err => alert('해외 모멘텀 저장 오류: ' + err)); } function updateUsMomFxHint() { const apply = (fx) => { if (fx > 0) setDisplayFxRate(fx); const el = $('usmom_fx_hint'); if (!el) return; const r = getDisplayFxRate(); if (!(r > 0)) { el.textContent = '$↔원 = 표시용(캐시/폴백)'; return; } const slot = Number($('usmom_slot_money')?.value || 0); const bud = Number($('usmom_total_budget')?.value || 0); el.textContent = `FX ${r.toFixed(2)} · 1회≈${fmtWon(Math.round(slot * r))}원 · 한도≈${fmtWon(Math.round(bud * r))}원`; }; const cached = getDisplayFxRate(); if (cached > 0) apply(cached); fetch('/api/fx/usd_krw') .then(r => r.json()) .then(j => { const fx = Number(j.display_rate || 0); if (fx > 0) apply(fx); }) .catch(() => {}); } function fillUsMomentumFormFromApi(m) { if (!m || typeof m !== 'object') return; const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; set('usmom_rsi_min', m.mom_rsi_min); set('usmom_rsi_max', m.mom_rsi_max); set('usmom_vol_mult', m.mom_vol_mult); set('usmom_vol_win', m.mom_vol_win); set('usmom_time_start', m.mom_time_start_hm || m.time_start); set('usmom_time_end', m.mom_time_end_hm || m.time_end); set('usmom_sell_time_end', m.sell_time_end); if (m.eod_enabled !== undefined && $('usmom_eod_enabled')) { $('usmom_eod_enabled').checked = !!m.eod_enabled; } if (m.eod_hm != null) set('usmom_eod_hm', m.eod_hm); set('usmom_sl', m.sl_pct); set('usmom_tp', m.tp_pct); set('usmom_tp_max', m.tp_max_pct); set('usmom_smin', m.shoulder_min_high); set('usmom_scut', m.shoulder_cut_pct); if (m.trail_pct != null) set('usmom_trail', m.trail_pct); if (m.trail_arm_pct != null) set('usmom_trail_arm', m.trail_arm_pct); if (m.max_hold_bars != null) set('usmom_max_hold', m.max_hold_bars); if ($('usmom_ratchet')) $('usmom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : ''); set('usmom_cooldown', m.cooldown_min); set('usmom_max_daily', m.max_daily); set('usmom_slot_money', m.slot_money); set('usmom_slots', m.mom_slots || m.slots); if (m.total_budget_krw != null) set('usmom_total_budget', m.total_budget_krw); set('usmom_high_chase', m.high_chase_thr); set('usmom_max_daily_chg', m.max_daily_chg); set('usmom_min_price', m.min_price); set('usmom_max_loss', m.max_loss_krw); set('usmom_min_margin', m.min_margin); set('usmom_max_from_open', m.mom_max_from_open_pct); set('usmom_min_from_open', m.mom_min_from_open_pct); if ($('usmom_pat_breakout') && m.pattern_breakout !== undefined) { $('usmom_pat_breakout').checked = !!m.pattern_breakout; } if ($('usmom_pat_pullback') && m.pattern_pullback !== undefined) { $('usmom_pat_pullback').checked = !!m.pattern_pullback; } set('usmom_chase_lookback', m.chase_lookback_min); set('usmom_pullback_lookback', m.pullback_lookback_min); set('usmom_pullback_min', m.pullback_min_pct); set('usmom_pullback_max', m.pullback_max_pct); set('usmom_setup_vol', m.setup_vol_max_mult); set('usmom_setup_bear', m.setup_bear_bars_min); if (m.use_defense_filters !== undefined && $('usmom_use_defense')) { $('usmom_use_defense').checked = !!m.use_defense_filters; } if ($('usmom_use_high_chase_f') && m.use_high_chase_filter !== undefined) { $('usmom_use_high_chase_f').checked = !!m.use_high_chase_filter; } if ($('usmom_use_daily_range') && m.use_daily_range_filter !== undefined) { $('usmom_use_daily_range').checked = !!m.use_daily_range_filter; } if ($('usmom_use_ema_f') && m.use_ema_filter !== undefined) { $('usmom_use_ema_f').checked = !!m.use_ema_filter; } if ($('usmom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) { $('usmom_use_rsi_max_f').checked = !!m.use_rsi_max_filter; } if (m.ema_fast_period != null) set('usmom_ema_fast', m.ema_fast_period); if (m.ema_slow_period != null) set('usmom_ema_slow', m.ema_slow_period); if (m.daily_profit_enabled !== undefined && $('usmom_daily_profit_enabled')) { $('usmom_daily_profit_enabled').checked = !!m.daily_profit_enabled; } if (m.daily_risk_budget_enabled !== undefined && $('usmom_daily_risk_budget_enabled')) { $('usmom_daily_risk_budget_enabled').checked = !!m.daily_risk_budget_enabled; } if (m.daily_trail_tiers != null) set('usmom_daily_trail_tiers', m.daily_trail_tiers); if (m.daily_profit_mode != null) set('usmom_daily_profit_mode', m.daily_profit_mode); updateUsMomFxHint(); } /** 영구구독 US 전 종목 → KIS 해외 1분봉 → ws_candles.market=US */ function usmomFetchCandles(tf) { const start = $('usmom_start')?.value || ''; const end = $('usmom_end')?.value || ''; if (!start || !end) { alert('시작일·종료일을 먼저 넣으세요.'); return; } const st = $('usmom_fetch_status'); if (st) st.textContent = '수집 시작…'; fetch('/api/ws_candles/fetch_us', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start, end, tf: tf || 1, rollup_3m: true }), }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); const jobId = d.job_id; if (!jobId) throw new Error('job_id 없음'); const poll = () => { fetch('/api/ws_candles/fetch_us/status/' + jobId) .then(r => r.json()) .then(j => { if (st) { st.textContent = `${j.status || '?'} · ${j.current || ''} saved=${j.saved || 0}` + (j.error ? ' · ' + j.error : ''); } if (j.status === 'running') setTimeout(poll, 1500); else if (j.status === 'done' && st) { st.textContent = `완료 · 종목 ${j.codes || 0} · 저장 ${j.saved || 0}봉` + (j.rollup_3m ? ` · 3m롤업 ${j.rollup_3m}` : ''); } }) .catch(e => { if (st) st.textContent = '폴링 오류: ' + e; }); }; poll(); }) .catch(err => { if (st) st.textContent = '실패: ' + err; alert('US 봉 수집 오류: ' + err); }); } function saveMomentumConfig() { const body = { sl_pct: parseFloat($('mom_sl').value), tp_pct: parseFloat($('mom_tp').value), tp_max_pct: parseFloat($('mom_tp_max').value), slot_money: parseInt($('mom_slot_money').value, 10) || 0, slots: parseInt($('mom_slots').value, 10) || 3, total_budget_krw: parseInt($('mom_total_budget')?.value || '0', 10), cooldown_min: parseFloat($('mom_cooldown').value), shoulder_min_high: parseFloat($('mom_smin').value), shoulder_cut_pct: parseFloat($('mom_scut').value), trail_pct: parseFloat($('mom_trail')?.value || '0'), trail_arm_pct: parseFloat($('mom_trail_arm')?.value || '0'), max_hold_bars: parseInt($('mom_max_hold')?.value || '0', 10), ratchet_tiers: ($('mom_ratchet')?.value || '').trim(), time_start: parseInt($('mom_time_start').value, 10), max_daily: parseInt($('mom_max_daily').value, 10), high_chase_thr: parseFloat($('mom_high_chase').value), max_daily_chg: parseFloat($('mom_max_daily_chg').value), min_price: parseFloat($('mom_min_price').value), max_loss_krw: parseInt($('mom_max_loss_krw').value, 10), min_margin: parseFloat($('mom_min_margin').value), use_defense_filters: !!($('mom_use_defense')?.checked), use_high_chase_filter: !!($('mom_use_high_chase_f')?.checked), use_daily_range_filter: !!($('mom_use_daily_range')?.checked), use_ema_filter: !!($('mom_use_ema_f')?.checked), use_rsi_max_filter: !!($('mom_use_rsi_max_f')?.checked), pattern_breakout: !!($('mom_pat_breakout')?.checked), pattern_pullback: !!($('mom_pat_pullback')?.checked), chase_lookback_min: parseInt($('mom_chase_lookback')?.value || '10', 10), pullback_lookback_min: parseInt($('mom_pullback_lookback')?.value || '15', 10), pullback_min_pct: parseFloat($('mom_pullback_min')?.value || '0.3'), pullback_max_pct: parseFloat($('mom_pullback_max')?.value || '3.0'), setup_vol_max_mult: parseFloat($('mom_setup_vol')?.value || '0.8'), setup_bear_bars_min: parseInt($('mom_setup_bear')?.value || '1', 10), ema_fast_period: parseInt($('mom_ema_fast')?.value || '9', 10), ema_slow_period: parseInt($('mom_ema_slow')?.value || '21', 10), mom_rsi_min: parseFloat($('mom_rsi_min').value), mom_rsi_max: parseFloat($('mom_rsi_max').value), mom_vol_mult: parseFloat($('mom_vol_mult').value), mom_vol_win: parseInt($('mom_vol_win').value, 10), e_min_chg_pct: parseFloat($('mom_e_min_chg')?.value || '0.2'), mom_time_end: parseInt($('mom_time_end_buy').value, 10), mom_max_from_open_pct: parseFloat($('mom_max_from_open')?.value || '999'), mom_min_from_open_pct: parseFloat($('mom_min_from_open')?.value || '-999'), ob_filter: !!($('mom_ob_filter')?.checked), pg_filter: !!($('mom_pg_filter')?.checked), daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing', daily_profit_enabled: !!($('mom_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('mom_daily_risk_budget_enabled')?.checked), eod_enabled: !!($('mom_eod_enabled')?.checked), eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', backtest_skip_pre_subscribe: !!($('mom_skip_pre_sub')?.checked), }; if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) return; fetch('/api/backtest/momentum/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } btAddPresetOption('mom_daily_trail_preset', $('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value); const byTbl = d.saved_by_table || {}; const tblLines = Object.entries(byTbl).map(([t, ks]) => ` ${t}: ${ks.join(', ')}`).join('\n'); alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`); }).catch(e => alert('오류: ' + e)); } function renderMomentumBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음')); return; } if (!d.summary) { alert('백테스트 응답 오류 (summary 없음)'); return; } const s = d.summary || {}; const trades = d.trades || []; const p = d.params || {}; const momRa = $('mom_result_area'); if (momRa) momRa.style.display = 'block'; const momPb = $('mom_params_bar'); if (momPb) momPb.style.display = 'block'; const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; const momUnivLabel = formatUniverseLabel(p); const effTp = p.effective_tp_pct != null ? p.effective_tp_pct : p.tp_pct; const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : ''; const patParts = []; if (p.pattern_breakout) patParts.push('돌파'); if (p.pattern_pullback) patParts.push('눌림'); const patLabel = patParts.length ? patParts.join('+') : '패턴OFF'; const rsiLabel = p.use_rsi_max_filter ? `RSI ${p.mom_rsi_min}~${p.mom_rsi_max}` : `RSI≥${p.mom_rsi_min}`; const momSummaryLine = `${momUnivLabel} ` + `${patLabel}(${p.chase_lookback_min || '—'}분) | ${rsiLabel} | 거래량×${p.mom_vol_mult} | ` + `손절-${p.sl_pct}% | 어깨(${p.shoulder_min_high}%/${p.shoulder_cut_pct}%)` + `${p.ratchet_tiers ? '·래칫' + p.ratchet_tiers : ''} | ` + `익절상한+${effTp}%${periodHint} | ` + `EMA ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` + `1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` + `쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | 종목수 ${p.codes_analyzed || '—'}개`; if (momPb) momPb.innerHTML = momSummaryLine; const momCtx = $('mom_trade_context'); if (momCtx) { fillTradePnLContext(momCtx, { label: '백테 MOMENTUM', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } setTxt('mom_total', (s.total_trades || 0) + '건'); setTxt('mom_winrate', (s.win_rate || 0) + '%'); colorPnl($('mom_winrate'), (s.win_rate || 0) - 50); setTxt('mom_pnl', fmtKrw(s.total_pnl)); colorPnl($('mom_pnl'), s.total_pnl); setTxt('mom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('mom_pf'), (s.profit_factor || 0) - 1); setTxt('mom_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('mom_hold', (s.avg_hold_min || 0) + '분'); const momSign = v => (v > 0 ? '+' : ''); setTxt('mom_bot_pct', momSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('mom_bot_pct'), s.bot_pct || 0); setTxt('mom_daily_avg_pct', momSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('mom_daily_avg_pct'), s.daily_avg_pct || 0); if (s.budget_warning) { if (momPb) momPb.innerHTML += `
💰 ${s.budget_warning}
`; if (momCtx) momCtx.innerHTML += `
💰 ${s.budget_warning}
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning ? `거래 0건 — ${s.budget_warning}` : '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (총한도 < 동시×1회투자 이면 체결 불가)'; const zhtml = `
⚠️ ${zmsg}
`; if (momPb) momPb.innerHTML += zhtml; if (momCtx) momCtx.innerHTML += zhtml; } if (s.universe_warning) { if (momPb) momPb.innerHTML += `
⚠️ ${s.universe_warning}
`; if (momCtx) momCtx.innerHTML += `
⚠️ ${s.universe_warning}
`; } const tickMeta = s.tick_backtest || {}; const skipStats = s.skip_stats || {}; if (tickMeta.tick_bar_coverage_pct != null || skipStats.tick_exit_count != null) { const covLabel = fmtTickCoverageLabel(tickMeta); const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—'; const te = skipStats.tick_entry_count != null ? skipStats.tick_entry_count : '—'; const tx = skipStats.tick_exit_count != null ? skipStats.tick_exit_count : '—'; const oe = skipStats.ohlc_entry_count != null ? skipStats.ohlc_entry_count : '—'; const ox = skipStats.ohlc_exit_count != null ? skipStats.ohlc_exit_count : '—'; const tickLine = `📊 ws_ticks ${rows}건 · 분봉커버 ${covLabel} | ` + `진입 틱${te}/시가${oe} · 청산 틱${tx}/OHLC${ox}`; if (momPb) momPb.innerHTML += `
${tickLine}
`; if (momCtx) momCtx.innerHTML += `
${tickLine}
`; } if (skipStats.buy_queue_mode) { const qm = skipStats.buy_queue_mode === 'live_scan' ? `매수큐 실매형 ${skipStats.scan_sec || 10}초 · 스캔${skipStats.scan_events ?? '—'} · 매수${skipStats.scan_buys ?? '—'}` : '매수큐 분봉(레거시)'; const um = skipStats.universe_mode === 'scan_at' ? `유니버스 스캔시각·디바운스${skipStats.universe_debounce_sec ?? 30}초` : (skipStats.universe_mode === 'minute_slot' ? '유니버스 분슬롯' : ''); const line = um ? `🔄 ${qm} | ${um}` : `🔄 ${qm}`; if (momPb) momPb.innerHTML += `
${line}
`; if (momCtx) momCtx.innerHTML += `
${line}
`; } const peakCum = Number(s.peak_cum_pnl || 0); const peakAt = fmtTradeTime(s.peak_cum_at || ''); if (peakCum > 0 && peakAt) { const peakLine = `💡 장중 누적 최고 +${peakCum.toLocaleString()}원 (${peakAt})` + ` · 최종 ${fmtKrw(s.total_pnl)}`; if (momPb) momPb.innerHTML += `
${peakLine}
`; } const winbar = $('mom_winbar_card'); if ((s.total_trades || 0) > 0) { if (winbar) winbar.style.display = 'block'; const wr = s.win_rate || 0; setTxt('mom_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); setTxt('mom_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); const rg = $('mom_ratio_g'); if (rg) rg.style.width = wr + '%'; const rr = $('mom_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%'; } else if (winbar) { winbar.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('mom_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); } else { destroyChart('mom_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('mom_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('mom_reason_chart'); } if (d.daily && d.daily.length) { barChart('mom_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else { destroyChart('mom_daily_chart'); } } catch (chartErr) { console.error('mom chart render', chartErr); } renderVirtualTrades('mom_tbody', trades || [], { showDebug: true, showCumulative: true, totalBudget: Number(p.total_budget_krw || 0), }); if (momRa) momRa.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function saveScalpConfig() { const body = { save_kind: 'reversal', rsi_period: parseInt($('bt_rsi_period')?.value || '3', 10), rsi_oversold: parseFloat($('bt_rsi_oversold').value), rsi_overbought: parseFloat($('bt_rsi_overbought')?.value || 75), sl_pct: parseFloat($('bt_sl').value), tp_pct: parseFloat($('bt_tp').value), tp_max_pct: parseFloat($('bt_tp_max').value), drop_rate: parseFloat($('bt_drop').value), vol_mult: parseFloat($('bt_vol_mult')?.value || '0'), shoulder_min_high: parseFloat($('bt_smin').value), shoulder_cut_pct: parseFloat($('bt_scut').value), cooldown_min: parseFloat($('bt_cooldown').value), high_chase_thr: parseFloat($('bt_high_chase').value), max_daily_chg: parseFloat($('bt_max_daily_chg').value), min_price: parseFloat($('bt_min_price').value), max_loss_krw: parseFloat($('bt_max_loss_krw').value), min_margin: parseFloat($('bt_min_margin').value), use_defense_filters: !!($('bt_use_defense')?.checked), use_macd_cross: !!($('bt_use_macd')?.checked), time_start_hm: parseInt($('bt_time_start')?.value || '830', 10), time_end_hm: parseInt($('bt_time_end')?.value || '1530', 10), max_daily: parseInt($('bt_max_daily')?.value || '3', 10), skip_hts_scan_dupes: !!($('bt_skip_hts_dupes')?.checked), require_reversal_candle: !!($('bt_require_reversal')?.checked), slot_money: parseFloat($('bt_slot')?.value || '0'), max_stocks: parseInt($('bt_max_stocks')?.value || '3', 10), total_budget_krw: parseFloat($('bt_total_budget')?.value || '0'), eod_enabled: !!($('bt_eod_enabled')?.checked), eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25', }; if (!confirm(`💾 스캘핑 봇에 아래 파라미터를 저장합니까?\n\n` + `RSI기간: ${body.rsi_period} / 과매도: ${body.rsi_oversold} / 과열: ${body.rsi_overbought}\n` + `손절: ${body.sl_pct}% / 익절: ${body.tp_pct}% / 낙폭: ${body.drop_rate}% / 거래량×: ${body.vol_mult}\n` + `EOD: ${body.eod_enabled ? 'ON' : 'OFF'} ${body.eod_hm}\n` + `\n⚠️ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return; fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } alert(`✅ 저장 완료 (env_id: ${d.env_id})\n저장 키: ${d.saved_keys?.join(', ')}`); }).catch(e => alert('오류: ' + e)); } // 방어 ON/OFF 토글은 클릭 즉시 DB 저장 (실시간 반영) async function saveScalpDefenseRealtime() { const enabled = !!($('bt_use_defense')?.checked); try { const r = await fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ use_defense_filters: enabled }), }); const d = await r.json(); if (d.error) { alert('❌ 방어로직 저장 실패: ' + d.error); return; } // DB 저장만 즉시 반영 — 날짜·파라미터 조정 후 「백테스트 실행」으로 확인 } catch (e) { alert('오류: ' + e); } } async function saveScalpMacdRealtime() { const enabled = !!($('bt_use_macd')?.checked); try { const r = await fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ use_macd_cross: enabled }), }); const d = await r.json(); if (d.error) { alert('❌ MACD 모드 저장 실패: ' + d.error); return; } // MACD 백테는 reversal 보다 무거움 — 저장만 즉시 반영, 백테는 사용자가 실행 } catch (e) { alert('오류: ' + e); } } function fillScalpFormFromApi(s) { if (!s || typeof s !== 'object') return; const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; set('bt_rsi_oversold', s.rsi_oversold); set('bt_rsi_overbought', s.rsi_overbought); set('bt_sl', s.sl_pct); set('bt_tp', s.tp_pct); set('bt_tp_max', s.tp_max_pct); set('bt_drop', s.drop_rate); set('bt_smin', s.shoulder_min_high); set('bt_scut', s.shoulder_cut_pct); set('bt_cooldown', s.cooldown_min); if (s.slot_money) set('bt_slot', s.slot_money); if (s.high_chase_thr != null) set('bt_high_chase', s.high_chase_thr); if (s.max_daily_chg != null) set('bt_max_daily_chg', s.max_daily_chg); if (s.min_price != null) set('bt_min_price', s.min_price); if (s.max_loss_krw != null) set('bt_max_loss_krw', s.max_loss_krw); if (s.min_margin != null) set('bt_min_margin', s.min_margin); if (s.use_defense_filters !== undefined && $('bt_use_defense')) { const v = String(s.use_defense_filters).trim().toLowerCase(); $('bt_use_defense').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on'); } if (s.use_macd_cross !== undefined && $('bt_use_macd')) { const v = String(s.use_macd_cross).trim().toLowerCase(); $('bt_use_macd').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on'); } set('bt_rsi_period', s.rsi_period); set('bt_vol_mult', s.vol_mult); set('bt_time_start', s.time_start_hm); set('bt_time_end', s.time_end_hm); set('bt_max_daily', s.max_daily); if (s.max_stocks != null) set('bt_max_stocks', s.max_stocks); if (s.total_budget_krw != null) set('bt_total_budget', s.total_budget_krw); if ($('bt_skip_hts_dupes')) $('bt_skip_hts_dupes').checked = s.skip_hts_scan_dupes !== false; if ($('bt_require_reversal')) { const rv = s.require_reversal_candle; $('bt_require_reversal').checked = rv !== false && rv !== 0 && rv !== '0' && rv !== 'false'; } if (s.eod_enabled !== undefined && $('bt_eod_enabled')) { const ev = String(s.eod_enabled).trim().toLowerCase(); $('bt_eod_enabled').checked = (ev === '1' || ev === 'true' || ev === 'y' || ev === 'yes' || ev === 'on' || s.eod_enabled === true); } if (s.eod_hm != null) set('bt_eod_hm', s.eod_hm); // 호가·휩쏘 읽기전용 (Optuna 적용값 확인) fillObWhipReadonly('bt', s); } /** 백테 탭 호가·익절·손절·휩쏘 읽기전용 채우기. prefix= bt|mom|tl|bo */ function fillObWhipReadonly(prefix, s) { if (!s || typeof s !== 'object') return; const p = prefix || 'bt'; const on = (id, v) => { const el = $(id); if (el) el.checked = !!v; }; const num = (id, v) => { if (v !== null && v !== undefined && $(id)) $(id).value = v; }; // 익절·손절 (스캘핑 RO 박스 / 상단 폼 동기) const sl = s.sl_pct != null ? s.sl_pct : s.stop_loss_pct; const tp = s.tp_pct != null ? s.tp_pct : s.take_profit_pct; const tpMax = s.tp_max_pct != null ? s.tp_max_pct : s.take_profit_max_pct; if (p === 'bt') { num('bt_sl_ro', sl); num('bt_tp_ro', tp); num('bt_tp_max_ro', tpMax); num('bt_sl', sl); num('bt_tp', tp); num('bt_tp_max', tpMax); } const obOn = s.ob_filter_enabled != null ? s.ob_filter_enabled : s.orderbook_filter_enabled; const spread = s.max_spread_pct != null ? s.max_spread_pct : s.orderbook_max_spread_pct; const ratio = s.ob_min_bid_ask_ratio != null ? s.ob_min_bid_ask_ratio : (s.min_bid_ask_ratio != null ? s.min_bid_ask_ratio : s.orderbook_min_bid_ask_ratio); const ask = s.ob_ask_max_mult != null ? s.ob_ask_max_mult : (s.ask_max_mult != null ? s.ask_max_mult : s.orderbook_entry_ask_max_mult); const whipOn = s.whipsaw_filter_enabled != null ? s.whipsaw_filter_enabled : s.whipsaw_enabled; on(`${p}_ob_ro_on`, obOn); num(`${p}_ob_ro_spread`, spread); num(`${p}_ob_ro_ratio`, ratio); num(`${p}_ob_ro_ask`, ask); on(`${p}_whip_ro_on`, whipOn); num(`${p}_whip_ro_sub`, s.whipsaw_subbar_sec != null ? s.whipsaw_subbar_sec : s.whipsaw_sub_sec); num(`${p}_whip_ro_lb`, s.whipsaw_lookback_sec != null ? s.whipsaw_lookback_sec : s.whipsaw_lb_sec); num(`${p}_whip_ro_dip`, s.whipsaw_dip_pct != null ? s.whipsaw_dip_pct : s.whipsaw_dip); const note = $(`${p}_ob_whip_ro_note`); if (note) { const bits = []; if (sl != null) bits.push(`손절${sl}%`); if (tp != null) bits.push(`익절${tp}%`); if (tpMax != null) bits.push(`상한${tpMax}%`); bits.push(obOn ? '호가ON' : '호가OFF'); if (spread != null) bits.push(`스프레드≤${spread}`); if (ratio != null) bits.push(`잔량≥${ratio}`); if (ask != null) bits.push(`ask×≤${ask}`); bits.push(whipOn ? '휩쏘ON' : '휩쏘OFF'); if (whipOn) { bits.push(`sub=${s.whipsaw_subbar_sec ?? '?'} lb=${s.whipsaw_lookback_sec ?? '?'} dip=${s.whipsaw_dip_pct ?? '?'}`); } note.textContent = 'DB: ' + bits.join(' · '); } } function fillBreakoutFormFromApi(b) { if (!b || typeof b !== 'object') return; const setM = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; setM('bo_lookback', b.lookback_min); setM('bo_vol_win', b.vol_window); setM('bo_vol_mult', b.vol_mult); setM('bo_min_turnover', b.min_turnover_1m_pct); setM('bo_prev_min', b.prev_chg_min); setM('bo_prev_max', b.prev_chg_max); if (b.entry_mode && $('bo_entry_mode')) { $('bo_entry_mode').value = String(b.entry_mode).toLowerCase(); } setM('bo_intrabar_slip', b.intrabar_slippage_pct); if (b.sl_mode && $('bo_sl_mode')) { $('bo_sl_mode').value = String(b.sl_mode).toLowerCase(); } setM('bo_sl', b.sl_pct); if (b.atr_period != null) setM('bo_atr_period', b.atr_period); if (b.atr_sl_mult != null) setM('bo_atr_sl_mult', b.atr_sl_mult); if (b.atr_sl_min_pct != null) setM('bo_atr_sl_min', b.atr_sl_min_pct); if (b.atr_sl_max_pct != null) setM('bo_atr_sl_max', b.atr_sl_max_pct); if (typeof boSyncSlModeColors === 'function') boSyncSlModeColors(); setM('bo_tp', b.tp_pct); setM('bo_trail', b.trail_pct); if (b.trail_arm_pct != null) setM('bo_trail_arm', b.trail_arm_pct); setM('bo_shoulder_smin', b.shoulder_min_high_pct); setM('bo_shoulder_scut', b.shoulder_cut_pct); setM('bo_time_start', b.time_start_hm); setM('bo_time_end', b.time_end_hm); if (b.eod_enabled !== undefined && $('bo_eod_enabled')) { $('bo_eod_enabled').checked = !!b.eod_enabled; } if (b.eod_hm != null) setM('bo_eod_hm', b.eod_hm); if (b.max_hold_bars != null) setM('bo_max_hold', b.max_hold_bars); setM('bo_max_daily', b.max_daily); setM('bo_cooldown', b.cooldown_min); setM('bo_max_chg', b.max_daily_chg); setM('bo_min_price', b.min_price); if (b.confirm_margin_pct != null) setM('bo_confirm_margin', b.confirm_margin_pct); if (b.body_min_pct != null) setM('bo_body_min', b.body_min_pct); if ($('bo_ratchet')) $('bo_ratchet').value = (b.ratchet_tiers != null ? String(b.ratchet_tiers) : ''); setM('bo_max_loss', b.max_loss_krw); if (b.use_ema_filter !== undefined && $('bo_use_ema_f')) { $('bo_use_ema_f').checked = !!b.use_ema_filter; } if (b.ema_fast_period != null) setM('bo_ema_fast', b.ema_fast_period); if (b.ema_slow_period != null) setM('bo_ema_slow', b.ema_slow_period); if ($('bo_skip_hts_dupes')) $('bo_skip_hts_dupes').checked = !!b.skip_hts_scan_dupes; // DB 실효값 — HTML 기본 checked 금지. 미수신 시 OFF. if ($('bo_ob_filter')) { $('bo_ob_filter').checked = !!b.ob_filter_enabled; } if (b.max_spread_pct != null && $('bo_max_spread_pct')) { $('bo_max_spread_pct').value = b.max_spread_pct; } if ($('bo_pg_filter')) { $('bo_pg_filter').checked = !!b.pg_filter_enabled; } setM('bo_slot', b.slot_money); if (b.max_stocks != null) setM('bo_max_stocks', b.max_stocks); if (b.total_budget_krw != null) setM('bo_total_budget', b.total_budget_krw); if (b.daily_profit_enabled !== undefined && $('bo_daily_profit_enabled')) { $('bo_daily_profit_enabled').checked = !!b.daily_profit_enabled; } if (b.daily_risk_budget_enabled !== undefined && $('bo_daily_risk_budget_enabled')) { $('bo_daily_risk_budget_enabled').checked = !!b.daily_risk_budget_enabled; } if ($('bo_daily_trail_tiers')) { $('bo_daily_trail_tiers').value = (b.daily_trail_tiers != null ? String(b.daily_trail_tiers) : ''); } if (b.daily_profit_mode && $('bo_daily_profit_mode')) { $('bo_daily_profit_mode').value = b.daily_profit_mode; } btFillPresetOptions('bo_daily_trail_preset', b.daily_trail_presets); } function fillTailFormFromApi(t) { if (!t || typeof t !== 'object') return; const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; set('tl_drop', t.drop); set('tl_rec', t.rec); set('tl_tail', t.tail_ratio); set('tl_sl', t.sl_pct); set('tl_tp', t.tp_pct); set('tl_smin', t.smin); set('tl_scut', t.scut); if ($('tl_ratchet')) $('tl_ratchet').value = (t.ratchet_tiers != null ? String(t.ratchet_tiers) : ''); if (t.trail_pct != null) set('tl_trail', t.trail_pct); if (t.trail_arm_pct != null) set('tl_trail_arm', t.trail_arm_pct); set('tl_cool', t.cool); set('tl_rsi', t.rsi); set('tl_ts', t.time_start); set('tl_te', t.time_end); if (t.eod_enabled !== undefined && $('tl_eod_enabled')) { $('tl_eod_enabled').checked = !!t.eod_enabled; } if (t.eod_hm != null) set('tl_eod_hm', t.eod_hm); set('tl_maxd', t.max_daily); if (t.symbol_daily_loss_limit_krw != null) set('tl_symbol_loss_krw', t.symbol_daily_loss_limit_krw); if (t.symbol_daily_loss_limit_pct != null) set('tl_symbol_loss_pct', t.symbol_daily_loss_limit_pct); if (t.reentry_min_edge_krw != null) set('tl_reentry_min_edge', t.reentry_min_edge_krw); set('tl_rsi_period', t.rsi_period); set('tl_tail_pct', t.tail_pct_min); set('tl_max_rec_3m', t.max_rec_3m); set('tl_high_chase', t.high_chase); set('tl_min_price', t.min_price); set('tl_max_daily_change', t.max_daily_change); set('tl_ma20_above', t.ma20_max_above); set('tl_max_loss_krw', t.max_loss_krw); if (t.min_drop_pct_for_loss_cut != null) set('tl_min_drop_loss_cut', t.min_drop_pct_for_loss_cut); set('tl_stop_atr', t.stop_atr_mult); set('tl_target_atr', t.target_atr_mult); set('tl_atr_sl_min', t.atr_sl_min_pct); set('tl_atr_sl_max', t.atr_sl_max_pct); set('tl_atr_tp_min', t.atr_tp_min_pct); set('tl_atr_tp_max', t.atr_tp_max_pct); if (t.slot_money) set('tl_slot', t.slot_money); if (t.max_stocks != null) set('tl_max_stocks', t.max_stocks); if (t.total_budget_krw != null) set('tl_total_budget', t.total_budget_krw); if (t.entry_mode && $('tl_entry_mode')) $('tl_entry_mode').value = String(t.entry_mode).toLowerCase(); set('tl_limit_atr_mult', t.limit_atr_mult); if (t.limit_anchor && $('tl_limit_anchor')) $('tl_limit_anchor').value = String(t.limit_anchor); set('tl_limit_valid_bars', t.limit_valid_bars); set('tl_limit_fill_slip', t.limit_fill_slip_pct); if ($('tl_skip_hts_dupes')) $('tl_skip_hts_dupes').checked = t.skip_hts_scan_dupes !== false; if ($('tl_use_rsi_filter')) $('tl_use_rsi_filter').checked = t.use_rsi_filter !== false; if ($('tl_use_ma20_filter')) $('tl_use_ma20_filter').checked = !!t.use_ma20_filter; if ($('tl_use_daily_range')) $('tl_use_daily_range').checked = t.use_daily_range_filter !== false; if ($('tl_use_high_chase_f')) $('tl_use_high_chase_f').checked = t.use_high_chase_filter !== false; if ($('tl_use_intraday_drop')) $('tl_use_intraday_drop').checked = !!t.use_intraday_drop; if (t.bar_chg_min_pct != null) set('tl_bar_chg_min', t.bar_chg_min_pct); if (t.bar_chg_max_pct != null) set('tl_bar_chg_max', t.bar_chg_max_pct); if (t.tail_vol_mult != null) set('tl_tail_vol_mult', t.tail_vol_mult); if (t.tail_vol_win != null) set('tl_tail_vol_win', t.tail_vol_win); if ($('tl_use_tick_db')) $('tl_use_tick_db').checked = t.backtest_use_tick_db !== false; if ($('tl_tick_fallback_ohlc')) $('tl_tick_fallback_ohlc').checked = !!t.backtest_tick_fallback_ohlc; if ($('tl_pat_hammer')) $('tl_pat_hammer').checked = t.pattern_hammer !== false; if ($('tl_pat_pin')) $('tl_pat_pin').checked = !!t.pattern_pin; if ($('tl_pat_engulfing')) $('tl_pat_engulfing').checked = !!t.pattern_engulfing; if ($('tl_pat_piercing')) $('tl_pat_piercing').checked = !!t.pattern_piercing; if ($('tl_pat_harami')) $('tl_pat_harami').checked = !!t.pattern_harami; if ($('tl_pat_doji')) $('tl_pat_doji').checked = !!t.pattern_doji; if ($('tl_pat_morning_star')) $('tl_pat_morning_star').checked = !!t.pattern_morning_star; // DB 실효값 — HTML 기본 checked 금지. 미수신 시 OFF. if ($('tl_ob_filter')) { $('tl_ob_filter').checked = !!t.ob_filter_enabled; } if (t.max_spread_pct != null && $('tl_max_spread_pct')) { $('tl_max_spread_pct').value = t.max_spread_pct; } if ($('tl_pg_filter')) { $('tl_pg_filter').checked = !!t.pg_filter_enabled; } // 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값·방식 복원 if (t.daily_profit_enabled !== undefined && $('tl_daily_profit_enabled')) { $('tl_daily_profit_enabled').checked = !!t.daily_profit_enabled; } if (t.daily_risk_budget_enabled !== undefined && $('tl_daily_risk_budget_enabled')) { $('tl_daily_risk_budget_enabled').checked = !!t.daily_risk_budget_enabled; } if ($('tl_daily_trail_tiers')) { $('tl_daily_trail_tiers').value = (t.daily_trail_tiers != null ? String(t.daily_trail_tiers) : ''); } if (t.daily_profit_mode && $('tl_daily_profit_mode')) { $('tl_daily_profit_mode').value = t.daily_profit_mode; } if ($('tl_daily_trail_arm') && t.daily_trail_arm_krw != null) { $('tl_daily_trail_arm').value = String(t.daily_trail_arm_krw); } if ($('tl_daily_trail_drop') && t.daily_trail_drop_pct != null) { $('tl_daily_trail_drop').value = String(t.daily_trail_drop_pct); } // DB(env)에 저장된 사용자 프리셋 목록(세미콜론) → 드롭다운 ★옵션 복원 btFillPresetOptions('tl_ratchet_preset', t.ratchet_presets); btFillPresetOptions('tl_daily_trail_preset', t.daily_trail_presets); tlSyncRatchetShoulderColors(); } // 파람서치 그리드 프리셋 드롭다운 → 텍스트 입력칸 채우기. // '__custom__'(직접입력)은 무시, 'off'는 빈칸(=단일 어깨/미사용)으로 채운다. function btApplyPreset(targetId, val, syncColors) { if (val === '__custom__') return; const el = document.getElementById(targetId); if (!el) return; el.value = (val === 'off') ? '' : val; if (syncColors && typeof tlSyncRatchetShoulderColors === 'function') { tlSyncRatchetShoulderColors(); } } // ── 커스텀 프리셋(직접입력값) 드롭다운 ─ DB(env) 영구 저장본 기반 ────────── // 봇에 설정저장하면 서버가 BT_RATCHET_PRESETS / BT_DAILY_TRAIL_PRESETS(세미콜론 목록)에 // 값을 누적 저장하고, 페이지 로드 시 /api/env/params 응답으로 내려준다. // 여기서는 그 목록을 드롭다운에 ★옵션으로 채우는 일만 한다. // 드롭다운에 값 하나를 ★옵션으로 추가(중복·빈값 제외) function btAddPresetOption(selId, val) { const sel = document.getElementById(selId); if (!sel) return; const v = String(val || '').trim(); if (!v) return; for (const opt of sel.options) { if (opt.value === v) return; } const o = document.createElement('option'); o.value = v; o.textContent = '★ ' + v; sel.appendChild(o); } // 세미콜론 구분 목록 문자열을 드롭다운에 일괄 채움 function btFillPresetOptions(selId, listStr) { if (!listStr) return; for (const v of String(listStr).split(';')) { btAddPresetOption(selId, v); } } // 돌파 손절모드 색상 — sl_mode=fixed vs atr 배타. atr ON 이면 고정 손절(%) 빨강(무시), atr OFF 이면 ATR 필드 빨강. function boSyncSlModeColors() { const modeEl = $('bo_sl_mode'); if (!modeEl) return; const slEl = $('bo_sl'); const atrIds = ['bo_atr_period', 'bo_atr_sl_mult', 'bo_atr_sl_min', 'bo_atr_sl_max']; const atrOn = String(modeEl.value || 'fixed').toLowerCase() === 'atr'; const RED = '#f85149'; const paint = (el, inactive) => { if (!el) return; if (inactive) { el.style.setProperty('border-color', RED, 'important'); el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important'); el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important'); } else { el.style.removeProperty('border-color'); el.style.removeProperty('background-color'); el.style.removeProperty('box-shadow'); } }; paint(slEl, atrOn); atrIds.forEach(id => paint($(id), !atrOn)); } // 래칫/어깨컷 입력칸 색상 토글 — 청산 엔진은 둘이 배타(if/else)다. // 래칫 값이 있으면 어깨컷(어깨발동·어깨폭)은 실행 안 됨 → 어깨컷 칸을 빨강(무시됨 표시). // 래칫이 비면 래칫 칸을 빨강(OFF), 어깨컷은 원래색(이때 어깨컷이 1순위 트레일). function tlSyncRatchetShoulderColors() { const r = $('tl_ratchet'); if (!r) return; const smin = $('tl_smin'); const scut = $('tl_scut'); const ratchetOn = !!(r.value && String(r.value).trim()); const RED = '#f85149'; // CSS .form-control 가 border 를 !important 로 강제하므로 인라인도 important 로 줘야 이김 const paint = (el, on) => { if (!el) return; if (on) { el.style.setProperty('border-color', RED, 'important'); el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important'); el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important'); } else { el.style.removeProperty('border-color'); el.style.removeProperty('background-color'); el.style.removeProperty('box-shadow'); } el.title = on ? (el === r ? '래칫 OFF — 현재 어깨컷이 1순위 트레일로 동작' : '래칫 ON — 이 어깨컷 값은 무시됨(래칫이 1순위 트레일)') : (el.getAttribute('data-orig-title') || el.title); }; // 래칫 ON → 어깨컷 빨강 / 래칫 OFF → 래칫 빨강 paint(smin, ratchetOn); paint(scut, ratchetOn); paint(r, !ratchetOn); } function fillMomentumFormFromApi(m) { if (!m || typeof m !== 'object') return; const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; set('mom_rsi_min', m.mom_rsi_min); set('mom_rsi_max', m.mom_rsi_max); set('mom_vol_mult', m.mom_vol_mult); set('mom_vol_win', m.mom_vol_win); set('mom_e_min_chg', m.e_min_chg_pct); set('mom_time_end_buy', m.mom_time_end_hm); set('mom_time_start', m.mom_time_start_hm); if (m.eod_enabled !== undefined && $('mom_eod_enabled')) { $('mom_eod_enabled').checked = !!m.eod_enabled; } if (m.backtest_skip_pre_subscribe !== undefined && $('mom_skip_pre_sub')) { $('mom_skip_pre_sub').checked = !!m.backtest_skip_pre_subscribe; } if (m.eod_hm != null) set('mom_eod_hm', m.eod_hm); set('mom_sl', m.sl_pct); set('mom_tp', m.tp_pct); set('mom_tp_max', m.tp_max_pct); set('mom_smin', m.shoulder_min_high); set('mom_scut', m.shoulder_cut_pct); if (m.trail_pct != null) set('mom_trail', m.trail_pct); if (m.trail_arm_pct != null) set('mom_trail_arm', m.trail_arm_pct); if (m.max_hold_bars != null) set('mom_max_hold', m.max_hold_bars); if ($('mom_ratchet')) $('mom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : ''); set('mom_cooldown', m.cooldown_min); set('mom_max_daily', m.max_daily); set('mom_slot_money', m.slot_money); set('mom_slots', m.mom_slots); if (m.total_budget_krw != null) set('mom_total_budget', m.total_budget_krw); const momPw = $('mom_portfolio_warn'); if (momPw) { if (m.portfolio_ui_warning) { momPw.textContent = '⚠️ ' + m.portfolio_ui_warning; momPw.style.display = 'block'; } else { momPw.style.display = 'none'; momPw.textContent = ''; } } set('mom_high_chase', m.high_chase_thr); set('mom_max_daily_chg', m.max_daily_chg); set('mom_min_price', m.min_price); set('mom_max_loss_krw', m.max_loss_krw); set('mom_min_margin', m.min_margin); set('mom_max_from_open', m.mom_max_from_open_pct); set('mom_min_from_open', m.mom_min_from_open_pct); if ($('mom_pat_breakout') && m.pattern_breakout !== undefined) { $('mom_pat_breakout').checked = !!m.pattern_breakout; } if ($('mom_pat_pullback') && m.pattern_pullback !== undefined) { $('mom_pat_pullback').checked = !!m.pattern_pullback; } set('mom_chase_lookback', m.chase_lookback_min); set('mom_pullback_lookback', m.pullback_lookback_min); set('mom_pullback_min', m.pullback_min_pct); set('mom_pullback_max', m.pullback_max_pct); set('mom_setup_vol', m.setup_vol_max_mult); set('mom_setup_bear', m.setup_bear_bars_min); if (m.use_defense_filters !== undefined && $('mom_use_defense')) { $('mom_use_defense').checked = !!m.use_defense_filters; } if ($('mom_use_high_chase_f') && m.use_high_chase_filter !== undefined) { $('mom_use_high_chase_f').checked = !!m.use_high_chase_filter; } if ($('mom_use_daily_range') && m.use_daily_range_filter !== undefined) { $('mom_use_daily_range').checked = !!m.use_daily_range_filter; } if ($('mom_use_ema_f') && m.use_ema_filter !== undefined) { $('mom_use_ema_f').checked = !!m.use_ema_filter; } if ($('mom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) { $('mom_use_rsi_max_f').checked = !!m.use_rsi_max_filter; } if (m.ema_fast_period != null) set('mom_ema_fast', m.ema_fast_period); if (m.ema_slow_period != null) set('mom_ema_slow', m.ema_slow_period); // 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원 if (m.daily_profit_enabled !== undefined && $('mom_daily_profit_enabled')) { $('mom_daily_profit_enabled').checked = !!m.daily_profit_enabled; } if (m.daily_risk_budget_enabled !== undefined && $('mom_daily_risk_budget_enabled')) { $('mom_daily_risk_budget_enabled').checked = !!m.daily_risk_budget_enabled; } if ($('mom_daily_trail_tiers')) { $('mom_daily_trail_tiers').value = (m.daily_trail_tiers != null ? String(m.daily_trail_tiers) : ''); } if (m.daily_profit_mode && $('mom_daily_profit_mode')) { $('mom_daily_profit_mode').value = m.daily_profit_mode; } btFillPresetOptions('mom_daily_trail_preset', m.daily_trail_presets); // 체크값 = 모멘텀 실효값(전용 우선·없으면 글로벌 상속). 밑에 글로벌/실매 출처를 표시. // HTML에 checked/「ON」고정 금지 — DB false면 체크·라벨 모두 OFF. const filterNote = (en, glob, explicit) => { const onoff = v => (v ? 'ON' : 'OFF'); const src = explicit ? '모멘텀 전용 설정값' : `글로벌 상속(전용 미설정)`; // 실매도 동일 규칙(orderbook_filter.orderbook_filter_enabled) → 실매 적용값 = 실효값 return `글로벌 ${onoff(glob)} · 실매 ${onoff(en)} · ${src}`; }; // 안내 문구는 매번 처음부터 다시 씀 (적용 후 reload 시 이전 DB상세가 누적되지 않게) if ($('mom_ob_filter_note')) $('mom_ob_filter_note').textContent = ''; const momObOn = !!m.ob_filter_enabled; if ($('mom_ob_filter')) { $('mom_ob_filter').checked = momObOn; if ($('mom_ob_filter_lbl')) { $('mom_ob_filter_lbl').textContent = momObOn ? '호가필터 ON (DB·읽기전용)' : '호가필터 OFF (DB·읽기전용)'; } if ($('mom_ob_filter_note')) { $('mom_ob_filter_note').textContent = filterNote( momObOn, !!m.ob_global_enabled, !!m.ob_strategy_explicit, ); } } const momPgOn = !!m.pg_filter_enabled; if ($('mom_pg_filter')) { $('mom_pg_filter').checked = momPgOn; if ($('mom_pg_filter_lbl')) { $('mom_pg_filter_lbl').textContent = momPgOn ? '프로그램필터 ON' : '프로그램필터 OFF'; } if ($('mom_pg_filter_note')) { $('mom_pg_filter_note').textContent = filterNote( momPgOn, !!m.pg_global_enabled, !!m.pg_strategy_explicit, ); } } if (m.max_spread_pct != null) set('mom_max_spread_pct', m.max_spread_pct); // 잔량·ask·휩쏘 읽기전용 칸 if ($('mom_ob_ro_ratio') || $('mom_whip_ro_on')) { fillObWhipReadonly('mom', m); } // 호가 잔량·휩쏘는 입력란 없음이었음 — 안내 문구는 유지 const obExtra = []; if (m.ob_min_bid_ask_ratio != null) obExtra.push(`잔량≥${m.ob_min_bid_ask_ratio}`); if (m.ob_ask_max_mult != null) obExtra.push(`ask×≤${m.ob_ask_max_mult}`); if (m.whipsaw_filter_enabled) { obExtra.push( `휩쏘ON sub=${m.whipsaw_subbar_sec ?? '?'} lb=${m.whipsaw_lookback_sec ?? '?'} dip=${m.whipsaw_dip_pct ?? '?'}`, ); } else if (m.whipsaw_filter_enabled === false) { obExtra.push('휩쏘OFF(DB)'); } if ($('mom_ob_filter_note') && obExtra.length) { const prev = $('mom_ob_filter_note').textContent || ''; $('mom_ob_filter_note').textContent = (prev ? prev + ' · ' : '') + 'DB상세: ' + obExtra.join(' · '); } } /** Optuna axis_patch → 해당 전략 폼에 즉시 반영 (DB roundtrip 전 1차) */ function optunaPaintAxisPatchOnForms(strat, patch) { if (!patch || typeof patch !== 'object') return; const s = String(strat || '').toLowerCase(); const truthy = (v) => { const t = String(v == null ? '' : v).trim().toLowerCase(); return t === '1' || t === 'true' || t === 't' || t === 'yes' || t === 'y' || t === 'on'; }; const setNum = (id, key) => { if (patch[key] == null || patch[key] === '') return; if ($(id)) $(id).value = patch[key]; }; const setChk = (id, key) => { if (patch[key] == null || patch[key] === '') return; if ($(id)) $(id).checked = truthy(patch[key]); }; if (s === 'momentum') { setChk('mom_ob_filter', 'MOMENTUM_ORDERBOOK_FILTER_ENABLED'); setNum('mom_max_spread_pct', 'MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT'); } else if (s === 'breakout') { setChk('bo_ob_filter', 'BREAKOUT_ORDERBOOK_FILTER_ENABLED'); setNum('bo_max_spread_pct', 'BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT'); } else if (s === 'tail') { setChk('tl_ob_filter', 'TAIL_ORDERBOOK_FILTER_ENABLED'); setNum('tl_max_spread_pct', 'TAIL_ORDERBOOK_MAX_SPREAD_PCT'); } // scalp: 호가 입력란 없음 — DB만 (백테는 get_env) } /** * Optuna 적용·페이지 로드 공통 — /api/env/params?fresh=1 로 DB 최신값 → 폼. * strategy 지정 시 해당 탭만, 없으면 국장 전략 전부. */ async function reloadStrategyFormsFromDb(strategy) { const r = await fetch('/api/env/params?fresh=1&_=' + Date.now()); const d = await r.json(); if (!d) return; const want = String(strategy || '').toLowerCase(); const all = !want; try { if (all || want === 'scalp') fillScalpFormFromApi(d.scalp || {}); } catch (e) { console.warn('reload scalp', e); } try { if (all || want === 'tail') fillTailFormFromApi(d.tail || {}); } catch (e) { console.warn('reload tail', e); } try { if (all || want === 'momentum') fillMomentumFormFromApi(d.momentum || {}); } catch (e) { console.warn('reload momentum', e); } try { if (all || want === 'us_momentum') fillUsMomentumFormFromApi(d.us_momentum || d.momentum || {}); } catch (e) { console.warn('reload us_mom', e); } try { if (all || want === 'breakout') fillBreakoutFormFromApi(d.breakout || {}); } catch (e) { console.warn('reload breakout', e); } return d; } /** @deprecated reloadStrategyFormsFromDb('momentum') 사용 */ async function reloadMomentumFormFromDb() { try { await reloadStrategyFormsFromDb('momentum'); } catch (e) { console.warn('reloadMomentumFormFromDb', e); } } function loadMomentumSearchResults() { showSpinner(true); fetch('/api/backtest/momentum/search_results?top=30') .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } momRenderParamSearch(d.top || [], d.meta || {}); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function momApplySearchResult(rank, saveDb) { showSpinner(true); fetch('/api/backtest/momentum/apply_search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ rank, save_db: !!saveDb }), }) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } if (d.ui) fillMomentumFormFromApi(d.ui); const m = d.metrics || {}; const msg = saveDb ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건` : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`; alert(msg); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function momRenderParamSearch(top, meta) { window._mom_last_top = top || []; const area = $('mom_search_result'); if (area) area.style.display = ''; const labelMap = { mom_rsi_min: 'RSI min', mom_rsi_max: 'RSI max', mom_vol_mult: '거래량×', e_min_chg_pct: '전일종가+%', mom_vol_win: '관찰봉', sl_pct: '손절%', tp_pct: '익절%', tp_max_pct: '익절상한%', shoulder_min_high: '어깨발동', shoulder_cut_pct: '어깨컷', trail_trigger: '트레일무장%', trail_stop: '트레일%', mom_max_from_open_pct: '시가+과열', }; const pctKeys = new Set([ 'sl_pct', 'tp_pct', 'tp_max_pct', 'shoulder_min_high', 'shoulder_cut_pct', 'trail_trigger', 'trail_stop', 'trail_pct', 'trail_arm_pct', ]); const fixedLabel = (k) => labelMap[k] || k; const keys = top && top.length ? Object.keys(top[0].params || {}) : []; const thead = $('mom_search_thead'); if (thead) { thead.innerHTML = '순위' + keys.map(k => `${fixedLabel(k)}`).join('') + '손익(원)승률거래PF보유(분)폼반영DB저장'; } const tbody = $('mom_search_tbody'); if (!tbody) return; tbody.innerHTML = ''; (top || []).forEach((r) => { const p = r.params || {}; const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : (r.total_pnl < 0 ? 'text-pnl-neg' : ''); const paramCells = keys.map(k => { const v = p[k]; if (v === undefined || v === null) return '-'; if (pctKeys.has(k) && typeof v === 'number') return `${Number(v).toFixed(2)}%`; return `${v}`; }).join(''); const rank = r.rank; tbody.insertAdjacentHTML('beforeend', ` ${rank} ${paramCells} ${fmtWon(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold_min != null ? r.avg_hold_min : '-'} `); }); const hint = $('mom_search_hint'); if (hint) { hint.style.display = ''; const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object' ? '
' + Object.entries(meta.grid_axis_hints).map(([k, txt]) => `
${fixedLabel(k)} — ${txt}
` ).join('') : ''; hint.innerHTML = `파일: ${meta.path || '?'} · ` + `${meta.start || '?'} ~ ${meta.end || '?'} · ` + `모드 ${meta.mode || '-'} · 조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}
` + `폼반영: 입력란만 갱신 · DB저장: config_momentum MOMENTUM_* INSERT 후 폼 동기화` + ` (총손익≤0 은 거부).` + gh; } if (area) area.scrollIntoView({ behavior: 'smooth' }); } function loadTailSearchResults() { showSpinner(true); fetch('/api/backtest/tail/search_results?top=30') .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } tlRenderParamSearch(d.top || [], d.meta || {}); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function tlApplySearchResult(rank, saveDb) { showSpinner(true); fetch('/api/backtest/tail/apply_search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ rank, save_db: !!saveDb }), }) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } if (d.ui) fillTailFormFromApi(d.ui); const m = d.metrics || {}; const msg = saveDb ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건` : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`; alert(msg); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function tlRenderParamSearch(top, meta) { window._tl_last_top = top || []; $('tl_search_result').style.display = ''; const labelMap = { max_daily_change: '급등컷%', min_drop_rate: '낙폭', min_recovery_ratio: '회복', tail_ratio_min: '꼬리/몸통', max_rec_3m: '3분회복', shoulder_min_high: '어깨발동', shoulder_cut_pct: '어깨컷', stop_atr_mult: 'ATR손절', target_atr_mult: 'ATR익절', limit_atr_mult: '지정가ATR', entry_mode: '진입모드', trail_pct: '보조트레일%', trail_arm_pct: '트레일무장%', }; const fracKeys = new Set(['min_drop_rate', 'min_recovery_ratio', 'shoulder_min_high', 'shoulder_cut_pct']); const ratioKeys = new Set(['max_rec_3m']); const fixedLabel = (k) => labelMap[k] || k; const keys = top && top.length ? Object.keys(top[0].params || {}) : []; const thead = $('tl_search_thead'); thead.innerHTML = '순위' + keys.map(k => `${fixedLabel(k)}`).join('') + '손익(원)승률거래PF보유(분)폼반영DB저장'; const tbody = $('tl_search_tbody'); tbody.innerHTML = ''; (top || []).forEach((r) => { const p = r.params || {}; const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : (r.total_pnl < 0 ? 'text-pnl-neg' : ''); const paramCells = keys.map(k => { const v = p[k]; if (v === undefined || v === null) return '-'; if (fracKeys.has(k)) return `${(Number(v) * 100).toFixed(2)}%`; if (ratioKeys.has(k)) return `${Number(v) <= 1 ? (Number(v) * 100).toFixed(0) : v}%`; return `${v}`; }).join(''); const rank = r.rank; tbody.insertAdjacentHTML('beforeend', ` ${rank} ${paramCells} ${fmtWon(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold_min != null ? r.avg_hold_min : '-'} `); }); const hint = $('tl_search_hint'); if (hint) { hint.style.display = ''; const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object' ? '
' + Object.entries(meta.grid_axis_hints).map(([k, txt]) => `
${fixedLabel(k)} — ${txt}
` ).join('') : ''; hint.innerHTML = `파일: ${meta.path || '?'} · ` + `${meta.start || '?'} ~ ${meta.end || '?'} · TF ${meta.timeframe || 3}분 · ` + `조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}
` + `폼반영: 입력란만 갱신 · DB저장: config_short TAIL_* INSERT 후 폼 동기화` + ` (총손익≤0 은 거부). 승률·PF가 높은 조합은 순위와 다를 수 있음 — 표 순위 열 기준.` + gh; } $('tl_search_result').scrollIntoView({behavior: 'smooth'}); } function saveTailConfig() { const body = { min_drop_rate: parseFloat($('tl_drop').value), min_recovery_ratio: parseFloat($('tl_rec').value), tail_ratio_min: parseFloat($('tl_tail').value), tail_pct_min: parseFloat($('tl_tail_pct').value), max_rec_3m: parseFloat($('tl_max_rec_3m').value), sl_pct: parseFloat($('tl_sl').value), tp_pct: parseFloat($('tl_tp').value), shoulder_cut_pct: parseFloat($('tl_scut').value), shoulder_min_high: parseFloat($('tl_smin').value), high_chase_thr: parseFloat($('tl_high_chase').value), cooldown_min: parseFloat($('tl_cool').value), rsi_threshold: parseFloat($('tl_rsi').value), rsi_period: parseInt($('tl_rsi_period').value, 10), time_start: parseInt($('tl_ts').value, 10), time_end: parseInt($('tl_te').value, 10), max_daily: parseInt($('tl_maxd').value, 10), symbol_daily_loss_limit_krw: parseFloat($('tl_symbol_loss_krw')?.value || '30000'), symbol_daily_loss_limit_pct: parseFloat($('tl_symbol_loss_pct')?.value || '1.5'), reentry_min_edge_krw: parseFloat($('tl_reentry_min_edge')?.value || '0'), min_price: parseFloat($('tl_min_price').value), max_daily_change: parseFloat($('tl_max_daily_change').value), ma20_max_above: parseFloat($('tl_ma20_above').value), max_loss_krw: parseInt($('tl_max_loss_krw').value, 10), min_drop_pct_for_loss_cut: parseFloat($('tl_min_drop_loss_cut').value) || 1.5, // % 단위로 전송 (1.5) stop_atr_mult: parseFloat($('tl_stop_atr').value), target_atr_mult: parseFloat($('tl_target_atr').value), atr_sl_min_pct: parseFloat($('tl_atr_sl_min').value), atr_sl_max_pct: parseFloat($('tl_atr_sl_max').value), atr_tp_min_pct: parseFloat($('tl_atr_tp_min').value), atr_tp_max_pct: parseFloat($('tl_atr_tp_max').value), slot_money: parseInt($('tl_slot')?.value || '3000000', 10) || 3000000, max_stocks: parseInt($('tl_max_stocks')?.value || '3', 10) || 3, total_budget_krw: parseInt($('tl_total_budget')?.value || '0', 10) || 0, entry_mode: ($('tl_entry_mode') && $('tl_entry_mode').value) || 'limit_atr', limit_atr_mult: parseFloat($('tl_limit_atr_mult')?.value || '1.5'), limit_anchor: ($('tl_limit_anchor') && $('tl_limit_anchor').value) || 'signal_low', limit_valid_bars: parseInt($('tl_limit_valid_bars')?.value || '1', 10) || 1, limit_fill_slip_pct: parseFloat($('tl_limit_fill_slip')?.value || '0'), skip_hts_scan_dupes: $('tl_skip_hts_dupes')?.checked !== false, use_intraday_drop: !!$('tl_use_intraday_drop')?.checked, use_ma20_filter: !!$('tl_use_ma20_filter')?.checked, use_rsi_filter: $('tl_use_rsi_filter')?.checked !== false, use_daily_range_filter: $('tl_use_daily_range')?.checked !== false, use_high_chase_filter: $('tl_use_high_chase_f')?.checked !== false, bar_chg_min_pct: parseFloat($('tl_bar_chg_min')?.value || '-10'), bar_chg_max_pct: parseFloat($('tl_bar_chg_max')?.value || '-1.5'), tail_vol_mult: parseFloat($('tl_tail_vol_mult')?.value || '0'), tail_vol_win: parseInt($('tl_tail_vol_win')?.value || '5', 10) || 5, ratchet_tiers: ($('tl_ratchet') && $('tl_ratchet').value.trim()) || '', daily_trail_tiers: ($('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value.trim()) || '', daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing', daily_profit_enabled: !!($('tl_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('tl_daily_risk_budget_enabled')?.checked), max_hold_bars: 0, trail_pct: parseFloat($('tl_trail')?.value || '0'), trail_arm_pct: parseFloat($('tl_trail_arm')?.value || '0'), backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false, backtest_tick_fallback_ohlc: !!$('tl_tick_fallback_ohlc')?.checked, pattern_hammer: $('tl_pat_hammer')?.checked !== false, pattern_pin: !!$('tl_pat_pin')?.checked, pattern_engulfing: !!$('tl_pat_engulfing')?.checked, pattern_piercing: !!$('tl_pat_piercing')?.checked, pattern_harami: !!$('tl_pat_harami')?.checked, pattern_doji: !!$('tl_pat_doji')?.checked, pattern_morning_star: !!$('tl_pat_morning_star')?.checked, ob_filter: !!($('tl_ob_filter')?.checked), pg_filter: !!($('tl_pg_filter')?.checked), eod_enabled: !!($('tl_eod_enabled')?.checked), eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', }; if (!confirm(`💾 꼬리잡기 봇(config_short TAIL_*)에 저장합니까?\n\n` + `1회투자: ${body.slot_money}원 | 동시보유: ${body.max_stocks}종 | 총운용한도: ${body.total_budget_krw || body.max_stocks * body.slot_money}원 | ` + `낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` + `손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` + `최소가격: ${body.min_price}원 | 최대급등: ${body.max_daily_change}% | MA20이격: ${body.ma20_max_above}% | 최대손실: ${body.max_loss_krw}원\n` + `ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}배 | ` + `캡 SL ${body.atr_sl_min_pct}~${body.atr_sl_max_pct}% TP ${body.atr_tp_min_pct}~${body.atr_tp_max_pct}%\n` + `래칫: ${body.ratchet_tiers || '(OFF·단일어깨)'}\n` + `다단트레일: ${body.daily_profit_enabled ? 'ON' : 'OFF'} | 리스크버짓(B): ${body.daily_risk_budget_enabled ? 'ON' : 'OFF'} | 다단규칙: ${body.daily_trail_tiers || '(미설정)'}\n\n` + `저장: config_short (SHORT·tail_engine·tail_param_search 와 동일 키)`)) return; fetch('/api/backtest/tail/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } // 서버가 DB(env)에 프리셋을 누적 저장했다. 재조회 없이 즉시 드롭다운에도 반영 btAddPresetOption('tl_ratchet_preset', $('tl_ratchet') && $('tl_ratchet').value); btAddPresetOption('tl_daily_trail_preset', $('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value); const tbl = d.saved_by_table ? Object.entries(d.saved_by_table).map(([k,v]) => k + ': ' + v.length + '키').join(', ') : ''; alert(`✅ 저장 완료 (env_id: ${d.env_id})\n${tbl}\n키: ${d.saved_keys?.join(', ')}`); }).catch(e => alert('오류: ' + e)); } // 유니버스 배지 문구 (저장 이력 / 시뮬 / 전종목) function isHistoryUniverseSource(src) { return String(src || '').startsWith('history'); } function univHistorySourceParam(selectId) { const el = $(selectId); const v = (el && el.value) ? String(el.value).trim().toLowerCase() : 'kiwoom'; return (v === 'ls') ? 'ls' : 'kiwoom'; } function formatUniverseLabel(p) { const histSrc = isHistoryUniverseSource(p.universe_source); if (histSrc && (p.universe_history_slots || 0) > 0) { const strictTag = (p.universe_timing === 'strict' || String(p.universe_source || '').includes('strict')) ? '·strict' : ''; const lsTag = String(p.universe_source || '').includes('history_ls') ? '·LS' : ''; return `유니버스: 저장 이력 ${p.universe_history_slots}슬롯${lsTag}${strictTag}`; } if (p.universe_source === 'sim') return '유니버스: 시뮬레이션'; if (p.universe_source === 'all') return '유니버스: 전체 ws_candles 종목'; return '유니버스: 시뮬레이션(폴백)'; } /** env 타임라인 쿼리값 — 체크박스 없으면 0(OFF 기본). */ function envTimelineParam(checkboxId) { return $(checkboxId)?.checked ? 1 : 0; } function runBacktest() { const params = { start: $('bt_start').value, end: $('bt_end').value, rsi_period: $('bt_rsi_period').value, rsi_oversold: $('bt_rsi_oversold').value, rsi_overbought: $('bt_rsi_overbought').value, sl_pct: $('bt_sl').value, tp_pct: $('bt_tp').value, tp_max_pct: $('bt_tp_max').value, drop_rate: $('bt_drop').value, slot_money: $('bt_slot').value, cooldown_min: $('bt_cooldown').value, vol_mult: $('bt_vol_mult').value, shoulder_min_high: $('bt_smin').value, shoulder_cut_pct: $('bt_scut').value, time_start: $('bt_time_start').value, time_end: $('bt_time_end').value, max_daily: $('bt_max_daily').value, high_chase_thr: $('bt_high_chase').value, max_daily_chg: $('bt_max_daily_chg').value, min_price: $('bt_min_price').value, max_loss_krw: $('bt_max_loss_krw').value, min_margin: $('bt_min_margin').value, use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0, use_macd_cross: $('bt_use_macd')?.checked ? 1 : 0, skip_hts_scan_dupes: $('bt_skip_hts_dupes')?.checked ? 1 : 0, require_reversal_candle: $('bt_require_reversal')?.checked ? 1 : 0, max_stocks: $('bt_max_stocks')?.value, total_budget_krw: $('bt_total_budget')?.value, eod_enabled: $('bt_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25', env_timeline: envTimelineParam('bt_env_timeline'), }; params.universe = $('bt_use_univ_history')?.checked ? 'history' : 'sim'; params.universe_history_source = univHistorySourceParam('bt_univ_history_source'); params.candle_source = $('bt_candle_source')?.value || ''; params.tick_source = $('bt_tick_source')?.value || ''; params.ob_source = $('bt_ob_source')?.value || ''; const qs = new URLSearchParams(params).toString(); showSpinner(true); fetch('/api/backtest/scalping?' + qs) .then(r => r.json()) .then(d => { showSpinner(false); renderBacktest(d); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function renderBacktest(d) { const s = d.summary; const p = d.params; $('bt_params_bar').style.display = ''; const universeLabel = formatUniverseLabel(p); const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : v); const btSummaryLine = `${universeLabel} ` + `방어:${p.use_defense_filters ? 'ON' : 'OFF'} ` + `MACD:${p.use_macd_cross ? 'ON' : 'OFF'} ` + `EOD:${p.eod_enabled ? 'ON' : 'OFF'} ${p.eod_hm || ''} ` + `RSI(${p.rsi_period}) <${p.rsi_oversold} / >${p.rsi_overbought ?? 75} 과열차단 | ` + `손절-${tr(p.sl_pct)}% 익절+${tr(p.effective_tp_pct ?? p.tp_pct)}%(상한${tr(p.tp_max_pct)}%) | ` + `낙폭≥${tr(p.drop_rate)}% | 쿨다운${p.cooldown_min}분 | ` + `거래량${tr(p.vol_mult)}배 | 어깨(${tr(p.shoulder_min_high)}%발동/${tr(p.shoulder_cut_pct)}%하락) | ` + `${p.time_window} | 일${p.max_daily}회 | ` + `종목수 ${p.codes_analyzed}개`; $('bt_params_bar').innerHTML = btSummaryLine; const btTick = (d.tick_backtest || d.meta && d.meta.tick_backtest) || {}; const btBuySrc = d.backtest_buy_source || (d.meta && d.meta.backtest_buy_source) || ''; if (btTick.tick_bar_coverage_pct != null || btBuySrc) { const covLabel = fmtTickCoverageLabel(btTick); const rows = btTick.ws_tick_rows_loaded != null ? Number(btTick.ws_tick_rows_loaded).toLocaleString() : '—'; const srcLabel = btBuySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (btBuySrc === 'ohlc_fallback' ? 'OHLC 폴백' : (btBuySrc || '—')); const tickColor = btBuySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; $('bt_params_bar').innerHTML += `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; } const ctx = $('bt_trade_context'); if (ctx) { fillTradePnLContext(ctx, { label: '백테 SCALP', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } $('b_total').textContent = fmt(s.total_trades) + '건'; $('b_winrate').textContent = s.win_rate + '%'; colorPnl($('b_winrate'), s.win_rate - 50); $('b_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('b_pnl'), s.total_pnl); $('b_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor; colorPnl($('b_pf'), s.profit_factor - 1); $('b_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('b_hold').textContent = s.avg_hold_min + '분'; const btSign = (v) => (v > 0 ? '+' : ''); if ($('b_bot_pct')) { $('b_bot_pct').textContent = btSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; colorPnl($('b_bot_pct'), s.bot_pct || 0); } if ($('b_daily_avg_pct')) { $('b_daily_avg_pct').textContent = btSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'; colorPnl($('b_daily_avg_pct'), s.daily_avg_pct || 0); } if (s.total_trades > 0) { $('bt_winbar_card').style.display = ''; const wr = s.win_rate; $('b_win_label').textContent = `🟢 익절+손절 승 ${s.win_trades}건 (${wr}%)`; $('b_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`; $('b_ratio_g').style.width = wr + '%'; $('b_ratio_r').style.width = (100 - wr) + '%'; } else if ($('bt_winbar_card')) { // 0건이면 이전 백테 승패 바 잔상 제거 $('bt_winbar_card').style.display = 'none'; } lineChart('b_equity_chart', d.equity.map(e => e.date.slice(0,4)+'-'+e.date.slice(4,6)+'-'+e.date.slice(6)), d.equity.map(e => e.cum_pnl), '가상누적손익'); const rKeys = Object.keys(d.reasons); doughnutChart('b_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); barChart('b_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); renderVirtualTrades('bt_tbody', d.trades || [], { showRsi: true, showCumulative: true, totalBudget: Number((d.params && d.params.total_budget_krw) || 0), }); } // ──────────────────────────────────────────── // DBBAND — 종목별 더블 볼린저 (UPDOW와 동일: 종목 1개씩 파라미터) // ──────────────────────────────────────────── function dbSyncExitModeUi() { const mode = ($('db_exit_mode') && $('db_exit_mode').value) || 'classic'; const isV4 = mode === 'v4_scalp'; const mh = $('db_max_hold_wrap'); if (mh) mh.style.opacity = isV4 ? '1' : '0.45'; if ($('db_max_hold') && !isV4 && ($('db_max_hold').value === '16' || $('db_max_hold').value === '')) { $('db_max_hold').value = '0'; } } function fillDbBandFormFromApi(b) { if (!b || !Object.keys(b).length) return; const set = (id, val) => { if (val != null && $(id)) $(id).value = val; }; set('db_period', b.bb_period); set('db_inner_std', b.bb_inner_std); set('db_outer_std', b.bb_outer_std); set('db_trend_ma', b.trend_ma_period); set('db_sl', b.sl_pct); set('db_tp', b.tp_pct); set('db_rr', b.rr_ratio); set('db_valid_bars', b.entry_valid_bars); set('db_max_hold', b.max_hold_bars); set('db_slot', b.slot_money); if (b.timeframe) set('db_tf', b.timeframe); if (b.tp_mode && $('db_tp_mode')) $('db_tp_mode').value = b.tp_mode; if (b.exit_mode && $('db_exit_mode')) $('db_exit_mode').value = b.exit_mode; if ($('db_use_trend') && b.use_trend_filter !== undefined) { $('db_use_trend').checked = !!b.use_trend_filter; } dbSyncExitModeUi(); } function dbCollectFormBody() { return { code: $('db_code')?.value || '', tf_min: parseInt($('db_tf').value, 10), bb_period: parseInt($('db_period').value, 10), bb_inner_std: parseFloat($('db_inner_std').value), bb_outer_std: parseFloat($('db_outer_std').value), trend_ma_period: parseInt($('db_trend_ma').value, 10), stop_loss_pct: parseFloat($('db_sl').value), take_profit_pct: parseFloat($('db_tp').value), tp_mode: $('db_tp_mode').value, rr_ratio: parseFloat($('db_rr').value), entry_valid_bars: parseInt($('db_valid_bars').value, 10), max_hold_bars: parseInt($('db_max_hold').value, 10), slot_money: parseInt($('db_slot').value, 10), use_trend_filter: $('db_use_trend').checked, exit_mode: ($('db_exit_mode') && $('db_exit_mode').value) || 'classic', side_mode: 'long_only', entry_mode: 'break_high', stop_mode: 'signal_low', }; } function dbSelectedMeta() { const sel = $('db_code'); const opt = sel && sel.selectedOptions[0]; const rawName = (opt && opt.textContent) || (sel && sel.value) || ''; const name = String(rawName).replace(/\s*\[[^\]]*\]\s*/g, '').replace(/\s*\([^)]*\)\s*$/, '').trim(); return { name: name || (sel && sel.value) || '', market_type: (opt && opt.dataset.marketType) || (opt && opt.dataset.market) || 'KR', exchange: (opt && opt.dataset.exchange) || 'KRX', symbol: (opt && opt.dataset.symbol) || (sel && sel.value) || '', }; } function dbFillFormFromEngine(e) { if (!e) return; const set = (id, v) => { if ($(id) && v != null) $(id).value = v; }; set('db_tf', e.timeframe); set('db_period', e.bb_period); set('db_inner_std', e.bb_inner_std); set('db_outer_std', e.bb_outer_std); set('db_trend_ma', e.trend_ma_period); set('db_sl', e.sl_pct); set('db_tp', e.tp_pct); set('db_rr', e.rr_ratio); set('db_valid_bars', e.entry_valid_bars); set('db_max_hold', e.max_hold_bars); set('db_slot', e.slot_money); if ($('db_tp_mode') && e.tp_mode) $('db_tp_mode').value = e.tp_mode; if ($('db_exit_mode') && e.exit_mode) $('db_exit_mode').value = e.exit_mode; if ($('db_use_trend')) $('db_use_trend').checked = !!e.use_trend_filter; dbSyncExitModeUi(); } function dbLoadStocks() { const sel = $('db_code'); if (!sel) return; const prev = sel.value; Promise.all([ fetch('/api/dbband/stocks').then(r => r.json()), fetch('/api/permanent_subs').then(r => r.json()).catch(() => ({ rows: [] })), ]) .then(([saved, perm]) => { const byCode = {}; (saved || []).forEach(it => { byCode[it.code] = it; }); ((perm && perm.rows) || []).forEach(r => { const c = r.code || r.symbol; if (!c || byCode[c]) return; byCode[c] = { code: c, name: r.note || r.name || c, market_type: r.market_type || 'KR', exchange: r.exchange || 'KRX', symbol: r.symbol || c, _candidate: true, }; }); const list = Object.values(byCode).sort((a, b) => String(a.code).localeCompare(String(b.code))); sel.innerHTML = ''; if (!list.length) { const o = document.createElement('option'); o.value = ''; o.textContent = '(종목 없음 — 영구구독 등록 후 [종목저장])'; sel.appendChild(o); return; } list.forEach(it => { const o = document.createElement('option'); o.value = it.code; o.dataset.marketType = it.market_type || 'KR'; o.dataset.exchange = it.exchange || 'KRX'; o.dataset.symbol = it.symbol || it.code; const mkt = String(o.dataset.marketType).toUpperCase() === 'US' ? `[US:${o.dataset.exchange}] ` : ''; const tag = it._candidate ? ' (미저장)' : ''; o.textContent = mkt + (it.name || it.code) + ' (' + (it.symbol || it.code) + ')' + tag; sel.appendChild(o); }); if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev; else if (sel.options.length) sel.selectedIndex = 0; sel.onchange = dbLoadConfig; if (sel.value) dbLoadConfig(); if ($('db_tf') && !$('db_tf').dataset.dbBadgeBound) { $('db_tf').dataset.dbBadgeBound = '1'; $('db_tf').addEventListener('change', dbRefreshMinBadge); } }) .catch(err => alert('DBBAND 종목 로드 실패: ' + err)); } function dbRefreshMinBadge() { const code = $('db_code')?.value; const tf = $('db_tf')?.value || '15'; const badge = $('db_min_badge'); if (!code || !badge) return; fetch(`/api/holding/min_stats?code=${encodeURIComponent(code)}&tf=${encodeURIComponent(tf)}`) .then(r => r.json()) .then(st => { if (st.error || !st.count) { badge.textContent = `DB봉(${tf}분): 없음 — 분봉수집 필요`; badge.style.color = '#e3b341'; return; } badge.textContent = `DB봉(${tf}분): ${st.count}봉 ${(st.min || '').slice(0, 10)}~${(st.max || '').slice(0, 10)}`; badge.style.color = 'var(--muted)'; }) .catch(() => { badge.textContent = 'DB봉: 조회실패'; }); } function dbLoadConfig() { const code = $('db_code')?.value; if (!code) return; fetch('/api/dbband/config?code=' + encodeURIComponent(code)) .then(r => r.json()) .then(d => { if (d.error) { alert(d.error); return; } dbFillFormFromEngine(d.engine || {}); if (d.dbband_tf_min != null) $('db_tf').value = d.dbband_tf_min; const ps = $('db_param_source'); if (ps) { ps.textContent = d.param_source || ''; ps.className = 'badge ' + (d.dbband_stock_saved ? 'badge-info' : 'badge-secondary'); } dbRefreshMinBadge(); }) .catch(err => alert('설정 로드 실패: ' + err)); } /** holding_min_candles 수집 — 백테·파라서치 공통 (UPDOW udFetchMinKiwoom 동일 API) */ function dbFetchMinKiwoom(tf) { tf = parseInt(String(tf != null ? tf : ($('db_tf') && $('db_tf').value) || '15'), 10) || 15; const code = $('db_code')?.value; if (!code) { alert('종목을 선택하세요'); return; } const selOpt = $('db_code').selectedOptions[0] || null; const marketType = (selOpt && selOpt.dataset.marketType) || 'KR'; const exchange = (selOpt && selOpt.dataset.exchange) || (String(marketType).toUpperCase() === 'US' ? 'NASD' : 'KRX'); const symbol = (selOpt && selOpt.dataset.symbol) || code; const name = (selOpt && selOpt.textContent) || code; const start = $('db_fetch_start')?.value || $('db_start')?.value; const end = $('db_end')?.value; const statusEl = $('db_min_status'); if (!start || !end) { alert('분봉수집 시작일·종료일을 설정하세요'); return; } const srcLabel = String(marketType).toUpperCase() === 'US' ? 'KIS 해외' : '키움'; if (!confirm(`🗂 ${name}(${code}) ${srcLabel} ${tf}분봉 수집\n기간: ${start} ~ ${end}\n→ holding_min_candles (백테·파라서치용)\n계속할까요?`)) return; if (statusEl) statusEl.textContent = ''; fetch('/api/holding/min_candles/fetch_kiwoom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, start, end, tf, market_type: marketType, exchange: exchange, symbol: symbol, }), }) .then(r => r.json()) .then(d => { if (d.error) { alert('❌ ' + d.error); return; } const jobId = d.job_id; function poll() { fetch(`/api/holding/min_candles/status/${jobId}`) .then(r => r.json()) .then(j => { if (j.status === 'running') { const jtf = j.tf != null ? j.tf : tf; if (statusEl) statusEl.textContent = `⏳ ${srcLabel} ${jtf}분… ${j.fetched || 0} → 저장 ${j.saved || 0}봉`; setTimeout(poll, 2000); } else if (j.status === 'done') { const jtf = j.tf != null ? j.tf : tf; if (statusEl) statusEl.textContent = `✅ 완료 ${j.saved || 0}봉 (${jtf}분)`; if ($('db_tf')) $('db_tf').value = String(jtf); dbRefreshMinBadge(); } else { if (statusEl) statusEl.textContent = '❌ ' + (j.error || j.status || '오류'); } }) .catch(() => setTimeout(poll, 3000)); } poll(); }) .catch(err => alert('오류: ' + err)); } function runDbBandBacktest() { const code = $('db_code')?.value; if (!code) { alert('종목을 선택하거나 [종목저장]으로 등록하세요.'); return; } const qs = new URLSearchParams({ code, start: $('db_start').value, end: $('db_end').value, tf: $('db_tf').value, sl_pct: $('db_sl').value, tp_pct: $('db_tp').value, tp_mode: $('db_tp_mode').value, rr_ratio: $('db_rr').value, bb_period: $('db_period').value, bb_inner_std: $('db_inner_std').value, bb_outer_std: $('db_outer_std').value, trend_ma_period: $('db_trend_ma').value, entry_valid_bars: $('db_valid_bars').value, max_hold_bars: $('db_max_hold').value, slot_money: $('db_slot').value, use_trend_filter: $('db_use_trend').checked ? '1' : '0', exit_mode: ($('db_exit_mode') && $('db_exit_mode').value) || 'classic', env_timeline: envTimelineParam('db_env_timeline'), }); showSpinner(true); fetch('/api/dbband/backtest?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); renderDbBandBacktest(d); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function renderDbBandBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음')); return; } if (!d.summary) { alert('백테스트 응답 오류 (summary 없음)'); return; } const s = d.summary || {}; const trades = d.trades || []; const p = d.params || {}; const dbRa = $('db_result_area'); if (dbRa) dbRa.style.display = 'block'; const dbPb = $('db_params_bar'); if (dbPb) dbPb.style.display = 'block'; const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; const sign = v => (v > 0 ? '+' : ''); const srcBadge = p.param_source === 'dbband_stock_config' ? 'badge-info' : 'badge-secondary'; const srcLabel = p.param_source === 'dbband_stock_config' ? '종목저장' : (p.param_source || d.param_source || 'env-fallback'); const trendLab = p.use_trend_filter ? `추세MA${p.trend_ma_period}` : '추세필터OFF'; const exitLab = (p.exit_mode === 'v4_scalp') ? '청산V4' : '청산영상원형'; const tpLab = p.tp_mode === 'rr' ? `RR${p.rr_ratio}` : (p.tp_mode || 'opposite_band'); const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : ''; const dbSummaryLine = `${p.name || d.code}(${p.code || d.code}) ` + `BB ${p.bb_period}/${p.bb_inner_std}σ·${p.bb_outer_std}σ | ${trendLab} | ${exitLab} | ` + `손절-${p.sl_pct}% 익절+${p.tp_pct}% (${tpLab}) | ` + `어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` + `유효${p.entry_valid_bars}봉·최대${p.max_hold_bars}봉${periodHint} | ` + `1회 ${Number(p.slot_money || 0).toLocaleString()}원 · 동시${p.max_stocks || 1}종 · 한도 ${Number(p.total_budget_krw || 0).toLocaleString()}원 | ` + `쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | ${d.tf || p.timeframe}분봉 ${d.candle_count || p.candle_count || '—'}봉 · ${srcLabel}`; if (dbPb) dbPb.innerHTML = dbSummaryLine; const psEl = $('db_param_source'); if (psEl) { psEl.textContent = srcLabel; psEl.className = 'badge ' + (p.param_source === 'dbband_stock_config' ? 'badge-info' : 'badge-secondary'); psEl.style.fontSize = '11px'; } setTxt('db_total', (s.total_trades || 0) + '건'); setTxt('db_winrate', (s.win_rate || 0) + '%'); colorPnl($('db_winrate'), (s.win_rate || 0) - 50); setTxt('db_pnl', fmtKrw(s.total_pnl)); colorPnl($('db_pnl'), s.total_pnl); setTxt('db_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('db_pf'), (s.profit_factor || 0) - 1); setTxt('db_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('db_hold', (s.avg_hold_min || 0) + '분'); setTxt('db_bot_pct', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('db_bot_pct'), s.bot_pct || 0); setTxt('db_daily_avg_pct', sign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('db_daily_avg_pct'), s.daily_avg_pct || 0); setTxt('db_candles', d.candle_count != null ? d.candle_count : (p.candle_count || '-')); if (s.budget_warning) { if (dbPb) dbPb.innerHTML += `
💰 ${s.budget_warning}
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning ? `거래 0건 — ${s.budget_warning}` : '거래 0건 — 기간·BB이탈·복귀 패턴·추세필터를 확인하세요.'; const zhtml = `
⚠️ ${zmsg}
`; if (dbPb) dbPb.innerHTML += zhtml; } const winbar = $('db_winbar_card'); if ((s.total_trades || 0) > 0) { if (winbar) winbar.style.display = 'block'; const wr = s.win_rate || 0; setTxt('db_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); setTxt('db_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); const rg = $('db_ratio_g'); if (rg) rg.style.width = wr + '%'; const rr = $('db_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%'; } else if (winbar) { winbar.style.display = 'none'; } if (s.bnh_pct !== undefined && s.bnh_pct !== null) { const bnhRow = $('db_bnh_row'); if (bnhRow) bnhRow.style.display = ''; setTxt('db_bot_pct_bnh', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('db_bot_pct_bnh'), s.bot_pct || 0); setTxt('db_bnh_pct', sign(s.bnh_pct) + s.bnh_pct + '%'); colorPnl($('db_bnh_pct'), s.bnh_pct); setTxt('db_bnh_pnl', sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl)); colorPnl($('db_bnh_pnl'), s.bnh_pnl); setTxt('db_alpha', sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p'); colorPnl($('db_alpha'), s.alpha_pct || 0); } else { const bnhRow = $('db_bnh_row'); if (bnhRow) bnhRow.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('db_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); } else { destroyChart('db_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('db_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('db_reason_chart'); } } catch (chartErr) { console.error('db chart render', chartErr); } const tbody = $('db_tbody'); if (tbody) { tbody.innerHTML = ''; trades.forEach(t => { const tr = document.createElement('tr'); const pnl = Number(t.pnl || 0); tr.innerHTML = `${t.side || 'long'}${t.entry_time || ''}${t.exit_time || ''}` + `${fmtWon(t.entry)}${fmtWon(t.exit)}` + `${fmtWon(pnl)}` + `${t.hold_min || '-'}${t.reason || ''}`; tbody.appendChild(tr); }); } if (dbRa) dbRa.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function saveDbBandStockConfig() { const code = $('db_code')?.value; if (!code) { alert('종목을 선택하세요'); return; } const meta = dbSelectedMeta(); const body = Object.assign(dbCollectFormBody(), { code }, meta); if (!confirm(`${code} 파라미터를 dbband_stock_config에 저장할까요?`)) return; fetch('/api/dbband/save_holding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (d.error) alert('❌ ' + d.error); else { alert('✅ 저장 완료 (' + code + ', tf=' + d.dbband_tf_min + '분)'); dbLoadStocks(); } }) .catch(err => alert('저장 오류: ' + err)); } function dbApplySearchParams(p) { if (!p) return; dbFillFormFromEngine({ bb_period: p.bb_period, bb_inner_std: p.bb_inner_std, bb_outer_std: p.bb_outer_std, trend_ma_period: p.trend_ma_period, sl_pct: p.stop_loss_pct != null ? p.stop_loss_pct : (p.sl_pct != null ? p.sl_pct * 100 : null), tp_pct: p.take_profit_pct != null ? p.take_profit_pct : (p.tp_pct != null ? p.tp_pct * 100 : null), tp_mode: p.tp_mode, rr_ratio: p.rr_ratio, entry_valid_bars: p.entry_valid_bars, max_hold_bars: p.max_hold_bars, slot_money: p.slot_money, use_trend_filter: p.use_trend_filter, }); } function runDbBandParamSearch() { const code = $('db_code')?.value; if (!code) { alert('종목을 선택하세요'); return; } const qs = new URLSearchParams({ code, start: $('db_start').value, end: $('db_end').value, tf: $('db_tf').value, top: '20', }); showSpinner(true); fetch('/api/dbband/param_search?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert(d.error); return; } window._db_last_search = d.results || []; $('db_search_results').style.display = ''; const tb = $('db_search_tbody'); tb.innerHTML = ''; (d.results || []).forEach((row, i) => { const p = row.params || {}; const tr = document.createElement('tr'); tr.innerHTML = `${i + 1}${fmtWon(row.total_pnl)}${row.win_rate}%` + `${row.total_trades}${p.bb_inner_std}/${p.bb_outer_std}` + `${p.trend_ma_period}` + ``; tr.querySelector('button').onclick = () => dbSaveSearchRow(row, i + 1); tb.appendChild(tr); }); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function dbSaveSearchRow(row, rank) { const code = $('db_code')?.value; if (!code || !row) return; const p = row.params || {}; dbApplySearchParams(p); const meta = dbSelectedMeta(); const body = Object.assign(dbCollectFormBody(), meta, { code, bb_period: p.bb_period, bb_inner_std: p.bb_inner_std, bb_outer_std: p.bb_outer_std, trend_ma_period: p.trend_ma_period, stop_loss_pct: p.stop_loss_pct != null ? p.stop_loss_pct : (p.sl_pct != null ? p.sl_pct * 100 : undefined), take_profit_pct: p.take_profit_pct != null ? p.take_profit_pct : (p.tp_pct != null ? p.tp_pct * 100 : undefined), tp_mode: p.tp_mode, rr_ratio: p.rr_ratio, entry_valid_bars: p.entry_valid_bars, max_hold_bars: p.max_hold_bars, slot_money: p.slot_money != null ? p.slot_money : parseInt($('db_slot').value, 10), use_trend_filter: p.use_trend_filter != null ? p.use_trend_filter : $('db_use_trend').checked, }); if (!confirm(`#${rank} 파라미터를 ${code}에 저장할까요?`)) return; fetch('/api/dbband/save_holding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (d.error) alert('❌ ' + d.error); else { alert('✅ #' + rank + ' 적용·저장 완료'); dbLoadStocks(); } }) .catch(err => alert('저장 오류: ' + err)); } // ──────────────────────────────────────────── // 꼬리잡기 백테스트 // ──────────────────────────────────────────── function formatLimitEntryLabel(p) { if (!p) return ''; const em = String(p.entry_mode || 'align').toLowerCase(); if (em === 'limit_atr') { return ` · 진입 C limit_atr · ${p.limit_anchor || 'signal_low'} −ATR×${p.limit_atr_mult != null ? p.limit_atr_mult : '-'} · 유효${p.limit_valid_bars != null ? p.limit_valid_bars : 1}봉 · 슬립${p.limit_fill_slip_pct != null ? p.limit_fill_slip_pct : 0}%`; } return ' · 진입 A align(다음봉 시가)'; } function runTailBacktest() { const params = { start: $('tl_start').value, end: $('tl_end').value, timeframe: $('tl_tf').value, min_drop_rate: $('tl_drop').value, min_recovery_ratio: $('tl_rec').value, tail_ratio_min: $('tl_tail').value, tail_pct_min: $('tl_tail_pct').value, max_rec_3m: $('tl_max_rec_3m').value, sl_pct: $('tl_sl').value, tp_pct: $('tl_tp').value, shoulder_min_high: $('tl_smin').value, shoulder_cut_pct: $('tl_scut').value, high_chase_thr: $('tl_high_chase').value, rsi_period: $('tl_rsi_period').value, rsi_threshold: $('tl_rsi').value, cooldown_min: $('tl_cool').value, time_start: $('tl_ts').value, time_end: $('tl_te').value, max_daily: $('tl_maxd').value, symbol_daily_loss_limit_krw: $('tl_symbol_loss_krw')?.value || '30000', symbol_daily_loss_limit_pct: $('tl_symbol_loss_pct')?.value || '1.5', reentry_min_edge_krw: $('tl_reentry_min_edge')?.value || '0', slot_money: $('tl_slot').value, max_stocks: $('tl_max_stocks').value, total_budget_krw: $('tl_total_budget').value, min_price: $('tl_min_price').value, max_daily_change: $('tl_max_daily_change').value, ma20_max_above: $('tl_ma20_above').value, max_loss_krw: $('tl_max_loss_krw').value, min_drop_pct_for_loss_cut: (parseFloat($('tl_min_drop_loss_cut').value) / 100) || 0.015, stop_atr_mult: $('tl_stop_atr').value, target_atr_mult: $('tl_target_atr').value, atr_sl_min_pct: $('tl_atr_sl_min').value, atr_sl_max_pct: $('tl_atr_sl_max').value, atr_tp_min_pct: $('tl_atr_tp_min').value, atr_tp_max_pct: $('tl_atr_tp_max').value, universe: $('tl_use_univ_history')?.checked ? 'history' : 'all', universe_history_source: univHistorySourceParam('tl_univ_history_source'), candle_source: $('tl_candle_source')?.value || '', tick_source: $('tl_tick_source')?.value || '', ob_source: $('tl_ob_source')?.value || '', entry_mode: ($('tl_entry_mode') && $('tl_entry_mode').value) || 'limit_atr', limit_atr_mult: $('tl_limit_atr_mult')?.value || '1.5', limit_anchor: ($('tl_limit_anchor') && $('tl_limit_anchor').value) || 'signal_low', limit_valid_bars: $('tl_limit_valid_bars')?.value || '1', limit_fill_slip_pct: $('tl_limit_fill_slip')?.value || '0', skip_hts_scan_dupes: $('tl_skip_hts_dupes')?.checked !== false ? '1' : '0', use_intraday_drop: $('tl_use_intraday_drop')?.checked ? '1' : '0', use_ma20_filter: $('tl_use_ma20_filter')?.checked ? '1' : '0', use_rsi_filter: $('tl_use_rsi_filter')?.checked !== false ? '1' : '0', use_daily_range_filter: $('tl_use_daily_range')?.checked !== false ? '1' : '0', use_high_chase_filter: $('tl_use_high_chase_f')?.checked !== false ? '1' : '0', bar_chg_min_pct: $('tl_bar_chg_min')?.value || '-10', bar_chg_max_pct: $('tl_bar_chg_max')?.value || '-1.5', tail_vol_mult: $('tl_tail_vol_mult')?.value || '0', tail_vol_win: $('tl_tail_vol_win')?.value || '5', ratchet_tiers: ($('tl_ratchet') && $('tl_ratchet').value.trim()) || '', max_hold_bars: '0', trail_pct: $('tl_trail')?.value || '0', trail_arm_pct: $('tl_trail_arm')?.value || '0', backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false ? '1' : '0', backtest_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.checked ? '1' : '0', pattern_hammer: $('tl_pat_hammer')?.checked !== false ? '1' : '0', pattern_pin: $('tl_pat_pin')?.checked ? '1' : '0', pattern_engulfing: $('tl_pat_engulfing')?.checked ? '1' : '0', pattern_piercing: $('tl_pat_piercing')?.checked ? '1' : '0', pattern_harami: $('tl_pat_harami')?.checked ? '1' : '0', pattern_doji: $('tl_pat_doji')?.checked ? '1' : '0', pattern_morning_star: $('tl_pat_morning_star')?.checked ? '1' : '0', ob_filter: $('tl_ob_filter')?.checked ? 1 : 0, pg_filter: $('tl_pg_filter')?.checked ? 1 : 0, max_spread_pct: $('tl_max_spread_pct')?.value, daily_trail_tiers: ($('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value.trim()) || '', daily_trail_drop_pct: $('tl_daily_trail_drop')?.value || '0', daily_trail_arm_krw: $('tl_daily_trail_arm')?.value || '0', daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing', daily_profit_enabled: $('tl_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('tl_daily_risk_budget_enabled')?.checked ? 1 : 0, eod_enabled: $('tl_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', env_timeline: envTimelineParam('tl_env_timeline'), }; const qs = new URLSearchParams(params).toString(); showSpinner(true); fetch('/api/backtest/tail?' + qs) .then(r => r.json()) .then(d => { showSpinner(false); renderTailBacktest(d); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } // ════════════════════════════════════════════════════════════════ // 전략 백테 CLI 잡 (꼬리·스캘핑·돌파·모멘텀) — 전역 1잡 · 탭 이동 OK · 좀비방지 // ════════════════════════════════════════════════════════════════ const BTJOB_LS_KEY = 'kis_bt_job_id'; const BTJOB_LS_KEY_LEGACY = 'kis_tail_bt_job_id'; let _btJobPollTimer = null; let _btJobId = localStorage.getItem(BTJOB_LS_KEY) || localStorage.getItem(BTJOB_LS_KEY_LEGACY) || ''; let _btJobRenderedId = ''; let _btJobStrategy = ''; const BTJOB_CFG = { tail: { tab: 'tail', label: '꼬리 백테', startId: 'tl_start', endId: 'tl_end', tfId: 'tl_tf', univId: 'tl_use_univ_history', univSrcId: 'tl_univ_history_source', tickId: 'tl_use_tick_db', envId: 'tl_env_timeline', obId: 'tl_ob_filter', stopBtn: 'tl_btn_stop_bg', tabFill: 'tlbt_tab_fill', tabStatus: 'tlbt_tab_status', clearPrefix: 'tl_', universeOn: 'history', universeOff: 'all', }, scalp: { tab: 'backtest', label: '스캘핑 백테', startId: 'bt_start', endId: 'bt_end', tfId: null, univId: 'bt_use_univ_history', univSrcId: 'bt_univ_history_source', tickId: null, envId: 'bt_env_timeline', obId: null, stopBtn: 'bt_btn_stop_bg', tabFill: 'scalp_bt_tab_fill', tabStatus: 'scalp_bt_tab_status', clearPrefix: 'b_', universeOn: 'history', universeOff: 'sim', }, breakout: { tab: 'breakout', label: '돌파 백테', startId: 'bo_start', endId: 'bo_end', tfId: null, univId: 'bo_use_univ_history', univSrcId: 'bo_univ_history_source', tickId: null, envId: 'bo_env_timeline', obId: null, stopBtn: 'bo_btn_stop_bg', tabFill: 'bo_bt_tab_fill', tabStatus: 'bo_bt_tab_status', clearPrefix: 'bo_', universeOn: 'history', universeOff: 'all', }, momentum: { tab: 'momentum', label: '모멘텀 백테', startId: 'mom_start', endId: 'mom_end', tfId: null, univId: 'mom_use_univ_history', univSrcId: 'mom_univ_history_source', tickId: null, envId: 'mom_env_timeline', obId: 'mom_ob_filter', stopBtn: 'mom_btn_stop_bg', tabFill: 'mom_bt_tab_fill', tabStatus: 'mom_bt_tab_status', clearPrefix: 'mom_', universeOn: 'history', universeOff: 'sim', }, }; function btJobGoTab(strat) { const s = strat || _btJobStrategy || 'tail'; const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail; btShowTab(cfg.tab); } function btJobClearUi(strat, msg) { const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail; const map = { tail: ['tl_total','tl_winrate','tl_pnl','tl_pf','tl_mdd','tl_hold','tl_bot_pct','tl_daily_avg_pct'], scalp: ['b_total','b_winrate','b_pnl','b_pf','b_mdd','b_hold','b_bot_pct','b_daily_avg_pct'], breakout: ['bo_total','bo_winrate','bo_pnl','bo_pf','bo_mdd','bo_hold','bo_bot_pct','bo_daily_avg_pct'], momentum: ['mom_total','mom_winrate','mom_pnl','mom_pf','mom_mdd','mom_hold','mom_bot_pct','mom_daily_avg_pct'], }; const ids = map[strat] || map.tail; ids.forEach((id, i) => { const el = $(id); if (!el) return; if (i === 2) el.textContent = '실행중'; else el.textContent = i < 2 ? '…' : '—'; }); const tbody = strat === 'tail' ? $('tl_tbody') : (strat === 'scalp' ? $('bt_tbody') : (strat === 'breakout' ? $('bo_tbody') : $('mom_tbody'))); if (tbody) { tbody.innerHTML = `${msg || '백그라운드 실행 중…'}`; } if (cfg.tabStatus && $(cfg.tabStatus)) { $(cfg.tabStatus).textContent = msg || '실행중…'; } } function btJobSetNav(job) { const bar = $('btjob_nav_bar'); if (!bar) return; if (!job) { bar.classList.add('d-none'); return; } const strat = job.strategy || _btJobStrategy || 'tail'; _btJobStrategy = strat; const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail; const prog = job.progress || {}; const pct = Number(prog.pct || 0); const st = job.status || ''; const label = (job.label || cfg.label) + (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : ''))); const range = (job.start && job.end) ? ` ${job.start}~${job.end}` : ''; if ($('btjob_nav_label')) $('btjob_nav_label').textContent = label + range; if ($('btjob_nav_pct')) $('btjob_nav_pct').textContent = `${pct}%`; const fill = $('btjob_nav_fill'); if (fill) { fill.style.width = `${Math.min(100, pct)}%`; fill.classList.toggle('is-done', st === 'done'); fill.classList.toggle('is-error', st === 'error'); } bar.classList.remove('d-none'); bar.onclick = () => btJobGoTab(strat); const tabFill = cfg.tabFill && $(cfg.tabFill); if (tabFill) { tabFill.style.width = `${Math.min(100, pct)}%`; tabFill.classList.toggle('is-done', st === 'done'); tabFill.classList.toggle('is-error', st === 'error'); } const stEl = cfg.tabStatus && $(cfg.tabStatus); if (stEl) { const msg = prog.message || ''; stEl.innerHTML = `${st} ${pct}%` + range + (msg ? ` · ${msg}` : '') + (job.log_path ? ` · ${job.log_path}` : '') + ' · 전역 1잡 · 탭 이동 OK'; } // 모든 전략 중지 버튼 동기화 Object.values(BTJOB_CFG).forEach(c => { const b = c.stopBtn && $(c.stopBtn); if (b) b.disabled = st !== 'running'; }); } async function btJobLoadResult(jobId, strategy) { const rr = await fetch('/api/backtest/job/result?job_id=' + encodeURIComponent(jobId)); const rd = await rr.json(); if (!rd.ok) throw new Error(rd.error || '결과 없음'); const strat = strategy || rd.strategy || 'tail'; const payload = rd.data || rd.raw || rd; // 과거 CLI 버그: 핸들러 500을 ok:true + error 로 저장 → 새로고침마다 alert 재발 const failMsg = (payload && payload.error) || rd.error || ''; if (failMsg) { const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail; const stEl = cfg.tabStatus && $(cfg.tabStatus); if (stEl) { stEl.textContent = '실패(구잡): ' + String(failMsg).slice(0, 120) + ' · 다시 실행하세요'; } _btJobId = ''; localStorage.removeItem(BTJOB_LS_KEY); localStorage.removeItem(BTJOB_LS_KEY_LEGACY); btJobSetNav(null); throw new Error(String(failMsg)); } if (strat === 'scalp') { renderBacktest(payload); } else if (strat === 'breakout') { renderBreakoutBacktest(payload); } else if (strat === 'momentum') { renderMomentumBacktest(payload); } else { if (payload && payload.equity) renderTailBacktest(payload); else if (rd.ok) renderTailBacktestFromCli(rd); } } async function btJobPollOnce() { const requestedId = _btJobId; if (!requestedId) return; try { const r = await fetch('/api/backtest/job/status?job_id=' + encodeURIComponent(requestedId)); if (requestedId !== _btJobId) return; const j = await r.json(); if (!j.ok) { if (r.status === 404 && requestedId === _btJobId) { _btJobId = ''; localStorage.removeItem(BTJOB_LS_KEY); localStorage.removeItem(BTJOB_LS_KEY_LEGACY); btJobSetNav(null); } return; } const job = j.job || {}; const jid = String(job.job_id || requestedId); if (jid !== _btJobId) return; btJobSetNav(job); const st = job.status; if (st === 'done' && _btJobRenderedId !== jid) { try { await btJobLoadResult(jid, job.strategy); if (jid !== _btJobId) return; _btJobRenderedId = jid; try { btJobGoTab(job.strategy); } catch (e2) {} } catch (e) { console.warn('btJobLoadResult', e); } } } catch (e) { /* ignore */ } } function btJobStartPolling() { if (_btJobPollTimer) clearInterval(_btJobPollTimer); btJobPollOnce(); _btJobPollTimer = setInterval(btJobPollOnce, 2000); } function btJobCollectBody(strategy) { const cfg = BTJOB_CFG[strategy]; if (!cfg) throw new Error('알 수 없는 전략: ' + strategy); const univOn = !!(cfg.univId && $(cfg.univId)?.checked); const body = { strategy, start: $(cfg.startId)?.value, end: $(cfg.endId)?.value, universe: univOn ? cfg.universeOn : cfg.universeOff, universe_history_source: cfg.univSrcId ? univHistorySourceParam(cfg.univSrcId) : 'kiwoom', // 폼에 호가체크 없으면 DB 따름(auto). scalp/breakout obId=null 이 off 강제되던 버그 수정 orderbook_filter: cfg.obId ? ($(cfg.obId)?.checked ? 'on' : 'off') : 'auto', env_timeline: cfg.envId ? envTimelineParam(cfg.envId) : 0, }; if (cfg.tfId && $(cfg.tfId)) body.timeframe = $(cfg.tfId).value || 3; if (cfg.tickId) body.backtest_use_tick_db = $(cfg.tickId)?.checked !== false ? 1 : 0; return body; } async function btJobStart(strategy) { const body = btJobCollectBody(strategy); if (!body.start || !body.end) { alert('시작·종료일을 입력하세요'); return; } if (!confirm( `${BTJOB_CFG[strategy].label} 백그라운드 시작?\n${body.start}~${body.end}\n` + `파라미터=현재 DB (+기간/유니버스)\n다른 전략 백테와 동시 실행 불가(전역 1잡).\n탭 이동 OK.` )) return; try { const r = await fetch('/api/backtest/job/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; } _btJobId = j.job.job_id; _btJobStrategy = strategy; _btJobRenderedId = ''; localStorage.setItem(BTJOB_LS_KEY, _btJobId); localStorage.removeItem(BTJOB_LS_KEY_LEGACY); btJobClearUi(strategy, `실행중 ${body.start}~${body.end}`); btJobSetNav(j.job); btJobStartPolling(); } catch (e) { alert('오류: ' + e); } } async function btJobStop() { if (!_btJobId) return; if (!confirm('백그라운드 백테를 중지할까요? (프로세스 정리)')) return; try { const r = await fetch('/api/backtest/job/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: _btJobId }), }); const j = await r.json(); if (!j.ok) { alert(j.error || '중지 실패'); return; } btJobSetNav(j.job); } catch (e) { alert('오류: ' + e); } } // 하위호환 function runTailBacktestJob() { return btJobStart('tail'); } function tlBtStop() { return btJobStop(); } function tlBtGoTab() { return btJobGoTab('tail'); } function renderTailBacktestFromCli(rd) { // CLI JSON → 웹 /api/backtest/tail 과 동일 스키마 (equity/daily/reasons/win_trades) // 없으면 renderTailBacktest 가 equity.map 에서 터져 승패·차트·거래내역이 안 나옴 const summary = rd.summary || (rd.data && rd.data.summary) || {}; const trades = rd.trades || (rd.data && rd.data.trades) || []; const meta = rd.meta || (rd.data && rd.data.meta) || {}; const pu = rd.params_used || (rd.data && rd.data.params_used) || {}; const sorted = trades.slice().sort((a, b) => String(a.exit_time || a.sell_time || '').localeCompare(String(b.exit_time || b.sell_time || '')) ); let cum = 0; let peak = 0; let peakAt = ''; let mdd = 0; const equity = []; const dailyMap = {}; const reasons = {}; for (const t of sorted) { const pnl = Number(t.pnl ?? t.realized_pnl ?? 0) || 0; cum += pnl; if (cum > peak) { peak = cum; peakAt = String(t.exit_time || t.sell_time || ''); } const dd = peak - cum; if (dd > mdd) mdd = dd; const et = String(t.exit_time || t.sell_time || '').replace(/[-: T]/g, ''); const day = et.length >= 8 ? `${et.slice(0, 4)}-${et.slice(4, 6)}-${et.slice(6, 8)}` : (et || '—'); equity.push({ date: day, cum_pnl: Math.round(cum), pnl: Math.round(pnl) }); dailyMap[day] = (dailyMap[day] || 0) + pnl; const rk = String(t.reason || t.sell_reason || '기타'); reasons[rk] = (reasons[rk] || 0) + 1; } const daily = Object.keys(dailyMap).sort().map(d => ({ date: d, pnl: Math.round(dailyMap[d]), })); const winsN = Number( summary.win_trades != null ? summary.win_trades : (summary.wins != null ? summary.wins : trades.filter(t => Number(t.pnl ?? t.realized_pnl ?? 0) > 0).length) ); const lossN = Number( summary.loss_trades != null ? summary.loss_trades : (summary.losses != null ? summary.losses : Math.max(0, trades.length - winsN)) ); const fake = { summary: { total_trades: summary.total_trades != null ? summary.total_trades : trades.length, win_trades: winsN, loss_trades: lossN, win_rate: summary.win_rate || 0, total_pnl: summary.total_pnl || 0, profit_factor: summary.profit_factor != null ? summary.profit_factor : (summary.pf || 0), max_drawdown: summary.max_drawdown != null ? summary.max_drawdown : (summary.mdd != null ? summary.mdd : Math.round(mdd)), avg_hold_min: summary.avg_hold_min || 0, bot_pct: summary.bot_pct || 0, daily_avg_pct: summary.daily_avg_pct || 0, peak_cum_pnl: summary.peak_cum_pnl != null ? summary.peak_cum_pnl : Math.round(peak), peak_cum_at: summary.peak_cum_at || peakAt, tick_backtest: summary.tick_backtest || meta.tick_backtest, backtest_buy_source: summary.backtest_buy_source || meta.backtest_buy_source, tick_entry_sources: summary.tick_entry_sources || meta.tick_entry_sources, skip_stats: summary.skip_stats || meta.skip_stats || {}, }, params: { universe_source: meta.universe_source || rd.universe_source || 'history', timeframe: rd.timeframe || pu.timeframe || 3, slot_money: pu.slot_money || 0, max_stocks: pu.max_stocks || 0, total_budget_krw: pu.total_budget_krw || 0, codes_analyzed: meta.codes_analyzed || meta.n_codes || (Array.isArray(meta.codes) ? meta.codes.length : (trades.length || 0)), min_drop_rate: (function () { const v = Number(pu.min_drop_rate); if (!Number.isFinite(v)) return pu.min_drop_rate ?? ''; // 엔진 비율(0.055) → UI % (5.5) return (v > 0 && v <= 1) ? (v * 100) : v; })(), min_recovery_ratio: (function () { const v = Number(pu.min_recovery_ratio); if (!Number.isFinite(v)) return pu.min_recovery_ratio ?? ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), tail_ratio_min: pu.tail_ratio_min ?? '', sl_pct: (function () { const v = Number(pu.sl_pct); if (!Number.isFinite(v)) return pu.sl_pct ?? ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), tp_pct: (function () { const v = Number(pu.tp_pct); if (!Number.isFinite(v)) return pu.tp_pct ?? ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), shoulder_min_high: (function () { const v = Number(pu.shoulder_min_high ?? pu.shoulder_min_high_pct); if (!Number.isFinite(v)) return ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), shoulder_cut_pct: (function () { const v = Number(pu.shoulder_cut_pct); if (!Number.isFinite(v)) return ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), trail_pct: pu.trail_pct ?? 0, trail_arm_pct: pu.trail_arm_pct ?? 0, max_loss_krw: pu.max_loss_krw, rsi_period: pu.rsi_period ?? '', rsi_threshold: pu.rsi_threshold ?? '', cooldown_min: pu.cooldown_min ?? '', time_window: pu.time_window || ( (pu.time_start != null && pu.time_end != null) ? `${pu.time_start}-${pu.time_end}` : '' ), max_daily: pu.max_daily ?? '', entry_mode: pu.entry_mode, limit_atr_mult: pu.limit_atr_mult, limit_anchor: pu.limit_anchor, limit_valid_bars: pu.limit_valid_bars, limit_fill_slip_pct: pu.limit_fill_slip_pct, }, equity, daily, reasons, trades, }; try { renderTailBacktest(fake); } catch (e) { console.warn('renderTailBacktestFromCli', e); alert( `백그라운드 완료(화면 렌더 실패)\n거래 ${summary.total_trades} · WR ${summary.win_rate}% · PnL ${summary.total_pnl}\n` + (rd.result_json || '') + `\n${e}` ); } } // 페이지 로드 시 이전 잡 폴링 재개 (탭 무관) if (_btJobId) { setTimeout(() => btJobStartPolling(), 800); } function renderTailBacktest(d) { const s = d.summary; const p = d.params; $('tl_params_bar').style.display = ''; const tlUniverseLabel = formatUniverseLabel(p); const tlSummaryLine = `${tlUniverseLabel} ` + (p.timeframe != null ? `${p.timeframe}분봉 ` : '') + `낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` + `손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락)` + (Number(p.trail_pct) > 0 ? ` | 보조트레일${p.trail_pct}%` + (Number(p.trail_arm_pct) > 0 ? `(무장${p.trail_arm_pct}%)` : '') : '') + ` | ` + (p.max_loss_krw != null ? `최대손실컷 ${Number(p.max_loss_krw).toLocaleString()}원 | ` : '') + `1회투자 ${Number(p.slot_money||0).toLocaleString()}원 | 동시${p.max_stocks||3}종 | 총한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` + `RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회/종목 | 종목수 ${p.codes_analyzed}개 | 시각순 포트폴리오` + formatLimitEntryLabel(p); $('tl_params_bar').innerHTML = tlSummaryLine; const tlCtx = $('tl_trade_context'); if (tlCtx) { fillTradePnLContext(tlCtx, { label: '백테 SHORT(꼬리)', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } $('tl_total').textContent = (s.total_trades||0) + '건'; $('tl_winrate').textContent = (s.win_rate||0) + '%'; colorPnl($('tl_winrate'), (s.win_rate||0) - 50); $('tl_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('tl_pnl'), s.total_pnl); $('tl_pf').textContent = ((s.profit_factor ?? s.pf) || 0) >= 999 ? '∞' : ((s.profit_factor ?? s.pf) || 0); colorPnl($('tl_pf'), ((s.profit_factor ?? s.pf) || 0) - 1); $('tl_mdd').textContent = '-' + fmtWon(s.max_drawdown ?? s.mdd ?? 0) + '원'; $('tl_hold').textContent = (s.avg_hold_min||0) + '분'; const tlSign = v => (v > 0 ? '+' : ''); $('tl_bot_pct').textContent = tlSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; colorPnl($('tl_bot_pct'), s.bot_pct || 0); $('tl_daily_avg_pct').textContent = tlSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'; colorPnl($('tl_daily_avg_pct'), s.daily_avg_pct || 0); if (s.universe_warning) { $('tl_params_bar').innerHTML += `
⚠️ ${s.universe_warning}
`; if (tlCtx) tlCtx.innerHTML += `
⚠️ ${s.universe_warning}
`; } if (s.budget_warning) { $('tl_params_bar').innerHTML += `
💰 ${s.budget_warning}
`; if (tlCtx) tlCtx.innerHTML += `
💰 ${s.budget_warning}
`; } const tickMeta = s.tick_backtest || {}; const buySrc = s.backtest_buy_source || ''; if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) { const covLabel = fmtTickCoverageLabel(tickMeta); const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—'; const srcLabel = buySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (buySrc === 'ohlc_fallback' ? 'OHLC 폴백' : buySrc); const tickColor = buySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; const tickLine = `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; $('tl_params_bar').innerHTML += tickLine; if (tlCtx) tlCtx.innerHTML += tickLine; const srcMap = s.tick_entry_sources || {}; const srcKeys = Object.keys(srcMap); if (srcKeys.length) { const srcDetail = srcKeys.map(k => `${k}:${srcMap[k]}`).join(' · '); const srcLine = `
진입출처 ${srcDetail}
`; $('tl_params_bar').innerHTML += srcLine; if (tlCtx) tlCtx.innerHTML += srcLine; } } // 매수큐 실매형 스캔주기(초)·유니버스 디바운스(초) — 모멘텀 동일 표기 const tlSkip = s.skip_stats || {}; if (tlSkip.buy_queue_mode) { const qm = tlSkip.buy_queue_mode === 'live_scan' ? `매수큐 실매형 ${tlSkip.scan_sec || 10}초 · 스캔${tlSkip.scan_events ?? '—'} · 매수${tlSkip.scan_buys ?? '—'}` : '매수큐 분봉(레거시)'; const um = tlSkip.universe_mode === 'scan_at' ? `유니버스 스캔시각·디바운스${tlSkip.universe_debounce_sec ?? 30}초` : (tlSkip.universe_mode === 'minute_slot' ? '유니버스 분슬롯' : ''); const line = um ? `🔄 ${qm} | ${um}` : `🔄 ${qm}`; $('tl_params_bar').innerHTML += `
${line}
`; if (tlCtx) tlCtx.innerHTML += `
${line}
`; } // 장중 누적손익 최고점 — 파라미터 바에 참고 표기 (거래내역 컨텍스트와 동일 수치) const tlPeakCum = Number(s.peak_cum_pnl || 0); const tlPeakAt = fmtTradeTime(s.peak_cum_at || ''); if (tlPeakCum > 0 && tlPeakAt) { const peakLine = `💡 장중 누적 최고 +${tlPeakCum.toLocaleString()}원 (${tlPeakAt})` + ` · 최종 ${fmtKrw(s.total_pnl)}`; $('tl_params_bar').innerHTML += `
${peakLine}
`; } if ((s.total_trades||0) > 0) { $('tl_winbar_card').style.display = ''; const wr = s.win_rate||0; const winN = s.win_trades != null ? s.win_trades : (s.wins || 0); const lossN = s.loss_trades != null ? s.loss_trades : (s.losses || 0); $('tl_win_label').textContent = `🟢 승 ${winN}건 (${wr}%)`; $('tl_loss_label').textContent = `🔴 패 ${lossN}건 (${(100-wr).toFixed(1)}%)`; $('tl_ratio_g').style.width = wr + '%'; $('tl_ratio_r').style.width = (100 - wr) + '%'; } // 누적 손익 곡선 (백그라운드 CLI 결과는 equity 없을 수 있음 → 빈 배열 허용) const tlEquity = Array.isArray(d.equity) ? d.equity : []; lineChart('tl_equity_chart', tlEquity.map(e => e.date), tlEquity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); // 매도 이유 도넛 const tlReasons = d.reasons || {}; const rKeys = Object.keys(tlReasons); doughnutChart('tl_reason_chart', rKeys, rKeys.map(k => tlReasons[k])); // 일별 바 const tlDaily = Array.isArray(d.daily) ? d.daily : []; barChart('tl_daily_chart', tlDaily.map(e => String(e.date || '').slice(5)), tlDaily.map(e => e.pnl)); renderVirtualTrades('tl_tbody', d.trades || [], { showDebug: true, showCumulative: true, totalBudget: Number((d.params && d.params.total_budget_krw) || 0), }); } // ──────────────────────────────────────────── // 홀딩 전략 // ──────────────────────────────────────────── let hdCurrentCode = null; let hdCurrentName = null; let hdCurrentCfg = {}; function hdLoadStocks() { fetch('/api/holding/stocks') .then(r => r.json()) .then(stocks => hdRenderStockList(stocks)) .catch(err => alert('종목 로드 실패: ' + err)); } function hdRenderStockList(stocks) { const list = $('hd_stock_list'); list.innerHTML = ''; stocks.forEach(s => { const candleInfo = s.candle_count > 0 ? `✅ 일봉 ${s.candle_count}봉 (${s.candle_min||'?'} ~ ${s.candle_max||'?'})` : `⚠️ 일봉 없음`; const min60Info = s.min60_count > 0 ? `⏱ 60분봉 ${s.min60_count}봉 (${s.min60_min||'?'} ~ ${s.min60_max||'?'})` : `⏱ 60분봉 없음`; list.insertAdjacentHTML('beforeend', `
${s.name||s.code} ${s.code}
${candleInfo}
${min60Info}
▶ 추세 설정
▶ RSI 매수/매도
▶ 분할 비중(%) — 추세 ON이면 1·2차만 사용. 합 100% 권장 (탐색·추세BT·저장 동일)
▶ 샹들리에 엑시트 (추세장 청산 — 고점 − ATR × 배수)
▶ 낙폭 필터 (0=비활성 / 예: 20 → 고점 대비 20% 이상 빠진 날만 매수)
`); }); } function hdGetCfg(code) { const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell', 'take_profit_pct','stop_loss_pct','slot_money', 'buy1_ratio','buy2_ratio','buy3_ratio', 'atr_period','atr_mult', 'ma_fast','ma_slow','trail_stop_pct','trend_filter', 'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct']; const cfg = {code}; fields.forEach(f => { const el = $(`cfg_${code}_${f}`); if (el) cfg[f] = parseFloat(el.value); }); return cfg; } function hdFetchCandles(code, name) { const start = $('hd_fetch_start').value; const end = $('hd_end').value; if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; } showSpinner(true); fetch('/api/holding/candles/fetch', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({code, start, end}), }) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } alert(`✅ ${name}(${code}) 일봉 수집 완료\n수집: ${d.fetched}봉 / 저장: ${d.saved}봉`); hdLoadStocks(); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function hdFetchMinCandlesKiwoom(code, name, tf) { tf = parseInt(String(tf != null ? tf : '60'), 10) || 60; const start = $('hd_fetch_start').value; const end = $('hd_end').value; if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; } if (!confirm(`🗂 ${name}(${code}) 키움 ${tf}분봉 수집 시작\n기간: ${start} ~ ${end}\n\n키움 API는 1회 약 900봉, 연속조회로 긴 구간 수집 가능.\nDB에 KIWOOM_APP_KEY / KIWOOM_APP_SECRET 설정 필요.\n계속하시겠습니까?`)) return; fetch('/api/holding/min_candles/fetch_kiwoom', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({code, start, end, tf, market_type: 'KR', exchange: 'KRX', symbol: code}), }) .then(r => r.json()) .then(d => { if (d.error) { alert('❌ ' + d.error); return; } const jobId = d.job_id; const statusEl = document.getElementById(`min_status_${code}`) || (() => { const el = document.createElement('span'); el.id = `min_status_${code}`; el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px'; return el; })(); function poll() { fetch(`/api/holding/min_candles/status/${jobId}`) .then(r => r.json()) .then(j => { if (j.status === 'running') { const jtf = j.tf != null ? j.tf : tf; statusEl.textContent = `⏳ 키움${jtf}분 수집중… ${j.fetched}봉 → 저장 ${j.saved}봉`; setTimeout(poll, 2000); } else if (j.status === 'done') { const jtf = j.tf != null ? j.tf : tf; statusEl.textContent = `✅ 완료! ${j.saved}봉 저장 (${jtf}분·키움)`; hdLoadStocks(); } else { statusEl.textContent = `❌ ${j.error}`; } }) .catch(() => setTimeout(poll, 3000)); } poll(); }) .catch(err => alert('오류: ' + err)); } function hdFetchMinCandles(code, name) { // 60분봉 수집(KIS) — 백그라운드 스레드로 실행, 폴링으로 진행상황 표시 const start = $('hd_fetch_start').value; const end = $('hd_end').value; if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; } if (!confirm(`⏱ ${name}(${code}) KIS 60분봉 수집 시작\n기간: ${start} ~ ${end}\n\n⚠️ KIS는 최근 2일치 한계. 긴 기간은 [키움 60분봉]을 사용하세요.\n계속하시겠습니까?`)) return; fetch('/api/holding/min_candles/fetch', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({code, start, end, tf: 60}), }) .then(r => r.json()) .then(d => { if (d.error) { alert('❌ ' + d.error); return; } const jobId = d.job_id; // 수집 상태 표시 영역 업데이트 (스피너 대신 인라인 표시) const btn = document.querySelector(`[onclick*="hdFetchMinCandles('${code}'"]`); const statusEl = document.getElementById(`min_status_${code}`) || (() => { const el = document.createElement('span'); el.id = `min_status_${code}`; el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px'; if (btn) btn.parentNode.insertBefore(el, btn.nextSibling); return el; })(); function poll() { fetch(`/api/holding/min_candles/status/${jobId}`) .then(r => r.json()) .then(j => { if (j.error) { statusEl.textContent = '❌ ' + j.error; return; } const d_str = j.current_date ? ` (${j.current_date.slice(0,4)}-${j.current_date.slice(4,6)}-${j.current_date.slice(6,8)})` : ''; if (j.status === 'running') { statusEl.textContent = `⏳ 수집중… 1분봉 ${j.fetched}개 → 저장 ${j.saved}봉${d_str}`; setTimeout(poll, 2000); } else if (j.status === 'done') { statusEl.textContent = `✅ 완료! 저장 ${j.saved}봉`; hdLoadStocks(); } else { statusEl.textContent = `❌ 오류: ${j.error}`; } }) .catch(() => setTimeout(poll, 3000)); } poll(); }) .catch(err => alert('오류: ' + err)); } function hdRunMinBacktest(code, name) { // 60분봉 기반 백테스트 — 현재 카드 파라미터 적용 const cfg = hdGetCfg(code); const start = $('hd_start').value; const end = $('hd_end').value; const qs = new URLSearchParams(); qs.set('code', code); qs.set('start', start); qs.set('end', end); qs.set('tf', '60'); Object.entries(cfg).forEach(([k, v]) => { if (k === 'code') return; if (v === undefined || v === null || Number.isNaN(v)) return; qs.set(k, String(v)); }); showSpinner(true); fetch('/api/holding/min_backtest?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } hdCurrentCode = code; hdCurrentName = name; hdCurrentCfg = cfg; // 결과 렌더링 — 기존 일봉 백테스트와 동일 함수 재사용, 제목만 구분 hdRenderResult(d, `[${name}] 60분봉 백테스트 결과 (${d.candle_count}봉)`); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function hdRunBacktest(code, name) { const cfg = hdGetCfg(code); const start = $('hd_start').value; const end = $('hd_end').value; const qs = new URLSearchParams(); qs.set('code', code); qs.set('start', start); qs.set('end', end); Object.entries(cfg).forEach(([k, v]) => { if (k === 'code') return; if (v === undefined || v === null || Number.isNaN(v)) return; qs.set(k, String(v)); }); showSpinner(true); fetch('/api/holding/backtest?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } hdCurrentCode = code; hdCurrentName = name; hdRenderResult(d, name); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function hdGetV1Cfg(code) { // V1 전략 파라미터: 카드에서 RSI/낙폭 관련 값만 수집 (MA/추세 파라미터 제외) const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell', 'take_profit_pct','stop_loss_pct','slot_money', 'buy1_ratio','buy2_ratio','buy3_ratio', 'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct']; const cfg = {code}; fields.forEach(f => { const el = $(`cfg_${code}_${f}`); if (el) cfg[f] = parseFloat(el.value); }); return cfg; } function hdRunV1Backtest(code, name) { // V1 RSI 분할매수 백테스트 const cfg = hdGetV1Cfg(code); const start = $('hd_start').value; const end = $('hd_end').value; const qs = new URLSearchParams(); qs.set('code', code); qs.set('start', start); qs.set('end', end); Object.entries(cfg).forEach(([k, v]) => { if (k === 'code') return; if (v === undefined || v === null || Number.isNaN(v)) return; qs.set(k, String(v)); }); showSpinner(true); fetch('/api/holding/v1/backtest?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } hdRenderResult(d, name + ' [분할매수V1]'); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function hdMinTradesQs() { const el = $('hd_min_trades'); if (!el) return 1; let v = parseInt(el.value, 10); if (isNaN(v)) return 1; return Math.max(0, Math.min(30, v)); } function hdV1ParamSearch(code, name) { // V1 RSI 분할매수 파라미터탐색 const cfg = hdGetV1Cfg(code); const start = $('hd_start').value; const end = $('hd_end').value; const minTr = hdMinTradesQs(); const qs = new URLSearchParams(); qs.set('code', code); qs.set('start', start); qs.set('end', end); qs.set('min_trades', String(minTr)); Object.entries(cfg).forEach(([k, v]) => { if (k === 'code') return; if (v === undefined || v === null || Number.isNaN(v)) return; qs.set(k, String(v)); }); showSpinner(true); fetch('/api/holding/v1/param_search?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } hdRenderParamSearch(d.top, code, name + ' [분할매수V1]', d.meta); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function hdParamSearch(code, name) { const start = $('hd_start').value; const end = $('hd_end').value; // 카드의 현재 파라미터를 기준값으로 전달 (rsi_sell, rsi_period 등 그리드에 없는 값도 정확히 반영) const cfg = hdGetCfg(code); const minTr = hdMinTradesQs(); const qs = new URLSearchParams(); qs.set('code', code); qs.set('start', start); qs.set('end', end); qs.set('min_trades', String(minTr)); Object.entries(cfg).forEach(([k, v]) => { if (k === 'code') return; if (v === undefined || v === null || Number.isNaN(v)) return; qs.set(k, String(v)); }); showSpinner(true); fetch('/api/holding/param_search?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } hdRenderParamSearch(d.top, code, name, d.meta); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function hdSaveConfig(code, name) { const cfg = hdGetCfg(code); cfg.name = name; fetch(`/api/holding/config/${code}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(cfg), }) .then(async r => { const ct = r.headers.get('content-type') || ''; const raw = await r.text(); if (!ct.includes('application/json')) { throw new Error(`HTTP ${r.status} — JSON이 아닌 응답(로그인/404/서버오류 페이지일 수 있음): ${raw.slice(0, 200)}`); } let d; try { d = JSON.parse(raw); } catch (e) { throw new Error('JSON 파싱 실패: ' + raw.slice(0, 120)); } if (!r.ok) throw new Error(d.error || d.message || `HTTP ${r.status}`); return d; }) .then(d => { if (d.ok) alert(`✅ ${name}(${code}) 파라미터 저장 완료`); else alert('❌ ' + (d.error || '저장 실패')); }) .catch(err => alert('오류: ' + err)); } function hdApplySearchResult(code, name, row) { // apply_cfg: 탐색에 사용된 전체 cfg / params: 그리드만(구버전) / 그 외: row 자체가 cfg dict 인 경우 let src; if (row && row.apply_cfg && typeof row.apply_cfg === 'object') { src = row.apply_cfg; } else if (row && row.params && typeof row.params === 'object') { src = row.params; } else { src = row; } Object.entries(src).forEach(([k, v]) => { if (v === undefined || v === null) return; const el = $(`cfg_${code}_${k}`); if (!el) return; el.value = String(v); }); alert(`✅ ${name} 탐색 결과를 카드에 반영했습니다.\n💾 설정저장 버튼을 눌러 DB에 저장하세요.`); } function hdRenderResult(d, name) { const s = d.summary || {}; $('hd_result_area').style.display = ''; $('hd_search_result').style.display = 'none'; const hHint = $('hd_search_hint'); if (hHint) { hHint.style.display = 'none'; hHint.textContent = ''; } const nBar = d.candle_count != null ? d.candle_count : ((d.candle_rows && d.candle_rows.length) || 0); $('hd_result_title').textContent = `📈 [${name}] 백테스트 결과 (${nBar}봉)`; const pe = $('hd_bt_params_echo'); if (pe) { if (d.params) { const p = d.params; const tfOn = Number(p.trend_filter) >= 0.5; pe.style.display = ''; pe.innerHTML = '이번 실행에 쓰인 파라미터(서버 반영): ' + `MA ${p.ma_fast}/${p.ma_slow} · 매수RSI≤ ${p.rsi_buy1}/${p.rsi_buy2} · RSI기간 ${p.rsi_period} · 비중 ${p.buy1_ratio}/${p.buy2_ratio}/${p.buy3_ratio}%` + ` · 투자금 ${fmt(p.slot_money)}원 · 추세필터 ${tfOn ? 'ON' : 'OFF'} · 샹들리에×${p.atr_mult}
` + '요약의 봇 수익률·거래 건수가 안 바뀌면, 바꾼 값이 그 구간에선 진입/청산 조건을 바꾸지 못한 것입니다(전일비 표와는 별개).'; } else { pe.style.display = 'none'; pe.textContent = ''; } } $('hd_total').textContent = (s.total_trades||0) + '건'; $('hd_wr').textContent = (s.win_rate||0) + '%'; colorPnl($('hd_wr'), (s.win_rate||0) - 50); $('hd_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('hd_pnl'), s.total_pnl); $('hd_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0); colorPnl($('hd_pf'), (s.profit_factor||0) - 1); $('hd_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('hd_hold').textContent = (s.avg_hold_days||0) + '일'; const sign = v => (v > 0 ? '+' : ''); if ($('hd_bot_pct_sum')) { $('hd_bot_pct_sum').textContent = sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; colorPnl($('hd_bot_pct_sum'), s.bot_pct || 0); } if ($('hd_daily_avg_pct')) { let dAvg = s.daily_avg_pct; if (dAvg == null && s.bot_pct != null) { // 홀딩은 일봉 — 보유일 평균이 있으면 그걸로, 없으면 bot_pct 그대로 표시용 const holdD = Number(s.avg_hold_days || 0); dAvg = holdD > 0 ? Math.round((Number(s.bot_pct) / Math.max(1, holdD)) * 1000) / 1000 : s.bot_pct; } $('hd_daily_avg_pct').textContent = (dAvg == null) ? '-' : (sign(dAvg) + dAvg + '%'); if (dAvg != null) colorPnl($('hd_daily_avg_pct'), dAvg); } // Buy & Hold 비교 (전체 구간) if (s.bnh_pct !== undefined && s.bnh_pct !== null) { $('hd_bnh_row').style.display = ''; $('hd_bot_pct').textContent = sign(s.bot_pct||0) + (s.bot_pct||0) + '%'; colorPnl($('hd_bot_pct'), s.bot_pct||0); $('hd_bnh_pct').textContent = sign(s.bnh_pct) + s.bnh_pct + '%'; colorPnl($('hd_bnh_pct'), s.bnh_pct); $('hd_bnh_pnl').textContent = sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl); colorPnl($('hd_bnh_pnl'), s.bnh_pnl); $('hd_alpha').textContent = sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p'; colorPnl($('hd_alpha'), s.alpha_pct||0); } else { $('hd_bnh_row').style.display = 'none'; } const alRow = $('hd_bnh_aligned_row'); if (alRow) { if (s.bnh_aligned_pct !== undefined && s.bnh_aligned_pct !== null) { alRow.style.display = ''; $('hd_bnh_aligned_pct').textContent = sign(s.bnh_aligned_pct) + s.bnh_aligned_pct + '%'; colorPnl($('hd_bnh_aligned_pct'), s.bnh_aligned_pct); $('hd_bnh_aligned_pnl').textContent = sign(s.bnh_aligned_pnl) + fmtKrw(s.bnh_aligned_pnl); colorPnl($('hd_bnh_aligned_pnl'), s.bnh_aligned_pnl); const aal = s.alpha_aligned_pct != null ? s.alpha_aligned_pct : 0; $('hd_alpha_aligned').textContent = sign(aal) + aal + '%p'; colorPnl($('hd_alpha_aligned'), aal); } else { alRow.style.display = 'none'; } } const cBlk = $('hd_candle_block'); const cTb = $('hd_candle_tbody'); if (cBlk && cTb) { const rows = d.candle_rows || []; if (rows.length) { cBlk.style.display = ''; cTb.innerHTML = ''; rows.forEach(r => { const up = '#f85149', dn = '#58a6ff', fl = 'var(--muted)'; const cWon = r.chg_won, cPct = r.chg_pct; const colW = r.dir > 0 ? up : (r.dir < 0 ? dn : fl); const colP = colW; const wStr = (cWon == null) ? '—' : ((cWon > 0 ? '+' : '') + fmt(cWon)); const pStr = (cPct == null) ? '—' : ((cPct > 0 ? '+' : '') + Number(cPct).toFixed(2) + '%'); cTb.insertAdjacentHTML('beforeend', ` ${r.dt || r.date} ${fmt(r.open)}${fmt(r.high)}${fmt(r.low)}${fmt(r.close)} ${wStr} ${pStr} `); }); } else { cBlk.style.display = 'none'; cTb.innerHTML = ''; } } lineChart('hd_equity_chart', d.equity.map(e=>e.date), d.equity.map(e=>e.cum_pnl), '누적손익', '#bc8cff'); const rKeys = Object.keys(d.reasons||{}); doughnutChart('hd_reason_chart', rKeys, rKeys.map(k=>d.reasons[k])); renderVirtualTrades('hd_trade_tbody', d.trades || [], { meta: { code: hdCurrentCode || '', name: hdCurrentName || '' }, showCumulative: true, totalBudget: Number((d.params && (d.params.total_budget_krw || d.params.slot_money)) || 0) || 1000000, }); const hdBudget = Number((d.params && (d.params.total_budget_krw || d.params.slot_money)) || 0) || 1000000; let hdPeak = Number(s.peak_cum_pnl || 0); let hdPeakAt = s.peak_cum_at || ''; if (!(hdPeak > 0)) { let cum = 0; const ordered = [...(d.trades || [])].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); for (const t of ordered) { cum += Number(t.pnl ?? t.realized_pnl ?? 0); if (cum > hdPeak) { hdPeak = cum; hdPeakAt = t.sell_time || t.exit_time || t.sell_date || ''; } } } fillTradePnLContext('hd_trade_context', { label: '백테 HOLDING', totalBudget: hdBudget, peakCum: hdPeak, peakAt: hdPeakAt, totalPnl: s.total_pnl, }); $('hd_result_area').scrollIntoView({behavior:'smooth'}); } function hdRenderParamSearch(top, code, name, meta) { $('hd_result_area').style.display = ''; $('hd_search_result').style.display = ''; if ($('hd_bnh_aligned_row')) $('hd_bnh_aligned_row').style.display = 'none'; if ($('hd_candle_block')) $('hd_candle_block').style.display = 'none'; const pe = $('hd_bt_params_echo'); if (pe) { pe.style.display = 'none'; pe.textContent = ''; } $('hd_result_title').textContent = `🔍 [${name}] 파라미터 탐색 결과`; // ── 파라미터 키 한글 레이블 매핑 ────────────────────────────── const labelMap = { ma_fast: 'MA단기', ma_slow: 'MA장기', atr_mult: '샹들리에배수', atr_period: 'ATR기간', trail_stop_pct: '트레일%', trend_filter: '추세필터', rsi_buy1: '매수1', rsi_buy2: '매수2', rsi_buy3: '매수3', rsi_sell: '매도RSI', take_profit_pct: '익절%', stop_loss_pct: '손절%', ath_drop_min_pct: 'ATH낙폭%', year_drop_min_pct: '연도낙폭%', w52_drop_min_pct: '52주낙폭%', }; const fixedLabel = (k) => labelMap[k] || k; // ── 실제 params 키로 헤더 동적 생성 ─────────────────────────── const keys = top && top.length ? Object.keys(top[0].params) : []; const thead = $('hd_search_thead'); thead.innerHTML = '' + keys.map(k => `${labelMap[k] || k}`).join('') + '손익(원)승률거래PF보유(일)적용'; const tbody = $('hd_search_tbody'); tbody.innerHTML = ''; (top||[]).forEach((r, idx) => { const p = r.params; const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : 'text-pnl-neg'; const paramCells = keys.map(k => { const v = p[k]; if (v === undefined || v === null) return '-'; // % 단위 컬럼은 % 표시 const isPct = k.includes('pct') || k.includes('rate'); return `${isPct ? v+'%' : v}`; }).join(''); const rowJson = JSON.stringify(r).replace(/ ${paramCells} ${fmtWon(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold}일 `); }); const hint = $('hd_search_hint'); if (hint) { hint.style.display = ''; let metaHtml = ''; if (meta && meta.cartesian_product != null) { const fk = (meta.fixed_param_keys || []).map(fixedLabel).join(', '); const gk = (meta.grid_keys || []).map(fixedLabel).join(', '); const mtNote = meta.min_trades === 0 ? '(필터 없음)' : `≥${meta.min_trades}`; metaHtml = `
탐색 동작 요약: 전체 그리드 조합 ${meta.cartesian_product}개 중 ` + `백테스트 실행 ${meta.backtests_run}회 → TOP 후보 ${meta.passed}개만 표시(최소거래 ${mtNote}). ` + `제외: MA무효 ${meta.skipped_invalid_ma}, RSI순서무효 ${meta.skipped_invalid_rsi_order}, ` + `백테오류 ${meta.skipped_backtest_error}, 거래수부족 ${meta.skipped_below_min_trades}.
` + `그리드에서 바꾸는 항목: ${gk}. ` + `카드값으로 고정(탐색 안 함): ${fk || '—'}.
` + `실매매 봇은 DB에 저장된 종목 설정만 사용합니다. TOP을 쓰려면 [적용] → [저장] 하세요.
` + `탐색 baseDB 최근 저장 전체에 카드 입력을 덮어씁니다(비중1·2·3·투자금이 추세BT와 같게 맞춰짐).`; } hint.innerHTML = '표의 숫자는 각 행마다 다른 파라미터 조합으로 돌린 백테스트 결과입니다(고정이 아님). ' + 'ATH낙폭% 등은 그리드에 포함된 경우에만 바뀝니다.
' + 'Buy & Hold 비교는 백테스트 화면의 추세BT 결과를 참고하세요.' + metaHtml; } if ((!top || !top.length) && meta && $('hd_search_hint')) { $('hd_search_hint').innerHTML += '
TOP이 비었습니다. 위 「탐색 최소거래」를 0 또는 1로 낮추거나, 백테스트 기간을 넓혀 보세요.'; } $('hd_result_area').scrollIntoView({behavior:'smooth'}); } // ──────────────────────────────────────────── // 운영 설정 탭 (live_config) // ──────────────────────────────────────────── let _lcTimer = null; let _lcData = null; function lcInitDate() { const el = $('lc_date'); if (!el) return; if (!el.value) { el.value = kstTradingDayIso(_krHolidays); } else { el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays); } } function lcOnTabShow() { lcInitDate(); lcLoad(); lcStartAutoRefresh(); } function lcStartAutoRefresh() { if (_lcTimer) clearInterval(_lcTimer); _lcTimer = setInterval(() => { const tab = document.querySelector('[data-tab].active'); if (!tab || tab.dataset.tab !== 'liveconfig') return; if ($('lc_auto_refresh') && !$('lc_auto_refresh').checked) return; lcLoad(true); }, 30000); } function lcEsc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(/
총합 봇 실현${acctLbl ? ' · ' + acctLbl : ''}
${dashFmtKrw(g.realized_pnl_krw)}
목표 ${dashFmtKrw(g.target_krw)} / ${Number(g.target_pct || 0).toFixed(2)}% · 한도 ${dashFmtKrw(g.budget_krw)} · ${haltedTxt}
`; (status.strategies || []).forEach(row => { if (!row.enabled) return; const subHalt = row.buy_halted ? 'badge-loss">중단' : 'badge-win">허용'; html += `
${lcEsc(row.label)} ${lcEsc(row.strategy_id)}
${dashFmtKrw(row.realized_pnl_krw)}
${Number(row.return_pct || 0).toFixed(2)}% · ${lcEsc(sidMatch[1])}` : ''; toggles += ``; }); root.innerHTML = `
${lcEsc(g.title || '전략 ON/OFF')}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
${toggles}
`; } function lcFieldInput(f) { const id = 'lc_' + f.key; const hint = f.hint ? `
${lcEsc(f.hint)}
` : ''; const tbl = f.table ? `${lcEsc(f.table)}` : ''; const keyTag = f.key ? `${lcEsc(f.key)}` : ''; const isDanger = !!(f.danger) || /FALLBACK_OHLC/i.test(String(f.key || '')); const wrapCls = isDanger ? 'lc-field lc-field-danger' : 'lc-field'; if (f.type === 'bool') { const chk = f.value ? 'checked' : ''; return `
${tbl}${hint}
`; } if (f.type === 'text') { const val = f.value != null ? f.value : ''; return `
${tbl} ${hint}
`; } if (f.type === 'select' && Array.isArray(f.choices)) { const val = f.value != null ? String(f.value) : ''; const valNorm = val.toLowerCase(); let matched = false; const opts = f.choices.map(c => { const cv = (typeof c === 'object') ? String(c.value) : String(c); const cl = (typeof c === 'object') ? String(c.label != null ? c.label : c.value) : String(c); const sel = (cv === val || cv.toLowerCase() === valNorm) ? ' selected' : ''; if (sel) matched = true; return ``; }).join(''); const extra = (!matched && val) ? `` : ''; return `
${tbl} ${hint}
`; } const step = f.type === 'int' ? '1' : '0.01'; const val = f.value != null ? f.value : ''; return `
${tbl} ${hint}
`; } /** env 키 → 전략 구역 ID (카테고리 안 시각 구분용) */ function lcInferStrategy(key) { const k = String(key || '').toUpperCase(); const m = k.match(/^STRATEGY_([A-Z0-9_]+)_ENABLED$/); if (m) return m[1]; // 긴 prefix 우선 const pairs = [ ['RANGE_BREAK_', 'RANGE_BREAK'], ['UPDOWN_', 'UPDOW'], ['UPDOW_', 'UPDOW'], ['BREAKOUT_', 'BREAKOUT'], ['MOMENTUM_', 'MOMENTUM'], ['SCALP_', 'SCALP'], ['DBBAND_', 'DBBAND'], ['TAIL_', 'SHORT'], ['SHORT_', 'SHORT'], ]; for (let i = 0; i < pairs.length; i++) { if (k.startsWith(pairs[i][0])) return pairs[i][1]; } return 'COMMON'; } /** 공통 섹션용 — 주제(호가/프로그램/휩쏘…)로 한 번 더 묶기 */ function lcInferTopic(key) { const k = String(key || '').toUpperCase(); if (k.includes('ORDERBOOK') || k.includes('SPREAD')) return 'orderbook'; if (k.includes('PROGRAM')) return 'program'; if (k.includes('WHIPSAW')) return 'whipsaw'; if (k.startsWith('WS_TRIGGER') || k.includes('TRIGGER_EVAL')) return 'trigger_master'; if (k.includes('PENDING') || k.includes('FILL') || k.includes('ORDER_') || k.includes('IOC') || k.includes('DEDUP') || k.includes('SLIP') || k.includes('STRICT_FILL') || k.includes('CANCEL_PARTIAL') || k.includes('DUPLICATE_ORDER')) return 'fill'; if (k.includes('BACKTEST') || k.includes('PARAM_SEARCH') || k.includes('POLL_MS') || k.includes('TICK_')) return 'backtest'; if (k.startsWith('KIS_') || k.includes('BALANCE_MAX') || k.includes('INTERVAL_SEC')) return 'rest'; if (k.startsWith('SCAN_') || k.startsWith('STRATEGY_LOOP')) return 'scan'; if (k.includes('ORPHAN') || k.includes('GHOST') || k.includes('MANUAL_HOLD') || k.includes('BULK_SELL') || k.includes('DRIFT')) return 'portfolio'; if (k.includes('DAILY_PROFIT') || k.includes('DAILY_STOP') || k.includes('CONSECUTIVE_LOSS') || k.includes('USE_RISK')) return 'risk'; return 'misc'; } const LC_TOPIC_ORDER = [ 'trigger_master', 'orderbook', 'program', 'whipsaw', 'fill', 'backtest', 'rest', 'scan', 'risk', 'portfolio', 'misc', ]; const LC_TOPIC_LABELS = { trigger_master: 'TRIGGER 마스터', orderbook: '호가', program: '프로그램', whipsaw: '휩쏘', fill: '체결·주문', backtest: '백테·틱청산', rest: 'REST 유량', scan: '스캔 루프', risk: '리스크', portfolio: '보유·고아', misc: '기타', }; const LC_STRAT_ORDER = [ 'COMMON', 'SHORT', 'MOMENTUM', 'BREAKOUT', 'UPDOW', 'SCALP', 'RANGE_BREAK', 'DBBAND', 'OTHER', ]; const LC_STRAT_LABELS = { COMMON: '공통', SHORT: '꼬리잡기', MOMENTUM: '모멘텀', BREAKOUT: '돌파', UPDOW: 'UPDOWN 박스', SCALP: '스캘핑', RANGE_BREAK: '박스권돌파', DBBAND: '더블BB', OTHER: '기타', }; /** 한 카테고리 fields → 전략별 구역 배열 */ function lcPartitionFieldsByStrategy(fields) { const buckets = {}; (fields || []).forEach(f => { let sid = lcInferStrategy(f.key); if (LC_STRAT_ORDER.indexOf(sid) < 0) sid = 'OTHER'; if (!buckets[sid]) buckets[sid] = []; buckets[sid].push(f); }); return LC_STRAT_ORDER .filter(sid => buckets[sid] && buckets[sid].length) .map(sid => ({ sid, label: LC_STRAT_LABELS[sid] || sid, fields: buckets[sid], })); } /** 공통 필드만 있을 때 주제별로 재분할 (2구역 이상이면 사용) */ function lcPartitionFieldsByTopic(fields) { const buckets = {}; (fields || []).forEach(f => { const tid = lcInferTopic(f.key); if (!buckets[tid]) buckets[tid] = []; buckets[tid].push(f); }); return LC_TOPIC_ORDER .filter(tid => buckets[tid] && buckets[tid].length) .map(tid => ({ sid: 'topic', topicId: tid, label: LC_TOPIC_LABELS[tid] || tid, fields: buckets[tid], })); } function lcRenderFieldGrid(fields) { let html = '
'; (fields || []).forEach(f => { html += lcFieldInput(f); }); html += '
'; return html; } function lcRenderBlock(p) { const cls = p.sid === 'topic' ? 'lc-strat-block lc-strat-topic' : ('lc-strat-block lc-strat-' + String(p.sid).toLowerCase()); const sidTag = (p.sid === 'COMMON' || p.sid === 'topic') ? '' : `${lcEsc(p.sid)}`; return `
${lcEsc(p.label)}${sidTag}
${lcRenderFieldGrid(p.fields)}
`; } function lcRenderGroups(groups) { const root = $('lc_groups'); if (!root) return; const switchGroup = (groups || []).find(g => g.id === 'strategy_switch'); lcRenderStrategySwitch(switchGroup || null); let html = ''; (groups || []).forEach(g => { if (g.id === 'strategy_switch') return; const parts = lcPartitionFieldsByStrategy(g.fields || []); const splitStrat = parts.length > 1; // 전략이 하나뿐(공통만)이면 주제로 한 번 더 나눔 let renderParts = parts; let useBlocks = splitStrat; if (!splitStrat && parts.length === 1) { const topics = lcPartitionFieldsByTopic(parts[0].fields); if (topics.length > 1) { renderParts = topics; useBlocks = true; } } html += `
${lcEsc(g.title)}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
`; if (useBlocks) { html += '
'; renderParts.forEach(p => { html += lcRenderBlock(p); }); html += '
'; } else { html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []); } html += '
'; }); root.innerHTML = html; } function lcCollectPatch(scopeGroupId) { const patch = {}; const root = scopeGroupId ? document.querySelector(`.lc-group[data-lc-group="${scopeGroupId}"]`) : $('tab-liveconfig'); if (!root) return patch; root.querySelectorAll('[data-lc-key]').forEach(el => { const key = el.dataset.lcKey; const typ = el.dataset.lcType || 'text'; if (typ === 'bool') { patch[key] = el.checked; } else { patch[key] = el.value; } }); return patch; } async function lcLoad(silent) { lcInitDate(); const day = $('lc_date').value; if (!silent) showSpinner(true); try { const groupsReady = $('lc_groups') && $('lc_groups').children.length; const q = '/api/live_config?date=' + encodeURIComponent(day) + accountQueryParam('lc_account') + (silent && groupsReady ? '&status_only=1' : ''); const r = await fetch(q); const d = await r.json(); if (!d.ok) { if (!silent) alert(d.error || '운영 설정 조회 실패'); return; } _lcData = d; const st = d.status || {}; $('lc_meta').textContent = '기준일 ' + d.date + (st.account_label ? ' · 계좌 ' + st.account_label : '') + ' · 갱신 ' + (d.as_of || ''); lcRenderStatus(d.status); if (!silent || !groupsReady) { if (d.groups) lcRenderGroups(d.groups); } } catch (e) { if (!silent) alert('운영 설정 조회 오류: ' + e); } finally { if (!silent) showSpinner(false); } } async function lcSavePatch(patch, label) { if (!patch || !Object.keys(patch).length) { alert('저장할 값이 없습니다.'); return; } if (!confirm('💾 ' + (label || '운영 설정') + '을(를) DB에 저장할까요?\n봇은 재시작 없이 다음 루프부터 반영됩니다.')) return; showSpinner(true); const resEl = $('lc_save_result'); try { const r = await fetch('/api/live_config/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ patch }), }); const d = await r.json(); if (!d.ok) { alert(d.error || '저장 실패'); return; } const tbl = d.saved_by_table ? Object.entries(d.saved_by_table).map(([t, ks]) => t + ': ' + ks.length + '키').join(' · ') : ''; resEl.innerHTML = `✅ 저장 완료 (env_id=${d.env_id}) — ${tbl}`; await lcLoad(false); } catch (e) { alert('저장 오류: ' + e); } finally { showSpinner(false); } } function lcSaveGroup(groupId) { lcSavePatch(lcCollectPatch(groupId), '섹션 ' + groupId); } function lcSaveAll() { lcSavePatch(lcCollectPatch(null), '전체 운영 설정'); } // ════════════════════════════════════════════════════════════════ // 박스엔진 UPDOWN (kis_trader/engine/updown_box) // watchlist(sticky) 관리 + 박스 백테 + 글로벌 파라서치 // ════════════════════════════════════════════════════════════════ // 입력란 id ↔ 박스 cfg 키 매핑 (env UPDOWN_BOX_* 와 1:1) const UBX_PARAM_FIELDS = [ 'lookback_bars', 'range_max_pct', 'bb_bw_max', 'ma_slope_tol_pct', 'near_low_pct', 'tp_pct', 'stop_loss_pct', 'breach_buffer_pct', 'panic_from_high_pct', 'shoulder_min_high_pct', 'shoulder_cut_pct', 'max_hold_bars', 'breakout_follow', ]; function ubxOnTabShow() { // 날짜 기본값 — 주말/휴장이면 이전 장운영일 const end = $('ubx_end'), start = $('ubx_start'); if (end && !end.value) end.value = kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); else if (end && end.value) end.value = kstClampToPrevTradingDayIso(end.value, _krHolidays); if (start && !start.value) { const endIso = (end && end.value) ? end.value : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); start.value = kstClampToPrevTradingDayIso(kstAddDaysIso(endIso, -180), _krHolidays); } else if (start && start.value) { start.value = kstClampToPrevTradingDayIso(start.value, _krHolidays); } ubxLoadCfg(); ubxLoadWatch(); ubxLoadStockList(); } /** 종목 셀렉트(datalist) 채우기 — watchlist + 종목별 핀 등록 종목 */ function ubxLoadStockList() { fetch('/api/updown_box/stock_list') .then(r => r.json()) .then(d => { const dl = $('ubx_stock_options'); if (!dl) return; const items = d.items || []; dl.innerHTML = items.map(it => { const pin = it.n_overrides > 0 ? ' ★핀' : ''; const st = it.status ? ` (${it.status})` : ''; return ``; }).join(''); }) .catch(e => console.warn('[ubx stock_list]', e)); } /** 종목 선택 시 그 종목의 박스 파라미터(있으면 핀, 없으면 글로벌) → 입력란 */ function ubxLoadStockCfg(code) { code = String(code || '').trim(); const badge = $('ubx_stock_cfg_badge'); if (!/^\d{6}$/.test(code)) { if (badge) badge.innerHTML = ''; return; } fetch('/api/updown_box/stock_cfg?code=' + encodeURIComponent(code)) .then(r => r.json()) .then(d => { if (d.error) { if (badge) badge.innerHTML = ''; return; } const eff = d.effective || {}; UBX_PARAM_FIELDS.forEach(k => { const el = $('ubx_' + k); if (el && eff[k] != null) el.value = eff[k]; }); const rt = $('ubx_ratchet_tiers'); if (rt) rt.value = (eff.ratchet_tiers != null ? eff.ratchet_tiers : ''); if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors(); const n = Object.keys(d.overrides || {}).length; if (badge) { badge.innerHTML = n > 0 ? `★ 종목별 핀 ${n}개 적용중 (저장값 로드)` : `글로벌 사용중 (핀 없음)`; } }) .catch(e => console.warn('[ubx stock_cfg]', e)); } /** 현재 입력값을 [종목코드] 종목별 파라미터로 고정 저장(핀) */ function ubxSaveStockCfg() { const code = ($('ubx_code').value || '').trim(); if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; } const overrides = {}; UBX_PARAM_FIELDS.forEach(k => { const el = $('ubx_' + k); if (el && el.value !== '' && el.value != null) overrides[k] = el.value; }); const rt = $('ubx_ratchet_tiers'); if (rt) overrides.ratchet_tiers = String(rt.value || '').trim(); // '' = 래칫 OFF if (!confirm(code + ' 종목을 이 파라미터로 고정(핀)할까요?\n실매·백테 모두 이 종목엔 글로벌 대신 이 값이 적용됩니다.')) return; fetch('/api/updown_box/stock_cfg', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '', overrides }), }) .then(r => r.json()) .then(d => { if (!d.ok) { alert('저장 실패: ' + (d.error || '')); return; } alert('✅ ' + code + ' 종목별 핀 저장 (' + (d.saved_keys || []).length + '개 파라미터)'); ubxLoadStockList(); ubxLoadStockCfg(code); }) .catch(e => alert('오류: ' + e)); } /** 이 종목의 핀 해제 → 글로벌 복귀 */ function ubxClearStockCfg() { const code = ($('ubx_code').value || '').trim(); if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; } if (!confirm(code + ' 종목별 핀을 해제하고 글로벌로 복귀할까요?')) return; fetch('/api/updown_box/stock_cfg', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, clear: '1' }), }) .then(r => r.json()) .then(d => { if (!d.ok) { alert('해제 실패: ' + (d.error || '')); return; } alert('↩ ' + code + ' 글로벌로 복귀'); ubxLoadStockList(); ubxLoadCfg(); ubxLoadStockCfg(code); }) .catch(e => alert('오류: ' + e)); } /** 운영설정 UPDOWN_BOX_* 현재값 → 입력란 + watchlist 통계 */ function ubxLoadCfg() { fetch('/api/updown_box/config') .then(r => r.json()) .then(d => { if (!d.ok) { console.warn('[ubx cfg]', d.error); return; } const c = d.cfg || {}; UBX_PARAM_FIELDS.forEach(k => { const el = $('ubx_' + k); if (el && c[k] != null) el.value = c[k]; }); const rt = $('ubx_ratchet_tiers'); if (rt) rt.value = (c.ratchet_tiers != null ? c.ratchet_tiers : ''); if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors(); if ($('ubx_tf') && d.scan_tf_min) $('ubx_tf').value = String(d.scan_tf_min); const st = $('ubx_watch_stat'); if (st) st.textContent = `— active ${d.watch_active} / max ${d.watch_max}`; }) .catch(e => console.warn('[ubx cfg]', e)); } /** watchlist 목록 표 */ function ubxLoadWatch() { const activeOnly = $('ubx_watch_active_only') && $('ubx_watch_active_only').checked; fetch('/api/updown_box/watchlist?active=' + (activeOnly ? '1' : '0')) .then(r => r.json()) .then(d => { const tb = $('ubx_watch_tbody'); if (!tb) return; if (!d.ok) { tb.innerHTML = `${d.error || '오류'}`; return; } const rows = d.rows || []; if (!rows.length) { tb.innerHTML = '등록된 관심종목 없음 (SCAN 또는 수동추가)'; return; } tb.innerHTML = rows.map(r => { const stColor = r.status === 'active' ? '#3fb950' : '#8b949e'; const sig = r.last_signal_at ? String(r.last_signal_at).slice(0, 16) : '-'; const added = r.added_at ? String(r.added_at).slice(0, 10) : '-'; return ` ${r.code} ${r.name || ''} ${r.status} ${r.source || ''} ${Number(r.box_low || 0).toLocaleString()} ${Number(r.box_high || 0).toLocaleString()} ${Number(r.box_score || 0).toFixed(1)} ${added} ${sig} `; }).join(''); }) .catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `${e}`; }); } function ubxAddWatch() { const code = ($('ubx_add_code').value || '').trim(); if (!/^\d{6}$/.test(code)) { alert('6자리 종목코드를 입력하세요'); return; } const body = { code, name: ($('ubx_add_name').value || '').trim(), box_low: parseFloat($('ubx_add_low').value || '0') || 0, box_high: parseFloat($('ubx_add_high').value || '0') || 0, }; fetch('/api/updown_box/watchlist/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (!d.ok) { alert('추가 실패: ' + (d.error || '한도초과/보유중')); return; } $('ubx_add_code').value = ''; $('ubx_add_name').value = ''; $('ubx_add_low').value = ''; $('ubx_add_high').value = ''; ubxLoadWatch(); ubxLoadCfg(); }) .catch(e => alert('오류: ' + e)); } /** 수정: 행 값을 입력폼에 채워 넣음 → 고친 뒤 '추가/수정' 누르면 upsert 갱신 */ function ubxEditWatch(code, name, low, high) { $('ubx_add_code').value = code || ''; $('ubx_add_name').value = name || ''; $('ubx_add_low').value = low || ''; $('ubx_add_high').value = high || ''; const c = $('ubx_add_code'); if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.focus(); } } function ubxRemoveWatch(code) { if (!confirm(code + ' 관심종목을 삭제할까요?')) return; fetch('/api/updown_box/watchlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }) .then(r => r.json()) .then(d => { if (!d.ok) alert('삭제 실패: ' + (d.error || '')); ubxLoadWatch(); ubxLoadCfg(); }) .catch(e => alert('오류: ' + e)); } /** 입력란 → 백테/파라서치 쿼리 (빈값 제외, env 오버라이드) */ function ubxCollectParamQs(qs) { UBX_PARAM_FIELDS.forEach(k => { const el = $('ubx_' + k); if (el && el.value !== '' && el.value != null) qs.append(k, String(el.value)); }); const rt = $('ubx_ratchet_tiers'); // 다단 래칫(문자열) — 숫자 필드와 별도 if (rt && rt.value != null && String(rt.value).trim() !== '') { qs.append('ratchet_tiers', String(rt.value).trim()); } } // 래칫/어깨컷 입력칸 색상 토글 — TAIL(tlSyncRatchetShoulderColors)과 동일. // 다단 래칫 값이 있으면 단일 어깨컷(어깨발동·어깨폭)은 무시됨 → 빨강 표시. function ubxSyncRatchetShoulderColors() { const r = $('ubx_ratchet_tiers'); if (!r) return; const smin = $('ubx_shoulder_min_high_pct'); const scut = $('ubx_shoulder_cut_pct'); const ratchetOn = !!(r.value && String(r.value).trim()); const RED = '#f85149'; const paint = (el, on) => { if (!el) return; if (on) { el.style.setProperty('border-color', RED, 'important'); el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important'); el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important'); } else { el.style.removeProperty('border-color'); el.style.removeProperty('background-color'); el.style.removeProperty('box-shadow'); } }; // 래칫 ON → 어깨컷 빨강(무시됨) / 래칫 OFF → 래칫 빨강(OFF, 어깨컷이 트레일) paint(smin, ratchetOn); paint(scut, ratchetOn); paint(r, !ratchetOn); } function ubxRunBacktest() { const code = ($('ubx_code').value || '').trim(); if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 입력하세요'); return; } const qs = new URLSearchParams({ code, tf: $('ubx_tf').value || '15', start: $('ubx_start').value || '', end: $('ubx_end').value || '', }); ubxCollectParamQs(qs); $('ubx_status').textContent = '백테 실행 중…'; showSpinner(true); fetch('/api/updown_box/backtest?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; } $('ubx_status').textContent = ''; $('ubx_ps_card').style.display = 'none'; ubxRenderSummary(d); ubxRenderTrades(d.trades || [], `${d.code} · ${d.tf}분 · ${d.candle_count}봉`, { code: d.code, name: d.name || '' }); }) .catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); }); } function ubxRenderSummary(d) { const card = $('ubx_summary_card'), box = $('ubx_summary'); if (!card || !box) return; const bn = d.box_now || {}; const boxBadge = bn.is_box ? `박스권 ✔ (범위 ${Number(bn.range_pct || 0).toFixed(1)}% · 밴드폭 ${bn.bb_bw != null ? Number(bn.bb_bw).toFixed(1) : '-'}% · 기울기 ${bn.ma_slope != null ? Number(bn.ma_slope).toFixed(2) : '-'}%)` : `비박스 (${bn.reason || ''})`; const bh = (d.buy_hold_pct != null) ? Number(d.buy_hold_pct) : null; const alpha = (d.alpha_pct != null) ? Number(d.alpha_pct) : null; box.innerHTML = `
거래 ${d.n_trades}
봇 총손익 ${d.total_pnl}%
PF ${d.pf}
승률 ${d.win_rate}%
${bh != null ? `
단순보유(B&H) ${bh.toFixed(2)}%
` : ''} ${alpha != null ? `
초과수익(α) ${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%
` : ''}
${alpha != null ? `
${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}
` : ''}
현재 박스상태(최근봉): ${boxBadge} ${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}
`; $('ubx_summary_sub').textContent = `${d.code} · ${d.tf}분`; card.style.display = ''; } function ubxRenderTrades(trades, sub, meta) { const card = $('ubx_trades_card'); if (!card) return; meta = meta || {}; // 박스 백테 거래(run_backtest_box) → 공통 거래목록 스키마로 변환. // 손익(원)은 100만원 슬롯 가정으로 환산(수익률%는 실제 백테값 그대로). const mapped = (trades || []).map(t => { const ep = Number(t.buy || 0), xp = Number(t.sell || 0); const qty = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0; return { code: meta.code || '', name: meta.name || '', buy_price: ep, sell_price: xp, entry_time: t.entry_time, exit_time: t.exit_time, profit_rate: t.pnl_pct, pnl: Math.round((xp - ep) * qty), qty, sell_reason: t.reason, hold_min: t.bars_held, }; }); // peak / 최종 누적 (매도순) let peak = 0, peakAt = '', cum = 0; const ordered = [...mapped].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); for (const t of ordered) { cum += Number(t.pnl || 0); if (cum > peak) { peak = cum; peakAt = t.exit_time || ''; } } renderVirtualTrades('ubx_trades_tbody', mapped, { meta, showCumulative: true, totalBudget: 1000000, }); fillTradePnLContext('ubx_trade_context', { label: '백테 UPDOWN_BOX', totalBudget: 1000000, peakCum: peak, peakAt, totalPnl: cum, }); const subEl = $('ubx_trades_sub'); if (subEl) subEl.textContent = sub || ''; card.style.display = ''; } function ubxRunParamSearch() { const qs = new URLSearchParams({ tf: $('ubx_tf').value || '15', start: $('ubx_start').value || '', end: $('ubx_end').value || '', mode: $('ubx_mode').value || 'fast', min_trades: $('ubx_min_trades').value || '3', }); if ($('ubx_from_watchlist') && $('ubx_from_watchlist').checked) { qs.append('from_watchlist', '1'); } else { const codes = ($('ubx_codes').value || '').trim() || ($('ubx_code').value || '').trim(); if (!codes) { alert('탐색 종목(codes) 또는 watchlist 사용을 지정하세요'); return; } qs.append('codes', codes); } $('ubx_status').textContent = '파라미터 탐색 중… (조합 수에 따라 수십 초)'; showSpinner(true); fetch('/api/updown_box/param_search?' + qs.toString()) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; } $('ubx_status').textContent = ''; $('ubx_summary_card').style.display = 'none'; $('ubx_trades_card').style.display = 'none'; ubxRenderParamSearch(d); }) .catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); }); } function ubxRenderParamSearch(d) { const card = $('ubx_ps_card'), tb = $('ubx_ps_tbody'); if (!card || !tb) return; const top = d.top || []; const meta = d.meta || {}; const dropped = meta.dropped_trend || []; const dropTxt = dropped.length ? ` · 추세주 제외: ${dropped.map(x => `${x.code}(α${x.alpha}%)`).join(',')}` : ''; $('ubx_ps_sub').innerHTML = `종목 ${(d.codes || []).length} · 조합 ${meta.cartesian_product || 0} · 통과 ${meta.passed || 0} · ${meta.search_mode || ''} · 정렬:${meta.rank_by || 'alpha'}` + ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '') + (dropTxt ? `${dropTxt}` : ''); if (!top.length) { tb.innerHTML = '통과한 조합 없음 (최소거래 낮추거나 기간 확대)'; } else { tb.innerHTML = top.map((r, i) => { const cfgStr = Object.entries(r.apply_cfg || {}).map(([k, v]) => { if (k === 'ratchet_tiers') return `ratchet=${(v != null && String(v).trim()) ? v : 'OFF'}`; return `${k}=${v}`; }).join(' '); const cfgJson = encodeURIComponent(JSON.stringify(r.apply_cfg || {})); const alpha = (r.alpha_pct != null) ? Number(r.alpha_pct) : null; const bh = (r.buy_hold_pct != null) ? Number(r.buy_hold_pct) : null; return ` ${i + 1} ${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'} ${r.total_pnl}% ${bh != null ? bh + '%' : '-'} ${r.pf}${r.win_rate}%${r.n_trades} ${cfgStr} `; }).join(''); } card.style.display = ''; } function ubxApply(cfgJson) { let cfg; try { cfg = JSON.parse(decodeURIComponent(cfgJson)); } catch (e) { alert('파라미터 파싱 실패'); return; } if (!confirm('이 파라미터를 env_config UPDOWN_BOX_* 에 적용할까요?\n' + Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join('\n'))) return; fetch('/api/updown_box/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apply_cfg: cfg }), }) .then(r => r.json()) .then(d => { if (!d.ok) { alert('적용 실패: ' + (d.error || '')); return; } alert('✅ env_config 적용 (id=' + d.env_id + ')\n' + Object.entries(d.applied || {}).map(([k, v]) => `${k}=${v}`).join('\n')); ubxLoadCfg(); }) .catch(e => alert('오류: ' + e)); } // ──────────────────────────────────────────── // 스피너 // ──────────────────────────────────────────── function showSpinner(v) { $('spinner').classList.toggle('show', v); } // ════════════════════════════════════════════════════════════════ // Optuna 웹 잡 (상단 미니바 + Optuna 탭) // ════════════════════════════════════════════════════════════════ const OPTUNA_LS_KEY = 'kis_optuna_job_id'; let _optunaPollTimer = null; let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || ''; let _optunaPollStatus = ''; let _optunaJobsRefreshing = false; /** 호가8방 표 재렌더 방지용 — 폴링마다 innerHTML 갈아끼우면 스크롤이 위로 튐 */ let _optunaPostFp = ''; /** 프로그레스 갱신 체감용 2초 · 표는 fingerprint로 안 다시 그림 */ const OPTUNA_POLL_MS_BUSY = 2000; const OPTUNA_POLL_MS_IDLE = 2000; async function optunaRefreshJobsIfNeeded(force) { if (_optunaJobsRefreshing && !force) return; _optunaJobsRefreshing = true; try { await optunaRefreshJobs(); } finally { _optunaJobsRefreshing = false; } } function optunaGoTab() { btShowTab('optuna'); } function optunaOnTabShow() { optunaLoadDefaults(); optunaRefreshJobs(); if (_optunaJobId) optunaPollOnce(); } async function optunaLoadDefaults() { try { const r = await fetch('/api/optuna/defaults'); const j = await r.json(); if (!j.ok) return; if ($('opt_start') && !$('opt_start').value) $('opt_start').value = j.start; if ($('opt_end') && !$('opt_end').value) $('opt_end').value = j.end; if ($('opt_trials') && !$('opt_trials').dataset.touched) $('opt_trials').value = j.trials; } catch (e) { /* ignore */ } } function optunaSetNav(job) { const bar = $('optuna_nav_bar'); if (!bar) return; if (!job) { bar.classList.add('d-none'); return; } const prog = job.progress || {}; const post = job.postprocess || {}; const pct = Number(prog.pct || 0); const st = job.status || ''; const phase = job.phase || ''; const postBusy = phase === 'postprocess' || (st === 'running' && pct >= 99.9 && !post.ready && post.expect_ob !== false); const finalizeBusy = phase === 'finalize'; const label = (job.label || job.strategy || 'Optuna') + (prog.refine_phase === 'phase1' ? ' · 1차' : (prog.refine_phase === 'phase2' ? ' · 2차' : '')) + (postBusy ? ' 후처리' : (finalizeBusy ? ' 저장중' : (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : ''))))); const done = prog.trials_done != null ? prog.trials_done : '?'; const tot = prog.trials_total != null ? prog.trials_total : '?'; $('optuna_nav_label').textContent = `${label} ${done}/${tot}`; const met = optunaBestMetricsText(prog, job); const postPct = Number(post.pct || 0); if (postBusy) { $('optuna_nav_pct').textContent = `후처리 ${postPct}%` + (post.stage ? ` · ${post.stage}` : ''); } else if (finalizeBusy) { $('optuna_nav_pct').textContent = (post.hint || 'JSON 저장 중') + (met ? ` · ${met}` : ''); } else { $('optuna_nav_pct').textContent = met ? `${pct}% · ${met}` : `${pct}%`; } const fill = $('optuna_nav_fill'); fill.style.width = `${Math.min(100, pct)}%`; fill.classList.toggle('is-done', st === 'done' && !postBusy && !finalizeBusy); fill.classList.toggle('is-error', st === 'error'); const postTrack = $('optuna_nav_post_track'); const postFill = $('optuna_nav_post_fill'); if (postTrack && postFill) { // 사후호가방(expect_ob) 있을 때만 아래 후처리 트랙 표시 const showPost = (post.expect_ob !== false) && (postBusy || post.ready || postPct > 0); postTrack.classList.toggle('d-none', !showPost); postFill.style.width = `${Math.min(100, post.ready ? 100 : postPct)}%`; postFill.classList.toggle('is-done', !!post.ready); } bar.classList.remove('d-none'); } function optunaFmtNum(v, dig) { if (v == null || v === '') return '—'; const n = Number(v); if (!Number.isFinite(n)) return String(v); if (dig == null) return n.toLocaleString(); return n.toFixed(dig); } /** progress.best_* → 사람이 읽는 WR/PnL/PF/MDD (score 숨김) */ function optunaBestMetricsText(prog, job) { if (!prog || prog.best_pnl == null && prog.best_win_rate == null) return ''; const strat = String((job && (job.strategy || job.label)) || '').toLowerCase(); const isUs = !!((job && job.symbol) || strat.includes('us_momentum') || strat.includes('해외')); const pnl = Number(prog.best_pnl); const pnlStr = !Number.isFinite(pnl) ? '—' : (isUs ? `$${pnl.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : `${Math.round(pnl).toLocaleString()}원`); const wr = prog.best_win_rate != null ? `${Number(prog.best_win_rate).toFixed(1)}%` : '—'; const pf = prog.best_pf != null ? Number(prog.best_pf).toFixed(2) : '—'; const mdd = prog.best_mdd != null ? (isUs ? `$${Number(prog.best_mdd).toLocaleString(undefined, { maximumFractionDigits: 2 })}` : `${Math.round(Number(prog.best_mdd)).toLocaleString()}`) : '—'; const tr = prog.best_trades != null ? `${prog.best_trades}건` : ''; const tn = prog.best_trial != null ? `#${prog.best_trial}` : ''; return `best${tn} WR ${wr} · PnL ${pnlStr} · PF ${pf} · MDD ${mdd}` + (tr ? ` · ${tr}` : ''); } function optunaRenderCompare(sum) { const gatesEl = $('opt_stable_gates'); if (gatesEl) { const g = sum && sum.stable_gates; if (g) { let t = `안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. ` + `게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` + `활성일≥${g.min_active_days} · λ=${g.stability_lambda}`; if (g.fallback_rank_only || g.fallback_note) { t += ` · ${g.fallback_note || '게이트 0건 → 점수순 폴백(마이너스 포함)'}`; } else if (g.n_gate_pass != null) { t += ` · 게이트통과 ${g.n_gate_pass}건`; } gatesEl.innerHTML = t; } else if (sum) { gatesEl.textContent = '안정점수 = 일평균PnL − λ×일표준편차(원). 높을수록 좋음 · 만점 없음. (구 JSON은 게이트 미기록)'; } else { gatesEl.textContent = '—'; } } optunaRenderModeCombo(sum); optunaRenderModeTop10(sum); optunaRenderTrailRec(sum); optunaRenderPostprocess(sum); optunaRenderOverfitBadges(sum); optunaRenderOverfit(sum); optunaRenderParamDistChart(sum); } const OPTUNA_CARD_TOP_N = 5; function optunaRenderModeCombo(sum) { const el = $('opt_mode_combo_body'); const m = sum && sum.mode_combo_summary; const rows = (sum && sum.top5_mode) || []; // 사후합격 Top10과 동일 표 — mode는 축최빈 1회 실측이라 보통 1행 optunaFillRankTbody( 'opt_top5_mode_tbody', rows, 'mode', (sum ? 'mode_combo 실측 없음 (구 JSON·미산출)' : '완료 후 표시'), ); if (!el) return; if (!m || !m.ok) { const note = (sum && sum.mode_combo_note) || ''; el.innerHTML = sum ? (`mode_combo 실측 없음` + (note ? `
${note}
` : '')) : '완료 후 표시'; return; } const vs = m.vs_best || {}; const vsLine = (vs.best_pnl != null && vs.mode_pnl != null) ? `vs학습1위 PnL ${Number(vs.mode_pnl).toLocaleString()} / ${Number(vs.best_pnl).toLocaleString()}` : ''; el.innerHTML = `
` + `Top${m.top_n != null ? m.top_n : 'N'} 축최빈 조립` + (m.pool_kind ? ` · pool=${m.pool_kind}` : '') + (m.pool_size != null ? ` · ${m.pool_size}건` : '') + (vsLine ? ` · ${vsLine}` : '') + ` · 아래 표=사후합격과 같은 열(안정·과적합 포함). trial 번호는 조립이라 없음.` + `
` + (m.note ? `
${m.note}
` : '') + `
` + `` + `
`; } function optunaRenderModeTop10(sum) { const metaEl = $('opt_mode_top_meta'); const meta = (sum && sum.mode_consensus_meta) || {}; if (metaEl) { if (!sum) { metaEl.textContent = '—'; } else if (meta.mode_pool_size != null || meta.note) { metaEl.innerHTML = `pool ${meta.mode_pool_kind || 'positive'}` + (meta.mode_pool_size != null ? ` · ${meta.mode_pool_size}건` : '') + (meta.mode_params_keys != null ? ` · 축 ${meta.mode_params_keys}개` : '') + (meta.note ? ` · ${meta.note}` : '') + (meta.scoring ? ` · ${meta.scoring}` : ''); } else { metaEl.textContent = 'mode Top10 — 완료 후 표시'; } } optunaFillModeTopTbody( 'opt_top5_mode_top_tbody', (sum && sum.top5_consensus) || [], 'consensus', sum ? 'mode Top10 없음 (pool 0 · grid/results 확인)' : '완료 후 표시', ); optunaRenderModeBandTop3(sum, window._optunaLastJob || null); } function optunaDailyAvgPnlCell(r) { const v = (r.period_daily_avg_pnl != null) ? r.period_daily_avg_pnl : r.daily_pnl_mean; if (v == null) return '—'; const n = Number(v); const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; return `${optunaFmtNum(v)}`; } function optunaDailyAvgPctCell(r) { let p = r.period_daily_avg_pct; if (p == null && r.daily_avg_pct != null) p = r.daily_avg_pct; if (p == null && r.bot_pct != null && r.n_period_trading_days > 0) { p = Number(r.bot_pct) / Number(r.n_period_trading_days); } if (p == null) return '—'; const n = Number(p); const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; const sign = n > 0 ? '+' : ''; return `${sign}${optunaFmtNum(n, 3)}%`; } function optunaModeTopTableRow(r, source) { const rank = r.rank || 1; const src = source; const applyCls = 'btn-outline-success'; const cm = r.consensus_match_pct; const cmN = r.consensus_match_n; const cmOf = r.consensus_match_of; const cmCell = (cm != null && cmOf != null) ? `${optunaFmtNum(cm, 1)}%
${cmN}/${cmOf}축 밴드
` : '—'; return ` ${rank} #${r.optuna_trial_number ?? '—'} ${cmCell} ${optunaFmtNum(r.total_trades, 0)} ${optunaFmtNum(r.win_rate, 1)}% ${optunaFmtNum(r.pf, 2)} ${optunaFmtNum(r.total_pnl)} ${optunaDailyAvgPnlCell(r)} ${optunaDailyAvgPctCell(r)} ${optunaObWhipCell(r)} ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'} ${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'} ${optunaStableScoreCell(r)} ${optunaOverfitCell(r)} `; } function optunaFillModeTopTbody(tbodyId, rows, source, emptyMsg) { const tb = $(tbodyId); if (!tb) return; if (!rows || !rows.length) { tb.innerHTML = `${emptyMsg}`; return; } tb.innerHTML = rows.map((r) => optunaModeTopTableRow(r, source)).join(''); } function optunaRenderOverfit(sum) { const scoreEl = $('opt_overfit_score'); const facTb = $('opt_overfit_factors_tbody'); const distTb = $('opt_thresh_dist_tbody'); const poolEl = $('opt_thresh_pool'); const noteEl = $('opt_overfit_note'); const d = sum && sum.overfit_diagnostics; if (!d || d.overfit_risk_pct == null) { const done = window._optunaLastJob && window._optunaLastJob.status === 'done'; const empty = done ? '완료 · 진단 없음(후보 0건)' : '완료 후 표시'; if (scoreEl) scoreEl.textContent = empty; if (facTb) facTb.innerHTML = `${empty}`; if (distTb) distTb.innerHTML = `${empty}`; if (poolEl) poolEl.textContent = ''; if (noteEl) noteEl.textContent = '—'; return; } const risk = Number(d.overfit_risk_pct); const ready = Number(d.apply_readiness_pct); const verd = d.verdict || ''; let cls = 'text-muted'; if (verd === '비권장' || risk >= 70) cls = 'text-pnl-neg'; else if (verd === '주의' || risk >= 40) cls = 'text-warning'; else cls = 'text-pnl-pos'; if (scoreEl) { scoreEl.innerHTML = `과적합 ${risk}% · 적용가능 ${ready}% · ${verd}` + (d.verdict_ko ? `
${d.verdict_ko}
` : ''); } const factors = d.factors || []; if (facTb) { if (!factors.length) { facTb.innerHTML = '요인 없음'; } else { facTb.innerHTML = factors.map(f => ` ${f.label || f.id || '—'} +${optunaFmtNum(f.points, 1)} ${f.detail || ''} `).join(''); } } if (poolEl) { poolEl.textContent = d.threshold_pool ? `(${d.threshold_pool} · n=${d.threshold_pool_n || 0})` : ''; } const dist = d.threshold_distribution || []; if (distTb) { if (!dist.length) { distTb.innerHTML = '분포 없음'; } else { distTb.innerHTML = dist.map(r => { const share = r.mode_share != null ? `${(Number(r.mode_share) * 100).toFixed(0)}%` : '—'; const fmt = (v) => (v == null || v === '') ? '—' : optunaFmtNum(v, 4); return ` ${r.param || ''} ${fmt(r.median)} ${fmt(r.p25)} ${fmt(r.p75)} ${r.mode != null ? r.mode : '—'} ${share} `; }).join(''); } } if (noteEl) noteEl.textContent = d.note || '—'; } function optunaRefinePhaseLabel(job) { const sy = String((job && job.study_name) || ''); const sh = String((job && job.study_short) || ''); if (sy.includes('refine2') || sh === 'refine2') return '2차 TPE'; if (sy.includes('refine1') || sh === 'refine1') return '1차 TPE'; const ph = (job && job.progress && job.progress.refine_phase) || ''; if (ph === 'phase2') return '2차 TPE'; if (ph === 'phase1') return '1차 TPE'; return ''; } function optunaFormatGatedTopRowHtml(t, i, showBorder) { const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean; const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct; const rank = t.rank || i; const bdr = showBorder !== false ? ' border-bottom border-secondary' : ''; return `
#${i} trial #${t.optuna_trial_number ?? '—'} PnL ${Number(t.total_pnl || 0).toLocaleString()} ${dAvg != null ? `일평균 ${Number(dAvg).toLocaleString()}원` : ''} ${dPct != null ? `일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : ''} WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · ${Number(t.total_trades || 0)}건 ${optunaObWhipCell(t)}
`; } function optunaBuildGatedTop3Html(rows, opts) { const o = opts || {}; const n = o.topN || OPTUNA_CARD_TOP_N; const list = (rows || []).slice(0, n); if (!list.length) return ''; const pr = o.periodRange ? ` · 기간 ${o.periodRange}` : ''; const phase = o.phaseLabel || '사후합격'; const note = o.running ? ' (진행 중 · 사후합격 미확정)' : ''; let html = `
${phase} Top${n}${pr}${note}
`; list.forEach((t, i) => { html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1); }); return html; } function optunaRenderGatedTop3Card(sum, job) { const body = $('opt_gated_top3_body'); const title = $('opt_gated_top3_title'); if (!body) return; const phase = optunaRefinePhaseLabel(job); const phaseTxt = phase ? `${phase} 사후합격 Top${OPTUNA_CARD_TOP_N}` : `사후합격 Top${OPTUNA_CARD_TOP_N}`; if (title) { title.innerHTML = `${phaseTxt} DB 적용 후보 · 일평균(원/%) · 익절/손절 비교`; } const pr = (job && job.start && job.end) ? optunaFmtPeriodShort(job.start, job.end) : ''; if (job && job.status === 'running' && job.live_summary) { const ls = job.live_summary; const ph = ls.refine_phase === 'phase2' ? '2차 TPE' : (ls.refine_phase === 'phase1' ? '1차 TPE' : phase); const rows = (ls.top3_gated && ls.top3_gated.length) ? ls.top3_gated : (ls.top3_learn || []); if (rows.length) { body.innerHTML = optunaBuildGatedTop3Html(rows, { phaseLabel: ph ? `${ph} learn` : 'learn', periodRange: ls.period_range || pr, running: !ls.top3_gated || !ls.top3_gated.length, }); return; } } const gated = (sum && sum.top5_gated) || []; if (!gated.length) { const done = job && job.status === 'done'; body.innerHTML = done ? '사후합격 후보 없음' : '완료 후 표시'; return; } body.innerHTML = optunaBuildGatedTop3Html(gated, { phaseLabel: phase ? `${phase} 사후합격` : '사후합격', periodRange: pr, running: false, }); } function optunaRenderModeBandTop3(sum, job) { const el = $('opt_mode_band_top3'); if (!el) return; const rows = (sum && sum.top5_consensus) || []; if (!rows.length) { el.innerHTML = `mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} 없음`; return; } const pr = (job && job.start && job.end) ? optunaFmtPeriodShort(job.start, job.end) : ''; const pool = (sum.mode_consensus_meta && sum.mode_consensus_meta.mode_pool_size) || null; el.innerHTML = optunaBuildModeTop3Html(rows.slice(0, OPTUNA_CARD_TOP_N), { periodRange: pr, poolSize: pool, topN: OPTUNA_CARD_TOP_N, title: `mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} (분포 참고 · DB 1순위 아님)`, }); } function optunaRenderParamDistChart(sum) { const el = $('opt_param_dist_chart'); if (!el) return; const d = sum && sum.overfit_diagnostics; const dist = (d && d.threshold_distribution) || []; if (!dist.length) { const done = window._optunaLastJob && window._optunaLastJob.status === 'done'; el.innerHTML = done ? '분포 데이터 없음' : '완료 후 표시'; return; } const rows = dist.filter((r) => { if (r.p25 == null || r.p75 == null) return false; const p = String(r.param || ''); return p && !p.endsWith('_mode') && p !== 'sl_mode'; }); const pri = ['tp_pct', 'sl_pct', 'trail_pct', 'trail_arm_pct', 'max_daily_chg', 'vol_mult']; rows.sort((a, b) => { const ia = pri.indexOf(String(a.param || '')); const ib = pri.indexOf(String(b.param || '')); const pa = ia >= 0 ? ia : 999; const pb = ib >= 0 ? ib : 999; if (pa !== pb) return pa - pb; return String(a.param || '').localeCompare(String(b.param || '')); }); if (!rows.length) { el.innerHTML = '수치형 분포 없음'; return; } const poolNote = d.threshold_pool ? `
표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}
` : ''; const bars = rows.map((r) => { const p25 = Number(r.p25); const p75 = Number(r.p75); const med = r.median != null ? Number(r.median) : null; const mode = r.mode != null ? Number(r.mode) : null; const vals = [p25, p75, med, mode].filter((v) => v != null && !Number.isNaN(v)); const lo = Math.min(...vals) * 0.92; const hi = Math.max(...vals) * 1.08; const span = hi - lo || 1; const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / span) * 100)); const share = r.mode_share != null ? `${(Number(r.mode_share) * 100).toFixed(0)}%` : '—'; const fmt = (v) => (v == null || Number.isNaN(v)) ? '—' : optunaFmtNum(v, 3); let marks = ''; if (med != null && !Number.isNaN(med)) { marks += `
`; } if (mode != null && !Number.isNaN(mode)) { marks += `
`; } const l = pct(p25).toFixed(1); const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1); return `
${r.param} mode ${fmt(r.mode)} (${share})
${marks}
p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}
`; }).join(''); el.innerHTML = poolNote + bars; } function optunaRenderTrailRec(sum) { const body = $('opt_daily_trail_rec_body'); if (!body) return; const rec = sum && sum.daily_trail_recommend; if (!rec) { body.textContent = '완료 후 표시'; return; } if (!rec.ok) { body.innerHTML = `추천 없음 — ${rec.reason || '양수 PnL 앵커 없음'}`; return; } const arm = Number(rec.arm_krw || 0).toLocaleString(); const anchor = Number(rec.anchor_krw || 0).toLocaleString(); body.innerHTML = `${rec.prefix || ''} · ENABLED=true · MODE=${rec.mode || 'trailing'}` + `
ARM ${arm}원 · TIERS ${rec.tiers || ''}` + `
앵커 ${anchor}원 (mode/median/best×frac) · 타점 적용과 별도 · 이후 운영탭에서 끄기/수정 가능` + ` `; } function optunaOverfitCell(r) { const p = r && r.overfit_risk_pct; if (p == null || Number.isNaN(Number(p))) return ''; const n = Number(p); let cls = 'text-pnl-pos'; if (n >= 70) cls = 'text-pnl-neg'; else if (n >= 40) cls = 'text-warning'; const v = r.overfit_verdict_ui || r.overfit_verdict || ''; return `${n}%${v ? ' · ' + v : ''}`; } function optunaStableScoreCell(r) { if (!r || r.stability_score == null) return ''; const n = Number(r.stability_score); const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; return `${optunaFmtNum(r.stability_score, 2)}`; } function optunaPostBusy(job) { if (!job) return false; const rerun = job.postprocess_rerun || {}; if (String(rerun.status || '') === 'running') return true; if (job.status === 'done' || job.status === 'error') return false; if (job.phase === 'postprocess') return true; const prog = job.progress || {}; return job.status === 'running' && Number(prog.pct || 0) >= 99.9; } function optunaObWhipCell(r) { if (!r) return ''; const on = r.ob_on; const whip = r.whip_on; const hasExit = r.tp_pct != null || r.tp_max_pct != null || r.sl_pct != null; if (on == null && whip == null && !hasExit && !r.ob_summary) { return ''; } const spr = r.ob_spread != null ? Number(r.ob_spread).toFixed(1) : null; const ratio = r.ob_ratio != null ? Number(r.ob_ratio).toFixed(2) : null; const ask = r.ob_ask != null ? Number(r.ob_ask).toFixed(0) : null; const tip = (Array.isArray(r.ob_summary_lines) && r.ob_summary_lines.length) ? r.ob_summary_lines.join(' | ') : (r.ob_summary || '본 TPE 호가·익절·손절·휩쏘'); const lines = []; if (on === true) { const bits = ['호가ON']; if (spr != null) bits.push(`spr${spr}`); if (ratio != null) bits.push(`r${ratio}`); if (ask != null) bits.push(`ask×${ask}`); lines.push(`
${bits.join(' ')}
`); } else if (on === false) { lines.push('
호가OFF
'); } if (hasExit) { const eb = []; if (r.tp_pct != null) eb.push(`익절${Number(r.tp_pct).toFixed(1)}%`); if (r.tp_max_pct != null) eb.push(`상한${Number(r.tp_max_pct).toFixed(1)}%`); if (r.sl_pct != null) eb.push(`손절${Number(r.sl_pct).toFixed(1)}%`); lines.push(`
${eb.join(' ')}
`); } if (whip === true) { const wb = ['휩쏘ON']; if (r.whip_subbar_sec != null) wb.push(`sub${Number(r.whip_subbar_sec)}s`); if (r.whip_lookback_sec != null) wb.push(`lb${Number(r.whip_lookback_sec)}s`); if (r.whip_dip_pct != null) { let d = Number(r.whip_dip_pct); if (d < 0.5) d *= 100; wb.push(`dip${d.toFixed(2)}%`); } if (r.whip_recovery_tol_pct != null) { let t = Number(r.whip_recovery_tol_pct); if (t < 0.5) t *= 100; wb.push(`tol${t.toFixed(2)}%`); } lines.push(`
${wb.join(' ')}
`); } else if (whip === false && (on != null || hasExit)) { lines.push('
휩쏘OFF
'); } if (!lines.length) { return `${r.ob_summary || '—'}`; } return `
${lines.join('')}
`; } function optunaRankTableRow(r, source) { const rank = r.rank || 1; const src = source; const detailBtn = ''; const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success'; return ` ${rank} #${r.optuna_trial_number ?? '—'} ${optunaFmtNum(r.total_trades, 0)} ${optunaFmtNum(r.win_rate, 1)}% ${optunaFmtNum(r.pf, 2)} ${optunaFmtNum(r.total_pnl)} ${optunaDailyAvgPnlCell(r)} ${optunaDailyAvgPctCell(r)} ${optunaObWhipCell(r)} ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'} ${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'} ${optunaStableScoreCell(r)} ${optunaOverfitCell(r)} ${src !== 'mode' ? `` : ''} ${detailBtn} `; } function optunaFillRankTbody(tbodyId, rows, source, emptyMsg) { const tb = $(tbodyId); if (!tb) return; if (!rows || !rows.length) { tb.innerHTML = `${emptyMsg}`; return; } tb.innerHTML = rows.map((r) => optunaRankTableRow(r, source)).join(''); } /** Optuna 후처리 표 — 행=gated# / mode / live, 열=호가·휩쏘·트레일 (원 정수). */ function optunaOverfitBadgeHtml(pct, verdict) { const p = Number(pct); if (pct == null || Number.isNaN(p)) return ''; let cls = 'text-pnl-pos'; let tone = '상대적으로 낮음'; if (p >= 70) { cls = 'text-pnl-neg'; tone = '위험 · 비권장'; } else if (p >= 40) { cls = 'text-warning'; tone = '주의'; } const v = verdict || tone; return `과적합 가능도 ${p}% · ${v}`; } function optunaRenderOverfitBadges(sum) { const pick = (key) => ((sum && sum[key]) || [])[0] || null; const set = (id, row) => { const el = $(id); if (!el) return; el.innerHTML = optunaOverfitBadgeHtml( row && row.overfit_risk_pct, row && (row.overfit_verdict_ui || row.overfit_verdict), ); }; set('opt_overfit_badge_gated', pick('top5_gated')); set('opt_overfit_badge_stable', pick('top5_stable')); const modeRow = ((sum && sum.compare_rows) || []).find((r) => r.source === 'mode'); set('opt_overfit_badge_mode', modeRow); } /** 호가8방 UI: 승률 색 기준(사후게이트 WR와 맞춤). PnL은 부호 색. */ const OPTUNA_OB8_WR_GOOD_PCT = 40; function _optunaOb8WrSpan(wr) { if (!Number.isFinite(wr)) return ''; const cls = wr >= OPTUNA_OB8_WR_GOOD_PCT ? 'text-pnl-pos' : 'text-pnl-neg'; return `승률 ${wr.toFixed(1)}%`; } function _optunaOb8PnlSpan(pnl) { if (pnl == null || !Number.isFinite(Number(pnl))) return ''; const n = Math.trunc(Number(pnl)); const cls = n > 0 ? 'text-pnl-pos' : (n < 0 ? 'text-pnl-neg' : ''); const sign = n > 0 ? '+' : ''; return `${sign}${n.toLocaleString()}원`; } function _optunaAxisStatsHtml(ax, recHint) { if (!ax || !ax.ok) { const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음'; const ko = { light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침', no_replay_fills: '체결 재실행 없음', not_enough_trades: '체결 부족', 'n/a_strategy': '해당없음', none: '없음', }; return `${ko[r] || r}`; } const p = ax.params || {}; const rs = ax.recommended_stats || {}; const os = ax.orig_stats || {}; const pnl = rs.pnl != null ? Math.trunc(Number(rs.pnl)) : null; const cnt = rs.count; const wr = rs.win_rate != null ? Number(rs.win_rate) : NaN; const origWr = os.win_rate != null ? Number(os.win_rate) : NaN; const origPnl = os.pnl != null ? Math.trunc(Number(os.pnl)) : null; const recBitsP = []; if (p.orderbook_max_spread_pct != null) { recBitsP.push(`스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`); } if (p.exit_ob_enabled != null) { recBitsP.push((p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF') + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '') + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : '')); } if (p.stop_ob_enabled != null) { recBitsP.push((p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF') + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '') + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : '')); } if (p.whipsaw_dip_pct != null) { recBitsP.push(`sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`); } const recLine = recBitsP.join(' · '); const wrDrop = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr); const recBits = []; if (cnt != null) recBits.push(cnt + '건'); if (Number.isFinite(wr)) recBits.push(_optunaOb8WrSpan(wr)); if (pnl != null) recBits.push(_optunaOb8PnlSpan(pnl)); const origBits = []; if (os.count != null && os.count !== cnt) origBits.push(os.count + '건'); if (Number.isFinite(origWr)) origBits.push(_optunaOb8WrSpan(origWr)); if (origPnl != null && origPnl !== pnl) origBits.push(_optunaOb8PnlSpan(origPnl)); const dropHint = wrDrop ? ' (사후호가 승률↓)' : ''; return `
${recLine || '추천있음'}` + (recBits.length ? `
사후호가(참고) ${recBits.join(' · ')}${dropHint}
` : '') + (origBits.length ? `
필터전(호가스냅) ${origBits.join(' · ')}
` : '') + `
`; } function optunaOb8wayState(ob) { const combos = ob && ob.combos; const hasCombos = !!(combos && typeof combos === 'object' && !Array.isArray(combos) && Object.keys(combos).length); const reason = String((ob && ob.reason) || '').trim(); const tc = (ob && ob.trade_count != null) ? Number(ob.trade_count) : null; const fc = (ob && ob.fill_count != null) ? Number(ob.fill_count) : null; if (hasCombos) return { kind: 'ok', reason, tc, fc, combos }; if (reason && reason !== 'light_skip' && reason !== 'none') { return { kind: 'failed', reason, tc, fc, combos: combos || {} }; } return { kind: 'legacy', reason, tc, fc, combos: combos || {} }; } function optunaObReasonKo(st) { if (!st || st.kind === 'ok') return ''; if (st.reason === 'not_enough_trades') { const n = Number.isFinite(st.tc) ? st.tc : '?'; const fill = (Number.isFinite(st.fc) && st.fc > n) ? (` · 체결${st.fc}`) : ''; return `호가스냅 ${n}건(<3)${fill} · 8방 TPE 생략`; } if (st.reason === 'no_orderbook_snapshots') return '호가 스냅샷 테이블 공백'; if (st.reason === 'no_replay_fills') return '체결 재실행 없음'; if (st.kind === 'legacy') return '구JSON(8방 키 없음)'; return st.reason || '8방 미산출'; } function optunaPool8wayKind(anchors) { const pool = (anchors || []).filter((a) => a && (a.role === 'gated' || a.role === 'learn' || a.role === 'mode')); if (!pool.length) return 'legacy'; let anyOk = false; let anyFailed = false; pool.forEach((a) => { const st = optunaOb8wayState(a.orderbook); if (st.kind === 'ok') anyOk = true; else if (st.kind === 'failed') anyFailed = true; }); if (anyOk) return 'ok'; if (anyFailed) return 'failed'; return 'legacy'; } function optunaSyncRerunButton(sum, job) { const btn = $('opt_btn_pp_rerun'); const stEl = $('opt_pp_rerun_status'); const topn = sum && sum.postprocess_topn; const anchors = (topn && topn.postprocess_by_anchor) || []; const kind = optunaPool8wayKind(anchors); const ran = !!(topn && topn.run_ob_whipsaw); const busy = optunaPostBusy(job); const needWarn = kind !== 'ok'; if (btn) { btn.disabled = !!busy; btn.classList.toggle('btn-warning', needWarn && !busy); btn.classList.toggle('btn-outline-secondary', !(needWarn && !busy)); } if (!stEl) return; const pp = job && job.postprocess_rerun; const prog = job && job.postprocess; if (pp && pp.status === 'running') { stEl.textContent = (prog && prog.hint) || ('재실행 중… 로그 ' + (pp.log_path || '')); return; } if (pp && pp.status === 'error') { stEl.textContent = '재실행 실패 · ' + (pp.error || ''); return; } if (kind === 'legacy' && (!ran || !anchors.length)) { stEl.textContent = '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요'; return; } if (kind === 'failed' || (kind === 'legacy' && ran)) { const st0 = optunaOb8wayState(((anchors.find((a) => a.role === 'gated') || {}).orderbook)); stEl.textContent = (optunaObReasonKo(st0) || '8방 미산출') + ' · 재실행해도 호가스냅이 안 늘면 동일'; return; } if (pp && pp.status === 'done') { stEl.textContent = '재실행 완료 · ' + (pp.log_path || ''); return; } if (prog && prog.hint) stEl.textContent = prog.hint; } /** Optuna 호가 8방 표시명 — 「진입」=매수호가축 (TRIGGER 진입방어와 구분) */ const OPTUNA_OB_COMBO_LABELS = { base: '000 타점만(호가OFF)', e: '100 매수호가만', x: '010 익절호가만', s: '001 손절호가만', ex: '110 매수+익절호가', es: '101 매수+손절호가', xs: '011 익절+손절호가', exs: '111 호가전부', '000': '000 타점만(호가OFF)', '100': '100 매수호가만', '010': '010 익절호가만', '001': '001 손절호가만', '110': '110 매수+익절호가', '101': '101 매수+손절호가', '011': '011 익절+손절호가', '111': '111 호가전부', entry: '100 매수호가만', exit: '110 매수+익절호가', stop: '111 호가전부', }; function optunaObComboLabel(cid, fallback) { const k = String(cid || '').trim(); if (OPTUNA_OB_COMBO_LABELS[k]) return OPTUNA_OB_COMBO_LABELS[k]; // 구 JSON 라벨 → 신규 const old = String(fallback || ''); const remap = { '100 진입만': '100 매수호가만', '100 진입': '100 매수호가', '010 익절만': '010 익절호가만', '001 손절만': '001 손절호가만', '110 진입+익절': '110 매수+익절호가', '101 진입+손절': '101 매수+손절호가', '011 익절+손절': '011 익절+손절호가', '111 전부': '111 호가전부', '111 호가전부': '111 호가전부', }; return remap[old] || old || k || '—'; } function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row, whipSkip) { const st = optunaOb8wayState(ob); const combos = st.combos || {}; // 8방 정의는 항상 8행. 전략이 익절/손절 호가축을 안 쓰면 행은 보이되 「미사용」. const allRows = [ ['base', OPTUNA_OB_COMBO_LABELS.base, true], ['e', OPTUNA_OB_COMBO_LABELS.e, true], ['x', OPTUNA_OB_COMBO_LABELS.x, !!canExitStop], ['s', OPTUNA_OB_COMBO_LABELS.s, !!canExitStop], ['ex', OPTUNA_OB_COMBO_LABELS.ex, !!canExitStop], ['es', OPTUNA_OB_COMBO_LABELS.es, !!canExitStop], ['xs', OPTUNA_OB_COMBO_LABELS.xs, !!canExitStop], ['exs', OPTUNA_OB_COMBO_LABELS.exs, !!canExitStop], ]; const hasCombos = st.kind === 'ok'; const btnOb = (cid, used) => { if (isLive || !rowSrc) return '참고'; if (!used) return '이 전략 미사용'; const c = hasCombos ? (combos[cid] || {}) : null; const disabled = (cid !== 'base' && hasCombos && !(c && c.ok)); if (disabled) return ``; if (cid !== 'base' && !hasCombos) { return ``; } return ``; }; const btnWhip = (cid, used) => { if (isLive || !rowSrc) return ''; if (whipSkip) return '휩쏘 해당없음'; if (!used) return ''; const c = hasCombos ? (combos[cid] || {}) : null; const ws = (c && c.whipsaw) || {}; // +휩쏘 = DB 적용만. 탐색은 「이 잡 후처리 재실행」. if (cid !== 'base' && hasCombos && !(c && c.ok)) { return ``; } if (ws && ws.ok === false && ws.reason === 'n/a_strategy') { return '해당없음'; } if (!ws || ws.ok !== true) { const why = (ws && ws.reason) ? String(ws.reason) : '미산출'; return ``; } return ``; }; const whipCell = (cid, used) => { if (!used) return ''; if (whipSkip) return '해당없음'; const c = hasCombos ? (combos[cid] || {}) : null; const ws = (c && c.whipsaw) || null; if (!ws) return '미산출'; if (ws.ok) return _optunaAxisStatsHtml(ws, ws); const why = ws.reason || '없음'; if (why === 'n/a_strategy') return '이 전략 미사용'; if (why === 'not_enough_trades') { const n = ws.trade_count != null ? ws.trade_count : '?'; return `통과 ${n}건(<min) · 스킵`; } return `${why}`; }; const trs = allRows.map(([id, lab, used]) => { if (!used) { return `${lab}—` + `이 전략에는 이 호가축 없음` + `—미사용`; } const c = hasCombos ? (combos[id] || {}) : null; let nt; let stats; if (hasCombos) { nt = (c && c.n_trials != null) ? c.n_trials : '—'; stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob); } else if (id === 'base' && row) { nt = st.kind === 'legacy' ? '구JSON' : '—'; const bits = []; if (row.total_trades != null) bits.push(row.total_trades + '건'); if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate))); if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl)); const why = optunaObReasonKo(st); stats = (bits.length ? `
${bits.join(' · ')}
` : '') + (why ? `
${why}
` : '8방 미산출'); } else { nt = st.kind === 'legacy' ? '구JSON' : '—'; stats = `${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}`; } return `${lab}${nt}${stats}` + `${whipCell(id, used)}` + `${btnOb(id, used)} ${btnWhip(id, used)}`; }).join(''); const foot = !canExitStop ? '스캘핑·꼬리: 호가 000·100만. 「호가만」=호가 ON + 휩쏘 OFF·수치비움(낙폭·방어 등 타점은 안 바꿈). 「휩쏘적용」만 휩쏘를 켭니다. 방별 휩쏘 TPE는 후처리 재실행.' : (st.kind === 'ok' ? '8방 호가 TPE → 방별 휩쏘는 후처리 재실행. 「호가만」=휩쏘 OFF·비움(타점 유지) / 「휩쏘적용」=DB 적용만.' : (st.kind === 'legacy' ? '구JSON · 「이 잡 후처리 재실행」으로 8방·방별휩쏘를 채우세요.' : `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`)); // 필터전 ≠ mode/타점 전체: 호가스냅 연동 분만 후처리 표본 const os0 = (ob && ob.orig_stats) || {}; const snapN = (os0.count != null) ? Number(os0.count) : (st.tc != null ? Number(st.tc) : null); const fillN = (st.fc != null) ? Number(st.fc) : null; const anchorN = (row && row.total_trades != null) ? Number(row.total_trades) : null; const anchorWr = (row && row.win_rate != null) ? Number(row.win_rate) : NaN; const anchorPnl = (row && row.total_pnl != null) ? Math.trunc(Number(row.total_pnl)) : null; let baseHint = ''; if (anchorN != null || snapN != null) { const bits = []; if (anchorN != null) { let a = `타점전체 ${anchorN}건`; if (Number.isFinite(anchorWr)) a += ` · 승률 ${anchorWr.toFixed(1)}%`; if (anchorPnl != null) a += ` · PnL ${anchorPnl.toLocaleString()}원`; bits.push(a); } if (snapN != null) { bits.push(`필터전(호가스냅) ${snapN}건` + (fillN != null && fillN > snapN ? ` · 체결원본 ${fillN}` : '')); } baseHint = `
` + `필터전 ≠ 위 mode/타점 숫자. ${bits.join(' → ')}. ` + `방마다 바뀌는 건 사후호가(참고)만. TPE에 호가가 포함되면 본 trial PnL이 정본. mode WR/PnL은 위 표를 보세요.` + `
`; } // 8방이 이미 있어도 「후처리 재실행」으로 방별 휩쏘를 다시 돌릴 수 있게 const rerunBtn = (!isLive) ? '
' : ''; return `${baseHint}
${trs}
호가 방호가TPE호가 성적이 방 휩쏘적용
${foot}
${rerunBtn}
`; } function _optunaBaseCell(a) { const pnl = a.total_pnl != null ? Math.trunc(Number(a.total_pnl)) : null; const wr = a.win_rate != null ? Number(a.win_rate) : NaN; const bits = []; if (a.total_trades != null) bits.push(a.total_trades + '건'); if (Number.isFinite(wr)) bits.push(_optunaOb8WrSpan(wr)); if (pnl != null) bits.push(_optunaOb8PnlSpan(pnl)); return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}` + (bits.length ? `
${bits.join(' · ')}
` : ''); } function optunaSelectPostprocess(_source, _rank) { // 호가 8방 UI 제거 — no-op } function optunaPostprocessFingerprint(sum) { const topn = (sum && sum.postprocess_topn) || {}; const anchors = topn.postprocess_by_anchor || []; const cons = topn.postprocess_consensus || {}; const cc = cons.combo || {}; const sel = window._optunaPostSel || {}; return [ sel.source || '', sel.rank || '', anchors.map((a) => `${a.id}:${(a.orderbook && a.orderbook.ok) ? 1 : 0}`).join(','), cc.combo_id || '', cc.ok ? 1 : 0, topn.apply_overfit_pct != null ? topn.apply_overfit_pct : '', topn.run_ob_whipsaw ? 1 : 0, ].join('|'); } function optunaRenderPostprocess(_sum) { // 호가 8방·후처리 비교표 UI 제거 — no-op (백엔드 postprocess_topn 은 유지) } function optunaRenderTop5(sum) { const ban = $('opt_tpe_ob_banner'); if (ban) { if (sum && sum.tpe_includes_orderbook) { const postOff = sum.post_run_ob_whipsaw === false; ban.innerHTML = postOff ? '본 TPE에 호가·휩쏘 축 포함' + ' · 표 호가열 = trial마다 ON/OFF·스프레드 (상세 안 눌러도 됨)' + ' · 사후 호가8방이 비는 것은 정상(사후탐색 OFF)' : '본 TPE에 호가 축 포함 · 표 호가열 참고' + (sum.post_run_ob_whipsaw ? ' · 사후8방도 돌림' : ''); ban.className = 'small mb-2'; } else if (sum) { ban.innerHTML = '이 JSON은 본 TPE 호가축 없음(구잡) · 호가열 — · 사후8방은 「후처리 재실행」'; ban.className = 'small mb-2 text-muted'; } else { ban.textContent = '—'; ban.className = 'small mb-2 text-muted'; } } optunaFillRankTbody( 'opt_top5_learn_tbody', (sum && sum.top5_learn) || [], 'learn', '학습 후보 없음 / 미완료', ); optunaFillRankTbody( 'opt_top5_tbody', (sum && sum.top5_gated) || [], 'gated', 'gated 후보 없음 / 미완료', ); optunaRenderTop5Stable(sum); // mode 표는 optunaRenderModeCombo 에서도 채움(중복 OK · 요약 카드와 동기) optunaFillRankTbody( 'opt_top5_mode_tbody', (sum && sum.top5_mode) || [], 'mode', 'mode 실측 없음 / 미완료', ); } function optunaRenderTop5Stable(sum) { optunaFillRankTbody( 'opt_top5_stable_tbody', (sum && sum.top5_stable) || [], 'stable', 'stable 후보 없음 (일별 필드 없는 구 JSON · 재실행 필요)', ); } function optunaHighlightGated(rank) { optunaShowCandidate('gated', rank || 1); window._optunaPostSel = { source: 'gated', rank: rank || 1 }; optunaRenderPostprocess(window._optunaLastSummary || null); } async function optunaShowCandidate(source, rank) { if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요'); return; } const src = source || 'gated'; const rk = rank || 1; const title = $('opt_detail_title'); const met = $('opt_detail_metrics'); const pre = $('opt_detail_params'); if (title) title.textContent = `로딩 ${src} #${rk}…`; if (met) met.textContent = '불러오는 중…'; try { const qs = new URLSearchParams({ job_id: _optunaJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { if (met) met.textContent = '❌ ' + (j.error || '실패'); if (pre) pre.textContent = '—'; return; } const m = j.metrics || {}; if (title) { title.textContent = `${j.strategy || ''} · ${m.label || src} · trial ${m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—'}`; } if (met) { const daily = m.daily_pnl && typeof m.daily_pnl === 'object' ? Object.keys(m.daily_pnl).sort().map(d => `${d}:${Number(m.daily_pnl[d]).toLocaleString()}`).join(' · ') : ''; met.innerHTML = `거래 ${optunaFmtNum(m.total_trades, 0)} · ` + `승률 ${optunaFmtNum(m.win_rate, 1)}% · ` + `PF ${optunaFmtNum(m.pf, 2)} · ` + `PnL ${optunaFmtNum(m.total_pnl)}` + (m.stability_score != null ? ` · 안정 ${optunaFmtNum(m.stability_score, 2)} (높을수록 좋음·만점없음 · 손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})` : '') + (m.overfit_risk_pct != null ? ` · 과적합 ${optunaFmtNum(m.overfit_risk_pct, 1)}% (높을수록 위험·0~100${m.overfit_verdict_ui ? ' · ' + m.overfit_verdict_ui : ''})` : '') + ` · 파라미터 ${j.params_count || 0}개` + (j.start && j.end ? ` · 기간 ${j.start}~${j.end}` : '') + (daily ? `
일별 PnL: ${daily}
` : '') + ` ` + ` ` + ` `; } if (pre) { pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2); } // 폼 반영용 캐시 window._optunaCandidateCache = { strategy: j.strategy, start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, }; // Top5 행 하이라이트 document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => { tr.style.outline = (i + 1 === rk) ? '1px solid var(--accent)' : ''; }); const card = $('opt_detail_card'); if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) { if (met) met.textContent = '오류: ' + e; } } function optunaFmtPeriodShort(start, end) { const s = String(start || '').slice(0, 10); const e = String(end || '').slice(0, 10); return (s && e) ? `${s}~${e}` : (s || e || '—'); } function optunaPeriodBadge(label, start, end, extraCls) { const r = optunaFmtPeriodShort(start, end); return `${label} ${r}`; } function optunaRenderPeriodBanner(job) { const el = $('opt_period_banner'); if (!el) return; const pi = job && job.period_info; const formS = ($('opt_start')?.value || '').trim().slice(0, 10); const formE = ($('opt_end')?.value || '').trim().slice(0, 10); const jobS = String((job && job.start) || '').slice(0, 10); const jobE = String((job && job.end) || '').slice(0, 10); if (!pi && !jobS) { el.innerHTML = ''; el.className = 'opt-period-banner d-none mb-2 p-2 rounded small'; return; } const formMismatch = !!(formS && formE && jobS && jobE && (formS !== jobS || formE !== jobE)); const hasRefine = !!(pi && pi.has_refine); const mismatch = !!(pi && pi.mismatch) || formMismatch; el.className = 'opt-period-banner mb-2 p-2 rounded small ' + (mismatch ? 'opt-period-warn' : 'opt-period-ok'); let html = mismatch ? '
⚠ 기간이 서로 다릅니다 — Top5·일평균·백테 비교 시 study별 기간을 확인하세요
' : '
📅 백테 기간 (전 단계 동일)
'; html += '
'; if (formS && formE) { const fm = formMismatch ? ' opt-period-badge-warn' : ''; html += optunaPeriodBadge('상단 폼', formS, formE, 'opt-period-badge-form' + fm); } if (jobS && jobE) { html += optunaPeriodBadge('잡(메인)', jobS, jobE, 'opt-period-badge-master'); } if (hasRefine && pi) { if (pi.phase1 && pi.phase1.start) { const c = (pi.phase1.start !== jobS || pi.phase1.end !== jobE) ? ' opt-period-badge-warn' : ''; html += optunaPeriodBadge('1차 TPE', pi.phase1.start, pi.phase1.end, 'opt-period-badge-p1' + c); } if (pi.phase2 && pi.phase2.study && pi.phase2.start) { const c = (pi.phase2.start !== jobS || pi.phase2.end !== jobE) ? ' opt-period-badge-warn' : ''; html += optunaPeriodBadge('2차 TPE', pi.phase2.start, pi.phase2.end, 'opt-period-badge-p2' + c); } if (pi.active && pi.active.study && pi.active.start) { html += optunaPeriodBadge('활성 study', pi.active.start, pi.active.end, 'opt-period-badge-active'); } } else if (pi && pi.active && pi.active.study && pi.active.start) { html += optunaPeriodBadge('study', pi.active.start, pi.active.end, 'opt-period-badge-active'); } html += '
'; if (formMismatch) { html += `
상단 폼(${formS}~${formE}) ≠ 선택 잡(${jobS}~${jobE}) — 새 잡 시작 시 폼 기간이 적용됩니다
`; } if (pi && pi.mismatch_notes && pi.mismatch_notes.length) { html += `
${pi.mismatch_notes.join(' · ')}
`; } if (pi && pi.refine_note) { html += `
${pi.refine_note}
`; } el.innerHTML = html; } function optunaFormatJobPeriodCell(job) { const pi = job.period_info; const base = optunaFmtPeriodShort(job.start, job.end); if (!pi || !pi.has_refine) return base; if (!pi.mismatch) return base; const p1 = pi.phase1 && pi.phase1.range ? pi.phase1.range : ''; const p2 = (pi.phase2 && pi.phase2.study && pi.phase2.range) ? pi.phase2.range : ''; let inner = ' ' + base; if (p1 && p1 !== base) inner += `
1차 ${p1}`; if (p2 && p2 !== base && p2 !== p1) inner += `
2차 ${p2}`; return inner; } function optunaRenderJob(job) { if (!job) return; if (job.job_id) { _optunaJobId = job.job_id; try { localStorage.setItem(OPTUNA_LS_KEY, job.job_id); } catch (e) { /* ignore */ } } const prog = job.progress || {}; const pct = Number(prog.pct || 0); const fill = $('opt_tab_fill'); if (fill) { fill.style.width = `${Math.min(100, pct)}%`; fill.classList.toggle('is-done', job.status === 'done'); fill.classList.toggle('is-error', job.status === 'error'); } if ($('opt_st_status')) { const stEl = $('opt_st_status'); stEl.textContent = job.status || '—'; stEl.style.cursor = 'pointer'; stEl.title = '클릭하면 다른 PC 실행 명령'; stEl.onclick = function () { optunaOpenJoinCmd(job.job_id); }; } if ($('opt_st_job')) $('opt_st_job').textContent = job.job_id || '—'; if ($('opt_st_study')) { $('opt_st_study').textContent = job.active_study_name || job.study_name || '—'; } const refineWrap = $('opt_st_refine_wrap'); const refineEl = $('opt_st_refine'); const piRef = job.period_info; if (refineWrap && refineEl) { if (piRef && piRef.has_refine && piRef.phase1 && piRef.phase2) { const p1r = piRef.phase1.range || optunaFmtPeriodShort(piRef.phase1.start, piRef.phase1.end); const p2r = piRef.phase2.range || optunaFmtPeriodShort(piRef.phase2.start, piRef.phase2.end); const p1s = (piRef.phase1.study || '').split('_').slice(-3, -1).join('_') || '1차'; refineEl.innerHTML = `1차 ${(piRef.phase1.study || '—').slice(0, 48)}` + ` ${p1r}` + ` · 2차 ${(piRef.phase2.study || '—').slice(0, 48)}` + ` ${p2r}`; refineWrap.classList.remove('d-none'); } else { refineEl.textContent = '—'; refineWrap.classList.add('d-none'); } } if ($('opt_st_trials')) { const met = optunaBestMetricsText(prog, job); const nowBits = [ job.current_strategy, job.current_entry_mode, ].filter(Boolean).join('/'); const seqBits = (prog.seq_step && prog.seq_steps) ? `단계 ${prog.seq_step}/${prog.seq_steps}` : ''; $('opt_st_trials').textContent = (() => { const done = prog.trials_done ?? '—'; const batch = prog.trials_total ?? job.trials ?? '—'; const goal = job.study_trials || 0; const nDone = job.n_complete != null ? job.n_complete : done; // 분모=이번 배치(100)면 126/100 처럼 보임 → 목표가 있으면 목표 기준으로 표시 if (goal > 0) { const p2 = Math.min(100, Math.round((Number(nDone || 0) / goal) * 1000) / 10); return `누적 ${nDone}/${goal} (${p2}%) · 이번배치 ${batch}`; } return `${done} / ${batch} (${pct}%)`; })() + (seqBits ? ` · ${seqBits}` : '') + (nowBits ? ` · now=${nowBits}` : '') + (met ? ` · ${met}` : '') + ((job.status === 'running' && Number(prog.trials_done || 0) === 0) ? ' · 준비(캔들·틱 로드) — Top5는 trial 시작 후' : ''); } const post = job.postprocess || {}; const postFill = $('opt_post_fill'); const postPct = post.ready ? 100 : Number(post.pct || 0); if (postFill) { postFill.style.width = `${Math.min(100, postPct)}%`; postFill.classList.toggle('is-done', !!post.ready); postFill.classList.toggle('is-error', false); } if ($('opt_st_post')) { const busy = optunaPostBusy(job); $('opt_st_post').textContent = post.hint || (busy ? '후처리 중' : (job.status === 'done' ? '후처리 —' : '후처리 — trial 끝난 뒤 시작')); } if ($('opt_btn_mode_detail')) $('opt_btn_mode_detail').disabled = optunaPostBusy(job); if ($('opt_btn_pp_rerun')) $('opt_btn_pp_rerun').disabled = optunaPostBusy(job); if ($('opt_st_log')) $('opt_st_log').textContent = job.display_log_path || job.log_path || '—'; if ($('opt_st_json')) { const notes = job.result_json_notes || []; if (notes.length > 1) { $('opt_st_json').textContent = notes.map(n => { const name = String(n.path || '').split('/').pop(); return `${name}(n=${n.n_all})`; }).join(' · '); } else { $('opt_st_json').textContent = job.result_json || '—'; } } const isSeq = job.kind === 'seq' || job.kind === 'seq4'; const kids = job.child_jobs || []; const sum = (isSeq && kids.length) ? null : (job.result_summary || null); window._optunaLastSummary = sum || null; window._optunaLastJob = job; if ($('opt_st_top')) { if (isSeq && kids.length) { $('opt_st_top').innerHTML = '
순차 묶음 — 적용은 전략별 잡에서. 버튼을 누르면 그 전략 Top5가 나옵니다.
' + kids.map(k => { const jid = String(k.job_id || '').replace(/'/g, ''); const lab = k.label || k.strategy || jid; return ``; }).join(''); } else if (job.status === 'running' && job.live_summary && (job.live_summary.top3_learn || []).length) { const ls = job.live_summary; const phase = ls.refine_phase === 'phase2' ? '2차' : '1차'; const pr = ls.period_range || optunaFmtPeriodShort(ls.period_start, ls.period_end); let html = `
${phase} learn Top3 · 기간 ${pr} (진행 중 · 축 분포 확인용)
`; (ls.top3_learn || []).forEach((t, i) => { html += `
${optunaFormatTopRow(t, i + 1)}
`; }); if (ls.refine_phase === 'phase2' && (ls.phase1_top3 || []).length) { const p1pr = ls.phase1_period_range || ''; html += `
1차 Top3 (참고${p1pr ? ' · 기간 ' + p1pr : ''})
`; ls.phase1_top3.forEach((t, i) => { html += `
${optunaFormatTopRow(t, i + 1)}
`; }); } $('opt_st_top').innerHTML = html; window._optunaLastSummary = { top5_learn: ls.top3_learn || [], n_all: ls.n_complete }; optunaRenderTop5(window._optunaLastSummary); } else if (sum && sum.top) { const t = sum.top; const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean; const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct; const mtBit = (sum.min_trades != null) ? ` · min≥${sum.min_trades}` + (sum.n_trading_days != null ? `(${sum.n_trading_days}일×${sum.min_trades_per_day || 4})` : '') : (job.min_trades != null ? ` · min≥${job.min_trades}` + (job.n_trading_days != null ? `(${job.n_trading_days}일×${job.min_trades_per_day || 4})` : '') : ''); $('opt_st_top').innerHTML = `상위(${t.source}): trial #${t.optuna_trial_number} · ` + `거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` + `PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` + (dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') + (dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') + ` · gated ${sum.n_gated}/${sum.n_all}` + (sum.n_stable != null ? ` · stable ${sum.n_stable}` : '') + mtBit; } else if (job.status === 'done') { $('opt_st_top').textContent = job.error || '완료 · 학습/게이트 통과 0건 (이 스터디는 표가 비는 것이 정상)'; } else { $('opt_st_top').textContent = job.error || '결과 요약 없음'; } } if ($('opt_log_tail')) $('opt_log_tail').textContent = job.log_tail || ''; if ($('opt_briefing')) { $('opt_briefing').textContent = job.briefing_preview || '—'; } const running = job.status === 'running'; const done = job.status === 'done'; if ($('opt_btn_start')) $('opt_btn_start').disabled = running; if ($('opt_btn_stop')) $('opt_btn_stop').disabled = !running; const applyBox = $('opt_apply_box'); if (applyBox) { applyBox.classList.toggle('d-none', !done || (isSeq && kids.length)); const canGate = !!(sum && sum.apply_ready); const canStable = !!(sum && sum.apply_stable_ready); if ($('opt_btn_apply_gated')) $('opt_btn_apply_gated').disabled = !canGate; if ($('opt_btn_apply_stable')) $('opt_btn_apply_stable').disabled = !canStable; if ($('opt_btn_apply_mode')) { const hasMode = !!(sum && (sum.compare_rows || []).some(r => r.source === 'mode')); $('opt_btn_apply_mode').disabled = !done || !hasMode; } } optunaRenderCompare(sum); optunaRenderTop5(sum); optunaRenderGatedTop3Card(sum, job); if (!window._optunaPostSel) window._optunaPostSel = { source: 'gated', rank: 1 }; optunaRenderPostprocess(sum); optunaRenderPeriodBanner(job); optunaSetNav(job); } async function optunaBacktestFromCandidate(source, rank) { if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요'); return; } const src = source || 'gated'; const rk = rank || 1; if (src === 'mode') { alert('mode_combo는 trial 번호가 없어 백테탭 자동 채우기를 지원하지 않습니다.'); return; } const curJob = window._optunaLastJob; if (curJob && (curJob.kind === 'seq' || curJob.kind === 'seq4') && (curJob.child_jobs || []).length) { alert('순차 묶음 잡입니다. 전략별 import 잡에서「백테」를 누르세요.'); return; } try { const qs = new URLSearchParams({ job_id: _optunaJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '후보 없음')); return; } window._optunaCandidateCache = { strategy: j.strategy, start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, metrics: j.metrics || {}, }; const cache = window._optunaCandidateCache; const strat = String(cache.strategy || curJob?.strategy || '').toLowerCase(); const p = cache.params || {}; const job = curJob || {}; const trialNo = (j.metrics && j.metrics.optuna_trial_number != null) ? j.metrics.optuna_trial_number : '—'; const applyJobSources = (cfg) => { if (cache.start && cfg.startId) setDateVal(cfg.startId, cache.start); if (cache.end && cfg.endId) setDateVal(cfg.endId, cache.end); const hist = job.universe_history_source || $('opt_univ_history_source')?.value || 'kiwoom'; if (cfg.univSrcId && $(cfg.univSrcId)) $(cfg.univSrcId).value = hist; if (cfg.univId && $(cfg.univId)) $(cfg.univId).checked = true; if (job.candle_source && cfg.candleId && $(cfg.candleId)) { $(cfg.candleId).value = job.candle_source; } if (job.tick_source && cfg.tickId && $(cfg.tickId)) { $(cfg.tickId).value = job.tick_source; } if (job.ob_source && cfg.obId && $(cfg.obId)) { $(cfg.obId).value = job.ob_source; } }; const tabMap = { breakout: 'breakout', tail: 'tail', scalp: 'backtest', momentum: 'momentum', us_momentum: 'us_momentum', }; const tabLabel = { breakout: '돌파매매', tail: '꼬리잡기', scalp: '스캘핑', momentum: '모멘텀', us_momentum: '해외 모멘텀', }; if (strat === 'momentum' || strat === 'us_momentum') { await optunaFillFormFromCandidate(src, rk); return; } if (strat === 'breakout') { applyJobSources({ startId: 'bo_start', endId: 'bo_end', univId: 'bo_use_univ_history', univSrcId: 'bo_univ_history_source', candleId: 'bo_candle_source', tickId: 'bo_tick_source', obId: 'bo_ob_source', }); fillBreakoutFormFromOptunaParams(p); } else if (strat === 'tail') { applyJobSources({ startId: 'tl_start', endId: 'tl_end', univId: 'tl_use_univ_history', univSrcId: 'tl_univ_history_source', tickId: 'tl_tick_source', obId: 'tl_ob_source', }); fillTailFormFromOptunaParams(p); } else if (strat === 'scalp') { applyJobSources({ startId: 'bt_start', endId: 'bt_end', univId: 'bt_use_univ_history', univSrcId: 'bt_univ_history_source', tickId: 'bt_tick_source', obId: 'bt_ob_source', }); fillScalpFormFromOptunaParams(p); } else { alert('백테탭 자동 채우기 미지원 전략: ' + strat); return; } const wantTab = tabMap[strat] || strat; const tab = document.querySelector(`[data-tab="${wantTab}"]`); if (tab && !tab.classList.contains('active')) tab.click(); const panelId = wantTab === 'backtest' ? 'tab-backtest' : ('tab-' + wantTab); const panel = $(panelId); if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); alert( `${tabLabel[strat] || strat} 백테 탭에 반영했습니다.\n` + `trial #${trialNo} · ${src} #${rk}\n` + `기간 ${cache.start || ''}~${cache.end || ''}\n` + '「백테 실행」으로 Optuna 숫자와 비교하세요.\n(DB 저장 아님)' ); } catch (e) { alert('백테 폼 반영 오류: ' + e); } } function fillBreakoutFormFromOptunaParams(p) { if (!p || typeof p !== 'object') return; const b = Object.assign({}, p); if (b.ob_filter_enabled == null && b._orderbook_filter_enabled != null) { b.ob_filter_enabled = !!b._orderbook_filter_enabled; } if (b.pg_filter_enabled == null && b._program_filter_enabled != null) { b.pg_filter_enabled = !!b._program_filter_enabled; } fillBreakoutFormFromApi(b); if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bo', b); } function fillTailFormFromOptunaParams(p) { if (!p || typeof p !== 'object') return; fillTailFormFromApi(p); if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('tl', p); } function fillScalpFormFromOptunaParams(p) { if (!p || typeof p !== 'object') return; fillScalpFormFromApi(p); if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bt', p); } async function optunaFillFormFromCandidate(source, rank) { // 캐시 없으면 다시 fetch let cache = window._optunaCandidateCache; if (!cache || !cache.params) { await optunaShowCandidate(source || 'gated', rank || 1); cache = window._optunaCandidateCache; } const p = (cache && cache.params) || {}; const strat = String((cache && cache.strategy) || '').toLowerCase(); const isUs = strat === 'us_momentum' || String(p.market || '').toUpperCase() === 'US'; const set = (id, val) => { const el = $(id); if (!el || val === undefined || val === null) return; if (el.type === 'checkbox') el.checked = !!(val === true || val === 1 || val === '1'); else el.value = val; }; const prefix = isUs ? 'usmom_' : 'mom_'; // 기간 = Optuna study 기간 (비교 시 필수) if (cache.start) setDateVal(isUs ? 'usmom_start' : 'mom_start', cache.start); if (cache.end) setDateVal(isUs ? 'usmom_end' : 'mom_end', cache.end); set(prefix + 'sl', p.sl_pct); set(prefix + 'tp', p.tp_pct); set(prefix + 'tp_max', p.tp_max_pct); set(prefix + 'slot_money', p.slot_money); set(prefix + (isUs ? 'slots' : 'slots'), p.max_stocks || p.slots || p.mom_slots); if (isUs) set('usmom_slots', p.max_stocks || p.slots); else set('mom_slots', p.max_stocks || p.mom_slots || p.slots); set(prefix + 'total_budget', p.total_budget_krw); set(prefix + 'cooldown', p.cooldown_min); set(prefix + 'smin', p.shoulder_min_high); set(prefix + 'scut', p.shoulder_cut_pct); set(prefix + 'trail', p.trail_pct); set(prefix + 'trail_arm', p.trail_arm_pct); set(prefix + 'max_hold', p.max_hold_bars); set(prefix + 'ratchet', p.ratchet_tiers != null ? p.ratchet_tiers : ''); set(prefix + 'time_start', p.time_start_hm || p.mom_time_start_hm); set(prefix + 'time_end', p.time_end_hm || p.mom_time_end_hm || p.mom_time_end); set(prefix + 'max_daily', p.max_daily); set(prefix + 'high_chase', p.high_chase_thr); set(prefix + 'max_daily_chg', p.max_daily_chg); set(prefix + 'min_price', p.min_price); set(prefix + 'max_loss', p.max_loss_krw); set(prefix + 'min_margin', p.min_margin); set(prefix + 'rsi_min', p.mom_rsi_min); set(prefix + 'rsi_max', p.mom_rsi_max); set(prefix + 'vol_mult', p.mom_vol_mult); set(prefix + 'vol_win', p.mom_vol_win); set(prefix + 'max_from_open', p.mom_max_from_open_pct); set(prefix + 'min_from_open', p.mom_min_from_open_pct); set(prefix + 'chase_lookback', p.chase_lookback_min); set(prefix + 'pullback_lookback', p.pullback_lookback_min); set(prefix + 'pullback_min', p.pullback_min_pct); set(prefix + 'pullback_max', p.pullback_max_pct); set(prefix + 'setup_vol', p.setup_vol_max_mult); set(prefix + 'setup_bear', p.setup_bear_bars_min); set(prefix + 'ema_fast', p.ema_fast_period); set(prefix + 'ema_slow', p.ema_slow_period); set(prefix + 'eod_hm', p.eod_hm); const defId = isUs ? 'usmom_use_defense' : 'mom_use_defense'; if ($(defId) && p.use_defense_filters !== undefined) $(defId).checked = !!p.use_defense_filters; const mapCb = [ ['use_high_chase_filter', isUs ? 'usmom_use_high_chase_f' : 'mom_use_high_chase_f'], ['use_daily_range_filter', isUs ? 'usmom_use_daily_range' : 'mom_use_daily_range'], ['use_ema_filter', isUs ? 'usmom_use_ema_f' : 'mom_use_ema_f'], ['use_rsi_max_filter', isUs ? 'usmom_use_rsi_max_f' : 'mom_use_rsi_max_f'], ['pattern_breakout', isUs ? 'usmom_pat_breakout' : 'mom_pat_breakout'], ['pattern_pullback', isUs ? 'usmom_pat_pullback' : 'mom_pat_pullback'], ['eod_enabled', isUs ? 'usmom_eod_enabled' : 'mom_eod_enabled'], ]; mapCb.forEach(([k, id]) => { if ($(id) && p[k] !== undefined) $(id).checked = !!p[k]; }); // 이미 해당 탭이면 click 금지 — us_momentum 탭 click 시 usmomLoadStocks→핀 cfg 가 // Optuna 폼 반영을 즉시 덮어씀 (폼에 넣기 무효화 버그). const wantTab = isUs ? 'us_momentum' : 'momentum'; const tab = document.querySelector(`[data-tab="${wantTab}"]`); if (tab && !tab.classList.contains('active')) tab.click(); alert( (isUs ? '해외 모멘텀' : '모멘텀') + ' 폼에 반영했습니다.\n기간 ' + (cache.start || '') + '~' + (cache.end || '') + '\n같은 기간으로 백테 실행해 Optuna와 비교하세요.\n(DB 저장 아님)' ); } async function optunaApply(source, rank) { // Top10「적용」= 그 줄 trial 전체(타점·익절·손절·호가ON/OFF·휩쏘). 구 base=호가OFF 덮어쓰기. return optunaApplyUpto(source, rank, 'full'); } async function optunaApplyTrail() { return optunaApplyUpto('gated', 1, 'trail'); } async function optunaApplyUpto(source, rank, upto) { if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서 보기 먼저'); return; } const curJob = window._optunaLastJob; if (curJob && (curJob.kind === 'seq' || curJob.kind === 'seq4') && (curJob.child_jobs || []).length) { const lines = (curJob.child_jobs || []).map(k => `· ${k.label || k.strategy}`).join('\n'); alert('순차 묶음 잡입니다.\n전략별 잡에서「보기」한 뒤 적용하세요.\n' + lines); return; } const src = source || 'gated'; const rk = rank || 1; const u = upto || 'base'; const labels = { full: '줄전체(타점·익절·손절·호가·휩쏘)', as_scored: '줄전체(타점·익절·손절·호가·휩쏘)', trial: '줄전체(타점·익절·손절·호가·휩쏘)', all: '줄전체(타점·익절·손절·호가·휩쏘)', base: OPTUNA_OB_COMBO_LABELS.base, e: OPTUNA_OB_COMBO_LABELS.e, x: OPTUNA_OB_COMBO_LABELS.x, s: OPTUNA_OB_COMBO_LABELS.s, ex: OPTUNA_OB_COMBO_LABELS.ex, es: OPTUNA_OB_COMBO_LABELS.es, xs: OPTUNA_OB_COMBO_LABELS.xs, exs: OPTUNA_OB_COMBO_LABELS.exs, entry: OPTUNA_OB_COMBO_LABELS.entry, exit: OPTUNA_OB_COMBO_LABELS.exit, stop: OPTUNA_OB_COMBO_LABELS.stop, whipsaw: '휩쏘 ON(호가 111은 전략별)', trail: '합의 다단트레일만(타점 미변경)', '000': OPTUNA_OB_COMBO_LABELS['000'], '100': OPTUNA_OB_COMBO_LABELS['100'], '010': OPTUNA_OB_COMBO_LABELS['010'], '001': OPTUNA_OB_COMBO_LABELS['001'], '110': OPTUNA_OB_COMBO_LABELS['110'], '101': OPTUNA_OB_COMBO_LABELS['101'], '011': OPTUNA_OB_COMBO_LABELS['011'], '111': OPTUNA_OB_COMBO_LABELS['111'], }; const isFull = (u === 'full' || u === 'as_scored' || u === 'trial' || u === 'all'); // 방+휩쏘 / 호가만 라벨 if (String(u).includes('whip')) { labels[u] = `${String(u).replace(/\+whip(saw)?$/i, '')}방+휩쏘 DB적용(탐색아님)`; } else if (isFull) { labels[u] = labels[u] || '줄전체'; } else if (u !== 'base' && u !== 'trail' && u !== '000') { // 「호가만」= 호가 수치만 · 휩쏘는 OFF+수치 비움 (남겨두면 안 됨) labels[u] = (labels[u] || u) + ' · 휩쏘 OFF·수치비움'; } else if (u === 'base' || u === '000') { labels[u] = (labels[u] || u) + ' · 휩쏘 OFF·수치비움'; } let extra = ''; try { const sum = window._optunaLastSummary || null; const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated'); const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)); const ofPct = ofRow && ofRow.overfit_risk_pct != null ? ofRow.overfit_risk_pct : (sum && (sum.apply_overfit_pct != null ? sum.apply_overfit_pct : (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct))); if (ofPct != null && ofPct !== '') { extra += `\n\n이 후보 과적합 ${Number(ofPct)}% (0~100 · 높을수록 위험 · 추정).`; } if (isFull) { extra += '\n\n이 줄 Top 요약에 나온 타점·익절·손절·호가ON/OFF·스프레드·잔량·ask×·휩쏘를 그대로 DB에 넣습니다.\n(사후 8방으로 호가OFF 덮어쓰지 않음)'; } else if (u !== 'trail') { extra += '\n\n선택한 방의 켠 축만 ON, 나머지 호가축은 ENABLED=false (숫자는 DB에 남김).'; if (!String(u).includes('whip')) { extra += '\n휩쏘는 OFF로 끄고 sub/lookback/dip 수치도 비웁니다(호가만=휩쏘 남기지 않음).'; } } if (u === 'trail') { const rec = sum && (sum.daily_trail_recommend || (sum.postprocess_topn && sum.postprocess_topn.postprocess_consensus && sum.postprocess_topn.postprocess_consensus.trail)); if (rec && rec.ok) { extra += `\n\n${rec.prefix}_DAILY_PROFIT_TRAIL_TIERS=${rec.tiers}\nARM=${Number(rec.arm_krw || 0).toLocaleString()}원`; } } } catch (e) { /* ignore */ } const who = (src === 'mode' ? 'mode_combo(축최빈조립·trial없음)' : (src === 'consensus' ? `mode Top10 #${rk}` : (src === 'stable' ? `안정 #${rk}` : (src === 'learn' ? `학습 #${rk}` : `사후합격 #${rk}`)))); // 적용 직전: 익절/손절 숫자를 confirm에 보여 혼동(mode vs gated) 방지 try { const sum2 = window._optunaLastSummary || null; let pr = null; if (src === 'mode') { const mr = ((sum2 && sum2.compare_rows) || []).find((r) => r.source === 'mode'); pr = (mr && (mr.params || mr)) || (sum2 && sum2.mode_combo_summary && sum2.mode_combo_summary.params) || null; } else { const ofKey2 = src === 'stable' ? 'top5_stable' : (src === 'consensus' ? 'top5_consensus' : (src === 'learn' ? 'top5_learn' : 'top5_gated')); const row2 = ((sum2 && sum2[ofKey2]) || []).find((x) => Number(x.rank) === Number(rk)); pr = row2 && (row2.params || row2); } if (pr && (pr.tp_pct != null || pr.sl_pct != null || pr.tp_max_pct != null)) { extra += `\n\n저장될 값(UI%): tp=${pr.tp_pct ?? '—'} · sl=${pr.sl_pct ?? '—'} · tmax=${pr.tp_max_pct ?? '—'}`; } if (src === 'mode') { extra += '\n※ mode=TopN 축별 최빈 조립(한 trial이 아님). 사후합격#1과 숫자가 다를 수 있음.'; } } catch (e2) { /* ignore */ } const msg = `${who} · ${labels[u] || u} 을 DB에 적용할까요?\n(TIME_* 미변경 · 실매 즉시 영향 · 적용 로그 남김)` + extra; if (!confirm(msg)) return; try { const r = await fetch('/api/optuna/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: _optunaJobId, source: src, rank: rk, upto: u }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; } const m = j.metrics || {}; const keys = Object.keys(j.axis_patch || {}); const strat = String(j.strategy || '').toLowerCase(); const stratLabel = ({ scalp: '스캘핑', momentum: '모멘텀', tail: '꼬리', breakout: '돌파', us_momentum: '해외모멘텀', })[strat] || strat || '전략'; // 1) axis_patch 즉시 페인트 → 2) fresh DB 전체 폼 동기화 (새로고침 불필요) try { optunaPaintAxisPatchOnForms(strat, j.axis_patch || {}); } catch (e0) { /* ignore */ } let formHint = ''; try { const d = await reloadStrategyFormsFromDb(strat); if (strat === 'momentum' && d && d.momentum) { const mm = d.momentum; formHint = `\n폼←DB: 스프레드≤${mm.max_spread_pct ?? '—'} · 잔량≥${mm.ob_min_bid_ask_ratio ?? '—'} · 휩쏘=${mm.whipsaw_filter_enabled ? 'ON' : 'OFF'}`; } else if (strat === 'breakout' && d && d.breakout) { const bb = d.breakout; formHint = `\n폼←DB: 스프레드≤${bb.max_spread_pct ?? '—'} · 호가=${bb.ob_filter_enabled ? 'ON' : 'OFF'}`; } else if (strat === 'tail' && d && d.tail) { const tt = d.tail; formHint = `\n폼←DB: 스프레드≤${tt.max_spread_pct ?? '—'} · 호가=${tt.ob_filter_enabled ? 'ON' : 'OFF'}`; } else if (strat === 'scalp') { const sc = (d && d.scalp) || {}; formHint = `\n폼←DB: 손절${sc.sl_pct ?? '—'}% · 익절${sc.tp_pct ?? '—'}% · 호가=${sc.ob_filter_enabled ? 'ON' : 'OFF'}` + ` spr≤${sc.max_spread_pct ?? '—'} · 휩쏘=${sc.whipsaw_filter_enabled ? 'ON' : 'OFF'}`; } } catch (e2) { formHint = '\n⚠️ 폼 DB 동기화 실패 — 새로고침 후 확인'; console.warn('optuna apply form reload', e2); } const tabMap = { scalp: 'backtest', momentum: 'momentum', tail: 'tail', breakout: 'breakout', us_momentum: 'us_momentum', }; const wantTab = tabMap[strat]; if (wantTab) { const tab = document.querySelector(`[data-tab="${wantTab}"]`); if (tab && !tab.classList.contains('active')) tab.click(); } alert( `✅ ${stratLabel} 적용 완료 (upto=${j.upto || u})` + (j.env_id != null ? ` (env_id=${j.env_id})` : '') + `\ntrial #${m.optuna_trial_number ?? '—'} · PnL ${m.total_pnl} · WR ${m.win_rate}` + (keys.length ? `\n후처리키: ${keys.slice(0, 12).join(', ')}${keys.length > 12 ? '…' : ''}` : '') + `\n${j.note || ''}` + formHint + `\n→ ${stratLabel} 백테 탭 폼을 DB로 다시 채웠습니다(새로고침 불필요).`, ); } catch (e) { alert('오류: ' + e); } } async function optunaRerunPostprocess() { const jid = _optunaJobId || (window._optunaLastJob && window._optunaLastJob.job_id) || ''; if (!jid) { alert('선택된 job 없음 — 최근 잡 목록에서 해당 잡을 누르세요'); return; } _optunaJobId = jid; try { localStorage.setItem(OPTUNA_LS_KEY, jid); } catch (e) { /* ignore */ } if (!confirm('이 잡 JSON에 후처리(진입/익절/손절/휩쏘)를 다시 붙일까요?\n실매 DB는 안 바뀝니다. 백그라운드 실행.\n호가스냅이 3건 미만이면 8방 숫자는 그대로일 수 있습니다.')) return; try { const r = await fetch('/api/optuna/postprocess/rerun', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: jid }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; } alert('후처리 재실행 시작\n로그: ' + (j.log_path || '') + '\n끝나면 새로고침하면 표가 채워집니다.'); if ($('opt_pp_rerun_status')) { $('opt_pp_rerun_status').textContent = '재실행 중… ' + (j.log_path || ''); } } catch (e) { alert('오류: ' + e); } } async function optunaPollOnce() { if (!_optunaJobId) { // 외부 CLI / latest try { const r = await fetch('/api/optuna/active'); const j = await r.json(); if (j.running && j.running[0]) { _optunaJobId = j.running[0].job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); const st = j.running[0].status || 'running'; const prev = _optunaPollStatus; _optunaPollStatus = st; optunaRenderJob(j.running[0]); await optunaRefreshJobsIfNeeded(prev !== st); return; } if (j.latest) { if (j.latest.job_id) { _optunaJobId = j.latest.job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); } const st = j.latest.status || ''; const prev = _optunaPollStatus; _optunaPollStatus = st; optunaRenderJob(j.latest); if (j.latest.status === 'done' || j.latest.status === 'error') { optunaSetNav(j.latest); } if (prev !== st || st === 'running') await optunaRefreshJobsIfNeeded(prev !== st && (st === 'done' || st === 'error')); } else if (j.external) { optunaSetNav({ label: 'CLI Optuna', status: 'running', progress: { pct: 5, trials_done: '?', trials_total: '?' }, study_name: j.external.cmdline, }); } else { optunaSetNav(null); } } catch (e) { /* ignore */ } return; } try { const jid = _optunaJobId; const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid)); const j = await r.json(); if (jid !== _optunaJobId) return; if (!j.ok) { _optunaJobId = ''; localStorage.removeItem(OPTUNA_LS_KEY); return; } const st = j.job.status || ''; const prev = _optunaPollStatus; const finishedNow = (st === 'done' || st === 'error') && prev === 'running'; const busy = st === 'running' || optunaPostBusy(j.job); _optunaPollStatus = st; optunaRenderJob(j.job); // 러닝 중·상태 변화·완료 직후 → 잡 목록도 자동 갱신 (수동 새로고침 불필요) if (busy || finishedNow || prev !== st) { await optunaRefreshJobsIfNeeded(finishedNow); } } catch (e) { /* ignore */ } } function optunaPollDelayMs() { const job = window._optunaLastJob; if (!job) return OPTUNA_POLL_MS_IDLE; if (job.status === 'running' || optunaPostBusy(job)) return OPTUNA_POLL_MS_BUSY; return OPTUNA_POLL_MS_IDLE; } function optunaStartPolling() { if (_optunaPollTimer) { clearTimeout(_optunaPollTimer); clearInterval(_optunaPollTimer); _optunaPollTimer = null; } const tick = async () => { try { await optunaPollOnce(); } catch (e) { /* ignore */ } _optunaPollTimer = setTimeout(tick, optunaPollDelayMs()); }; tick(); } function optunaSelectedStrategies() { const order = ['momentum', 'us_momentum', 'tail', 'breakout', 'scalp']; const checked = new Set( Array.from(document.querySelectorAll('.opt-strat-cb:checked')).map(cb => cb.value) ); if (optunaTailEntryModes().length) checked.add('tail'); if (optunaBreakoutSlModes().length) checked.add('breakout'); return order.filter(s => checked.has(s)); } function optunaPickKr4() { const kr = new Set(['momentum', 'breakout', 'scalp']); document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = kr.has(cb.value); }); if ($('opt_tail_em_align')) $('opt_tail_em_align').checked = true; if ($('opt_tail_em_limit')) $('opt_tail_em_limit').checked = false; if ($('opt_bo_sl_fixed')) $('opt_bo_sl_fixed').checked = true; if ($('opt_bo_sl_atr')) $('opt_bo_sl_atr').checked = false; if ($('opt_bo_ob_off')) $('opt_bo_ob_off').checked = true; if ($('opt_bo_ob_on')) $('opt_bo_ob_on').checked = false; } function optunaPickNone() { document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = false; }); document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; }); document.querySelectorAll('.opt-bo-sl').forEach(cb => { cb.checked = false; }); document.querySelectorAll('.opt-bo-ob').forEach(cb => { cb.checked = false; }); } function optunaTailEntryModes() { const out = []; if ($('opt_tail_em_align')?.checked) out.push('align'); if ($('opt_tail_em_limit')?.checked) out.push('limit_atr'); return out; } function optunaBreakoutSlModes() { const out = []; if ($('opt_bo_sl_fixed')?.checked) out.push('fixed'); if ($('opt_bo_sl_atr')?.checked) out.push('atr'); return out; } function optunaBreakoutObModes() { const out = []; if ($('opt_bo_ob_off')?.checked) out.push('off'); if ($('opt_bo_ob_on')?.checked) out.push('on'); // 손절만 켠 채 호가 미체크면 OFF 스터디 1개 (기존 UX 유지) return out.length ? out : ['off']; } async function optunaStart() { const strategies = optunaSelectedStrategies(); const start = $('opt_start')?.value; const end = $('opt_end')?.value; const trials = parseInt($('opt_trials')?.value || '200', 10); let studyTrials = parseInt($('opt_study_trials')?.value, 10); if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null; const mode = $('opt_mode')?.value || 'tpe'; const sortBy = $('opt_sort_by')?.value || 'score'; const univSrc = univHistorySourceParam('opt_univ_history_source'); const candleSrc = $('opt_candle_source')?.value || ''; const tickSrc = $('opt_tick_source')?.value || ''; const obSrc = $('opt_ob_source')?.value || ''; const entryModes = optunaTailEntryModes(); const slModes = optunaBreakoutSlModes(); const obModes = strategies.includes('breakout') ? optunaBreakoutObModes() : []; const boCombos = strategies.includes('breakout') ? slModes.flatMap(sm => obModes.map(om => `${sm}×${om}`)) : []; if (!strategies.length) { alert('전략을 1개 이상 체크하세요'); return; } if (!start || !end) { alert('시작·종료일을 입력하세요'); return; } const needSeq = strategies.length >= 2 || (strategies.includes('tail') && entryModes.length >= 2) || (strategies.includes('breakout') && boCombos.length >= 2); const how = needSeq ? `순차1·2차 (${[...strategies, ...(entryModes.length >= 2 ? ['꼬리×2'] : []), ...(boCombos.length >= 2 ? [`돌파×${boCombos.length}`] : [])].join('+')})` : strategies[0]; const srcLabel = univSrc === 'ls' ? 'LS' : '키움'; const sortLabel = ({ score: '수익·낙폭·표본', score_legacy: '(구) PnL/MDD하한', daily_avg: '일평균', pnl: '총손익', })[sortBy] || sortBy; const emLabel = strategies.includes('tail') ? `\n꼬리진입=${entryModes.join('+')}` + (entryModes.length >= 2 ? ' (순차·각 1·2차)' : '') : ''; const slLabel = strategies.includes('breakout') ? `\n돌파=${boCombos.join('+')}` + (boCombos.length >= 2 ? ` (순차·각 1·2차)` : '') : ''; const chainLabel = needSeq ? `\n각 전략/조합: 1차(넓은 Grid) → 2차(밴드 축소) 후 다음` : `\n1·2차 TPE 연쇄 (1차 넓은 Grid → 2차 밴드 축소)`; if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}` + (studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') + `${chainLabel}\n승리식=${sortLabel}\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return; try { const r = await fetch('/api/optuna/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ strategies, start, end, trials, mode, sort_by: sortBy, study_trials: studyTrials || undefined, universe_history_source: univSrc, candle_source: candleSrc, tick_source: tickSrc, ob_source: obSrc, entry_modes: strategies.includes('tail') ? entryModes : undefined, sl_modes: strategies.includes('breakout') ? slModes : undefined, ob_modes: strategies.includes('breakout') ? obModes : undefined, }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; } _optunaJobId = j.job.job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); optunaRenderJob(j.job); optunaStartPolling(); optunaRefreshJobs(); } catch (e) { alert('오류: ' + e); } } async function optunaContinue(jobId) { const jid = jobId || _optunaJobId; if (!jid) { alert('잡 없음'); return; } if (!confirm('같은 study 이름으로 남은 횟수를 웹에서 이어 돌릴까요?\n(시작 버튼의 새 이름은 쓰지 않습니다)')) return; try { const r = await fetch('/api/optuna/continue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: jid }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; } _optunaJobId = j.job.job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); optunaRenderJob(j.job); optunaStartPolling(); optunaRefreshJobs(); } catch (e) { alert('오류: ' + e); } } async function optunaConfirmStudy(jobId) { const jid = jobId || _optunaJobId; if (!jid) { alert('잡 없음'); return; } if (!confirm('지금 완료 수로 목표를 줄이고 후처리를 돌릴까요?\n윈도우/추가 웹 이어가기는 포기합니다. 실매 DB는 안 바뀝니다.')) return; try { const r = await fetch('/api/optuna/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: jid }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; } alert('확정 · 후처리 시작\n' + (j.log_path || j.note || '')); optunaRefreshJobs(); if (_optunaJobId) optunaPollOnce(); } catch (e) { alert('오류: ' + e); } } async function optunaStop() { if (!_optunaJobId) return; if (!confirm('실행 중 Optuna를 중지할까요?')) return; try { const r = await fetch('/api/optuna/stop/' + encodeURIComponent(_optunaJobId), { method: 'POST' }); const j = await r.json(); if (!j.ok) { alert(j.error || '중지 실패'); return; } optunaRenderJob(j.job); optunaRefreshJobs(); } catch (e) { alert('오류: ' + e); } } function optunaFormatModeTopRow(t, i) { const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean; const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct; const prox = t.consensus_match_pct; const proxN = t.consensus_match_n; const proxOf = t.consensus_match_of; let proxBit = ''; if (prox != null) { proxBit = ` · 근접 ${Number(prox).toFixed(1)}%`; if (proxN != null && proxOf != null) proxBit += ` (${proxN}/${proxOf}축)`; } return `#${i} trial ${t.optuna_trial_number ?? '—'} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` + (dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') + (dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') + proxBit + ` · WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)}`; } function optunaBuildModeTop3Html(rows, opts) { const o = opts || {}; const n = o.topN || OPTUNA_CARD_TOP_N; const list = (rows || []).slice(0, n); if (!list.length) return ''; const pr = o.periodRange ? ` · 기간 ${o.periodRange}` : ''; const pool = o.poolSize != null ? ` · pool ${o.poolSize}건` : ''; const head = o.title || `mode Top${n} (밴드 근접)`; let html = `
${head}${pr}${pool}
`; list.forEach((t, i) => { html += `
${optunaFormatModeTopRow(t, i + 1)}
`; }); return html; } function optunaFormatTopRow(t, i) { const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean; const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct; return `#${i} trial ${t.optuna_trial_number ?? '—'} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` + (dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') + (dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') + ` · WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · score ${Number(t.score || 0).toFixed(3)}`; } async function optunaRefreshJobs() { try { const sort = $('opt_jobs_sort')?.value || 'started'; const r = await fetch('/api/optuna/jobs?limit=40&sort=' + encodeURIComponent(sort)); const j = await r.json(); const tb = $('opt_jobs_tbody'); if (!tb) return; if (!j.ok || !(j.jobs || []).length) { tb.innerHTML = '잡 없음'; return; } tb.innerHTML = j.jobs.map(job => { const prog = job.progress || {}; const jid = job.job_id; const label = job.label || job.strategy || ''; const studyNote = (job.study_short && job.kind === 'import') ? `
${job.study_short}` : (job.source === 'cli' && job.study_short ? `
${job.study_short}` : ''); const sel = jid === _optunaJobId ? 'outline:1px solid var(--accent)' : ''; const st = String(job.status || ''); const refinePh = prog.refine_phase || ''; const phaseTag = refinePh === 'phase2' ? '2차' : (refinePh === 'phase1' ? '1차' : ''); const stCell = (st === 'running') ? `` : st; const statusNote = (st === 'running') ? (job.leftover_note ? ` ·${job.leftover_note}` : (phaseTag ? ` ·${phaseTag} TPE` : '')) : (job.leftover_note ? ` ·${job.leftover_note}` : ''); const extraBtns = (st === 'done' && job.can_continue) ? `` + `` : ''; return ` ${jid} ${label}${studyNote} ${optunaFormatJobPeriodCell(job)} ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote} ${(() => { const done = prog.trials_done ?? '—'; const batch = prog.trials_total ?? job.trials ?? '—'; const goal = job.study_trials; const nDone = job.n_complete != null ? job.n_complete : done; if (goal) return `${nDone}/${goal} ·배치${batch}`; return `${done}/${batch}`; })()}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''} ${job.started_at || ''} ${job.finished_at || '—'} ${extraBtns} `; }).join(''); } catch (e) { const tb = $('opt_jobs_tbody'); if (tb) tb.innerHTML = `${e}`; } } function optunaCloseJoinCmd() { const el = $('opt_join_overlay'); if (el) el.classList.remove('is-open'); } function optunaFillJoinOverlay(job) { if (!job) return; const pi = job.period_info; let periodHint = ''; if (pi && (pi.has_refine || pi.active && pi.active.study)) { const bits = []; if (job.start && job.end) bits.push(`잡 ${optunaFmtPeriodShort(job.start, job.end)}`); if (pi.phase1 && pi.phase1.start) bits.push(`1차 ${pi.phase1.range || optunaFmtPeriodShort(pi.phase1.start, pi.phase1.end)}`); if (pi.phase2 && pi.phase2.study && pi.phase2.start) bits.push(`2차 ${pi.phase2.range || optunaFmtPeriodShort(pi.phase2.start, pi.phase2.end)}`); if (bits.length) { periodHint = (pi.mismatch ? '⚠ 기간 불일치 · ' : '📅 ') + bits.join(' · '); } } if ($('opt_join_hint')) { const base = job.join_hint || ''; $('opt_join_hint').textContent = periodHint ? (periodHint + (base ? '\n' + base : '')) : base; } if ($('opt_join_study')) { $('opt_join_study').textContent = job.join_study || job.active_study_name || job.study_name || '—'; } if ($('opt_join_cmd')) $('opt_join_cmd').textContent = job.join_cmd || '(아직 study 없음 · 첫 START 후)'; if ($('opt_join_cmd_ps')) $('opt_join_cmd_ps').textContent = job.join_cmd_ps || '(아직 study 없음 · 첫 START 후)'; if ($('opt_web_cmd_full')) { $('opt_web_cmd_full').textContent = job.web_cmd_full || job.web_cmd || job.cmd || '—'; } if ($('opt_web_cmd')) $('opt_web_cmd').textContent = job.web_cmd || job.cmd || '—'; const refineBox = $('opt_join_refine'); if (refineBox) { const rows = job.seq_refine_cmds || []; window._optunaSeqRefine = rows; if (!rows.length) { refineBox.innerHTML = ''; } else { refineBox.innerHTML = '
PC별 병렬 — 전략/조합마다 1·2차 (refine runner)
' + '
MariaDB 공유 · PC마다 한 줄 · 2차만은 --phase1-study (DB payload)
' + rows.map((row, i) => { const done = row.phase1_db ? ' (1차 DB OK)' : (row.done ? ' (1차 OK)' : ''); const note = row.phase2_note ? `
${row.phase2_note}
` : ''; return `
${row.step}. ${row.label || row.strategy}${done}
${note}

            

          
`; }).join(''); rows.forEach((row, i) => { const pf = document.getElementById('opt_refine_full_' + i); if (pf) pf.textContent = row.cmd || ''; const p2 = document.getElementById('opt_refine_p2_' + i); if (p2) p2.textContent = row.cmd_phase2 || ''; }); } } const box = $('opt_join_all'); if (box) { const all = job.join_cmds_all || []; if (!all.length) { box.innerHTML = ''; return; } window._optunaJoinAll = all; box.innerHTML = '
스터디별 trial 추가 (1·2차 study 이름)
' + all.map((row, i) => { const lab = [row.strategy, row.extra].filter(Boolean).join('/'); return `
${i + 1}. ${lab} · ${row.study || ''}

        
`;
      }).join('');
    all.forEach((row, i) => {
      const pre = document.getElementById('opt_join_all_' + i);
      if (pre) pre.textContent = row.cmd || '';
      const ps = document.getElementById('opt_join_all_ps_' + i);
      if (ps) ps.textContent = row.cmd_ps || '';
    });
  }
}

function optunaCopySeqRefine(i, which) {
  const row = (window._optunaSeqRefine || [])[i];
  if (!row) return;
  let t = row.cmd || '';
  if (which === 'full_ps') t = row.cmd_ps || '';
  if (which === 'p2') t = row.cmd_phase2 || '';
  if (which === 'p2_ps') t = row.cmd_phase2_ps || '';
  optunaCopyText(t);
}

function optunaCopyText(text) {
  const t = String(text || '');
  if (!t) return;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(t).then(() => {}, () => { prompt('복사:', t); });
  } else {
    prompt('복사:', t);
  }
}

function optunaCopyJoinAll(i, which) {
  const row = (window._optunaJoinAll || [])[i];
  if (!row) return;
  optunaCopyText(which === 'ps' ? (row.cmd_ps || '') : (row.cmd || ''));
}

function optunaCopyJoin(which) {
  let el = $('opt_join_cmd');
  if (which === 'web') el = $('opt_web_cmd');
  if (which === 'web_full') el = $('opt_web_cmd_full');
  if (which === 'ps') el = $('opt_join_cmd_ps');
  optunaCopyText(el ? el.textContent : '');
}

async function optunaOpenJoinCmd(jobId) {
  const jid = jobId || _optunaJobId || (window._optunaLastJob && window._optunaLastJob.job_id);
  const ov = $('opt_join_overlay');
  if (!ov) return;
  if (!jid) { alert('선택된 잡 없음'); return; }
  ov.classList.add('is-open');
  if (window._optunaLastJob && window._optunaLastJob.job_id === jid) {
    optunaFillJoinOverlay(window._optunaLastJob);
  }
  try {
    const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
    const j = await r.json();
    if (j.ok && j.job) optunaFillJoinOverlay(j.job);
    else if (j.error) alert(j.error);
  } catch (e) {
    alert('명령 로드 실패: ' + e);
  }
}

function optunaWatch(jobId) {
  _optunaJobId = jobId;
  localStorage.setItem(OPTUNA_LS_KEY, jobId);
  if ($('opt_st_job')) $('opt_st_job').textContent = jobId;
  const card = $('opt_status_card');
  if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
  document.querySelectorAll('#opt_jobs_tbody tr').forEach(tr => {
    tr.style.outline = tr.getAttribute('data-job-id') === jobId ? '1px solid var(--accent)' : '';
  });
  optunaPollOnce();
  optunaStartPolling();
}

function optunaBoot() {
  optunaLoadDefaults();
  optunaStartPolling();
}

// ────────────────────────────────────────────
// 상단 매매 모의/실전 (KIS_MOCK) — DB 저장 + kis_trader_main 재시작
// ────────────────────────────────────────────
let _kisMockBusy = false;
let _kisMockCurrent = null; // true=모의, false=실전, null=미로드

/**
 * WS 프로바이더 라벨 생성.
 * @param {string} prov  - "kiwoom" | "kis" | "ls" | ...
 * @param {boolean} isMock
 * @returns {string} e.g. "키움(실)" | "KIS(모)"
 */
function _wsProvLabel(prov, isMock) {
  const nm = (prov || '').toLowerCase();
  const name = nm === 'kiwoom' ? '키움' : nm === 'kis' ? 'KIS' : nm === 'ls' ? 'LS' : (prov || '?').toUpperCase();
  return `${name}(${isMock ? '모' : '실'})`;
}

function kisMockApplyUi(d) {
  const mock = !!(d && d.kis_mock);
  _kisMockCurrent = mock;
  const btnM = $('kis_mock_btn_mock');
  const btnL = $('kis_mock_btn_live');
  if (btnM) {
    btnM.classList.toggle('active', mock);
    btnM.classList.toggle('btn-warning', mock);
    btnM.classList.toggle('btn-outline-warning', !mock);
    btnM.disabled = _kisMockBusy;
  }
  if (btnL) {
    btnL.classList.toggle('active', !mock);
    btnL.classList.toggle('btn-danger', !mock);
    btnL.classList.toggle('btn-outline-danger', mock);
    btnL.disabled = _kisMockBusy;
  }
  const st = $('kis_mock_status');
  if (st) {
    const bot = d && d.bot_active ? 'active' : ((d && d.bot_status) || '?');
    const label = (d && d.label) || (mock ? '모의' : '실전');
    st.textContent = label + ' · ' + bot;
    st.className = mock ? 'text-warning' : 'text-danger';
    st.style.fontSize = '11px';
    st.style.whiteSpace = 'nowrap';
  }
  const lss = $('live_source_status');
  if (lss && d) {
    // 프로바이더 모의/실전 판정
    const tickProv = (d.tick_provider || 'kiwoom').toLowerCase();
    const obProv   = (d.ob_provider   || 'kiwoom').toLowerCase();
    const tickMock = tickProv.includes('kis') ? !!d.kis_price_mock : !!d.kiwoom_mock;
    const obMock   = obProv.includes('kis')   ? !!d.kis_price_mock : !!d.kiwoom_mock;

    const tickLbl = _wsProvLabel(tickProv, tickMock);
    const obLbl   = _wsProvLabel(obProv, obMock);

    // 틱 DB 적재 표시
    const scopeShort = (d.tick_db_scope || '전체').replace('candidates', '후보');
    const dbLbl = d.tick_db_enabled ? `DB:${scopeShort}` : 'DB:OFF';

    lss.textContent = `시세:${tickLbl} | 호가:${obLbl} | ${dbLbl}`;

    // hover tooltip — 전체 상세 (캔들 소스 포함)
    const c = d.candle_source || '전체';
    lss.title = [
      `캔들 소스: ${c}`,
      `시세 우선: ${_wsProvLabel(tickProv, tickMock)} → 폴백 자동`,
      `호가 우선: ${_wsProvLabel(obProv, obMock)} → 폴백 자동`,
      `KIS 시세WS: 실전 고정 (is_mock=false)`,
      `틱 DB 저장: ${d.tick_db_enabled ? 'ON (' + (d.tick_db_scope || '?') + ')' : 'OFF'}`,
    ].join('\n');
    lss.style.cursor = 'help';
  }
}

async function kisMockLoad() {
  try {
    const r = await fetch('/api/ops/kis_mock');
    const d = await r.json();
    if (!d || !d.ok) {
      const st = $('kis_mock_status');
      if (st) st.textContent = (d && d.error) || '조회실패';
      return;
    }
    kisMockApplyUi(d);
  } catch (e) {
    const st = $('kis_mock_status');
    if (st) st.textContent = '조회오류';
  }
}

async function kisMockSet(wantMock) {
  if (_kisMockBusy) return;
  if (_kisMockCurrent === wantMock) {
    await kisMockLoad();
    return;
  }
  const label = wantMock ? '모의' : '실전';
  const msg = wantMock
    ? '매매를 모의 계좌로 전환합니다.\n시세는 실키 유지 · kis_trader_main 이 재시작됩니다.\n계속할까요?'
    : '⚠️ 매매를 실전 계좌로 전환합니다.\n실주문이 나갑니다. 시세는 실키 유지 · 봇이 재시작됩니다.\n정말 전환할까요?';
  if (!confirm(msg)) return;

  _kisMockBusy = true;
  kisMockApplyUi({ kis_mock: _kisMockCurrent, label: _kisMockCurrent ? '모의' : '실전', bot_active: false, bot_status: '재시작중' });
  const st = $('kis_mock_status');
  if (st) {
    st.textContent = '저장·재시작중…';
    st.className = 'text-muted';
  }
  try {
    const r = await fetch('/api/ops/kis_mock', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ kis_mock: !!wantMock }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok || !d.ok) {
      alert(
        (d && d.error) ||
        ('전환 실패 (HTTP ' + r.status + ')\n' +
          'db_ok=' + (d && d.db_ok) + ' restart_ok=' + (d && d.restart_ok))
      );
      await kisMockLoad();
      return;
    }
    kisMockApplyUi(d);
    alert('✅ 매매 ' + label + ' 전환 완료\n봇: ' + (d.bot_status || '') + '\n' + (d.note || ''));
  } catch (e) {
    alert('전환 오류: ' + e);
    await kisMockLoad();
  } finally {
    _kisMockBusy = false;
    await kisMockLoad();
  }
}

function kisMockBoot() {
  kisMockLoad();
}

// ────────────────────────────────────────────
// 최초 로드
// ※ loadActual 은 /api/env/params 날짜 확정 후 호출 (위 fetch then)
// ────────────────────────────────────────────
optunaBoot();
kisMockBoot();
btRestoreTab();