10889 lines
475 KiB
Plaintext
10889 lines
475 KiB
Plaintext
// ────────────────────────────────────────────
|
||
// 유틸
|
||
// ────────────────────────────────────────────
|
||
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} <span style="color:var(--muted);font-size:11px">(${fmtWon(krw)}원 · ${r.toFixed(2)})</span>`;
|
||
};
|
||
/** 요약 카드용 — 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 += ` | 장중 누적 최고 <b style="color:var(--green)">+${fmtMoney(peak)}</b> (${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 += ` | 최종 누적 <b>${fmtUsd(opts.totalPnl)} <span style="color:var(--muted);font-size:11px">(${fmtWon(opts.totalPnlKrw)}원)</span></b>`;
|
||
} else {
|
||
line += ` | 최종 누적 <b>${pnlHtml}</b>`;
|
||
}
|
||
}
|
||
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 `
|
||
<div style="margin-top:2px; display:flex; gap:3px;">
|
||
<a href="https://finance.naver.com/item/main.naver?code=${c}" target="_blank" class="btn btn-xs btn-outline-secondary" style="font-size:9px;padding:0 3px">N</a>
|
||
<a href="https://m.stock.naver.com/domestic/stock/${c}/total" target="_blank" class="btn btn-xs btn-outline-secondary" style="font-size:9px;padding:0 3px">N(M)</a>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/** 호가셀 — 녹/노랑/빨강 뱃지 + 쉬운 말 (실매·가상 공용). 임계는 enrich lim_* 만 사용. */
|
||
function _obEscAttr(s) {
|
||
return String(s || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<');
|
||
}
|
||
|
||
function _obNum(v) {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : NaN;
|
||
}
|
||
|
||
function _obMetricLine(ok, okHead, badHead, rest) {
|
||
if (ok === null) {
|
||
return `<span style="color:var(--muted)">${rest}</span>`;
|
||
}
|
||
const cls = ok ? 'ob-metric-ok' : 'ob-metric-bad';
|
||
return `<span class="${cls}">${ok ? okHead : badHead}</span>`
|
||
+ ` <span style="color:var(--muted)">${rest}</span>`;
|
||
}
|
||
|
||
function _entryObCellHtml(ob) {
|
||
if (!ob || ob.matched === false) {
|
||
return '<span class="badge-flat" style="font-size:10px" title="'
|
||
+ '체결(매수 성공)과 호가 저장은 다른 일이에요. '
|
||
+ '호가는 「사기 직전 검사」할 때만 사진처럼 남습니다. '
|
||
+ '구독이 없거나 검사가 스킵되면 기록없음입니다.'
|
||
+ '">기록없음</span>'
|
||
+ '<br><span style="color:var(--muted);font-size:10px">체결≠호가저장</span>';
|
||
}
|
||
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(`<span style="color:var(--muted)">사겠음 ${b}주 · 팔겠음 ${a}주</span>`);
|
||
}
|
||
if (Number.isFinite(dsec)) {
|
||
const dS = `${dsec >= 0 ? '+' : ''}${Math.round(dsec)}s`;
|
||
lines.push(`<span style="color:var(--muted)">시차 ${dS}${ob.source ? ' · ' + _obEscAttr(ob.source) : ''}</span>`);
|
||
}
|
||
|
||
const tip = [
|
||
badgeTip,
|
||
ob.reject_msg || '',
|
||
'가격간격 = 바로 살 수 있는 값과 바로 팔 수 있는 값의 차이(%)',
|
||
'살 힘 = 사겠다고 걸어둔 양 ÷ 팔겠다고 걸어둔 양 (위 3칸 합, 배수)',
|
||
ob.snap_time ? `snap ${ob.snap_time}` : '',
|
||
'※ 빨간 뱃지여도 당시 필터 스킵(만료 구멍)이면 실제 매수는 됐을 수 있어요',
|
||
'※ 체결 성공 ≠ 호가 사진이 꼭 있다는 뜻은 아님',
|
||
].filter(Boolean).join('\n');
|
||
return `<span class="${badgeCls}" title="${_obEscAttr(tip)}" style="font-size:10px">${badgeTxt}</span>`
|
||
+ `<br><span style="font-size:10px;line-height:1.45">${lines.join('<br>')}</span>`;
|
||
}
|
||
|
||
function _exitObCellHtml(ob, isOpen) {
|
||
if (isOpen) {
|
||
return '<span class="badge-win" style="font-size:10px" title="아직 안 팔아서 매도 시각 호가가 없어요">보유중</span>';
|
||
}
|
||
if (!ob || ob.matched === false) {
|
||
return '<span class="badge-flat" style="font-size:10px" title="'
|
||
+ '팔린 시각 근처 호가 사진이 없어요. '
|
||
+ '호가 시계열이 꺼져 있거나, 구독이 없던 종목일 수 있어요.'
|
||
+ '">기록없음</span>';
|
||
}
|
||
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(`<span style="color:var(--muted)">총사겠음 ${b} · 총팔겠음 ${a}</span>`);
|
||
}
|
||
if (Number.isFinite(dsec)) {
|
||
const dS = `${dsec >= 0 ? '+' : ''}${Math.round(dsec)}s`;
|
||
lines.push(`<span style="color:var(--muted)">시차 ${dS}${ob.source ? ' · ' + _obEscAttr(ob.source) : ''}</span>`);
|
||
}
|
||
|
||
const tip = [
|
||
badgeTip,
|
||
'OR = 호가창 전체 사겠음 ÷ 팔겠음',
|
||
'가격간격 = 매수1↔매도1 차이(%)',
|
||
ob.snap_time ? `snap ${ob.snap_time}` : '',
|
||
'※ 매수호가 칸과 다른 칸 — 살 때 vs 팔 때',
|
||
].filter(Boolean).join('\n');
|
||
return `<span class="${badgeCls}" title="${_obEscAttr(tip)}" style="font-size:10px">${badgeTxt}</span>`
|
||
+ `<br><span style="font-size:10px;line-height:1.45">${lines.join('<br>')}</span>`;
|
||
}
|
||
|
||
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}<br><span style="color:var(--muted);font-size:11px">${r.code || ''}</span>`
|
||
: (r.code || '-');
|
||
const linksHtml = _tradeExtLinksHtml(r);
|
||
const reasonHtml = `<span class="badge-reason">${r.reason || '-'}</span>`;
|
||
const debugHtml = r.debugTick
|
||
? `<span class="badge badge-secondary" style="font-size:10px" title="체결 경로">${r.debugTick}</span>`
|
||
: (r.entrySource || r.exitSource
|
||
? `<span style="font-size:10px;color:var(--muted)">${r.entrySource || '-'}→${r.exitSource || '-'}</span>`
|
||
: '-');
|
||
const rowFx = getDisplayFxRate(r.fxRate || defaultFx);
|
||
const cumPnlHtml = Number.isFinite(r.cumPnl)
|
||
? `<span class="${r.cumPnl > 0 ? 'text-pnl-pos' : (r.cumPnl < 0 ? 'text-pnl-neg' : '')}">${fmtPnlCell(r.cumPnl, rowFx)}</span>`
|
||
: '-';
|
||
const cumPctHtml = Number.isFinite(r.cumReturnPct)
|
||
? `<span class="${r.cumReturnPct > 0 ? 'badge-win' : (r.cumReturnPct < 0 ? 'badge-loss' : 'badge-flat')}">${fmtPct(r.cumReturnPct)}</span>`
|
||
: '-';
|
||
const sellCell = r.isOpen
|
||
? '<span class="badge-flat">보유중</span>'
|
||
: `<span style="font-size:11px">${fmtTradeTime(r.sellRaw)}</span>`;
|
||
const exitPxCell = r.isOpen
|
||
? `${fmtPx(r.xp, rowFx)} <span style="color:var(--muted);font-size:10px">현재가</span>`
|
||
: fmtPx(r.xp, rowFx);
|
||
const pnlCell = r.isOpen
|
||
? `${fmtPnlCell(r.pnl, rowFx)} <span style="color:var(--muted);font-size:10px">평가</span>`
|
||
: 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
|
||
? `<span class="parity-cause-corner" title="정합 예외 경로">${r.parityCause || '예외'}</span>`
|
||
: '';
|
||
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 = `<tr class="${rowCls}"${rowStyle}${liveAttrs}>`;
|
||
if (opts.showAccount) {
|
||
const al = r.accountLabel || '미상';
|
||
const ab = al === '모의' ? 'badge-flat' : (al === '실전' ? 'badge-win' : 'badge-loss');
|
||
html += `<td><span class="${ab}" style="font-size:10px" title="is_mock">${al}</span></td>`;
|
||
}
|
||
html += `
|
||
<td class="trade-name-cell">${causeBadge}${nameCell}${linksHtml}</td>
|
||
<td style="font-size:11px">${fmtTradeTime(r.buyRaw)}</td>
|
||
<td style="font-size:11px">${sellCell}</td>
|
||
<td>${fmtPx(r.ep, rowFx)}</td>
|
||
<td data-live-cell="px">${exitPxCell}</td>
|
||
<td>${r.qty}</td>
|
||
<td class="${pnlCls}" data-live-cell="pnl">${pnlCell}</td>
|
||
<td><span class="${rateBadge}" data-live-cell="pct">${fmtPct(r.rateNum)}</span></td>`;
|
||
if (opts.showCumulative) {
|
||
html += `<td>${cumPnlHtml}</td><td>${cumPctHtml}</td>`;
|
||
}
|
||
html += `<td>${r.hold}</td>`;
|
||
if (opts.showDebug) html += `<td style="font-size:10px">${debugHtml}</td>`;
|
||
html += `<td style="font-size:10px;line-height:1.35">${_entryObCellHtml(r.entryOb)}</td>`;
|
||
html += `<td style="font-size:10px;line-height:1.35">${_exitObCellHtml(r.exitOb, r.isOpen)}</td>`;
|
||
html += `<td style="font-size:11px">${reasonHtml}</td>`;
|
||
if (opts.showRsi) html += `<td>${r.rsi != null && r.rsi !== '' ? r.rsi : '-'}</td>`;
|
||
html += '</tr>';
|
||
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 => '<code style="margin-right:8px">' + b + '</code>').join('');
|
||
}
|
||
const notes = j.notes || [];
|
||
if ($('fs_notes')) {
|
||
$('fs_notes').innerHTML = notes.length
|
||
? notes.map(n => '<div class="alert alert-warning py-2 px-3 mb-2" style="font-size:12px">' + n + '</div>').join('')
|
||
: '<div class="text-muted" style="font-size:12px">특이 노트 없음</div>';
|
||
}
|
||
|
||
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 = '<tr><td colspan="8" class="text-muted">없음</td></tr>';
|
||
} 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 '<tr' + st + '><td><b>' + name + '</b></td>' +
|
||
'<td class="text-end">' + fsFmtN(v.tick_n) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(v.tick_codes) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(v.ob_n) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(v.ob_codes) + '</td>' +
|
||
'<td class="text-end">' + fsRatio(v.ob_per_tick) + '</td>' +
|
||
'<td class="text-end">' + fsPct(v.ob_per_tick_pct) + '</td>' +
|
||
'<td style="font-size:12px">' + fsVendorNote(v) + '</td></tr>';
|
||
}).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 = '<tr><td colspan="13" class="text-muted">집계 없음(recv_ts·시각 파싱 확인)</td></tr>';
|
||
} 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 '<tr' + st + '><td>' + (a.channel || '') + '</td><td>' + (a.source || '') + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(a.total) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(a.pass_n) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(a.fail_n) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(a.unknown_n) + '</td>' +
|
||
'<td class="text-end"><b>' + fsFmtN(a.usable_n) + '</b></td>' +
|
||
'<td class="text-end">' + fsPct(a.usable_pct) + '</td>' +
|
||
'<td class="text-end">' + share + '</td>' +
|
||
'<td class="text-end">' + pickN + '</td>' +
|
||
'<td class="text-end"><b>' + pickP + '</b></td>' +
|
||
'<td class="text-end">' + fsPct(a.fail_pct) + '</td>' +
|
||
'<td class="text-end">' + emp + '</td></tr>';
|
||
}).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 = '<tr><td colspan="8" class="text-muted">끊김 집계 없음</td></tr>';
|
||
} 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 '<tr' + st + '><td><b>' + name + '</b></td>' +
|
||
'<td class="text-end">' + fsFmtN(g.pair_n) + '</td>' +
|
||
'<td class="text-end">' + fsFmtN(g.soft_n) + '</td>' +
|
||
'<td class="text-end">' + fsPct(g.soft_pct) + '</td>' +
|
||
'<td class="text-end"><b>' + fsFmtN(g.hard_n) + '</b></td>' +
|
||
'<td class="text-end">' + fsPct(g.hard_pct) + '</td>' +
|
||
'<td class="text-end">' + (g.avg_soft_gap_sec != null ? g.avg_soft_gap_sec : '—') + '</td>' +
|
||
'<td class="text-end">' + (g.max_gap_sec != null ? fsFmtN(g.max_gap_sec) : '—') + '</td></tr>';
|
||
}).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 = '<tr><td colspan="3" class="text-muted">' + (emptyMsg || '0') + '</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = rows.map(r =>
|
||
'<tr><td>' + (r.key || '') + '</td><td class="text-end">' + fsFmtN(r.n) +
|
||
'</td><td class="text-end">' + fsFmtN(r.codes) + '</td></tr>'
|
||
).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 = '<tr><td colspan="9" class="text-muted">커버리지 없음</td></tr>';
|
||
} 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 '<tr' + style + '><td>' + (c.vendor || '') +
|
||
'</td><td class="text-end">' + fsFmtN(c.tick_n) +
|
||
'</td><td class="text-end">' + fsFmtN(c.tick_codes) +
|
||
'</td><td class="text-end">' + fsFmtN(c.ob_n) +
|
||
'</td><td class="text-end">' + fsFmtN(c.ob_codes) +
|
||
'</td><td class="text-end">' + noOb +
|
||
'</td><td class="text-end">' + noPct +
|
||
'</td><td class="text-end">' + sync +
|
||
'</td><td class="text-end">' + miss + '</td></tr>';
|
||
}).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 =>
|
||
'<tr><td>' + r.key + '</td><td class="text-end">' + fsFmtN(r.n) +
|
||
'</td><td class="text-end">' + fsFmtN(r.codes) + '</td></tr>'
|
||
).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 => `<tr>
|
||
<td>${x.stock_code||''}</td><td>${x.corp_name||''}</td>
|
||
<td class="small">${(x.report_nm||'').slice(0,40)}</td>
|
||
<td class="small">${x.added_at||''}</td><td class="small">${x.expires_at||''}</td>
|
||
</tr>`).join('') : '<tr><td colspan="5" class="text-muted">워치 없음 (구독 스위치 OFF 또는 공시 대기)</td></tr>';
|
||
}
|
||
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 ? '<span class="text-success">통과</span>'
|
||
: ok === false ? `<span class="text-warning">${(x.filter_reason||'제외').slice(0,24)}</span>`
|
||
: '-';
|
||
const pct = (x.sales_pct != null && x.sales_pct !== '') ? Number(x.sales_pct).toFixed(1) : '-';
|
||
return `<tr>
|
||
<td class="small">${x.first_seen_at||''}</td>
|
||
<td>${x.stock_code||''}</td><td>${x.corp_name||''}</td>
|
||
<td class="small">${pct}</td>
|
||
<td class="small">${badge}</td>
|
||
<td class="small">${(x.report_nm||'').trim()}</td>
|
||
<td>${x.url ? `<a href="${x.url}" target="_blank" rel="noopener">원문</a>` : ''}</td>
|
||
</tr>`;
|
||
}).join('') : '<tr><td colspan="7" class="text-muted">공시 없음</td></tr>';
|
||
}
|
||
} 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 '<span class="text-muted">—</span><br><span style="color:var(--muted);font-size:10px">시세대기</span>';
|
||
}
|
||
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 = '<b>$' + px.toFixed(4) + '</b>';
|
||
if (krw > 0) {
|
||
main += '<br><span style="color:var(--muted);font-size:11px">('
|
||
+ Math.round(krw).toLocaleString('ko-KR') + '원'
|
||
+ (fx > 0 ? (' · ' + fx.toFixed(2)) : '')
|
||
+ ')</span>';
|
||
}
|
||
} else {
|
||
main = '<b>' + Math.round(px).toLocaleString('ko-KR') + '</b>';
|
||
}
|
||
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 = '<br><span class="' + cls + '" style="font-size:11px">'
|
||
+ (v >= 0 ? '+' : '') + v.toFixed(2) + '%</span>';
|
||
}
|
||
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 ? ('<br><span style="color:var(--muted);font-size:10px">' + meta + '</span>') : '');
|
||
}
|
||
|
||
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 = '<tr><td colspan="10" class="text-muted">등록된 영구구독 없음</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = rows.map(function (x) {
|
||
const onBadge = x.enabled
|
||
? '<span class="badge bg-success">ON</span>'
|
||
: '<span class="badge bg-secondary">OFF</span>';
|
||
const mkBadge = (String(x.market_type).toUpperCase() === 'US')
|
||
? '<span class="badge bg-info text-dark">US</span>'
|
||
: '<span class="badge bg-primary">KR</span>';
|
||
const nm = (x.name || '').trim();
|
||
const code = x.code || '';
|
||
const title = nm && nm !== code
|
||
? ('<b>' + code + '</b><br><span style="color:var(--muted);font-size:11px">' + nm + '</span>')
|
||
: ('<b>' + code + '</b>');
|
||
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 = '<div class="d-flex flex-wrap gap-1 align-items-center" style="min-width:170px">'
|
||
+ '<select class="form-select form-select-sm" style="width:68px;font-size:11px" id="perm_as_' + code + '">'
|
||
+ '<option value="gte"' + (aside === 'gte' ? ' selected' : '') + '>이상</option>'
|
||
+ '<option value="lte"' + (aside === 'lte' ? ' selected' : '') + '>이하</option>'
|
||
+ '</select>'
|
||
+ '<input type="number" step="any" class="form-control form-control-sm" style="width:88px;font-size:11px" '
|
||
+ 'id="perm_ap_' + code + '" value="' + ap + '" placeholder="목표가">'
|
||
+ '<button class="btn btn-sm btn-outline-primary py-0 px-1" style="font-size:11px" '
|
||
+ 'onclick="permSaveAlert(\'' + code + '\')">저장</button>';
|
||
if (ap !== '' && !armed) {
|
||
alertCell += '<button class="btn btn-sm btn-outline-warning py-0 px-1" style="font-size:11px" '
|
||
+ 'onclick="permRearmAlert(\'' + code + '\')">재무장</button>';
|
||
}
|
||
if (ap !== '') {
|
||
alertCell += '<button class="btn btn-sm btn-outline-secondary py-0 px-1" style="font-size:11px" '
|
||
+ 'onclick="permClearAlert(\'' + code + '\')">해제</button>';
|
||
}
|
||
alertCell += '</div>';
|
||
if (ap !== '') {
|
||
alertCell += '<div style="font-size:10px;color:var(--muted)">'
|
||
+ (armed ? '<span class="text-success">감시중</span>' : '<span class="text-warning">발화대기</span>')
|
||
+ (fired ? (' · ' + fired) : '')
|
||
+ '</div>';
|
||
}
|
||
return '<tr>'
|
||
+ '<td>' + title + '</td>'
|
||
+ '<td style="white-space:nowrap">' + permFmtPriceCell(x, fx) + '</td>'
|
||
+ '<td>' + alertCell + '</td>'
|
||
+ '<td>' + mkBadge + '</td>'
|
||
+ '<td>' + (x.exchange || '') + '</td>'
|
||
+ '<td><span class="badge bg-dark">' + baseTf + '분</span></td>'
|
||
+ '<td><code style="font-size:11px">' + (x.ws_tr_key || '') + '</code></td>'
|
||
+ '<td>' + onBadge + '</td>'
|
||
+ '<td style="font-size:11px">' + (x.note || '') + '</td>'
|
||
+ '<td><button class="btn btn-sm btn-outline-secondary" onclick=\'permEdit(' + JSON.stringify(editPayload) + ')\'>수정</button> '
|
||
+ (String(x.market_type).toUpperCase() === 'KR'
|
||
? '<button class="btn btn-sm btn-outline-primary" onclick="permFillCandles(\'' + code + '\')">확정봉</button> '
|
||
: '')
|
||
+ '<button class="btn btn-sm btn-outline-danger" onclick="permDelete(\'' + code + '\')">삭제</button></td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
} catch (e) {
|
||
if (!quiet) {
|
||
tb.innerHTML = '<tr><td colspan="10" class="text-danger">로드 실패: ' + e + '</td></tr>';
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = '<div class="text-muted">로딩…</div>';
|
||
try {
|
||
const r = await fetch('/api/env/strategy_history?limit=3');
|
||
const j = await r.json();
|
||
if (!j.ok) {
|
||
root.innerHTML = '<div class="text-danger">로드 실패: ' + (j.error || '') + '</div>';
|
||
return;
|
||
}
|
||
_envHistData = j.strategies || [];
|
||
envHistRender();
|
||
} catch (e) {
|
||
root.innerHTML = '<div class="text-danger">로드 오류: ' + e + '</div>';
|
||
}
|
||
}
|
||
|
||
function envHistEsc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').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 = '<div class="text-muted">이력 없음</div>';
|
||
return;
|
||
}
|
||
root.innerHTML = rows.map(function (st) {
|
||
const snaps = st.snapshots || [];
|
||
const n = snaps.length;
|
||
if (!n) {
|
||
return '<div class="mb-3"><b>' + envHistEsc(st.label) + '</b> '
|
||
+ '<span class="text-muted">(' + envHistEsc(st.table) + ') 스냅샷 없음</span></div>';
|
||
}
|
||
let keys;
|
||
if (changedOnly) {
|
||
keys = (st.changed_keys || []).slice();
|
||
if (!keys.length) {
|
||
return '<div class="mb-3 p-2 border rounded">'
|
||
+ '<div class="d-flex justify-content-between"><b>' + envHistEsc(st.label) + '</b>'
|
||
+ '<span class="badge bg-secondary">' + n + '스냅 · 변경0</span></div>'
|
||
+ '<div class="text-muted small mt-1">최근 ' + n + '건 사이에 값 변화 없음'
|
||
+ ' <span style="color:var(--muted)">('
|
||
+ snaps.map(function (s) { return envHistEsc(s.created_at); }).join(' → ')
|
||
+ ')</span></div></div>';
|
||
}
|
||
} else {
|
||
const set = {};
|
||
snaps.forEach(function (s) {
|
||
Object.keys(s.values || {}).forEach(function (k) { set[k] = 1; });
|
||
});
|
||
keys = Object.keys(set).sort();
|
||
}
|
||
const head = '<tr><th style="position:sticky;left:0;background:var(--bs-body-bg,#fff);z-index:1">키</th>'
|
||
+ snaps.map(function (s, i) {
|
||
return '<th style="min-width:120px;font-weight:600">#' + (i + 1)
|
||
+ '<br><span style="font-weight:400;font-size:10px;color:var(--muted)">'
|
||
+ envHistEsc(s.created_at) + '<br>id=' + s.id + '</span></th>';
|
||
}).join('') + '</tr>';
|
||
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 '<td style="font-family:ui-monospace,monospace;font-size:11px;' + style + '">'
|
||
+ (v === '' ? '<span class="text-muted">—</span>' : envHistEsc(v))
|
||
+ '</td>';
|
||
}).join('');
|
||
return '<tr><td style="position:sticky;left:0;background:var(--bs-body-bg,#fff);'
|
||
+ 'font-family:ui-monospace,monospace;font-size:11px;white-space:nowrap">'
|
||
+ envHistEsc(k) + '</td>' + tds + '</tr>';
|
||
}).join('');
|
||
return '<div class="mb-3">'
|
||
+ '<div class="d-flex justify-content-between align-items-center mb-1">'
|
||
+ '<b>' + envHistEsc(st.label) + '</b> <code class="small">' + envHistEsc(st.table) + '</code>'
|
||
+ '<span class="badge bg-dark">' + keys.length + '키'
|
||
+ (changedOnly ? ' 변경' : '') + ' · ' + n + '스냅</span></div>'
|
||
+ '<div class="table-responsive" style="max-height:340px;overflow:auto;border:1px solid #dee2e6;border-radius:6px">'
|
||
+ '<table class="table table-sm table-bordered mb-0" style="font-size:.8rem">'
|
||
+ '<thead>' + head + '</thead><tbody>' + body + '</tbody></table></div></div>';
|
||
}).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 `<span class="${ab}" style="font-size:10px" title="is_mock">${al}</span>`;
|
||
}
|
||
|
||
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', `
|
||
<tr class="dash-broker-head dash-broker-${broker}">
|
||
<td colspan="14"><span class="${brokerSrcCls(broker)}">${brokerLabel(broker)}</span></td>
|
||
</tr>`);
|
||
prevBroker = broker;
|
||
}
|
||
const isLive = !!row.is_live_source;
|
||
const onBadge = isLive
|
||
? (row.enabled ? '<span class="badge-win">ON</span>' : '<span class="badge-loss">OFF</span>')
|
||
: '<span class="text-muted">—</span>';
|
||
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 ? ' <span class="badge-win" style="font-size:10px">실매</span>' : '';
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr class="${trCls}">
|
||
<td>${row.label || sid}<br><code style="font-size:11px">${sid}</code>${liveTag}</td>
|
||
<td>${onBadge}</td>
|
||
<td class="text-end">${fmtOrDash(row.buy_turnover_krw, dashFmtKrw)}</td>
|
||
<td class="text-end">${fmtOrDash(row.sell_turnover_krw, dashFmtKrw)}</td>
|
||
<td class="text-end">${fmtOrDash(row.turnover_krw, dashFmtKrw)}</td>
|
||
<td class="text-end">${mute ? '—' : row.closed_trades}</td>
|
||
<td class="text-end">${mute ? '—' : row.open_positions}</td>
|
||
<td class="text-end ${mute ? '' : pnlCls}">${fmtOrDash(row.realized_pnl_krw, dashFmtKrw)}</td>
|
||
<td class="text-end">${fmtOrDash(row.budget_limit_krw, dashFmtKrw)}</td>
|
||
<td class="text-end">${fmtOrDash(row.budget_used_krw, dashFmtKrw)}</td>
|
||
<td class="text-end text-muted">${fmtOrDash(row.budget_now_krw || 0, dashFmtKrw)}</td>
|
||
<td class="text-end">${fmtOrDash(row.budget_usage_pct, dashFmtPct)}</td>
|
||
<td class="text-end ${mute ? '' : retCls}">${fmtOrDash(row.return_pct, dashFmtPct)}</td>
|
||
<td>${isLive
|
||
? `<button class="btn btn-sm btn-link p-0" style="font-size:11px"
|
||
onclick="dashDrill('${sid}')">상세</button>`
|
||
: ''}</td>
|
||
</tr>`);
|
||
});
|
||
$('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
|
||
? ' <span class="badge-win" style="font-size:10px">실매</span>'
|
||
: '';
|
||
univBody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td>${row.label || sid}<br><code style="font-size:11px">${sid}</code>${liveBadge}</td>
|
||
<td><code style="font-size:11px">${liveSrc}</code></td>
|
||
<td class="text-end">${Number(row.slots || 0).toLocaleString()}</td>
|
||
<td class="text-end">${Number(row.codes || 0).toLocaleString()}</td>
|
||
<td class="text-end">${Number(row.rows || 0).toLocaleString()}</td>
|
||
<td style="font-size:11px;white-space:nowrap">${firstAt}</td>
|
||
<td style="font-size:11px;white-space:nowrap">${lastAt}</td>
|
||
</tr>`);
|
||
});
|
||
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)} <span style="color:var(--muted);font-size:10px">평가</span>`
|
||
: 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)
|
||
? `<span style="font-size:11px">${liveQuoteFmtPctNum(buyPct)}</span>`
|
||
+ `<div style="color:var(--muted);font-size:9px;line-height:1.2">매수대비</div>`
|
||
: '—';
|
||
liveQuoteApplyPctBadge(pctEl, buyPct);
|
||
} else {
|
||
pctEl.innerHTML = Number.isFinite(dayPct)
|
||
? `<div style="font-weight:700">${liveQuoteFmtPctNum(dayPct)}</div>`
|
||
+ `<div style="color:var(--muted);font-size:10px;line-height:1.2">당일`
|
||
+ (Number.isFinite(buyPct) ? ` · 매수 ${liveQuoteFmtPctNum(buyPct)}` : '')
|
||
+ `</div>`
|
||
: (Number.isFinite(buyPct)
|
||
? `${liveQuoteFmtPctNum(buyPct)}<div style="color:var(--muted);font-size:9px">매수대비</div>`
|
||
: '—');
|
||
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 '<span class="text-muted">—</span>';
|
||
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 `<b>${d}</b><br><span style="color:var(--accent);font-size:12px;font-weight:600">${tm}</span>`;
|
||
}
|
||
return `<b>${d}</b>`;
|
||
}
|
||
|
||
/** 실계좌 대조 시 상단 — 입금액·예수금·평가금 */
|
||
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 = '<span class="text-muted">—</span>';
|
||
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 = '<span class="' + cls + '" title="' + (it.sync_note || '') + '">' + lbl + '</span>';
|
||
} else if (it.sync_ok) {
|
||
syncBadge = '<span class="badge-win">OK</span>';
|
||
} else {
|
||
syncBadge = '<span class="badge-loss" title="' + (it.sync_note || 'DB·실계좌 수량 불일치') + '">주의</span>';
|
||
}
|
||
}
|
||
const codeEsc = String(it.code).replace(/'/g, "\\'");
|
||
const stratEsc = String(it.strategy || '').replace(/'/g, "\\'");
|
||
const stratLbl = it.strategy ? '<code>' + it.strategy + '</code>' : '<span class="text-muted">—</span>';
|
||
// 종목명이 코드와 동일하면 이름 중복 숨김 (코드만 표시)
|
||
const nameDisplay = (it.name && it.name !== it.code)
|
||
? `${it.name}<br><small class="text-muted">${it.code}</small>`
|
||
: `<b>${it.code}</b>`;
|
||
const sellBtn = it.can_sell
|
||
? `<button class="btn btn-sm btn-danger" style="font-size:11px"
|
||
onclick="pfSell('${codeEsc}','${stratEsc}','${String(it.name).replace(/'/g, '')}',${it.db_qty})">
|
||
시장가 전량</button>`
|
||
: '<span class="text-muted" style="font-size:11px" title="active_trades 없음 · 실잔고만 있음. 「3. 잔고=DB 맞추기」로 등록하거나, 수동보유면 그대로 두면 됨">DB없음</span>';
|
||
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', `
|
||
<tr${liveAttrs}>
|
||
<td>${accountBadgeHtml(it.account_label)}</td>
|
||
<td>${stratLbl}</td>
|
||
<td>${nameDisplay}</td>
|
||
<td class="text-nowrap">${pfFmtBuyDate(it.buy_date)}</td>
|
||
<td>${it.db_qty}</td>
|
||
<td>${it.broker_qty == null ? '—' : it.broker_qty}</td>
|
||
<td>${syncBadge}</td>
|
||
<td>${it.buy_price > 0 ? fmtWon(it.buy_price) : '—'}</td>
|
||
<td>${it.broker_avg_price != null && it.broker_avg_price > 0
|
||
? fmtWon(it.broker_avg_price)
|
||
: '—'}</td>
|
||
<td data-live-cell="px">${fmtWon(it.current_price)}</td>
|
||
<td class="${pnlCls}" data-live-cell="day">—</td>
|
||
<td class="${pnlCls}" data-live-cell="pct">${it.pnl_pct >= 0 ? '+' : ''}${it.pnl_pct}%</td>
|
||
<td data-live-cell="eval">${fmtWon(it.eval_amt)}</td>
|
||
<td>${sellBtn}</td>
|
||
</tr>`);
|
||
});
|
||
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)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.total_pnl_krw)}원)</span>`;
|
||
} 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)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.max_drawdown_krw)}원)</span>`;
|
||
} 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(`📌 보유중 <b>${openCnt}</b>건 — 매수만 된 포지션(노란 행), 매도 후 trade_history 로 이동`);
|
||
}
|
||
if (filtered > 0) {
|
||
parts.push(`⚠️ 집계 제외: 0원 강제정리 <b>${filtered}</b>건`);
|
||
}
|
||
if (s.peak_cum_pnl > 0 && s.total_pnl < s.peak_cum_pnl) {
|
||
parts.push(
|
||
`💡 장중 누적이 <b>+${fmtKrw(s.peak_cum_pnl)}</b>까지 갔다가 이후 청산으로 <b>${fmtKrw(s.total_pnl)}</b> — `
|
||
+ `누적손익 열은 <b>매도 완료 순</b> 합계입니다.`,
|
||
);
|
||
}
|
||
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('<br>');
|
||
} 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 =
|
||
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}">${universeLabel}</span> ` +
|
||
`진입 ${(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 += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
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 += `<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}</div>`;
|
||
}
|
||
if ((s.total_trades || 0) === 0) {
|
||
const zmsg = s.budget_warning
|
||
? `거래 0건 — ${s.budget_warning}`
|
||
: '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (1회투자 > 총한도 이면 체결 불가)';
|
||
barHtml += `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
|
||
}
|
||
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)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.total_pnl_krw)}원)</span>`);
|
||
} 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)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.max_drawdown_krw)}원)</span>`);
|
||
} 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 =>
|
||
`<option value="${s.code}">${s.code} (${s.stock_group || 'STOCK'})</option>`
|
||
).join('') || '<option value="">—</option>';
|
||
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
|
||
}
|
||
if (tb) {
|
||
if (!_usmomStockCache.length) {
|
||
tb.innerHTML = '<tr><td colspan="5" class="text-muted">영구구독 US 없음</td></tr>';
|
||
} else {
|
||
tb.innerHTML = _usmomStockCache.map(s =>
|
||
`<tr style="cursor:pointer" onclick="usmomPickStock('${s.code}')">
|
||
<td>${s.code}</td><td>${s.stock_group || ''}</td><td>${s.exchange || ''}</td>
|
||
<td>${s.has_stock_cfg ? '✅' : '—'}</td>
|
||
<td>${s.enabled ? 'ON' : 'OFF'}</td></tr>`
|
||
).join('');
|
||
}
|
||
}
|
||
if (sel && sel.value) usmomOnStockSelect();
|
||
})
|
||
.catch(e => {
|
||
const tb = $('usmom_stock_tbody');
|
||
if (tb) tb.innerHTML = `<tr><td colspan="5" class="text-danger">${e}</td></tr>`;
|
||
});
|
||
}
|
||
|
||
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 = `<tr><td colspan="7" class="text-muted">${source} 후보 없음</td></tr>`;
|
||
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 `<tr style="${outline}">
|
||
<td>${rank}</td>
|
||
<td>#${r.optuna_trial_number ?? '—'}</td>
|
||
<td class="text-end">${usmomOptFmtUsdOrNum(r.total_trades, 0)}</td>
|
||
<td class="text-end">${usmomOptFmtUsdOrNum(r.win_rate, 1)}%</td>
|
||
<td class="text-end">${usmomOptFmtUsdOrNum(r.pf, 2)}</td>
|
||
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${usmomOptFmtUsdOrNum(r.total_pnl)}</td>
|
||
<td class="text-nowrap">
|
||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="usmomStockOptunaView('${src}',${rank})">보기</button>
|
||
<button type="button" class="btn btn-sm btn-outline-info py-0" onclick="usmomStockOptunaFillForm('${src}',${rank})">폼</button>
|
||
<button type="button" class="btn btn-sm btn-outline-success py-0" onclick="usmomStockOptunaApply('${src}',${rank})">적용</button>
|
||
</td>
|
||
</tr>`;
|
||
}).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 ? `<br><code style="font-size:11px">${job.log_path}</code>` : '';
|
||
usmomOptSetStatus(
|
||
`<b>${job.label || '종목 Optuna'}</b> · ${st}` +
|
||
(job.error ? `<br>❌ ${job.error}` : '') +
|
||
(job.result_json ? `<br>JSON: <code style="font-size:11px">${job.result_json}</code>` : '') +
|
||
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 =
|
||
`거래 <b>${usmomOptFmtUsdOrNum(m.total_trades, 0)}</b> · ` +
|
||
`승률 <b>${usmomOptFmtUsdOrNum(m.win_rate, 1)}%</b> · ` +
|
||
`PF <b>${usmomOptFmtUsdOrNum(m.pf, 2)}</b> · ` +
|
||
`PnL <b class="${Number(m.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${usmomOptFmtUsdOrNum(m.total_pnl)}</b>` +
|
||
(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 =
|
||
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}" title="후보 이력(키움/LS)">${momUnivLabel}</span> ` +
|
||
`${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 += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
if ((s.total_trades || 0) === 0) {
|
||
const zmsg = s.budget_warning
|
||
? `거래 0건 — ${s.budget_warning}`
|
||
: '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (총한도 < 동시×1회투자 이면 체결 불가)';
|
||
const zhtml = `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
|
||
if (momPb) momPb.innerHTML += zhtml;
|
||
if (momCtx) momCtx.innerHTML += zhtml;
|
||
}
|
||
if (s.universe_warning) {
|
||
if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
}
|
||
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 += `<div class="mt-1" style="color:#58a6ff;font-size:12px">${tickLine}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#58a6ff;font-size:12px">${tickLine}</div>`;
|
||
}
|
||
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 += `<div class="mt-1" style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
}
|
||
const peakCum = Number(s.peak_cum_pnl || 0);
|
||
const peakAt = fmtTradeTime(s.peak_cum_at || '');
|
||
if (peakCum > 0 && peakAt) {
|
||
const peakLine =
|
||
`💡 장중 누적 최고 <b style="color:var(--green)">+${peakCum.toLocaleString()}원</b> (${peakAt})` +
|
||
` · 최종 <b>${fmtKrw(s.total_pnl)}</b>`;
|
||
if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${peakLine}</div>`;
|
||
}
|
||
|
||
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 = '<tr><th>순위</th>' +
|
||
keys.map(k => `<th>${fixedLabel(k)}</th>`).join('') +
|
||
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(분)</th><th>폼반영</th><th>DB저장</th></tr>';
|
||
}
|
||
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 '<td>-</td>';
|
||
if (pctKeys.has(k) && typeof v === 'number') return `<td>${Number(v).toFixed(2)}%</td>`;
|
||
return `<td>${v}</td>`;
|
||
}).join('');
|
||
const rank = r.rank;
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td><b>${rank}</b></td>
|
||
${paramCells}
|
||
<td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
|
||
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
|
||
<td>${r.pf}</td><td>${r.avg_hold_min != null ? r.avg_hold_min : '-'}</td>
|
||
<td><button class="btn btn-xs btn-outline-primary" style="font-size:11px;padding:1px 6px"
|
||
onclick="momApplySearchResult(${rank}, false)">폼</button></td>
|
||
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
|
||
onclick="momApplySearchResult(${rank}, true)">저장</button></td>
|
||
</tr>`);
|
||
});
|
||
const hint = $('mom_search_hint');
|
||
if (hint) {
|
||
hint.style.display = '';
|
||
const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object'
|
||
? '<br>' + Object.entries(meta.grid_axis_hints).map(([k, txt]) =>
|
||
`<div style="margin:3px 0 0 8px;border-left:2px solid #30363d;padding-left:8px"><b>${fixedLabel(k)}</b> — ${txt}</div>`
|
||
).join('')
|
||
: '';
|
||
hint.innerHTML =
|
||
`<span style="color:var(--accent)">파일:</span> <code>${meta.path || '?'}</code> · `
|
||
+ `<b>${meta.start || '?'}</b> ~ <b>${meta.end || '?'}</b> · `
|
||
+ `모드 ${meta.mode || '-'} · 조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}<br>`
|
||
+ `<b>폼반영</b>: 입력란만 갱신 · <b>DB저장</b>: 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 = '<tr><th>순위</th>' +
|
||
keys.map(k => `<th>${fixedLabel(k)}</th>`).join('') +
|
||
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(분)</th><th>폼반영</th><th>DB저장</th></tr>';
|
||
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 '<td>-</td>';
|
||
if (fracKeys.has(k)) return `<td>${(Number(v) * 100).toFixed(2)}%</td>`;
|
||
if (ratioKeys.has(k)) return `<td>${Number(v) <= 1 ? (Number(v) * 100).toFixed(0) : v}%</td>`;
|
||
return `<td>${v}</td>`;
|
||
}).join('');
|
||
const rank = r.rank;
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td><b>${rank}</b></td>
|
||
${paramCells}
|
||
<td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
|
||
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
|
||
<td>${r.pf}</td><td>${r.avg_hold_min != null ? r.avg_hold_min : '-'}</td>
|
||
<td><button class="btn btn-xs btn-outline-primary" style="font-size:11px;padding:1px 6px"
|
||
onclick="tlApplySearchResult(${rank}, false)">폼</button></td>
|
||
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
|
||
onclick="tlApplySearchResult(${rank}, true)">저장</button></td>
|
||
</tr>`);
|
||
});
|
||
const hint = $('tl_search_hint');
|
||
if (hint) {
|
||
hint.style.display = '';
|
||
const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object'
|
||
? '<br>' + Object.entries(meta.grid_axis_hints).map(([k, txt]) =>
|
||
`<div style="margin:3px 0 0 8px;border-left:2px solid #30363d;padding-left:8px"><b>${fixedLabel(k)}</b> — ${txt}</div>`
|
||
).join('')
|
||
: '';
|
||
hint.innerHTML =
|
||
`<span style="color:var(--accent)">파일:</span> <code>${meta.path || '?'}</code> · `
|
||
+ `<b>${meta.start || '?'}</b> ~ <b>${meta.end || '?'}</b> · TF ${meta.timeframe || 3}분 · `
|
||
+ `조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}<br>`
|
||
+ `<b>폼반영</b>: 입력란만 갱신 · <b>DB저장</b>: config_short TAIL_* INSERT 후 폼 동기화`
|
||
+ ` (총손익≤0 은 거부). 승률·PF가 높은 조합은 순위와 다를 수 있음 — 표 <b>순위</b> 열 기준.`
|
||
+ 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 =
|
||
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}" title="후보 이력(키움/LS)">${universeLabel}</span> ` +
|
||
`<span class="badge ${p.use_defense_filters ? 'badge-info' : 'badge-secondary'}">방어:${p.use_defense_filters ? 'ON' : 'OFF'}</span> ` +
|
||
`<span class="badge ${p.use_macd_cross ? 'badge-warning' : 'badge-secondary'}">MACD:${p.use_macd_cross ? 'ON' : 'OFF'}</span> ` +
|
||
`<span class="badge ${p.eod_enabled ? 'badge-info' : 'badge-secondary'}">EOD:${p.eod_enabled ? 'ON' : 'OFF'} ${p.eod_hm || ''}</span> ` +
|
||
`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 +=
|
||
`<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}</div>`;
|
||
}
|
||
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 =
|
||
`<span class="badge ${srcBadge}" title="dbband_stock_config">${p.name || d.code}(${p.code || d.code})</span> ` +
|
||
`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 += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
if ((s.total_trades || 0) === 0) {
|
||
const zmsg = s.budget_warning
|
||
? `거래 0건 — ${s.budget_warning}`
|
||
: '거래 0건 — 기간·BB이탈·복귀 패턴·추세필터를 확인하세요.';
|
||
const zhtml = `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
|
||
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 =
|
||
`<td>${t.side || 'long'}</td><td>${t.entry_time || ''}</td><td>${t.exit_time || ''}</td>` +
|
||
`<td>${fmtWon(t.entry)}</td><td>${fmtWon(t.exit)}</td>` +
|
||
`<td class="${pnl >= 0 ? 'text-success' : 'text-danger'}">${fmtWon(pnl)}</td>` +
|
||
`<td>${t.hold_min || '-'}</td><td>${t.reason || ''}</td>`;
|
||
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 =
|
||
`<td>${i + 1}</td><td>${fmtWon(row.total_pnl)}</td><td>${row.win_rate}%</td>` +
|
||
`<td>${row.total_trades}</td><td>${p.bb_inner_std}/${p.bb_outer_std}</td>` +
|
||
`<td>${p.trend_ma_period}</td>` +
|
||
`<td><button type="button" class="btn btn-sm btn-outline-primary">적용</button></td>`;
|
||
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 = `<tr><td colspan="12" class="text-muted text-center py-3">${msg || '백그라운드 실행 중…'}</td></tr>`;
|
||
}
|
||
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 =
|
||
`<b>${st}</b> ${pct}%` + range +
|
||
(msg ? ` · ${msg}` : '') +
|
||
(job.log_path ? ` · <code style="font-size:10px">${job.log_path}</code>` : '') +
|
||
' · 전역 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 =
|
||
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}" title="후보 이력(키움/LS)">${tlUniverseLabel}</span> ` +
|
||
(p.timeframe != null ? `<span class="badge badge-primary" title="ws_candles 분봉">${p.timeframe}분봉</span> ` : '') +
|
||
`낙폭≥${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 += `<div class="mt-1" style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
if (tlCtx) tlCtx.innerHTML += `<div style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
}
|
||
if (s.budget_warning) {
|
||
$('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
if (tlCtx) tlCtx.innerHTML += `<div style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
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 = `<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}</div>`;
|
||
$('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 = `<div style="color:#8b949e;font-size:11px">진입출처 ${srcDetail}</div>`;
|
||
$('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 += `<div class="mt-1" style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
if (tlCtx) tlCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
}
|
||
// 장중 누적손익 최고점 — 파라미터 바에 참고 표기 (거래내역 컨텍스트와 동일 수치)
|
||
const tlPeakCum = Number(s.peak_cum_pnl || 0);
|
||
const tlPeakAt = fmtTradeTime(s.peak_cum_at || '');
|
||
if (tlPeakCum > 0 && tlPeakAt) {
|
||
const peakLine =
|
||
`💡 장중 누적 최고 <b style="color:var(--green)">+${tlPeakCum.toLocaleString()}원</b> (${tlPeakAt})` +
|
||
` · 최종 <b>${fmtKrw(s.total_pnl)}</b>`;
|
||
$('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${peakLine}</div>`;
|
||
}
|
||
|
||
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
|
||
? `<span style="color:var(--green);font-size:11px">✅ 일봉 ${s.candle_count}봉 (${s.candle_min||'?'} ~ ${s.candle_max||'?'})</span>`
|
||
: `<span style="color:var(--red);font-size:11px">⚠️ 일봉 없음</span>`;
|
||
const min60Info = s.min60_count > 0
|
||
? `<span style="color:#58a6ff;font-size:11px">⏱ 60분봉 ${s.min60_count}봉 (${s.min60_min||'?'} ~ ${s.min60_max||'?'})</span>`
|
||
: `<span style="color:var(--muted);font-size:11px">⏱ 60분봉 없음</span>`;
|
||
list.insertAdjacentHTML('beforeend', `
|
||
<div class="col-12 col-md-6 col-lg-4">
|
||
<div class="card p-3">
|
||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||
<div>
|
||
<b style="font-size:15px">${s.name||s.code}</b>
|
||
<span style="color:var(--muted);font-size:12px;margin-left:6px">${s.code}</span>
|
||
</div>
|
||
<div class="text-end">${candleInfo}<br>${min60Info}</div>
|
||
</div>
|
||
<!-- 파라미터 인풋 -->
|
||
<div class="row g-1" style="font-size:12px">
|
||
<div class="col-12" style="color:var(--accent);font-size:11px;font-weight:600;padding:2px 4px">▶ 추세 설정</div>
|
||
<div class="col-4"><label>MA단기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_fast" value="${s.ma_fast||20}" min="5" max="60" step="1"></label></div>
|
||
<div class="col-4"><label>MA장기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_slow" value="${s.ma_slow||60}" min="20" max="200" step="5"></label></div>
|
||
<div class="col-4"><label>트레일(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_trail_stop_pct" value="${s.trail_stop_pct??8}" min="0" max="30" step="0.5" title="고점 대비 X% 하락 시 추세이탈 청산. 0=비활성"></label></div>
|
||
<div class="col-6"><label>추세필터
|
||
<select class="form-control form-control-sm" id="cfg_${s.code}_trend_filter">
|
||
<option value="1" ${(s.trend_filter??1)>=0.5?'selected':''}>ON (추세추종)</option>
|
||
<option value="0" ${(s.trend_filter??1)<0.5?'selected':''}>OFF (역추세)</option>
|
||
</select></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ RSI 매수/매도</div>
|
||
<div class="col-6"><label>RSI기간 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_period" value="${s.rsi_period||14}" min="5" max="30"></label></div>
|
||
<div class="col-6"><label>매수1 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy1" value="${s.rsi_buy1||55}" min="10" max="70" step="1" title="추세 ON: 1차 눌림목 / 추세 OFF: 1단계"></label></div>
|
||
<div class="col-6"><label>매수2 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy2" value="${s.rsi_buy2||45}" min="10" max="65" step="1" title="추세 ON: 2차 눌림목 / 추세 OFF: 2단계"></label></div>
|
||
<div class="col-6"><label>매수3 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy3" value="${s.rsi_buy3||35}" min="5" max="55" step="1" title="추세 OFF 전용 3단계 (추세 ON에선 미사용)"></label></div>
|
||
<div class="col-6"><label>매도 RSI≥ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_sell" value="${s.rsi_sell||75}" min="50" max="95" step="1"></label></div>
|
||
<div class="col-6"><label>익절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_take_profit_pct" value="${s.take_profit_pct||15}" min="1" max="50" step="1" title="추세장엔 크게 잡아야 탈 안 남"></label></div>
|
||
<div class="col-6"><label>손절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_stop_loss_pct" value="${s.stop_loss_pct||10}" min="2" max="30" step="0.5"></label></div>
|
||
<div class="col-6"><label>투자금(원) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_slot_money" value="${s.slot_money||3000000}" min="500000" step="500000"></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 분할 비중(%) — 추세 ON이면 1·2차만 사용. 합 100% 권장 (탐색·추세BT·저장 동일)</div>
|
||
<div class="col-4"><label>비중1 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_buy1_ratio" value="${s.buy1_ratio??50}" min="0" max="100" step="5"></label></div>
|
||
<div class="col-4"><label>비중2 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_buy2_ratio" value="${s.buy2_ratio??50}" min="0" max="100" step="5"></label></div>
|
||
<div class="col-4"><label>비중3 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_buy3_ratio" value="${s.buy3_ratio??0}" min="0" max="100" step="5" title="추세 OFF(역추세) 3단계에만 사용"></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 샹들리에 엑시트 (추세장 청산 — 고점 − ATR × 배수)</div>
|
||
<div class="col-6"><label title="ATR 계산 기간 (기본 14일)">ATR기간
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_period"
|
||
value="${s.atr_period||14}" min="5" max="30" step="1"></label></div>
|
||
<div class="col-6"><label title="샹들리에 배수. 2=타이트 / 3=기본 / 4=여유 (클수록 오래 홀딩)">샹들리에배수
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_mult"
|
||
value="${s.atr_mult||3}" min="1" max="6" step="0.5"></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 낙폭 필터 (0=비활성 / 예: 20 → 고점 대비 20% 이상 빠진 날만 매수)</div>
|
||
<div class="col-4"><label title="역대 최고가(ATH) 대비 현재가 낙폭이 X% 이상일 때만 매수. 0=끔">ATH낙폭(%)
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ath_drop_min_pct"
|
||
value="${s.ath_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
|
||
<div class="col-4"><label title="당해연도 고점 대비 낙폭 필터. 0=끔">연도낙폭(%)
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_year_drop_min_pct"
|
||
value="${s.year_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
|
||
<div class="col-4"><label title="52주 고점 대비 낙폭 필터. 0=끔">52주낙폭(%)
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_w52_drop_min_pct"
|
||
value="${s.w52_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
|
||
</div>
|
||
<!-- 버튼 (일봉 + 키움 분봉 수집) -->
|
||
<div class="d-flex gap-1 mt-2 flex-wrap">
|
||
<button class="btn btn-sm btn-outline-secondary" onclick="hdFetchCandles('${s.code}','${s.name||s.code}')" title="KIS 일봉 수집">📥 일봉수집</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',60)" title="키움 ka10080 · 60분봉 (연속조회)">🗂60분</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',15)" title="키움 ka10080 · 15분봉">15분</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',5)" title="키움 ka10080 · 5분봉">5분</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',3)" title="키움 ka10080 · 3분봉">3분</button>
|
||
<button class="btn btn-sm btn-primary" onclick="hdRunBacktest('${s.code}','${s.name||s.code}')" title="추세추종 전략 (MA + RSI)">🚀 추세BT</button>
|
||
<button class="btn btn-sm btn-info" style="color:#fff" onclick="hdRunV1Backtest('${s.code}','${s.name||s.code}')" title="RSI 3단계 분할매수 전략 (횡보장)">📊 분할매수BT</button>
|
||
<button class="btn btn-sm btn-warning" onclick="hdParamSearch('${s.code}','${s.name||s.code}')" title="추세추종 파라미터 탐색">🔍 추세탐색</button>
|
||
<button class="btn btn-sm btn-outline-info" onclick="hdV1ParamSearch('${s.code}','${s.name||s.code}')" title="분할매수 파라미터 탐색">🔍 분할탐색</button>
|
||
<button class="btn btn-sm btn-success" onclick="hdSaveConfig('${s.code}','${s.name||s.code}')">💾 저장</button>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
});
|
||
}
|
||
|
||
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}<br>`
|
||
+ '<span style="opacity:0.88">요약의 봇 수익률·거래 건수가 안 바뀌면, 바꾼 값이 그 구간에선 <b>진입/청산 조건을 바꾸지 못한 것</b>입니다(전일비 표와는 별개).</span>';
|
||
} 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', `<tr>
|
||
<td>${r.dt || r.date}</td>
|
||
<td>${fmt(r.open)}</td><td>${fmt(r.high)}</td><td>${fmt(r.low)}</td><td>${fmt(r.close)}</td>
|
||
<td style="color:${colW};font-weight:600">${wStr}</td>
|
||
<td style="color:${colP};font-weight:600">${pStr}</td>
|
||
</tr>`);
|
||
});
|
||
} 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 = '<tr>' +
|
||
keys.map(k => `<th>${labelMap[k] || k}</th>`).join('') +
|
||
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(일)</th><th>적용</th></tr>';
|
||
|
||
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 '<td>-</td>';
|
||
// % 단위 컬럼은 % 표시
|
||
const isPct = k.includes('pct') || k.includes('rate');
|
||
return `<td>${isPct ? v+'%' : v}</td>`;
|
||
}).join('');
|
||
const rowJson = JSON.stringify(r).replace(/</g, '\\u003c');
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
${paramCells}
|
||
<td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
|
||
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
|
||
<td>${r.pf}</td><td>${r.avg_hold}일</td>
|
||
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
|
||
onclick='hdApplySearchResult("${code}","${name}",${rowJson})'>적용</button></td>
|
||
</tr>`);
|
||
});
|
||
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 =
|
||
`<br><span style="color:var(--accent)">탐색 동작 요약:</span> 전체 그리드 조합 <b>${meta.cartesian_product}</b>개 중 `
|
||
+ `백테스트 실행 <b>${meta.backtests_run}</b>회 → TOP 후보 <b>${meta.passed}</b>개만 표시(최소거래 ${mtNote}). `
|
||
+ `제외: MA무효 <b>${meta.skipped_invalid_ma}</b>, RSI순서무효 <b>${meta.skipped_invalid_rsi_order}</b>, `
|
||
+ `백테오류 <b>${meta.skipped_backtest_error}</b>, 거래수부족 <b>${meta.skipped_below_min_trades}</b>.<br>`
|
||
+ `<span style="color:var(--muted)">그리드에서 바꾸는 항목:</span> ${gk}. `
|
||
+ `<span style="color:var(--muted)">카드값으로 고정(탐색 안 함):</span> ${fk || '—'}.<br>`
|
||
+ `<b>실매매 봇</b>은 DB에 저장된 종목 설정만 사용합니다. TOP을 쓰려면 <b>[적용] → [저장]</b> 하세요.<br>`
|
||
+ `<span style="color:var(--muted)">탐색 base</span>는 <b>DB 최근 저장</b> 전체에 카드 입력을 덮어씁니다(비중1·2·3·투자금이 추세BT와 같게 맞춰짐).`;
|
||
}
|
||
hint.innerHTML =
|
||
'표의 숫자는 각 행마다 <b>다른 파라미터 조합</b>으로 돌린 백테스트 결과입니다(고정이 아님). '
|
||
+ 'ATH낙폭% 등은 그리드에 포함된 경우에만 바뀝니다.<br>'
|
||
+ 'Buy & Hold 비교는 백테스트 화면의 추세BT 결과를 참고하세요.'
|
||
+ metaHtml;
|
||
}
|
||
if ((!top || !top.length) && meta && $('hd_search_hint')) {
|
||
$('hd_search_hint').innerHTML += '<br><span style="color:#f85149">TOP이 비었습니다. 위 「탐색 최소거래」를 0 또는 1로 낮추거나, 백테스트 기간을 넓혀 보세요.</span>';
|
||
}
|
||
$('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(/</g, '<').replace(/"/g, '"');
|
||
}
|
||
|
||
function lcRenderStatus(status) {
|
||
const box = $('lc_status_cards');
|
||
if (!box || !status) return;
|
||
const g = status.global || {};
|
||
const haltedCls = g.buy_halted ? 'badge-loss' : 'badge-win';
|
||
const haltedTxt = g.buy_halted ? '매수중단' : '매수허용';
|
||
const acctLbl = status.account_label || '';
|
||
let html = `
|
||
<div class="lc-status-chip stat-card">
|
||
<div class="lc-status-label">총합 봇 실현${acctLbl ? ' · ' + acctLbl : ''}</div>
|
||
<div class="lc-status-value ${dashPnlCls(g.realized_pnl_krw)}">${dashFmtKrw(g.realized_pnl_krw)}</div>
|
||
<div class="lc-status-meta">
|
||
목표 ${dashFmtKrw(g.target_krw)} / ${Number(g.target_pct || 0).toFixed(2)}%
|
||
· 한도 ${dashFmtKrw(g.budget_krw)}
|
||
· <span class="${haltedCls}">${haltedTxt}</span>
|
||
</div>
|
||
</div>`;
|
||
(status.strategies || []).forEach(row => {
|
||
if (!row.enabled) return;
|
||
const subHalt = row.buy_halted ? 'badge-loss">중단' : 'badge-win">허용';
|
||
html += `
|
||
<div class="lc-status-chip">
|
||
<div class="lc-status-label"><b>${lcEsc(row.label)}</b> <code>${lcEsc(row.strategy_id)}</code></div>
|
||
<div class="lc-status-value ${dashPnlCls(row.realized_pnl_krw)}">${dashFmtKrw(row.realized_pnl_krw)}</div>
|
||
<div class="lc-status-meta">${Number(row.return_pct || 0).toFixed(2)}% · <span class="${subHalt}</span></div>
|
||
</div>`;
|
||
});
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
function lcRenderStrategySwitch(g) {
|
||
const root = $('lc_strategy_top');
|
||
if (!root || !g) {
|
||
if (root) root.innerHTML = '';
|
||
return;
|
||
}
|
||
const fields = g.fields || [];
|
||
let toggles = '';
|
||
fields.forEach(f => {
|
||
if (f.type !== 'bool' || !f.key) return;
|
||
const id = 'lc_' + f.key;
|
||
const chk = f.value ? 'checked' : '';
|
||
const warnCls = f.key === 'OVERSEAS_WS_ENABLED' ? ' lc-switch-warn' : '';
|
||
const sidMatch = String(f.key).match(/^STRATEGY_([A-Z0-9_]+)_ENABLED$/);
|
||
const sidTag = sidMatch
|
||
? `<span class="lc-switch-sid">${lcEsc(sidMatch[1])}</span>`
|
||
: '';
|
||
toggles += `<label class="lc-switch-chip${warnCls}" title="${lcEsc(f.hint || f.key)}">
|
||
<input type="checkbox" data-lc-key="${lcEsc(f.key)}" data-lc-type="bool" id="${id}" ${chk}>
|
||
<span>${lcEsc(f.label)}</span>${sidTag}
|
||
</label>`;
|
||
});
|
||
root.innerHTML = `<div class="card p-3 mb-0 lc-group" data-lc-group="strategy_switch">
|
||
<div class="d-flex justify-content-between align-items-start mb-1 flex-wrap gap-2">
|
||
<div>
|
||
<div class="card-title mb-0">${lcEsc(g.title || '전략 ON/OFF')}</div>
|
||
${g.hint ? `<div style="font-size:12px;color:var(--muted)">${lcEsc(g.hint)}</div>` : ''}
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-primary flex-shrink-0" onclick="lcSaveGroup('strategy_switch')">💾 이 섹션 저장</button>
|
||
</div>
|
||
<div class="lc-switch-row">${toggles}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function lcFieldInput(f) {
|
||
const id = 'lc_' + f.key;
|
||
const hint = f.hint ? `<div class="lc-field-hint">${lcEsc(f.hint)}</div>` : '';
|
||
const tbl = f.table ? `<code class="lc-field-tbl" title="저장 테이블">${lcEsc(f.table)}</code>` : '';
|
||
const keyTag = f.key
|
||
? `<code class="lc-field-key" title="${lcEsc(f.key)}">${lcEsc(f.key)}</code>`
|
||
: '';
|
||
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 `<div class="${wrapCls}" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label"><input type="checkbox" data-lc-key="${lcEsc(f.key)}" data-lc-type="bool" id="${id}" ${chk}>
|
||
${lcEsc(f.label)} ${keyTag}</label> ${tbl}${hint}
|
||
</div>`;
|
||
}
|
||
if (f.type === 'text') {
|
||
const val = f.value != null ? f.value : '';
|
||
return `<div class="${wrapCls}" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
|
||
<input type="text" class="form-control form-control-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="text"
|
||
id="${id}" value="${lcEsc(val)}" placeholder="예: 005930,000660">
|
||
${hint}
|
||
</div>`;
|
||
}
|
||
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 `<option value="${lcEsc(cv)}"${sel}>${lcEsc(cl)}</option>`;
|
||
}).join('');
|
||
const extra = (!matched && val)
|
||
? `<option value="${lcEsc(val)}" selected>${lcEsc(val)} (현재·목록외)</option>`
|
||
: '';
|
||
return `<div class="${wrapCls}" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
|
||
<select class="form-select form-select-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="text" id="${id}">${extra}${opts}</select>
|
||
${hint}
|
||
</div>`;
|
||
}
|
||
const step = f.type === 'int' ? '1' : '0.01';
|
||
const val = f.value != null ? f.value : '';
|
||
return `<div class="${wrapCls}" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
|
||
<input type="number" class="form-control form-control-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="${lcEsc(f.type)}"
|
||
id="${id}" value="${lcEsc(val)}" step="${step}">
|
||
${hint}
|
||
</div>`;
|
||
}
|
||
|
||
/** 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 = '<div class="lc-field-grid">';
|
||
(fields || []).forEach(f => { html += lcFieldInput(f); });
|
||
html += '</div>';
|
||
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')
|
||
? ''
|
||
: `<span class="lc-strat-sid">${lcEsc(p.sid)}</span>`;
|
||
return `<div class="${cls}">
|
||
<div class="lc-strat-head"><span>${lcEsc(p.label)}</span>${sidTag}</div>
|
||
${lcRenderFieldGrid(p.fields)}
|
||
</div>`;
|
||
}
|
||
|
||
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 += `<div class="card p-3 mb-3 lc-group" data-lc-group="${lcEsc(g.id)}">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div>
|
||
<div class="card-title mb-0">${lcEsc(g.title)}</div>
|
||
${g.hint ? `<div style="font-size:12px;color:var(--muted)">${lcEsc(g.hint)}</div>` : ''}
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="lcSaveGroup('${lcEsc(g.id)}')">💾 이 섹션 저장</button>
|
||
</div>`;
|
||
if (useBlocks) {
|
||
html += '<div class="lc-strat-stack">';
|
||
renderParts.forEach(p => { html += lcRenderBlock(p); });
|
||
html += '</div>';
|
||
} else {
|
||
html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []);
|
||
}
|
||
html += '</div>';
|
||
});
|
||
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 = `<span style="color:#3fb950">✅ 저장 완료 (env_id=${d.env_id}) — ${tbl}</span>`;
|
||
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 `<option value="${it.code}">${it.name || it.code}${st}${pin}</option>`;
|
||
}).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
|
||
? `<span style="color:#3fb950">★ 종목별 핀 ${n}개 적용중</span> <span class="text-muted">(저장값 로드)</span>`
|
||
: `<span class="text-muted">글로벌 사용중 (핀 없음)</span>`;
|
||
}
|
||
})
|
||
.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 = `<tr><td colspan="10" class="text-danger">${d.error || '오류'}</td></tr>`; return; }
|
||
const rows = d.rows || [];
|
||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="10" class="text-muted">등록된 관심종목 없음 (SCAN 또는 수동추가)</td></tr>'; 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 `<tr>
|
||
<td><b>${r.code}</b></td>
|
||
<td>${r.name || ''}</td>
|
||
<td style="color:${stColor}">${r.status}</td>
|
||
<td>${r.source || ''}</td>
|
||
<td>${Number(r.box_low || 0).toLocaleString()}</td>
|
||
<td>${Number(r.box_high || 0).toLocaleString()}</td>
|
||
<td>${Number(r.box_score || 0).toFixed(1)}</td>
|
||
<td>${added}</td>
|
||
<td>${sig}</td>
|
||
<td class="text-nowrap">
|
||
<button class="btn btn-sm btn-outline-info" onclick="ubxEditWatch('${r.code}', ${JSON.stringify(r.name || '')}, ${Number(r.box_low || 0)}, ${Number(r.box_high || 0)})">수정</button>
|
||
<button class="btn btn-sm btn-outline-danger" onclick="ubxRemoveWatch('${r.code}')">삭제</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
})
|
||
.catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `<tr><td colspan="10" class="text-danger">${e}</td></tr>`; });
|
||
}
|
||
|
||
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
|
||
? `<span style="color:#3fb950">박스권 ✔</span> (범위 ${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) : '-'}%)`
|
||
: `<span style="color:#f0883e">비박스</span> (${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 = `
|
||
<div class="row g-3" style="font-size:13px">
|
||
<div class="col-auto">거래 <b>${d.n_trades}</b></div>
|
||
<div class="col-auto">봇 총손익 <b style="color:${d.total_pnl >= 0 ? '#3fb950' : '#f85149'}">${d.total_pnl}%</b></div>
|
||
<div class="col-auto">PF <b>${d.pf}</b></div>
|
||
<div class="col-auto">승률 <b>${d.win_rate}%</b></div>
|
||
${bh != null ? `<div class="col-auto">단순보유(B&H) <b style="color:${bh >= 0 ? '#3fb950' : '#f85149'}">${bh.toFixed(2)}%</b></div>` : ''}
|
||
${alpha != null ? `<div class="col-auto">초과수익(α) <b style="color:${alpha >= 0 ? '#3fb950' : '#f85149'}">${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%</b></div>` : ''}
|
||
</div>
|
||
${alpha != null ? `<div class="mt-1" style="font-size:12px;color:${alpha >= 0 ? '#3fb950' : '#f85149'}">${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}</div>` : ''}
|
||
<div class="mt-2 text-muted" style="font-size:12px">현재 박스상태(최근봉): ${boxBadge}
|
||
${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}</div>`;
|
||
$('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 || ''} · 정렬:<b>${meta.rank_by || 'alpha'}</b>`
|
||
+ ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '')
|
||
+ (dropTxt ? `<span style="color:#f0883e">${dropTxt}</span>` : '');
|
||
if (!top.length) {
|
||
tb.innerHTML = '<tr><td colspan="9" class="text-muted">통과한 조합 없음 (최소거래 낮추거나 기간 확대)</td></tr>';
|
||
} 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 `<tr>
|
||
<td>${i + 1}</td>
|
||
<td style="color:${(alpha != null && alpha >= 0) ? '#3fb950' : '#f85149'}"><b>${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'}</b></td>
|
||
<td style="color:${r.total_pnl >= 0 ? '#3fb950' : '#f85149'}">${r.total_pnl}%</td>
|
||
<td class="text-muted">${bh != null ? bh + '%' : '-'}</td>
|
||
<td>${r.pf}</td><td>${r.win_rate}%</td><td>${r.n_trades}</td>
|
||
<td style="font-size:11px">${cfgStr}</td>
|
||
<td><button class="btn btn-sm btn-outline-success" onclick="ubxApply('${cfgJson}')">ENV적용</button></td>
|
||
</tr>`;
|
||
}).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 += ` · <span class="text-warning">${g.fallback_note || '게이트 0건 → 점수순 폴백(마이너스 포함)'}</span>`;
|
||
} 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
|
||
? (`<span class="text-muted">mode_combo 실측 없음</span>`
|
||
+ (note ? `<div class="text-muted" style="font-size:11px">${note}</div>` : ''))
|
||
: '완료 후 표시';
|
||
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 =
|
||
`<div class="text-muted" style="font-size:11px">` +
|
||
`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}` : '') +
|
||
` · 아래 표=사후합격과 <b>같은 열</b>(안정·과적합 포함). trial 번호는 조립이라 없음.` +
|
||
`</div>` +
|
||
(m.note ? `<div class="text-muted" style="font-size:11px">${m.note}</div>` : '') +
|
||
`<div class="mt-1">` +
|
||
`<button type="button" class="btn btn-sm btn-outline-warning py-0 ms-0" id="opt_btn_apply_mode_card" onclick="optunaApplyUpto('mode',1,'base')">mode 타점 DB적용</button>` +
|
||
`</div>`;
|
||
}
|
||
|
||
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 ? ` · <span class="text-muted">${meta.note}</span>` : '')
|
||
+ (meta.scoring ? ` · <span class="text-muted">${meta.scoring}</span>` : '');
|
||
} 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 '<td class="text-end text-muted">—</td>';
|
||
const n = Number(v);
|
||
const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
||
return `<td class="text-end ${cls}" title="총손익÷기간거래일 (없으면 활성일 평균)">${optunaFmtNum(v)}</td>`;
|
||
}
|
||
|
||
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 '<td class="text-end text-muted">—</td>';
|
||
const n = Number(p);
|
||
const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
||
const sign = n > 0 ? '+' : '';
|
||
return `<td class="text-end ${cls}" title="총수익률(운용한도)÷기간거래일">${sign}${optunaFmtNum(n, 3)}%</td>`;
|
||
}
|
||
|
||
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)}%<div class="text-muted" style="font-size:10px">${cmN}/${cmOf}축 밴드</div>`
|
||
: '—';
|
||
return `<tr>
|
||
<td>${rank}</td>
|
||
<td>#${r.optuna_trial_number ?? '—'}</td>
|
||
<td class="text-end fw-semibold">${cmCell}</td>
|
||
<td class="text-end">${optunaFmtNum(r.total_trades, 0)}</td>
|
||
<td class="text-end">${optunaFmtNum(r.win_rate, 1)}%</td>
|
||
<td class="text-end">${optunaFmtNum(r.pf, 2)}</td>
|
||
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
|
||
${optunaDailyAvgPnlCell(r)}
|
||
${optunaDailyAvgPctCell(r)}
|
||
<td style="font-size:11px;min-width:148px;white-space:normal">${optunaObWhipCell(r)}</td>
|
||
<td class="text-end">${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}</td>
|
||
<td class="text-end ${Number(r.worst_day_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}</td>
|
||
<td class="text-end">${optunaStableScoreCell(r)}</td>
|
||
<td class="text-end">${optunaOverfitCell(r)}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
|
||
<button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>
|
||
</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function optunaFillModeTopTbody(tbodyId, rows, source, emptyMsg) {
|
||
const tb = $(tbodyId);
|
||
if (!tb) return;
|
||
if (!rows || !rows.length) {
|
||
tb.innerHTML = `<tr><td colspan="15" class="text-muted">${emptyMsg}</td></tr>`;
|
||
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 = `<tr><td colspan="3" class="text-muted">${empty}</td></tr>`;
|
||
if (distTb) distTb.innerHTML = `<tr><td colspan="6" class="text-muted">${empty}</td></tr>`;
|
||
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 =
|
||
`<span class="${cls}">과적합 ${risk}% · 적용가능 ${ready}% · ${verd}</span>` +
|
||
(d.verdict_ko ? `<div class="text-muted fw-normal" style="font-size:11px">${d.verdict_ko}</div>` : '');
|
||
}
|
||
const factors = d.factors || [];
|
||
if (facTb) {
|
||
if (!factors.length) {
|
||
facTb.innerHTML = '<tr><td colspan="3" class="text-muted">요인 없음</td></tr>';
|
||
} else {
|
||
facTb.innerHTML = factors.map(f => `<tr>
|
||
<td>${f.label || f.id || '—'}</td>
|
||
<td class="text-end">+${optunaFmtNum(f.points, 1)}</td>
|
||
<td class="text-muted">${f.detail || ''}</td>
|
||
</tr>`).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 = '<tr><td colspan="6" class="text-muted">분포 없음</td></tr>';
|
||
} 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 `<tr>
|
||
<td><code>${r.param || ''}</code></td>
|
||
<td class="text-end">${fmt(r.median)}</td>
|
||
<td class="text-end">${fmt(r.p25)}</td>
|
||
<td class="text-end">${fmt(r.p75)}</td>
|
||
<td>${r.mode != null ? r.mode : '—'}</td>
|
||
<td class="text-end">${share}</td>
|
||
</tr>`;
|
||
}).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 `<div class="d-flex flex-wrap align-items-start gap-2 py-1${bdr}">
|
||
<span class="fw-semibold">#${i} trial #${t.optuna_trial_number ?? '—'}</span>
|
||
<span>PnL <b class="${Number(t.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${Number(t.total_pnl || 0).toLocaleString()}</b>원</span>
|
||
${dAvg != null ? `<span>일평균 ${Number(dAvg).toLocaleString()}원</span>` : ''}
|
||
${dPct != null ? `<span>일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%</span>` : ''}
|
||
<span>WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · ${Number(t.total_trades || 0)}건</span>
|
||
<span>${optunaObWhipCell(t)}</span>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('gated',${rank})">보기</button>
|
||
<button type="button" class="btn btn-sm btn-outline-primary py-0" onclick="optunaBacktestFromCandidate('gated',${rank})">백테</button>
|
||
<button type="button" class="btn btn-sm btn-outline-success py-0" onclick="optunaApply('gated',${rank})">적용</button>
|
||
</div>`;
|
||
}
|
||
|
||
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 ? ` · 기간 <b>${o.periodRange}</b>` : '';
|
||
const phase = o.phaseLabel || '사후합격';
|
||
const note = o.running
|
||
? ' <span class="text-warning">(진행 중 · 사후합격 미확정)</span>'
|
||
: '';
|
||
let html = `<div class="mb-1 fw-semibold text-success">${phase} Top${n}${pr}${note}</div>`;
|
||
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} <small class="text-muted">DB 적용 후보 · 일평균(원/%) · 익절/손절 비교</small>`;
|
||
}
|
||
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
|
||
? '<span class="text-muted">사후합격 후보 없음</span>'
|
||
: '<span class="text-muted">완료 후 표시</span>';
|
||
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 = `<span class="text-muted">mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} 없음</span>`;
|
||
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
|
||
? '<span class="text-muted">분포 데이터 없음</span>'
|
||
: '<span class="text-muted">완료 후 표시</span>';
|
||
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 = '<span class="text-muted">수치형 분포 없음</span>';
|
||
return;
|
||
}
|
||
const poolNote = d.threshold_pool
|
||
? `<div class="text-muted mb-2" style="font-size:11px">표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}</div>`
|
||
: '';
|
||
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 += `<div style="position:absolute;left:${pct(med).toFixed(1)}%;top:0;width:2px;height:100%;background:#3fb950" title="median ${med}"></div>`;
|
||
}
|
||
if (mode != null && !Number.isNaN(mode)) {
|
||
marks += `<div style="position:absolute;left:${pct(mode).toFixed(1)}%;top:3px;width:8px;height:8px;background:#f0883e;border-radius:50%" title="mode ${mode}"></div>`;
|
||
}
|
||
const l = pct(p25).toFixed(1);
|
||
const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1);
|
||
return `<div class="mb-2">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<code style="font-size:11px">${r.param}</code>
|
||
<span class="text-muted" style="font-size:10px">mode ${fmt(r.mode)} (${share})</span>
|
||
</div>
|
||
<div style="position:relative;height:14px;background:#21262d;border-radius:4px;margin:2px 0">
|
||
<div style="position:absolute;left:${l}%;width:${w}%;height:100%;background:rgba(56,139,253,0.4);border-radius:3px"></div>
|
||
${marks}
|
||
</div>
|
||
<div class="text-muted" style="font-size:10px">p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}</div>
|
||
</div>`;
|
||
}).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 =
|
||
`<span class="text-muted">추천 없음</span> — ${rec.reason || '양수 PnL 앵커 없음'}`;
|
||
return;
|
||
}
|
||
const arm = Number(rec.arm_krw || 0).toLocaleString();
|
||
const anchor = Number(rec.anchor_krw || 0).toLocaleString();
|
||
body.innerHTML =
|
||
`<b>${rec.prefix || ''}</b> · ENABLED=true · MODE=${rec.mode || 'trailing'}` +
|
||
`<br>ARM <b>${arm}</b>원 · TIERS <code>${rec.tiers || ''}</code>` +
|
||
`<br><span class="text-muted">앵커 ${anchor}원 (mode/median/best×frac) · 타점 적용과 별도 · 이후 운영탭에서 끄기/수정 가능</span>`
|
||
+ ` <button class="btn btn-sm btn-outline-info py-0 ms-1" onclick="optunaApplyTrail()">트레일 합의 적용</button>`;
|
||
}
|
||
|
||
function optunaOverfitCell(r) {
|
||
const p = r && r.overfit_risk_pct;
|
||
if (p == null || Number.isNaN(Number(p))) return '<span class="text-muted">—</span>';
|
||
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 `<span class="${cls}" title="과적합%=높을수록 위험(0~100). 이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정).">${n}%${v ? ' · ' + v : ''}</span>`;
|
||
}
|
||
|
||
function optunaStableScoreCell(r) {
|
||
if (!r || r.stability_score == null) return '<span class="text-muted">—</span>';
|
||
const n = Number(r.stability_score);
|
||
const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
||
return `<span class="${cls}" title="높을수록 좋음. 만점 없음. 일평균PnL − λ×일표준편차(원).">${optunaFmtNum(r.stability_score, 2)}</span>`;
|
||
}
|
||
|
||
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 '<span class="text-muted">—</span>';
|
||
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 '<span class="text-muted" title="구JSON 또는 호가·익절·손절·휩쏘 축 없음">—</span>';
|
||
}
|
||
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(`<div class="text-pnl-pos" style="font-size:10px;line-height:1.25">${bits.join(' ')}</div>`);
|
||
} else if (on === false) {
|
||
lines.push('<div class="text-muted" style="font-size:10px;line-height:1.25">호가OFF</div>');
|
||
}
|
||
|
||
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(`<div class="text-muted" style="font-size:10px;line-height:1.25">${eb.join(' ')}</div>`);
|
||
}
|
||
|
||
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(`<div class="text-warning" style="font-size:10px;line-height:1.25">${wb.join(' ')}</div>`);
|
||
} else if (whip === false && (on != null || hasExit)) {
|
||
lines.push('<div class="text-muted" style="font-size:10px;line-height:1.25">휩쏘OFF</div>');
|
||
}
|
||
|
||
if (!lines.length) {
|
||
return `<span class="text-muted" title="${tip}">${r.ob_summary || '—'}</span>`;
|
||
}
|
||
return `<div title="${tip}" style="min-width:148px">${lines.join('')}</div>`;
|
||
}
|
||
|
||
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 `<tr>
|
||
<td>${rank}</td>
|
||
<td>#${r.optuna_trial_number ?? '—'}</td>
|
||
<td class="text-end">${optunaFmtNum(r.total_trades, 0)}</td>
|
||
<td class="text-end">${optunaFmtNum(r.win_rate, 1)}%</td>
|
||
<td class="text-end">${optunaFmtNum(r.pf, 2)}</td>
|
||
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
|
||
${optunaDailyAvgPnlCell(r)}
|
||
${optunaDailyAvgPctCell(r)}
|
||
<td style="font-size:11px;min-width:148px;white-space:normal">${optunaObWhipCell(r)}</td>
|
||
<td class="text-end">${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}</td>
|
||
<td class="text-end ${Number(r.worst_day_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}</td>
|
||
<td class="text-end">${optunaStableScoreCell(r)}</td>
|
||
<td class="text-end">${optunaOverfitCell(r)}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
|
||
${src !== 'mode' ? `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaBacktestFromCandidate('${src}',${rank})">백테</button>` : ''}
|
||
${detailBtn}
|
||
<button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>
|
||
</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function optunaFillRankTbody(tbodyId, rows, source, emptyMsg) {
|
||
const tb = $(tbodyId);
|
||
if (!tb) return;
|
||
if (!rows || !rows.length) {
|
||
tb.innerHTML = `<tr><td colspan="14" class="text-muted">${emptyMsg}</td></tr>`;
|
||
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 '<span class="text-muted">—</span>';
|
||
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 `<span class="${cls}" title="과적합%=이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정). AI 아님.">과적합 가능도 ${p}% · ${v}</span>`;
|
||
}
|
||
|
||
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 `<span class="${cls}" style="font-weight:600">승률 ${wr.toFixed(1)}%</span>`;
|
||
}
|
||
|
||
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 `<span class="${cls}" style="font-weight:700">${sign}${n.toLocaleString()}원</span>`;
|
||
}
|
||
|
||
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 `<span class="text-muted">${ko[r] || r}</span>`;
|
||
}
|
||
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 ? ' <span class="text-warning" style="font-size:11px">(사후호가 승률↓)</span>' : '';
|
||
return `<div style="font-size:12px;line-height:1.45">${recLine || '추천있음'}`
|
||
+ (recBits.length ? `<div>사후호가(참고) ${recBits.join(' · ')}${dropHint}</div>` : '')
|
||
+ (origBits.length
|
||
? `<div class="text-muted" style="font-size:11px" title="호가스냅이 붙은 체결만. 위 mode/타점 전체 건수·승률·PnL과 다름">필터전(호가스냅) ${origBits.join(' · ')}</div>`
|
||
: '')
|
||
+ `</div>`;
|
||
}
|
||
|
||
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 '<span class="text-muted">참고</span>';
|
||
if (!used) return '<span class="text-muted">이 전략 미사용</span>';
|
||
const c = hasCombos ? (combos[cid] || {}) : null;
|
||
const disabled = (cid !== 'base' && hasCombos && !(c && c.ok));
|
||
if (disabled) return `<button class="btn btn-sm btn-outline-secondary py-0" disabled>호가만</button>`;
|
||
if (cid !== 'base' && !hasCombos) {
|
||
return `<button class="btn btn-sm btn-outline-secondary py-0" disabled>호가만</button>`;
|
||
}
|
||
return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'${cid}')">호가만</button>`;
|
||
};
|
||
const btnWhip = (cid, used) => {
|
||
if (isLive || !rowSrc) return '<span class="text-muted">—</span>';
|
||
if (whipSkip) return '<span class="text-muted">휩쏘 해당없음</span>';
|
||
if (!used) return '<span class="text-muted">—</span>';
|
||
const c = hasCombos ? (combos[cid] || {}) : null;
|
||
const ws = (c && c.whipsaw) || {};
|
||
// +휩쏘 = DB 적용만. 탐색은 「이 잡 후처리 재실행」.
|
||
if (cid !== 'base' && hasCombos && !(c && c.ok)) {
|
||
return `<button class="btn btn-sm btn-outline-secondary py-0" disabled title="호가방 미통과">휩쏘적용</button>`;
|
||
}
|
||
if (ws && ws.ok === false && ws.reason === 'n/a_strategy') {
|
||
return '<span class="text-muted">해당없음</span>';
|
||
}
|
||
if (!ws || ws.ok !== true) {
|
||
const why = (ws && ws.reason) ? String(ws.reason) : '미산출';
|
||
return `<button class="btn btn-sm btn-outline-secondary py-0" disabled title="${why} · 「후처리 재실행」으로 방별 휩쏘 TPE 돌린 뒤 적용">휩쏘적용</button>`;
|
||
}
|
||
return `<button class="btn btn-sm btn-outline-info py-0" title="이 방 휩쏘 파라미터를 DB에 적용(탐색 아님)" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'${cid}+whip')">휩쏘적용</button>`;
|
||
};
|
||
const whipCell = (cid, used) => {
|
||
if (!used) return '<span class="text-muted">—</span>';
|
||
if (whipSkip) return '<span class="text-muted">해당없음</span>';
|
||
const c = hasCombos ? (combos[cid] || {}) : null;
|
||
const ws = (c && c.whipsaw) || null;
|
||
if (!ws) return '<span class="text-muted">미산출</span>';
|
||
if (ws.ok) return _optunaAxisStatsHtml(ws, ws);
|
||
const why = ws.reason || '없음';
|
||
if (why === 'n/a_strategy') return '<span class="text-muted">이 전략 미사용</span>';
|
||
if (why === 'not_enough_trades') {
|
||
const n = ws.trade_count != null ? ws.trade_count : '?';
|
||
return `<span class="text-muted">통과 ${n}건(<min) · 스킵</span>`;
|
||
}
|
||
return `<span class="text-muted">${why}</span>`;
|
||
};
|
||
const trs = allRows.map(([id, lab, used]) => {
|
||
if (!used) {
|
||
return `<tr class="text-muted"><td>${lab}</td><td>—</td>`
|
||
+ `<td><span class="text-muted">이 전략에는 이 호가축 없음</span></td>`
|
||
+ `<td>—</td><td><span class="text-muted">미사용</span></td></tr>`;
|
||
}
|
||
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 ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
|
||
+ (why ? `<div class="text-muted">${why}</div>` : '<span class="text-muted">8방 미산출</span>');
|
||
} else {
|
||
nt = st.kind === 'legacy' ? '구JSON' : '—';
|
||
stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
|
||
}
|
||
return `<tr><td>${lab}</td><td>${nt}</td><td>${stats}</td>`
|
||
+ `<td>${whipCell(id, used)}</td>`
|
||
+ `<td>${btnOb(id, used)} ${btnWhip(id, used)}</td></tr>`;
|
||
}).join('');
|
||
const foot = !canExitStop
|
||
? '스캘핑·꼬리: 호가 <b>000·100</b>만. 「호가만」=호가 ON + <b>휩쏘 OFF·수치비움</b>(낙폭·방어 등 타점은 안 바꿈). 「휩쏘적용」만 휩쏘를 켭니다. 방별 휩쏘 TPE는 <b>후처리 재실행</b>.'
|
||
: (st.kind === 'ok'
|
||
? '8방 호가 TPE → 방별 휩쏘는 <b>후처리 재실행</b>. 「호가만」=휩쏘 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 = `<div class="text-warning p-1" style="font-size:11px;line-height:1.4">`
|
||
+ `<b>필터전 ≠ 위 mode/타점 숫자</b>. ${bits.join(' → ')}. `
|
||
+ `방마다 바뀌는 건 <b>사후호가(참고)</b>만. TPE에 호가가 포함되면 본 trial PnL이 정본. mode WR/PnL은 위 표를 보세요.`
|
||
+ `</div>`;
|
||
}
|
||
// 8방이 이미 있어도 「후처리 재실행」으로 방별 휩쏘를 다시 돌릴 수 있게
|
||
const rerunBtn = (!isLive)
|
||
? '<div class="mt-1"><button type="button" class="btn btn-sm btn-warning py-0" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행(호가·방별휩쏘)</button></div>'
|
||
: '';
|
||
return `${baseHint}<div class="table-responsive mt-1 opt-ob8-zone" style="max-height:min(62vh,560px);overflow:auto;border:1px solid var(--border);border-radius:4px">
|
||
<table class="table table-sm mb-0" style="font-size:12px">
|
||
<thead style="position:sticky;top:0;background:var(--card-bg,#161b22);z-index:1">
|
||
<tr><th>호가 방</th><th title="이 방 호가파라미터를 고르기 위해 돌린 TPE 횟수(기본 1축=150). mode/학습 Optuna trial 번호 아님">호가TPE</th><th>호가 성적</th><th>이 방 휩쏘</th><th>적용</th></tr>
|
||
</thead>
|
||
<tbody>${trs}</tbody>
|
||
</table>
|
||
<div class="text-muted p-1" style="font-size:11px">${foot}</div>
|
||
${rerunBtn}
|
||
</div>`;
|
||
}
|
||
|
||
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 ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '');
|
||
}
|
||
|
||
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
|
||
? '<span class="text-pnl-pos">본 TPE에 호가·휩쏘 축 포함</span>'
|
||
+ ' · 표 <b>호가</b>열 = trial마다 ON/OFF·스프레드 (상세 안 눌러도 됨)'
|
||
+ ' · <span class="text-muted">사후 호가8방이 비는 것은 정상(사후탐색 OFF)</span>'
|
||
: '<span class="text-pnl-pos">본 TPE에 호가 축 포함</span> · 표 <b>호가</b>열 참고'
|
||
+ (sum.post_run_ob_whipsaw ? ' · 사후8방도 돌림' : '');
|
||
ban.className = 'small mb-2';
|
||
} else if (sum) {
|
||
ban.innerHTML = '<span class="text-muted">이 JSON은 본 TPE 호가축 없음(구잡) · 호가열 — · 사후8방은 「후처리 재실행」</span>';
|
||
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 =
|
||
`거래 <b>${optunaFmtNum(m.total_trades, 0)}</b> · ` +
|
||
`승률 <b>${optunaFmtNum(m.win_rate, 1)}%</b> · ` +
|
||
`PF <b>${optunaFmtNum(m.pf, 2)}</b> · ` +
|
||
`PnL <b class="${Number(m.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(m.total_pnl)}</b>` +
|
||
(m.stability_score != null
|
||
? ` · 안정 <b>${optunaFmtNum(m.stability_score, 2)}</b> (높을수록 좋음·만점없음 · 손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})`
|
||
: '') +
|
||
(m.overfit_risk_pct != null
|
||
? ` · 과적합 <b>${optunaFmtNum(m.overfit_risk_pct, 1)}%</b> (높을수록 위험·0~100${m.overfit_verdict_ui ? ' · ' + m.overfit_verdict_ui : ''})`
|
||
: '') +
|
||
` · 파라미터 ${j.params_count || 0}개` +
|
||
(j.start && j.end ? ` · 기간 ${j.start}~${j.end}` : '') +
|
||
(daily ? `<div class="mt-1 text-muted">일별 PnL: ${daily}</div>` : '') +
|
||
` <button class="btn btn-sm btn-outline-primary py-0 ms-2" onclick="optunaFillFormFromCandidate('${src}',${rk})">폼에 반영</button>` +
|
||
` <button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaBacktestFromCandidate('${src}',${rk})">백테탭</button>` +
|
||
` <button class="btn btn-sm btn-outline-success py-0 ms-1" onclick="optunaApply('${src}',${rk})">이 후보 DB적용</button>`;
|
||
}
|
||
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 `<span class="opt-period-badge ${extraCls || ''}" title="${label}">${label} <b>${r}</b></span>`;
|
||
}
|
||
|
||
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
|
||
? '<div class="fw-semibold mb-1">⚠ 기간이 서로 다릅니다 — Top5·일평균·백테 비교 시 study별 기간을 확인하세요</div>'
|
||
: '<div class="fw-semibold mb-1">📅 백테 기간 (전 단계 동일)</div>';
|
||
html += '<div class="d-flex flex-wrap gap-1 align-items-center">';
|
||
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 += '</div>';
|
||
if (formMismatch) {
|
||
html += `<div class="small mt-1 text-warning">상단 폼(${formS}~${formE}) ≠ 선택 잡(${jobS}~${jobE}) — 새 잡 시작 시 폼 기간이 적용됩니다</div>`;
|
||
}
|
||
if (pi && pi.mismatch_notes && pi.mismatch_notes.length) {
|
||
html += `<div class="small mt-1 text-muted">${pi.mismatch_notes.join(' · ')}</div>`;
|
||
}
|
||
if (pi && pi.refine_note) {
|
||
html += `<div class="small mt-1 text-info">${pi.refine_note}</div>`;
|
||
}
|
||
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 = '<span class="opt-period-cell-warn" title="기간 불일치">⚠</span> ' + base;
|
||
if (p1 && p1 !== base) inner += `<br><span class="small text-warning" title="1차 TPE">1차 ${p1}</span>`;
|
||
if (p2 && p2 !== base && p2 !== p1) inner += `<br><span class="small text-warning" title="2차 TPE">2차 ${p2}</span>`;
|
||
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 =
|
||
`<span class="text-muted">1차</span> <code title="${piRef.phase1.study || ''}">${(piRef.phase1.study || '—').slice(0, 48)}</code>` +
|
||
` <b>${p1r}</b>` +
|
||
` · <span class="text-muted">2차</span> <code title="${piRef.phase2.study || ''}">${(piRef.phase2.study || '—').slice(0, 48)}</code>` +
|
||
` <b>${p2r}</b>`;
|
||
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 =
|
||
'<div class="mb-1 text-warning">순차 묶음 — 적용은 전략별 잡에서. 버튼을 누르면 그 전략 Top5가 나옵니다.</div>' +
|
||
kids.map(k => {
|
||
const jid = String(k.job_id || '').replace(/'/g, '');
|
||
const lab = k.label || k.strategy || jid;
|
||
return `<button type="button" class="btn btn-sm btn-outline-primary py-0 me-1 mb-1" onclick="optunaWatch('${jid}')">${lab} · gated ${k.n_gated ?? '—'} / ${k.n_all ?? '—'}</button>`;
|
||
}).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 = `<div class="mb-1 text-info">${phase} learn Top3 · 기간 <b>${pr}</b> (진행 중 · 축 분포 확인용)</div>`;
|
||
(ls.top3_learn || []).forEach((t, i) => {
|
||
html += `<div class="small">${optunaFormatTopRow(t, i + 1)}</div>`;
|
||
});
|
||
if (ls.refine_phase === 'phase2' && (ls.phase1_top3 || []).length) {
|
||
const p1pr = ls.phase1_period_range || '';
|
||
html += `<div class="mt-2 mb-1 text-muted small">1차 Top3 (참고${p1pr ? ' · 기간 ' + p1pr : ''})</div>`;
|
||
ls.phase1_top3.forEach((t, i) => {
|
||
html += `<div class="small text-muted">${optunaFormatTopRow(t, i + 1)}</div>`;
|
||
});
|
||
}
|
||
$('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 ? ` · 기간 <b>${o.periodRange}</b>` : '';
|
||
const pool = o.poolSize != null ? ` · pool ${o.poolSize}건` : '';
|
||
const head = o.title || `mode Top${n} (밴드 근접)`;
|
||
let html = `<div class="mt-1 mb-1 text-warning">${head}${pr}${pool}</div>`;
|
||
list.forEach((t, i) => {
|
||
html += `<div class="small">${optunaFormatModeTopRow(t, i + 1)}</div>`;
|
||
});
|
||
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 = '<tr><td colspan="8" class="text-muted">잡 없음</td></tr>';
|
||
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')
|
||
? `<br><span class="text-muted" style="font-size:10px" title="${job.study_name || ''}">${job.study_short}</span>`
|
||
: (job.source === 'cli' && job.study_short
|
||
? `<br><span class="text-muted" style="font-size:10px">${job.study_short}</span>`
|
||
: '');
|
||
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')
|
||
? `<button type="button" class="btn btn-sm btn-outline-warning py-0" title="다른 PC 명령 보기" onclick="optunaOpenJoinCmd('${jid}')">${phaseTag ? phaseTag + ' ' : ''}running</button>`
|
||
: 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)
|
||
? `<button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaContinue('${jid}')">이어 돌리기</button>` +
|
||
`<button class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaConfirmStudy('${jid}')">확정</button>`
|
||
: '';
|
||
return `<tr data-job-id="${jid}" style="${sel}">
|
||
<td><code style="font-size:10px">${jid}</code></td>
|
||
<td>${label}${studyNote}</td>
|
||
<td>${optunaFormatJobPeriodCell(job)}</td>
|
||
<td>${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote}</td>
|
||
<td>${(() => {
|
||
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 + '%' : ''}</td>
|
||
<td>${job.started_at || ''}</td>
|
||
<td>${job.finished_at || '—'}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaWatch('${jid}')">보기</button>
|
||
${extraBtns}
|
||
<button class="btn btn-sm btn-outline-info py-0 ms-1" onclick="optunaOpenJoinCmd('${jid}')">명령</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
const tb = $('opt_jobs_tbody');
|
||
if (tb) tb.innerHTML = `<tr><td colspan="8" class="text-danger">${e}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
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 =
|
||
'<div class="fw-semibold mb-1">PC별 병렬 — 전략/조합마다 1·2차 (refine runner)</div>' +
|
||
'<div class="small text-muted mb-2">MariaDB 공유 · PC마다 한 줄 · 2차만은 --phase1-study (DB payload)</div>' +
|
||
rows.map((row, i) => {
|
||
const done = row.phase1_db
|
||
? ' <span class="text-success">(1차 DB OK)</span>'
|
||
: (row.done ? ' <span class="text-success">(1차 OK)</span>' : '');
|
||
const note = row.phase2_note ? `<div class="text-muted" style="font-size:10px">${row.phase2_note}</div>` : '';
|
||
return `<div class="border rounded p-2 mb-2" style="border-color:var(--border)!important">
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-1">
|
||
<span><strong>${row.step}.</strong> ${row.label || row.strategy}${done}</span>
|
||
<span>
|
||
<button type="button" class="btn btn-sm btn-success py-0" onclick="optunaCopySeqRefine(${i},'full')">1·2차 bash</button>
|
||
<button type="button" class="btn btn-sm btn-outline-success py-0 ms-1" onclick="optunaCopySeqRefine(${i},'full_ps')">1·2차 PS</button>
|
||
<button type="button" class="btn btn-sm btn-warning py-0 ms-1" onclick="optunaCopySeqRefine(${i},'p2')">2차만 bash</button>
|
||
<button type="button" class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaCopySeqRefine(${i},'p2_ps')">2차만 PS</button>
|
||
</span>
|
||
</div>
|
||
${note}
|
||
<pre class="opt-join-pre mb-1 mt-1" id="opt_refine_full_${i}" style="max-height:120px"></pre>
|
||
<pre class="opt-join-pre mb-0" id="opt_refine_p2_${i}" style="max-height:100px"></pre>
|
||
</div>`;
|
||
}).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 = '<div class="text-muted mb-1 mt-2">스터디별 trial 추가 (1·2차 study 이름)</div>' +
|
||
all.map((row, i) => {
|
||
const lab = [row.strategy, row.extra].filter(Boolean).join('/');
|
||
return `<div class="d-flex justify-content-between align-items-center mt-2">
|
||
<span class="text-muted">${i + 1}. ${lab} · ${row.study || ''}</span>
|
||
<span>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaCopyJoinAll(${i},'sh')">bash</button>
|
||
<button type="button" class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaCopyJoinAll(${i},'ps')">PS</button>
|
||
</span>
|
||
</div>
|
||
<pre class="opt-join-pre mb-1" id="opt_join_all_${i}"></pre>
|
||
<pre class="opt-join-pre mb-0" id="opt_join_all_ps_${i}"></pre>`;
|
||
}).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();
|