${lcEsc(row.label)} ${lcEsc(row.strategy_id)}
${dashFmtKrw(row.realized_pnl_krw)}
${Number(row.return_pct || 0).toFixed(2)}% · ${lcEsc(f.hint)}
` : '';
const tbl = f.table ? `
${lcEsc(f.table)}` : '';
const keyTag = f.key
? `
${lcEsc(f.key)}`
: '';
if (f.type === 'bool') {
const chk = f.value ? 'checked' : '';
return `
${tbl}${hint}
`;
}
if (f.type === 'text') {
const val = f.value != null ? f.value : '';
return `
${tbl}
${hint}
`;
}
const step = f.type === 'int' ? '1' : '0.01';
const val = f.value != null ? f.value : '';
return `
${tbl}
${hint}
`;
}
/** env 키 → 전략 구역 ID (카테고리 안 시각 구분용) */
function lcInferStrategy(key) {
const k = String(key || '').toUpperCase();
const m = k.match(/^STRATEGY_([A-Z0-9_]+)_ENABLED$/);
if (m) return m[1];
// 긴 prefix 우선
const pairs = [
['RANGE_BREAK_', 'RANGE_BREAK'],
['UPDOWN_', 'UPDOW'],
['UPDOW_', 'UPDOW'],
['BREAKOUT_', 'BREAKOUT'],
['MOMENTUM_', 'MOMENTUM'],
['SCALP_', 'SCALP'],
['DBBAND_', 'DBBAND'],
['TAIL_', 'SHORT'],
['SHORT_', 'SHORT'],
];
for (let i = 0; i < pairs.length; i++) {
if (k.startsWith(pairs[i][0])) return pairs[i][1];
}
return 'COMMON';
}
/** 공통 섹션용 — 주제(호가/프로그램/휩쏘…)로 한 번 더 묶기 */
function lcInferTopic(key) {
const k = String(key || '').toUpperCase();
if (k.includes('ORDERBOOK') || k.includes('SPREAD')) return 'orderbook';
if (k.includes('PROGRAM')) return 'program';
if (k.includes('WHIPSAW')) return 'whipsaw';
if (k.startsWith('WS_TRIGGER') || k.includes('TRIGGER_EVAL')) return 'trigger_master';
if (k.includes('PENDING') || k.includes('FILL') || k.includes('ORDER_') || k.includes('IOC')
|| k.includes('DEDUP') || k.includes('SLIP') || k.includes('STRICT_FILL')
|| k.includes('CANCEL_PARTIAL') || k.includes('DUPLICATE_ORDER')) return 'fill';
if (k.includes('BACKTEST') || k.includes('PARAM_SEARCH') || k.includes('POLL_MS')
|| k.includes('TICK_')) return 'backtest';
if (k.startsWith('KIS_') || k.includes('BALANCE_MAX') || k.includes('INTERVAL_SEC')) return 'rest';
if (k.startsWith('SCAN_') || k.startsWith('STRATEGY_LOOP')) return 'scan';
if (k.includes('ORPHAN') || k.includes('GHOST') || k.includes('MANUAL_HOLD')
|| k.includes('BULK_SELL') || k.includes('DRIFT')) return 'portfolio';
if (k.includes('DAILY_PROFIT') || k.includes('DAILY_STOP') || k.includes('CONSECUTIVE_LOSS')
|| k.includes('USE_RISK')) return 'risk';
return 'misc';
}
const LC_TOPIC_ORDER = [
'trigger_master', 'orderbook', 'program', 'whipsaw',
'fill', 'backtest', 'rest', 'scan', 'risk', 'portfolio', 'misc',
];
const LC_TOPIC_LABELS = {
trigger_master: 'TRIGGER 마스터',
orderbook: '호가',
program: '프로그램',
whipsaw: '휩쏘',
fill: '체결·주문',
backtest: '백테·틱청산',
rest: 'REST 유량',
scan: '스캔 루프',
risk: '리스크',
portfolio: '보유·고아',
misc: '기타',
};
const LC_STRAT_ORDER = [
'COMMON', 'SHORT', 'MOMENTUM', 'BREAKOUT', 'UPDOW',
'SCALP', 'RANGE_BREAK', 'DBBAND', 'OTHER',
];
const LC_STRAT_LABELS = {
COMMON: '공통',
SHORT: '꼬리잡기',
MOMENTUM: '모멘텀',
BREAKOUT: '돌파',
UPDOW: 'UPDOWN 박스',
SCALP: '스캘핑',
RANGE_BREAK: '박스권돌파',
DBBAND: '더블BB',
OTHER: '기타',
};
/** 한 카테고리 fields → 전략별 구역 배열 */
function lcPartitionFieldsByStrategy(fields) {
const buckets = {};
(fields || []).forEach(f => {
let sid = lcInferStrategy(f.key);
if (LC_STRAT_ORDER.indexOf(sid) < 0) sid = 'OTHER';
if (!buckets[sid]) buckets[sid] = [];
buckets[sid].push(f);
});
return LC_STRAT_ORDER
.filter(sid => buckets[sid] && buckets[sid].length)
.map(sid => ({
sid,
label: LC_STRAT_LABELS[sid] || sid,
fields: buckets[sid],
}));
}
/** 공통 필드만 있을 때 주제별로 재분할 (2구역 이상이면 사용) */
function lcPartitionFieldsByTopic(fields) {
const buckets = {};
(fields || []).forEach(f => {
const tid = lcInferTopic(f.key);
if (!buckets[tid]) buckets[tid] = [];
buckets[tid].push(f);
});
return LC_TOPIC_ORDER
.filter(tid => buckets[tid] && buckets[tid].length)
.map(tid => ({
sid: 'topic',
topicId: tid,
label: LC_TOPIC_LABELS[tid] || tid,
fields: buckets[tid],
}));
}
function lcRenderFieldGrid(fields) {
let html = '
';
(fields || []).forEach(f => { html += lcFieldInput(f); });
html += '
';
return html;
}
function lcRenderBlock(p) {
const cls = p.sid === 'topic'
? 'lc-strat-block lc-strat-topic'
: ('lc-strat-block lc-strat-' + String(p.sid).toLowerCase());
const sidTag = (p.sid === 'COMMON' || p.sid === 'topic')
? ''
: `
${lcEsc(p.sid)}`;
return `
${lcEsc(p.label)}${sidTag}
${lcRenderFieldGrid(p.fields)}
`;
}
function lcRenderGroups(groups) {
const root = $('lc_groups');
if (!root) return;
let html = '';
(groups || []).forEach(g => {
const parts = lcPartitionFieldsByStrategy(g.fields || []);
const splitStrat = parts.length > 1;
// 전략이 하나뿐(공통만)이면 주제로 한 번 더 나눔
let renderParts = parts;
let useBlocks = splitStrat;
if (!splitStrat && parts.length === 1) {
const topics = lcPartitionFieldsByTopic(parts[0].fields);
if (topics.length > 1) {
renderParts = topics;
useBlocks = true;
}
}
html += `
${lcEsc(g.title)}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
`;
if (useBlocks) {
html += '
';
renderParts.forEach(p => { html += lcRenderBlock(p); });
html += '
';
} else {
html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []);
}
html += '
';
});
root.innerHTML = html;
}
function lcCollectPatch(scopeGroupId) {
const patch = {};
const root = scopeGroupId
? document.querySelector(`.lc-group[data-lc-group="${scopeGroupId}"]`)
: $('tab-liveconfig');
if (!root) return patch;
root.querySelectorAll('[data-lc-key]').forEach(el => {
const key = el.dataset.lcKey;
const typ = el.dataset.lcType || 'text';
if (typ === 'bool') {
patch[key] = el.checked;
} else {
patch[key] = el.value;
}
});
return patch;
}
async function lcLoad(silent) {
lcInitDate();
const day = $('lc_date').value;
if (!silent) showSpinner(true);
try {
const r = await fetch('/api/live_config?date=' + encodeURIComponent(day));
const d = await r.json();
if (!d.ok) {
if (!silent) alert(d.error || '운영 설정 조회 실패');
return;
}
_lcData = d;
$('lc_meta').textContent = '기준일 ' + d.date + ' · 갱신 ' + (d.as_of || '');
lcRenderStatus(d.status);
// 자동 갱신: 손익·익절 상태만 — 폼 입력값은 유지
if (!silent || !$('lc_groups') || !$('lc_groups').children.length) {
lcRenderGroups(d.groups);
}
} catch (e) {
if (!silent) alert('운영 설정 조회 오류: ' + e);
} finally {
if (!silent) showSpinner(false);
}
}
async function lcSavePatch(patch, label) {
if (!patch || !Object.keys(patch).length) {
alert('저장할 값이 없습니다.');
return;
}
if (!confirm('💾 ' + (label || '운영 설정') + '을(를) DB에 저장할까요?\n봇은 재시작 없이 다음 루프부터 반영됩니다.')) return;
showSpinner(true);
const resEl = $('lc_save_result');
try {
const r = await fetch('/api/live_config/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patch }),
});
const d = await r.json();
if (!d.ok) {
alert(d.error || '저장 실패');
return;
}
const tbl = d.saved_by_table
? Object.entries(d.saved_by_table).map(([t, ks]) => t + ': ' + ks.length + '키').join(' · ')
: '';
resEl.innerHTML = `
✅ 저장 완료 (env_id=${d.env_id}) — ${tbl}`;
await lcLoad(false);
} catch (e) {
alert('저장 오류: ' + e);
} finally {
showSpinner(false);
}
}
function lcSaveGroup(groupId) {
lcSavePatch(lcCollectPatch(groupId), '섹션 ' + groupId);
}
function lcSaveAll() {
lcSavePatch(lcCollectPatch(null), '전체 운영 설정');
}
// ════════════════════════════════════════════════════════════════
// 박스엔진 UPDOWN (kis_trader/engine/updown_box)
// watchlist(sticky) 관리 + 박스 백테 + 글로벌 파라서치
// ════════════════════════════════════════════════════════════════
// 입력란 id ↔ 박스 cfg 키 매핑 (env UPDOWN_BOX_* 와 1:1)
const UBX_PARAM_FIELDS = [
'lookback_bars', 'range_max_pct', 'bb_bw_max', 'ma_slope_tol_pct',
'near_low_pct', 'tp_pct', 'stop_loss_pct', 'breach_buffer_pct',
'panic_from_high_pct', 'shoulder_min_high_pct', 'shoulder_cut_pct', 'max_hold_bars',
'breakout_follow',
];
function ubxOnTabShow() {
// 날짜 기본값 — 주말/휴장이면 이전 장운영일
const end = $('ubx_end'), start = $('ubx_start');
if (end && !end.value) end.value = kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays);
else if (end && end.value) end.value = kstClampToPrevTradingDayIso(end.value, _krHolidays);
if (start && !start.value) {
const endIso = (end && end.value) ? end.value : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays);
start.value = kstClampToPrevTradingDayIso(kstAddDaysIso(endIso, -180), _krHolidays);
} else if (start && start.value) {
start.value = kstClampToPrevTradingDayIso(start.value, _krHolidays);
}
ubxLoadCfg();
ubxLoadWatch();
ubxLoadStockList();
}
/** 종목 셀렉트(datalist) 채우기 — watchlist + 종목별 핀 등록 종목 */
function ubxLoadStockList() {
fetch('/api/updown_box/stock_list')
.then(r => r.json())
.then(d => {
const dl = $('ubx_stock_options');
if (!dl) return;
const items = d.items || [];
dl.innerHTML = items.map(it => {
const pin = it.n_overrides > 0 ? ' ★핀' : '';
const st = it.status ? ` (${it.status})` : '';
return `
`;
}).join('');
})
.catch(e => console.warn('[ubx stock_list]', e));
}
/** 종목 선택 시 그 종목의 박스 파라미터(있으면 핀, 없으면 글로벌) → 입력란 */
function ubxLoadStockCfg(code) {
code = String(code || '').trim();
const badge = $('ubx_stock_cfg_badge');
if (!/^\d{6}$/.test(code)) { if (badge) badge.innerHTML = ''; return; }
fetch('/api/updown_box/stock_cfg?code=' + encodeURIComponent(code))
.then(r => r.json())
.then(d => {
if (d.error) { if (badge) badge.innerHTML = ''; return; }
const eff = d.effective || {};
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && eff[k] != null) el.value = eff[k];
});
const rt = $('ubx_ratchet_tiers');
if (rt) rt.value = (eff.ratchet_tiers != null ? eff.ratchet_tiers : '');
if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
const n = Object.keys(d.overrides || {}).length;
if (badge) {
badge.innerHTML = n > 0
? `
★ 종목별 핀 ${n}개 적용중 (저장값 로드)`
: `
글로벌 사용중 (핀 없음)`;
}
})
.catch(e => console.warn('[ubx stock_cfg]', e));
}
/** 현재 입력값을 [종목코드] 종목별 파라미터로 고정 저장(핀) */
function ubxSaveStockCfg() {
const code = ($('ubx_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
const overrides = {};
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && el.value !== '' && el.value != null) overrides[k] = el.value;
});
const rt = $('ubx_ratchet_tiers');
if (rt) overrides.ratchet_tiers = String(rt.value || '').trim(); // '' = 래칫 OFF
if (!confirm(code + ' 종목을 이 파라미터로 고정(핀)할까요?\n실매·백테 모두 이 종목엔 글로벌 대신 이 값이 적용됩니다.')) return;
fetch('/api/updown_box/stock_cfg', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, name: '', overrides }),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('저장 실패: ' + (d.error || '')); return; }
alert('✅ ' + code + ' 종목별 핀 저장 (' + (d.saved_keys || []).length + '개 파라미터)');
ubxLoadStockList(); ubxLoadStockCfg(code);
})
.catch(e => alert('오류: ' + e));
}
/** 이 종목의 핀 해제 → 글로벌 복귀 */
function ubxClearStockCfg() {
const code = ($('ubx_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
if (!confirm(code + ' 종목별 핀을 해제하고 글로벌로 복귀할까요?')) return;
fetch('/api/updown_box/stock_cfg', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, clear: '1' }),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('해제 실패: ' + (d.error || '')); return; }
alert('↩ ' + code + ' 글로벌로 복귀');
ubxLoadStockList(); ubxLoadCfg(); ubxLoadStockCfg(code);
})
.catch(e => alert('오류: ' + e));
}
/** 운영설정 UPDOWN_BOX_* 현재값 → 입력란 + watchlist 통계 */
function ubxLoadCfg() {
fetch('/api/updown_box/config')
.then(r => r.json())
.then(d => {
if (!d.ok) { console.warn('[ubx cfg]', d.error); return; }
const c = d.cfg || {};
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && c[k] != null) el.value = c[k];
});
const rt = $('ubx_ratchet_tiers');
if (rt) rt.value = (c.ratchet_tiers != null ? c.ratchet_tiers : '');
if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
if ($('ubx_tf') && d.scan_tf_min) $('ubx_tf').value = String(d.scan_tf_min);
const st = $('ubx_watch_stat');
if (st) st.textContent = `— active ${d.watch_active} / max ${d.watch_max}`;
})
.catch(e => console.warn('[ubx cfg]', e));
}
/** watchlist 목록 표 */
function ubxLoadWatch() {
const activeOnly = $('ubx_watch_active_only') && $('ubx_watch_active_only').checked;
fetch('/api/updown_box/watchlist?active=' + (activeOnly ? '1' : '0'))
.then(r => r.json())
.then(d => {
const tb = $('ubx_watch_tbody');
if (!tb) return;
if (!d.ok) { tb.innerHTML = `
| ${d.error || '오류'} |
`; return; }
const rows = d.rows || [];
if (!rows.length) { tb.innerHTML = '
| 등록된 관심종목 없음 (SCAN 또는 수동추가) |
'; return; }
tb.innerHTML = rows.map(r => {
const stColor = r.status === 'active' ? '#3fb950' : '#8b949e';
const sig = r.last_signal_at ? String(r.last_signal_at).slice(0, 16) : '-';
const added = r.added_at ? String(r.added_at).slice(0, 10) : '-';
return `
| ${r.code} |
${r.name || ''} |
${r.status} |
${r.source || ''} |
${Number(r.box_low || 0).toLocaleString()} |
${Number(r.box_high || 0).toLocaleString()} |
${Number(r.box_score || 0).toFixed(1)} |
${added} |
${sig} |
|
`;
}).join('');
})
.catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `
| ${e} |
`; });
}
function ubxAddWatch() {
const code = ($('ubx_add_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('6자리 종목코드를 입력하세요'); return; }
const body = {
code,
name: ($('ubx_add_name').value || '').trim(),
box_low: parseFloat($('ubx_add_low').value || '0') || 0,
box_high: parseFloat($('ubx_add_high').value || '0') || 0,
};
fetch('/api/updown_box/watchlist/add', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('추가 실패: ' + (d.error || '한도초과/보유중')); return; }
$('ubx_add_code').value = ''; $('ubx_add_name').value = '';
$('ubx_add_low').value = ''; $('ubx_add_high').value = '';
ubxLoadWatch(); ubxLoadCfg();
})
.catch(e => alert('오류: ' + e));
}
/** 수정: 행 값을 입력폼에 채워 넣음 → 고친 뒤 '추가/수정' 누르면 upsert 갱신 */
function ubxEditWatch(code, name, low, high) {
$('ubx_add_code').value = code || '';
$('ubx_add_name').value = name || '';
$('ubx_add_low').value = low || '';
$('ubx_add_high').value = high || '';
const c = $('ubx_add_code');
if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.focus(); }
}
function ubxRemoveWatch(code) {
if (!confirm(code + ' 관심종목을 삭제할까요?')) return;
fetch('/api/updown_box/watchlist/remove', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
})
.then(r => r.json())
.then(d => { if (!d.ok) alert('삭제 실패: ' + (d.error || '')); ubxLoadWatch(); ubxLoadCfg(); })
.catch(e => alert('오류: ' + e));
}
/** 입력란 → 백테/파라서치 쿼리 (빈값 제외, env 오버라이드) */
function ubxCollectParamQs(qs) {
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && el.value !== '' && el.value != null) qs.append(k, String(el.value));
});
const rt = $('ubx_ratchet_tiers'); // 다단 래칫(문자열) — 숫자 필드와 별도
if (rt && rt.value != null && String(rt.value).trim() !== '') {
qs.append('ratchet_tiers', String(rt.value).trim());
}
}
// 래칫/어깨컷 입력칸 색상 토글 — TAIL(tlSyncRatchetShoulderColors)과 동일.
// 다단 래칫 값이 있으면 단일 어깨컷(어깨발동·어깨폭)은 무시됨 → 빨강 표시.
function ubxSyncRatchetShoulderColors() {
const r = $('ubx_ratchet_tiers');
if (!r) return;
const smin = $('ubx_shoulder_min_high_pct');
const scut = $('ubx_shoulder_cut_pct');
const ratchetOn = !!(r.value && String(r.value).trim());
const RED = '#f85149';
const paint = (el, on) => {
if (!el) return;
if (on) {
el.style.setProperty('border-color', RED, 'important');
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
} else {
el.style.removeProperty('border-color');
el.style.removeProperty('background-color');
el.style.removeProperty('box-shadow');
}
};
// 래칫 ON → 어깨컷 빨강(무시됨) / 래칫 OFF → 래칫 빨강(OFF, 어깨컷이 트레일)
paint(smin, ratchetOn);
paint(scut, ratchetOn);
paint(r, !ratchetOn);
}
function ubxRunBacktest() {
const code = ($('ubx_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 입력하세요'); return; }
const qs = new URLSearchParams({
code, tf: $('ubx_tf').value || '15',
start: $('ubx_start').value || '', end: $('ubx_end').value || '',
});
ubxCollectParamQs(qs);
$('ubx_status').textContent = '백테 실행 중…';
showSpinner(true);
fetch('/api/updown_box/backtest?' + qs.toString())
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
$('ubx_status').textContent = '';
$('ubx_ps_card').style.display = 'none';
ubxRenderSummary(d);
ubxRenderTrades(d.trades || [], `${d.code} · ${d.tf}분 · ${d.candle_count}봉`, { code: d.code, name: d.name || '' });
})
.catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
}
function ubxRenderSummary(d) {
const card = $('ubx_summary_card'), box = $('ubx_summary');
if (!card || !box) return;
const bn = d.box_now || {};
const boxBadge = bn.is_box
? `
박스권 ✔ (범위 ${Number(bn.range_pct || 0).toFixed(1)}% · 밴드폭 ${bn.bb_bw != null ? Number(bn.bb_bw).toFixed(1) : '-'}% · 기울기 ${bn.ma_slope != null ? Number(bn.ma_slope).toFixed(2) : '-'}%)`
: `
비박스 (${bn.reason || ''})`;
const bh = (d.buy_hold_pct != null) ? Number(d.buy_hold_pct) : null;
const alpha = (d.alpha_pct != null) ? Number(d.alpha_pct) : null;
box.innerHTML = `
거래 ${d.n_trades}
봇 총손익 ${d.total_pnl}%
PF ${d.pf}
승률 ${d.win_rate}%
${bh != null ? `
단순보유(B&H) ${bh.toFixed(2)}%
` : ''}
${alpha != null ? `
초과수익(α) ${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%
` : ''}
${alpha != null ? `
${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}
` : ''}
현재 박스상태(최근봉): ${boxBadge}
${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}
`;
$('ubx_summary_sub').textContent = `${d.code} · ${d.tf}분`;
card.style.display = '';
}
function ubxRenderTrades(trades, sub, meta) {
const card = $('ubx_trades_card');
if (!card) return;
meta = meta || {};
// 박스 백테 거래(run_backtest_box) → 공통 거래목록 스키마로 변환.
// 손익(원)은 100만원 슬롯 가정으로 환산(수익률%는 실제 백테값 그대로).
const mapped = (trades || []).map(t => {
const ep = Number(t.buy || 0), xp = Number(t.sell || 0);
const qty = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0;
return {
code: meta.code || '',
name: meta.name || '',
buy_price: ep,
sell_price: xp,
entry_time: t.entry_time,
exit_time: t.exit_time,
profit_rate: t.pnl_pct,
pnl: Math.round((xp - ep) * qty),
qty,
sell_reason: t.reason,
hold_min: t.bars_held,
};
});
// peak / 최종 누적 (매도순)
let peak = 0, peakAt = '', cum = 0;
const ordered = [...mapped].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b)));
for (const t of ordered) {
cum += Number(t.pnl || 0);
if (cum > peak) { peak = cum; peakAt = t.exit_time || ''; }
}
renderVirtualTrades('ubx_trades_tbody', mapped, {
meta,
showCumulative: true,
totalBudget: 1000000,
});
fillTradePnLContext('ubx_trade_context', {
label: '백테 UPDOWN_BOX',
totalBudget: 1000000,
peakCum: peak,
peakAt,
totalPnl: cum,
});
const subEl = $('ubx_trades_sub');
if (subEl) subEl.textContent = sub || '';
card.style.display = '';
}
function ubxRunParamSearch() {
const qs = new URLSearchParams({
tf: $('ubx_tf').value || '15',
start: $('ubx_start').value || '', end: $('ubx_end').value || '',
mode: $('ubx_mode').value || 'fast',
min_trades: $('ubx_min_trades').value || '3',
});
if ($('ubx_from_watchlist') && $('ubx_from_watchlist').checked) {
qs.append('from_watchlist', '1');
} else {
const codes = ($('ubx_codes').value || '').trim() || ($('ubx_code').value || '').trim();
if (!codes) { alert('탐색 종목(codes) 또는 watchlist 사용을 지정하세요'); return; }
qs.append('codes', codes);
}
$('ubx_status').textContent = '파라미터 탐색 중… (조합 수에 따라 수십 초)';
showSpinner(true);
fetch('/api/updown_box/param_search?' + qs.toString())
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
$('ubx_status').textContent = '';
$('ubx_summary_card').style.display = 'none';
$('ubx_trades_card').style.display = 'none';
ubxRenderParamSearch(d);
})
.catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
}
function ubxRenderParamSearch(d) {
const card = $('ubx_ps_card'), tb = $('ubx_ps_tbody');
if (!card || !tb) return;
const top = d.top || [];
const meta = d.meta || {};
const dropped = meta.dropped_trend || [];
const dropTxt = dropped.length
? ` · 추세주 제외: ${dropped.map(x => `${x.code}(α${x.alpha}%)`).join(',')}`
: '';
$('ubx_ps_sub').innerHTML =
`종목 ${(d.codes || []).length} · 조합 ${meta.cartesian_product || 0} · 통과 ${meta.passed || 0} · ${meta.search_mode || ''} · 정렬:
${meta.rank_by || 'alpha'}`
+ ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '')
+ (dropTxt ? `
${dropTxt}` : '');
if (!top.length) {
tb.innerHTML = '
| 통과한 조합 없음 (최소거래 낮추거나 기간 확대) |
';
} else {
tb.innerHTML = top.map((r, i) => {
const cfgStr = Object.entries(r.apply_cfg || {}).map(([k, v]) => {
if (k === 'ratchet_tiers') return `ratchet=${(v != null && String(v).trim()) ? v : 'OFF'}`;
return `${k}=${v}`;
}).join(' ');
const cfgJson = encodeURIComponent(JSON.stringify(r.apply_cfg || {}));
const alpha = (r.alpha_pct != null) ? Number(r.alpha_pct) : null;
const bh = (r.buy_hold_pct != null) ? Number(r.buy_hold_pct) : null;
return `
| ${i + 1} |
${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'} |
${r.total_pnl}% |
${bh != null ? bh + '%' : '-'} |
${r.pf} | ${r.win_rate}% | ${r.n_trades} |
${cfgStr} |
|
`;
}).join('');
}
card.style.display = '';
}
function ubxApply(cfgJson) {
let cfg;
try { cfg = JSON.parse(decodeURIComponent(cfgJson)); } catch (e) { alert('파라미터 파싱 실패'); return; }
if (!confirm('이 파라미터를 env_config UPDOWN_BOX_* 에 적용할까요?\n' +
Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join('\n'))) return;
fetch('/api/updown_box/apply', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apply_cfg: cfg }),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('적용 실패: ' + (d.error || '')); return; }
alert('✅ env_config 적용 (id=' + d.env_id + ')\n' +
Object.entries(d.applied || {}).map(([k, v]) => `${k}=${v}`).join('\n'));
ubxLoadCfg();
})
.catch(e => alert('오류: ' + e));
}
// ────────────────────────────────────────────
// 스피너
// ────────────────────────────────────────────
function showSpinner(v) {
$('spinner').classList.toggle('show', v);
}
// ────────────────────────────────────────────
// 최초 로드
// ────────────────────────────────────────────
loadActual();