auto: 일일 백업 2026-06-13 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-13 02:00:02 +09:00
parent 137b160a09
commit c749e66460
153 changed files with 1169 additions and 531 deletions
+275 -20
View File
@@ -68,7 +68,7 @@ PTR_HTML = """
# 전체 리로드여도 보던 화면이 유지되게 한다 (자동·당겨서·수동 새로고침 공통).
REFRESH_JS = """
<script>(function(){
var TABS=['lab','watch','market'],SUBS=['bt','cmp'];
var TABS=['lab','watch','market'],SUBS=['bt','cmp','day'];
var p=new URLSearchParams(location.search);
var t=p.get('t'),s=p.get('s'),vw=p.get('view');
if(SUBS.indexOf(t)>=0){s=t;t='lab';} /* 레거시 ?t=bt/cmp 하위호환 */
@@ -79,7 +79,10 @@ REFRESH_JS = """
function simURL(){
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
return '/?t='+(cur||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'')+((cur==='watch'&&vw)?'&view='+encodeURIComponent(vw):'');
var u='/?t='+(cur||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'')+((cur==='watch'&&vw)?'&view='+encodeURIComponent(vw):'');
if(cur==='lab'&&sub==='day'){var dd=new URLSearchParams(location.search).get('d');
if(dd)u+='&d='+encodeURIComponent(dd);} /* 거래목록 선택 날짜 보존 */
return u;
}
window._simURL=simURL;
function syncUrl(){try{history.replaceState(null,'',simURL());}catch(e){}}
@@ -914,6 +917,12 @@ def _compare_panel() -> str:
# 스캔 시점 '노리는 종목' (variants_compare.json) — id별 매핑
watch_map = {c['id']: c.get('watching', [])
for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', [])}
# 라이브 거래일수 — equity_history.json 에 거래일마다 (id,ph) 1점씩 → 등장한 거래일 수 (백테스트 제외)
_eh = _load(config.STATE_DIR / 'equity_history.json', {}) or {}
live_days: dict = {}
for _d, _items in _eh.items():
for _e in _items:
live_days.setdefault((_e.get('id'), _e.get('ph')), set()).add(_d)
def _row(rid, name, params, pf, tpath):
s = pf.summary()
@@ -961,13 +970,12 @@ def _compare_panel() -> str:
scanned = _load(config.LAST_SCAN_PATH, {}).get('scanned_at', '')[:16].replace('T', ' ')
head = (f'<div class="sub">실시간 누적 비교 · 마지막 스캔 {scanned or ""} '
f'· 전략 {len(rows_data)}개</div>')
clear_btn = ('' if not defs else
'<form method="post" action="/variants_clear_all" style="display:inline;margin-left:8px" '
'onsubmit="return confirm(\'메인을 제외한 비교군 변이를 전부 삭제할까요?\')">'
f'<button type="submit" class="cmp-clear">🗑 전체삭제 {len(defs)}</button></form>')
bt_btn = ('<label for="sub-bt" class="cmp-btlink" role="button" tabindex="0" '
'title="백테스트 순위표 보기">📊 모의결과보기</label>')
title = f'<h2 class="cmp-title">병렬 페이퍼 비교{clear_btn}{bt_btn}</h2>'
day_btn = ('<label for="sub-day" class="cmp-btlink" role="button" tabindex="0" '
'title="날짜별 거래 종목">📋 거래목록</label>')
title = (f'<div class="cmp-title"><h2>병렬 페이퍼 비교</h2>'
f'<div class="cmp-acts">{bt_btn}{day_btn}</div></div>')
rows = ''
for i, v in enumerate(rows_data):
s = v['summary']
@@ -982,6 +990,8 @@ def _compare_panel() -> str:
mf_sum = _market_fit_summary(mp)
mf_w = _market_fit_words(mp)
bm = _bear_mode(mp)
_ph = hashlib.md5(json.dumps(mp, sort_keys=True).encode()).hexdigest()[:8]
ndays = len(live_days.get((v['id'], _ph), ()))
w = watch_map.get(v['id'], [])
_STL = {'BUY': '🟢 매수', 'ADD': '🟢 추격', 'WAIT': '🟡 대기'}
watch_html = ''.join(
@@ -1029,6 +1039,7 @@ def _compare_panel() -> str:
f'<div class="kv"><span class="k">약세장대처</span><span><b>{_BEAR_NAME[bm]}</b>'
f'<button type="button" class="chk-i" data-title="약세장대처 — {_BEAR_NAME[bm]}" '
f'data-detail="{_esc(_BEAR_DESC[bm] + _BEAR_LEGEND)}">ⓘ</button></span></div>'
f'<div class="kv"><span class="k">운영 기간</span><span>{ndays or ""}거래일째 <span class="muted">(백테스트 제외)</span></span></div>'
f'<div class="kv"><span class="k">평가자산</span><span>{s.get("equity",0):,}원</span></div>'
f'<div class="kv"><span class="k">예수금 / 주식</span><span>{s.get("cash",0):,} / {s.get("held_value",0):,}</span></div>'
f'<div class="kv"><span class="k">수익률</span><span style="color:{rc}">{ret:+g}%</span></div>'
@@ -1262,7 +1273,7 @@ td:first-child,th:first-child{text-align:left}
# 종합 코멘트 (결정론 — LLM 미경유)
notes = []
rk_s = f'#{rank}위/{len(ranked)}' if rank else '순위 산정 전'
notes.append(f'운영 {len(series) or 1}일째 · 현재 {rk_s} · 누적 <b>{ret:+.2f}%</b>')
notes.append(f'라이브 {len(series) or 1}거래일째(백테스트 제외) · 현재 {rk_s} · 누적 <b>{ret:+.2f}%</b>')
ak = (bench.get('KOSPI') or {}).get('alpha')
if ak is not None:
notes.append(f'같은 기간 KOSPI 대비 <b>{ak:+.2f}%p</b> — '
@@ -1359,6 +1370,201 @@ td:first-child,th:first-child{text-align:left}
</body></html>"""
_DAY_CAL_JS = """<script>(function(){
var TD=__DATES__, SEL='__SEL__', TODAY='__TODAY__';
var tset={}; TD.forEach(function(d){tset[d]=1;});
var box=document.getElementById('day-cal'); if(!box)return;
var cur=(SEL||TD[0]||'').slice(0,7); /* 표시 중인 YYYY-MM */
var WD=['','','','','','',''];
function pad(n){return ('0'+n).slice(-2);}
function render(){
var p=cur.split('-'), y=+p[0], m=+p[1];
var startDow=new Date(y,m-1,1).getDay(), ndays=new Date(y,m,0).getDate();
var minYM=(TD[TD.length-1]||'').slice(0,7), maxYM=(TD[0]||'').slice(0,7);
var h='<div class="cal-hd">'
+'<button type="button" data-nav="-1"'+(cur<=minYM?' disabled':'')+'></button>'
+'<span>'+y+'.'+pad(m)+'</span>'
+'<button type="button" data-nav="1"'+(cur>=maxYM?' disabled':'')+'></button></div>';
h+='<div class="cal-grid">';
WD.forEach(function(w){h+='<div class="cal-wd">'+w+'</div>';});
for(var i=0;i<startDow;i++)h+='<div></div>';
for(var d=1;d<=ndays;d++){
var ds=y+'-'+pad(m)+'-'+pad(d);
var cls='cal-d'+(ds===TODAY?' tdy':'');
if(tset[ds]){h+='<a class="'+cls+' on'+(ds===SEL?' sel':'')+'" data-d="'+ds+'" href="/?t=lab&s=day&d='+ds+'">'+d+'</a>';}
else{h+='<span class="'+cls+' off">'+d+'</span>';}
}
h+='</div>';
box.innerHTML=h;
box.querySelectorAll('[data-nav]').forEach(function(b){
b.addEventListener('click',function(){
if(b.disabled)return;
var p=cur.split('-'), nm=+p[1]+(+b.dataset.nav), ny=+p[0];
if(nm<1){nm=12;ny--;} if(nm>12){nm=1;ny++;}
cur=ny+'-'+pad(nm); render();
});
});
box.querySelectorAll('.cal-d.on').forEach(function(a){
a.addEventListener('click',function(e){ e.preventDefault(); pick(a.getAttribute('data-d')); });
});
}
var loading=false;
function pick(ds){
/* 날짜 선택 — 달력 닫지 않고 거래내역만 fetch 갱신. 로딩 표시로 진행 상태를 보여줌 */
if(loading)return; loading=true;
var url='/?t=lab&s=day&d='+ds;
var clbl=document.getElementById('day-sel-label');
if(clbl)clbl.innerHTML=ds+' <span class="day-load">⏳ 불러오는 중…</span>';
var rowsEl=document.getElementById('day-rows');
if(rowsEl)rowsEl.style.opacity='.35'; /* 갱신 중 흐리게 */
SEL=ds; render(); /* 선택 하이라이트 즉시 이동(반응성) */
fetch(url).then(function(r){return r.text();}).then(function(html){
var doc=new DOMParser().parseFromString(html,'text/html');
['day-rows','day-sub'].forEach(function(id){
var nw=doc.getElementById(id), cu=document.getElementById(id);
if(nw&&cu)cu.innerHTML=nw.innerHTML;
});
var lbl=doc.getElementById('day-sel-label');
if(lbl&&clbl)clbl.innerHTML=lbl.innerHTML;
if(rowsEl)rowsEl.style.opacity='';
try{history.replaceState(null,'',url);}catch(e){}
loading=false;
}).catch(function(){
if(clbl)clbl.innerHTML=ds+' <span class="day-load" style="color:#ef4444">불러오기 실패 · 다시 탭</span>';
if(rowsEl)rowsEl.style.opacity='';
loading=false;
});
}
render();
var cb=document.getElementById('day-cal-close');
if(cb)cb.addEventListener('click',function(){
var w=document.getElementById('day-cal-wrap'); if(w)w.open=false;
});
})();</script>"""
def _day_trades_panel(sel_date: str | None = None) -> str:
"""전 전략(변이)이 거래한 종목 — 날짜별 매수/매도 집계. sel_date=YYYY-MM-DD (없으면 최근 거래일)."""
from . import variants as vmod
# 순위맵 (비교 스냅샷) — 전략번호 옆 현재 순위
cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', []) if c.get('id') != 'main']
ranked = sorted(cv, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
rank_map = {c['id']: n + 1 for n, c in enumerate(ranked)}
# 전 전략 거래로그 1회 로드 — 날짜 목록 + 선택일 집계 동시 처리
all_rows = [] # (date, vid, record)
for v in vmod.load_variants():
_, tp = vmod.variant_paths(v['id'])
if not tp.exists():
continue
for ln in tp.read_text().splitlines():
if not ln.strip():
continue
try:
r = json.loads(ln)
except Exception:
continue
d = str(r.get('ts', ''))[:10]
if d:
all_rows.append((d, v['id'], r))
dates = sorted({d for d, _, _ in all_rows}, reverse=True) # 최신순
back = '<label for="sub-cmp" class="cmp-btlink" role="button" tabindex="0">← 비교로</label>'
if not dates:
return (f'<h2 class="cmp-title">📋 거래목록 {back}</h2>'
'<div class="empty">아직 거래 기록이 없어요 — 스캔이 진입 조건을 기다리는 중</div>')
if sel_date not in dates:
sel_date = dates[0] # 기본 = 최근 거래일
# 달력형 날짜 선택 — 거래일만 밝게·클릭가능, 거래 없는 날은 어둡게. 월 이동은 JS(새로고침 X).
is_today = sel_date == datetime.now(config.KST).strftime('%Y-%m-%d')
cal_js = (_DAY_CAL_JS.replace('__DATES__', json.dumps(dates))
.replace('__SEL__', sel_date)
.replace('__TODAY__', datetime.now(config.KST).strftime('%Y-%m-%d')))
picker = (f'<details class="day-cal-wrap" id="day-cal-wrap"><summary>'
f'📅 <b id="day-sel-label">{sel_date}{" (오늘)" if is_today else ""}</b> '
f'<span class="muted">▾ 날짜 선택</span>'
f'</summary><div id="day-cal" class="day-cal"></div>'
f'<button type="button" id="day-cal-close" class="day-cal-close">닫기</button>'
f'{cal_js}</details>')
head = (f'<h2 class="cmp-title"><span>📋 거래목록</span>'
f'<div class="cmp-acts">{back}</div></h2>{picker}')
# 선택일 집계: (code, side) → {name, vids[], prices[], pnls[]}
agg: dict = {}
for d, vid, r in all_rows:
if d != sel_date:
continue
key = (r.get('code'), r.get('side'))
a = agg.setdefault(key, {'name': r.get('name', ''), 'vids': [],
'prices': [], 'pnls': [], 'reason': r.get('reason', ''),
'kinds': {}, 'time': r.get('ts', '')[11:16]})
a['vids'].append(vid)
if r.get('price'):
a['prices'].append(r['price'])
if r.get('side') == 'SELL':
if r.get('pnl_pct') is not None:
a['pnls'].append(r['pnl_pct'])
rsn = r.get('reason', '') or ''
kind = ('익절' if '익절' in rsn else '손절' if '손절' in rsn
else '추세이탈' if '이탈' in rsn else '청산')
a['kinds'][kind] = a['kinds'].get(kind, 0) + 1
for a in agg.values():
a['n'] = len(a['vids'])
if not agg:
return head + '<div class="empty">이 날은 거래가 없어요</div>'
# (종목, 방향) 단위로 한 아이템 — 같은 종목에 매수·매도 동시면 2개 아이템으로 분리.
# 정렬: 매도(청산) 먼저, 그다음 전략 수 많은 순.
_SIDE = {'BUY': ('매수', '🟢', '#22c55e'), 'SELL': ('매도', '🔴', '#ef4444')}
order = sorted(agg.items(),
key=lambda kv: (0 if kv[0][1] == 'SELL' else 1, -kv[1]['n']))
n_buy = sum(1 for (_, side) in agg if side == 'BUY')
n_sell = sum(1 for (_, side) in agg if side == 'SELL')
sub = (f'<div class="sub" id="day-sub">매수 {n_buy} · 매도 {n_sell} 아이템 (전 전략 합산, 종목·방향별 구분)</div>')
rows = ''
for (code, side), a in order:
label, emo, color = _SIDE.get(side, (side, '', '#9ca3af'))
ps = a['prices']
prng = (f'{min(ps):,}' if len(set(ps)) == 1 else f'{min(ps):,}~{max(ps):,}') if ps else ''
extra = ''
kind_badge = ''
kind_row = ''
if side == 'SELL':
if a['pnls']:
avg = sum(a['pnls']) / len(a['pnls'])
pc = '#22c55e' if avg >= 0 else '#ef4444'
extra = f' · <span style="color:{pc}">평균 {avg:+.1f}%</span>'
# 익절/손절 분류 — 매도 사유 기반. 요약엔 대표 1개, 본문엔 전체 내역
_KC = {'익절': '#22c55e', '손절': '#ef4444', '추세이탈': '#eab308', '청산': '#9ca3af'}
kinds = sorted(a['kinds'].items(), key=lambda kv: -kv[1])
if kinds:
top = kinds[0][0]
kind_badge = f'<span class="day-kind" style="color:{_KC[top]};border-color:{_KC[top]}">{top}</span> '
parts = ' · '.join(f'<span style="color:{_KC[k]}">{k} {c}</span>' for k, c in kinds)
kind_row = (f'<div class="kv"><span class="k">청산 유형</span><span>{parts}</span></div>')
vids = sorted(a['vids'], key=lambda x: rank_map.get(x, 999))
vid_str = ' '.join(
f'<a href="/strategy?id={x}" class="vchip">{_vid_disp(x)}'
+ (f'<span class="vr">{rank_map[x]}위</span>' if x in rank_map else '') + '</a>'
for x in vids)
rows += (
f'<details class="row"><summary>'
f'<span class="nm">{_esc(a["name"])}</span>'
f'<span class="rt">{kind_badge}<span class="day-badge" style="color:{color};border-color:{color}">'
f'{emo} {label} <b>{a["n"]}</b></span></span></summary>'
f'<div class="body">'
f'<div class="kv"><span class="k" style="color:{color}">{emo} {label} <b>{a["n"]}</b>개 전략</span>'
f'<span>{prng}{extra}</span></div>'
f'{kind_row}'
f'<div class="kv"><span class="k muted" style="font-size:11px">{a["time"]} · 사유</span>'
f'<span class="muted" style="font-size:11px">{_esc(a["reason"])[:30]}</span></div>'
f'<div class="vchips">{vid_str}</div>'
f'</div></details>')
return head + sub + f'<div id="day-rows">{rows}</div>'
def _engine_status(snap, asset_line) -> tuple[str, str]:
"""스캔 엔진 헬스 → (점 클래스 ok/idle/bad, 토스트 상세). 장중 최근스캔=초록, 장중 멈춤=빨강, 장외=대기."""
now = datetime.now(config.KST)
@@ -1394,6 +1600,7 @@ def _pos_table(pf) -> str:
pnl_amt = int(round((cur - entry) * qty))
pc = '#22c55e' if pnl >= 0 else '#ef4444'
evalv = cur * qty
costv = int(round(entry * qty))
trail = ' <span class="tag">트레일링</span>' if p.get('trailing_on') else ''
tr_n, tr_max = p.get('tranches', 1), config.ENTRY_TRANCHES
scaled = ' <span class="tag">일부익절</span>' if p.get('scaled_out') else ''
@@ -1405,6 +1612,7 @@ def _pos_table(pf) -> str:
f'<div class="body">'
f'<div class="kv"><span class="k">수량</span><span>{qty:,}주 <span class="muted">({tr_n}/{tr_max}분할)</span>{scaled}</span></div>'
f'<div class="kv"><span class="k">평단</span><span>{entry:,.0f}원</span></div>'
f'<div class="kv"><span class="k">매수금액</span><span>{costv:,}원</span></div>'
f'<div class="kv"><span class="k">평가금액</span><span>{evalv:,}원</span></div>'
f'<div class="kv"><span class="k">평가손익</span><span style="color:{pc}">{pnl_amt:+,}원 ({pnl:+.1f}%)</span></div>'
f'<div class="kv"><span class="k">손절</span><span>{p["stop"]:,}원</span></div>'
@@ -1503,7 +1711,8 @@ def _decision_card(d, watch_n: int = 0, held_n: int = 0, acc_detail: str = '') -
)
def render_html(view: str | None = None) -> str:
def render_html(view: str | None = None, day_date: str | None = None,
tab: str | None = None, sub: str | None = None) -> str:
snap = _load(config.LAST_SCAN_PATH, {})
from . import variants as vmod
_cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', [])
@@ -1679,6 +1888,14 @@ def render_html(view: str | None = None) -> str:
market_html = _market_panel()
bt_html = _backtest_panel()
cmp_html = _compare_panel()
day_html = _day_trades_panel(day_date)
# 활성 탭/서브탭을 서버에서 미리 checked — JS가 로드 후 바꾸며 생기던 깜빡임 제거.
tab = tab if tab in ('lab', 'watch', 'market') else 'lab'
sub = sub if sub in ('cmp', 'bt', 'day') else 'cmp'
ck = {f'tab-{tab}': ' checked', f'sub-{sub}': ' checked'}
def _ck(rid):
return ck.get(rid, '')
return f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
@@ -1733,10 +1950,37 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
.mfit{{display:block;width:100%;margin:8px 0 2px;background:#0e2236;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:8px;padding:8px;font-size:12px;font-weight:600;cursor:pointer}}
.bt-type{{margin:18px 0 8px;font-size:14px;font-weight:700;border-bottom:1px solid #1f2937;padding-bottom:5px}}
.bt-type .bt-type-d{{display:block;font-size:11px;font-weight:400;color:#9ca3af;margin-top:2px}}
.cmp-title{{display:flex;align-items:center}}
.cmp-clear{{background:#3a1518;color:#fca5a5;border:1px solid #7f1d1d;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;cursor:pointer}}
.cmp-btlink{{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;cursor:pointer;margin-left:8px;display:inline-block}}
.cmp-title{{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 6px}}
.cmp-title h2{{margin:0;flex:0 0 auto}}
.cmp-acts{{display:flex;flex-wrap:wrap;gap:6px;align-items:center;flex:1 1 auto;justify-content:flex-end}}
.cmp-acts form{{margin:0}}
.day-badge{{border:1px solid;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:600;white-space:nowrap}}
.day-kind{{border:1px solid;border-radius:6px;padding:2px 7px;font-size:10px;font-weight:600;white-space:nowrap}}
.vchips{{display:flex;flex-wrap:wrap;gap:5px;margin:6px 0 2px}}
.vchip{{display:inline-flex;align-items:center;gap:3px;background:#1e293b;color:#7dd3fc;border:1px solid #334155;border-radius:6px;padding:2px 7px;font-size:11px;font-weight:600;text-decoration:none}}
.vchip .vr{{color:#94a3b8;font-size:9px;font-weight:500}}
.cmp-clear{{background:#3a1518;color:#fca5a5;border:1px solid #7f1d1d;border-radius:6px;padding:4px 9px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap}}
.cmp-btlink{{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:6px;padding:4px 9px;font-size:11px;font-weight:600;cursor:pointer;display:inline-block;white-space:nowrap}}
#judge-view{{background:#141b24;border:1px solid #2a3142;color:#e5e7eb;border-radius:6px;padding:3px 6px;font-size:10px;max-width:88%}}
.day-cal-wrap{{margin:8px 0 4px;background:#111824;border:1px solid #1f2937;border-radius:10px;max-width:300px}}
.day-cal-wrap>summary{{cursor:pointer;padding:8px 10px;font-size:12px;list-style:none}}
.day-cal-wrap>summary::-webkit-details-marker{{display:none}}
.day-cal{{padding:2px 10px 8px}}
.cal-hd{{display:flex;justify-content:space-between;align-items:center;margin:2px 0 5px}}
.cal-hd button{{background:#1e293b;border:1px solid #334155;color:#7dd3fc;border-radius:5px;padding:2px 12px;font-size:13px;cursor:pointer}}
.cal-hd button:disabled{{opacity:.3;cursor:default}}
.cal-hd span{{font-weight:700;font-size:12px}}
.cal-grid{{display:grid;grid-template-columns:repeat(7,1fr);gap:2px}}
.cal-wd{{text-align:center;font-size:9px;color:#64748b;padding:1px 0}}
.cal-d{{position:relative;text-align:center;padding:5px 0;border-radius:5px;font-size:11px;font-variant-numeric:tabular-nums}}
.cal-d.on{{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;font-weight:700;text-decoration:none;cursor:pointer}}
.cal-d.off{{color:#2c3848}}
.cal-d.sel{{background:#2563eb;color:#fff;border-color:#60a5fa;box-shadow:0 0 0 1px #60a5fa}}
.cal-d.tdy::after{{content:'';position:absolute;bottom:2px;left:50%;transform:translateX(-50%);width:3px;height:3px;border-radius:50%;background:#7dd3fc}}
.cal-d.tdy.sel::after{{background:#fff}}
.day-cal-close{{display:block;width:calc(100% - 20px);margin:4px 10px 10px;background:#1e293b;border:1px solid #334155;color:#cbd5e1;border-radius:6px;padding:7px;font-size:12px;cursor:pointer}}
.day-load{{font-size:11px;color:#7dd3fc;font-weight:400}}
#day-rows{{transition:opacity .12s}}
.fav-b{{background:none;border:none;font-size:13px;cursor:pointer;padding:0 2px;line-height:1;flex:0 0 auto}}
.fav-none{{opacity:.35;filter:grayscale(1)}}
.fav-grid{{display:flex;flex-wrap:nowrap;overflow-x:auto;gap:8px;margin:10px 0;padding-bottom:4px}}
@@ -1808,7 +2052,7 @@ input[name="tab"],input[name="subtab"]{{display:none}}
.btn-mini{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:7px;padding:9px 11px;font-size:12px;font-weight:600;cursor:pointer;width:100%}}
.btn-mini.btn-warn{{background:#3f2d0a;border-color:#b45309;color:#fcd34d}}
.btn-mini.btn-danger{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}}
#sub-cmp:checked~#p-lab #ps-cmp,#sub-bt:checked~#p-lab #ps-bt{{display:block}}
#sub-cmp:checked~#p-lab #ps-cmp,#sub-bt:checked~#p-lab #ps-bt,#sub-day:checked~#p-lab #ps-day{{display:block}}
.btn-apply{{background:#15803d;color:#fff;border:1px solid #16a34a;border-radius:8px;padding:9px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}}
.tabs label{{font-size:12px;padding:9px 4px}}
.prow{{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid #161e29}}
@@ -1837,11 +2081,12 @@ input[name="tab"],input[name="subtab"]{{display:none}}
</style></head><body>
<div id="ptr"><span class="arrow">↓</span><span class="spinner"></span></div>
<div id="page">
<input type="radio" name="tab" id="tab-lab" checked>
<input type="radio" name="tab" id="tab-watch">
<input type="radio" name="tab" id="tab-market">
<input type="radio" name="subtab" id="sub-cmp" checked>
<input type="radio" name="subtab" id="sub-bt">
<input type="radio" name="tab" id="tab-lab"{_ck('tab-lab')}>
<input type="radio" name="tab" id="tab-watch"{_ck('tab-watch')}>
<input type="radio" name="tab" id="tab-market"{_ck('tab-market')}>
<input type="radio" name="subtab" id="sub-cmp"{_ck('sub-cmp')}>
<input type="radio" name="subtab" id="sub-bt"{_ck('sub-bt')}>
<input type="radio" name="subtab" id="sub-day"{_ck('sub-day')}>
<header class="hd">
<h1>🪎 레이 자동매매 시뮬레이션 <span id="health" class="hdot {hcls}" data-detail="{_esc(hdetail)}" role="button" tabindex="0" title="시뮬 상태 보기"></span></h1>
<div class=sub>마지막 스캔 {scanned or ''} · 시장 {mkt_h}</div>
@@ -1864,6 +2109,7 @@ input[name="tab"],input[name="subtab"]{{display:none}}
<section class="panel" id="p-lab">
<div class="subpanel" id="ps-cmp">{cmp_html}</div>
<div class="subpanel" id="ps-bt"><h2 class="cmp-title">백테스트 순위표 <label for="sub-cmp" class="cmp-btlink" role="button" tabindex="0">← 비교로</label></h2>{bt_html}</div>
<div class="subpanel" id="ps-day">{day_html}</div>
</section>
<div class=sub style="margin-top:20px">60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)</div>
</div>
@@ -1906,8 +2152,17 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(404)
self.end_headers()
return
_view = (urllib.parse.parse_qs(parsed.query).get('view') or ['main'])[0]
body = render_html(view=_view).encode('utf-8')
_qs = urllib.parse.parse_qs(parsed.query)
_view = (_qs.get('view') or ['main'])[0]
_dd = (_qs.get('d') or [None])[0]
_tab = (_qs.get('t') or [None])[0]
_sub = (_qs.get('s') or [None])[0]
# 레거시 ?t=bt/cmp/day (서브탭이 t로 옴) 하위호환 — REFRESH_JS와 동일 규칙
if _tab in ('bt', 'cmp', 'day'):
_sub, _tab = _tab, 'lab'
if _tab in ('acct', 'tune'):
_tab = 'lab'
body = render_html(view=_view, day_date=_dd, tab=_tab, sub=_sub).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
# 캐시 금지 — Safari 휴리스틱 캐싱이 옛 HTML/JS를 재사용해 탭 복원 로직이 구버전으로 돌던 문제 방지