auto: 일일 백업 2026-06-12 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ sim state 파일(last_scan.json·portfolio.json·trades.jsonl)만 읽어 렌더.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import subprocess
|
||||
@@ -57,7 +58,7 @@ PTR_HTML = """
|
||||
document.addEventListener('touchend',function(){
|
||||
if(!pull)return;pull=false;
|
||||
var d=dy*0.55;if(d>MAX)d=MAX;dy=0;
|
||||
if(d>T){c.style.transform='translateY('+(T+15)+'px)';p.style.opacity='1';if(arrow)arrow.style.transform='';p.classList.add('spinning');location.reload();}
|
||||
if(d>T){c.style.transform='translateY('+(T+15)+'px)';p.style.opacity='1';if(arrow)arrow.style.transform='';p.classList.add('spinning');location.href=(window._simURL?window._simURL():location.href);}
|
||||
else{reset();}
|
||||
});
|
||||
})();</script>
|
||||
@@ -74,6 +75,16 @@ REFRESH_JS = """
|
||||
if(t==='acct'||t==='tune')t='lab'; /* 삭제된 탭 하위호환 */
|
||||
if(t){var r=document.getElementById('tab-'+t); if(r)r.checked=true;}
|
||||
if(s){var rs=document.getElementById('sub-'+s); if(rs)rs.checked=true;}
|
||||
/* 탭 라디오 ↔ URL 동기화 — 어떤 새로고침(당겨서/브라우저/reload)이든 보던 탭으로 돌아오게 */
|
||||
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):'');
|
||||
}
|
||||
window._simURL=simURL;
|
||||
function syncUrl(){try{history.replaceState(null,'',simURL());}catch(e){}}
|
||||
document.querySelectorAll('input[name="tab"],input[name="subtab"]').forEach(function(r2){r2.addEventListener('change',syncUrl);});
|
||||
syncUrl();
|
||||
function banner(msg,color){var b=document.createElement('div');b.className='saved-banner';
|
||||
if(color)b.style.background=color;b.textContent=msg;document.body.appendChild(b);
|
||||
setTimeout(function(){b.remove();},2800);}
|
||||
@@ -281,7 +292,7 @@ _CHK_HELP = {
|
||||
'상승여력': {'t': '상승여력 (목표가 대비)', 'd': '애널리스트 평균 목표주가가 지금 가격보다 몇 % 높은지예요. +20%면 전문가들이 20% 더 오를 여지를 본다는 의미예요. 이미 목표가 근처라 여력이 적으면 매력이 줄어요.'},
|
||||
'목표가리비전': {'t': '목표가 리비전 (상향/하향)', 'd': '최근 애널리스트들이 목표주가를 올리는 중인지 내리는 중인지 봐요. "상향"이면 회사 전망이 좋아지는 방향이라 긍정 신호예요. "하향"이면 반대고요.'},
|
||||
'어닝서프라이즈': {'t': '어닝 서프라이즈', 'd': '가장 최근 실적이 시장이 예상했던 것(컨센서스)보다 좋았는지(+) 나빴는지(−)예요. 예상보다 잘 나오면(서프라이즈 +) 주가에 긍정적으로 작용하는 경우가 많아요.'},
|
||||
'시장강세': {'t': '시장 강세 (장 전체 분위기)', 'd': '그날 시장 전체에서 오른 종목의 비율이에요. 오른 종목이 기준(15%) 이상이면 "강세장"으로 봐요. 시장이 무너지는 날엔 좋은 종목도 같이 빠지기 쉬워서, 약세장에선 바닥잡기 매수는 잠깐 미루고 강한 돌파만 허용해요.'},
|
||||
'시장강세': {'t': '시장 강세 (장 전체 분위기)', 'd': '그날 시장 전체 분위기(상승비율+지수추세+당일등락 조합)예요. 시장이 무너지는 날엔 좋은 종목도 같이 빠지기 쉬워서 참고하는데, 이 항목 하나로 매수가 막히는 건 아니에요 — 전략의 "약세장대처" 모드에 따라 갈려요. 회피=약세면 안 삼 · 안정(기본)=돌파만 삼 · 적극=눌림목도 삼. 그래서 약세(!)인데도 적극 전략은 정상적으로 매수할 수 있어요.'},
|
||||
'과열': {'t': '과열 아님 (회전율)', 'd': '그날 거래 회전율(거래량을 상장주식 수로 나눈 값)이 평소 평균보다 지나치게(3배 이상) 높지 않은지 봐요. 너무 과열되면 단기 꼭대기일 위험이 커서, 그럴 땐 신규매수를 잠깐 보류해요.'},
|
||||
'돌파': {'t': '돌파 (전고점)', 'd': '현재가가 최근 고점을 뚫고 올라갔는지예요. 전고점 돌파는 새로운 상승의 출발 신호로 봐요. 거래량·회전율 급증이 함께 나오면 "진짜 돌파"로 판단해 바로 매수합니다.'},
|
||||
'거래량': {'t': '거래량 급증', 'd': '그날 거래량이 평소 평균보다 크게 많은지 봐요. 거래가 확 늘었다는 건 사람들의 관심이 몰렸다는 뜻이라, 돌파에 힘이 실렸는지 확인하는 신호예요.'},
|
||||
@@ -317,9 +328,15 @@ def _chips(checks) -> str:
|
||||
def chip(c):
|
||||
k = _chk_key(c.get('label'))
|
||||
info = f'<button type="button" class="chk-i" data-k="{k}" aria-label="설명">ⓘ</button>' if k else ''
|
||||
ok = c.get('ok')
|
||||
mark, color = _check_mark(ok), _check_color(ok)
|
||||
# 시장 강세는 그 자체로 매수를 막는 필수조건이 아니라 약세장대처 모드로 분기되는
|
||||
# 소프트 게이트 — 미충족이어도 빨간 ✗(위반 인상) 대신 노란 ! (참고)
|
||||
if c.get('label') == '시장 강세' and ok is False:
|
||||
mark, color = '!', '#eab308'
|
||||
return (
|
||||
f'<span class="chk" style="color:{_check_color(c.get("ok"))}">'
|
||||
f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}'
|
||||
f'<span class="chk" style="color:{color}">'
|
||||
f'{mark} {_esc(c.get("label"))}'
|
||||
f'<span class="cd">{_esc(c.get("detail"))}</span>{info}</span>'
|
||||
)
|
||||
|
||||
@@ -726,14 +743,14 @@ def _market_fit_words(p: dict) -> str:
|
||||
|
||||
|
||||
# 약세장 모드 단어 칩 — 비교군 행·드롭다운에서 한눈에 구분 (긴 접미사 대신).
|
||||
_BEAR_WORD = {0: '회피', 1: '돌파', 2: '적극'}
|
||||
_BEAR_NAME = {0: '회피 (현금화)', 1: '돌파 (기본)', 2: '적극 (눌림허용)'}
|
||||
_BEAR_WORD = {0: '회피', 1: '안정', 2: '적극'}
|
||||
_BEAR_NAME = {0: '회피 (현금화)', 1: '안정 (돌파만·기본)', 2: '적극 (눌림허용)'}
|
||||
_BEAR_DESC = {
|
||||
0: '이 전략은 시장이 약세 국면(혼조·약세·급락)이면 아무것도 안 사고 현금을 지켜요.',
|
||||
1: '이 전략은 시장이 약세 국면이어도 강하게 치고 오르는 돌파 종목만 사요. 바닥잡기(눌림목 매수)는 안 해요.',
|
||||
2: '이 전략은 시장이 약세 국면이어도 눌림목 저가매수까지 해요. 셋 중 가장 적극적이에요.',
|
||||
}
|
||||
_BEAR_LEGEND = '\n\n회피=약세장 매수 안 함 · 돌파=돌파만 매수(기본) · 적극=눌림목 저가매수도 함'
|
||||
_BEAR_LEGEND = '\n\n회피=약세장 매수 안 함 · 안정=돌파만 매수(기본) · 적극=눌림목 저가매수도 함'
|
||||
|
||||
|
||||
def _bear_mode(p) -> int:
|
||||
@@ -943,17 +960,14 @@ 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>'
|
||||
'<div class="sub" style="color:#b45309">⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. '
|
||||
'기간 길수록·거래 많을수록 신뢰 · 비교군 변경은 수동(코디에게 요청)</div>'
|
||||
'<div class="sub">약세장대처: <span class="bic bic0">회피</span>매수 안 함 · '
|
||||
'<span class="bic bic1">돌파</span>돌파만(기본) · '
|
||||
'<span class="bic bic2">적극</span>눌림목도 매수 — 칩 클릭 시 설명</div>')
|
||||
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>')
|
||||
title = f'<h2 class="cmp-title">병렬 페이퍼 비교{clear_btn}</h2>'
|
||||
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>'
|
||||
rows = ''
|
||||
for i, v in enumerate(rows_data):
|
||||
s = v['summary']
|
||||
@@ -982,12 +996,17 @@ def _compare_panel() -> str:
|
||||
f'onsubmit="return confirm(\'이 변이를 삭제할까요?\')">'
|
||||
f'<input type="hidden" name="id" value="{_esc(v["id"])}">'
|
||||
f'<button type="submit" class="btn-mini btn-danger">삭제</button></form>')
|
||||
actions = f'<div class="cmp-row-actions">{reset_btn}{del_btn}</div>'
|
||||
det_btn = (f'<a href="/strategy?id={_esc(v["id"])}" class="btn-mini" '
|
||||
f'style="text-decoration:none">📈 자세히보기</a>')
|
||||
actions = f'<div class="cmp-row-actions">{det_btn}{reset_btn}{del_btn}</div>'
|
||||
vid = v['id']
|
||||
_vk = _btkey(v.get('params') or {})
|
||||
bt = bt_map.get(_vk)
|
||||
gr = bt_grank.get(_vk)
|
||||
bt_attr = gr if gr else 9999
|
||||
# 평가순 정렬 키 = 고유번호 오름차순 (#1→#108) — 번호 자체가 평가순 부여라 그대로가 가장 직관적.
|
||||
# bt 미매칭 변이만 뒤로. (등수×1000+번호 방식은 #1·#37·#38… 로 보여 뒤섞인 인상 — 2026-06-11 단순화)
|
||||
_vnum = int(vid[1:]) if vid[1:].isdigit() else 999
|
||||
bt_attr = _vnum if (bt and gr) else 900000 + _vnum
|
||||
if bt and gr:
|
||||
bw = f"{bt['worst']:+g}%" if bt['worst'] is not None else '—'
|
||||
bt_row = (f'<div class="kv nb"><span class="k">모의평가</span>'
|
||||
@@ -1020,7 +1039,7 @@ def _compare_panel() -> str:
|
||||
f'{bt_row}'
|
||||
f'<details class="row"><summary><span class="nm">📦 보유종목 ({s.get("open_positions",0)})</span></summary>'
|
||||
f'<div class="body">{_pos_table(v["pf"])}</div></details>'
|
||||
f'<details class="row"><summary><span class="nm">📋 거래내역 ({s.get("closed_trades",0)}청산)</span></summary>'
|
||||
f'<details class="row"><summary><span class="nm">📋 거래내역 ({_trade_count(v["tpath"])}건 · {s.get("closed_trades",0)}청산)</span></summary>'
|
||||
f'<div class="body">{_trades_table(_read_trades(v["tpath"]))}</div></details>'
|
||||
f'<details class="row"><summary><span class="nm">🎯 관심종목 ({len(w)})</span></summary>'
|
||||
f'<div class="body">{watch_html}</div></details>'
|
||||
@@ -1028,7 +1047,7 @@ def _compare_panel() -> str:
|
||||
f'</div></details>'
|
||||
)
|
||||
sortbar = ('<div class="cmp-sortbar" id="cmp-sortbar">'
|
||||
'<button type="button" data-s="rank">손익</button>'
|
||||
'<button type="button" data-s="rank">손익순</button>'
|
||||
'<button type="button" data-s="bt">평가순</button>'
|
||||
'<button type="button" data-s="held">보유순</button>'
|
||||
'<button type="button" data-s="trades">거래순</button></div>')
|
||||
@@ -1058,7 +1077,7 @@ def _compare_panel() -> str:
|
||||
function post(vid, icon){
|
||||
fetch('/fav_set',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body:'id='+encodeURIComponent(vid)+'&icon='+encodeURIComponent(icon)})
|
||||
.then(function(){location.reload();});
|
||||
.then(function(){location.href=(window._simURL?window._simURL():location.href);});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var b=e.target.closest('.fav-b'); if(!b) return;
|
||||
@@ -1100,6 +1119,246 @@ def _compare_panel() -> str:
|
||||
return title + head + sortbar + f'<div id="cmp-rows">{rows}</div>' + sort_js + fav_js
|
||||
|
||||
|
||||
def _ret_chart_svg(series, idx_series) -> str:
|
||||
"""일별 누적수익률 라인차트 — 전략(실선) vs KOSPI(회색 점선, 같은 구간 등락률).
|
||||
|
||||
series=[(yyyymmdd, ret%)], idx_series=[ret%|None] (series와 같은 길이)."""
|
||||
if not series:
|
||||
return '<div class="empty">아직 수익률 이력이 없어요 — 매 거래일 하나씩 쌓입니다</div>'
|
||||
w, h = 720, 230
|
||||
pl, pr, pt, pb = 50, 14, 16, 28
|
||||
rets = [r for _, r in series]
|
||||
vals = rets + [r for r in idx_series if r is not None] + [0]
|
||||
lo, hi = min(vals), max(vals)
|
||||
span = (hi - lo) or 1.0
|
||||
lo -= span * 0.15; hi += span * 0.15; span = hi - lo
|
||||
n = len(series)
|
||||
|
||||
def X(i): return pl + (w - pl - pr) * (i / max(n - 1, 1))
|
||||
def Y(v): return pt + (h - pt - pb) * (1 - (v - lo) / span)
|
||||
|
||||
color = '#22c55e' if rets[-1] >= 0 else '#ef4444'
|
||||
parts = [f'<svg viewBox="0 0 {w} {h}" style="width:100%;height:auto" xmlns="http://www.w3.org/2000/svg">']
|
||||
# 0% 기준선 + y축 라벨
|
||||
parts.append(f'<line x1="{pl}" y1="{Y(0):.1f}" x2="{w-pr}" y2="{Y(0):.1f}" stroke="#334155" stroke-dasharray="4 4"/>')
|
||||
for v in {round(hi - span * 0.15, 1), 0.0, round(lo + span * 0.15, 1)}:
|
||||
parts.append(f'<text x="{pl-6}" y="{Y(v)+4:.1f}" text-anchor="end" font-size="11" fill="#64748b">{v:+g}%</text>')
|
||||
# KOSPI 점선
|
||||
ipts = [(i, r) for i, r in enumerate(idx_series) if r is not None]
|
||||
if len(ipts) >= 2:
|
||||
d = ' '.join(f'{X(i):.1f},{Y(r):.1f}' for i, r in ipts)
|
||||
parts.append(f'<polyline points="{d}" fill="none" stroke="#64748b" stroke-width="1.5" stroke-dasharray="5 4"/>')
|
||||
# 전략 실선 + 점
|
||||
if n >= 2:
|
||||
d = ' '.join(f'{X(i):.1f},{Y(r):.1f}' for i, r in enumerate(rets))
|
||||
parts.append(f'<polyline points="{d}" fill="none" stroke="{color}" stroke-width="2.5"/>')
|
||||
for i, r in enumerate(rets):
|
||||
parts.append(f'<circle cx="{X(i):.1f}" cy="{Y(r):.1f}" r="3" fill="{color}"/>')
|
||||
parts.append(f'<text x="{X(n-1):.1f}" y="{Y(rets[-1])-8:.1f}" text-anchor="end" font-size="12" '
|
||||
f'font-weight="700" fill="{color}">{rets[-1]:+g}%</text>')
|
||||
# x축 날짜 (처음·끝, 5개 이상이면 중간도)
|
||||
ticks = sorted({0, n - 1} | ({n // 2} if n >= 5 else set()))
|
||||
for i in ticks:
|
||||
dt = series[i][0]
|
||||
parts.append(f'<text x="{X(i):.1f}" y="{h-8}" text-anchor="middle" font-size="11" '
|
||||
f'fill="#64748b">{dt[4:6]}/{dt[6:8]}</text>')
|
||||
parts.append('</svg>')
|
||||
legend = (f'<div class="sub" style="margin-top:2px"><span style="color:{color}">━ 이 전략</span>'
|
||||
+ (' · <span style="color:#64748b">┅ KOSPI(같은 구간)</span>' if len(ipts) >= 2 else '') + '</div>')
|
||||
return ''.join(parts) + legend
|
||||
|
||||
|
||||
def _strategy_page(vid: str) -> str:
|
||||
"""전략 1개 자세히보기 — 수익률 차트 + 분석. /strategy?id=vN"""
|
||||
from . import variants as vmod, benchmark
|
||||
defs = vmod.load_variants()
|
||||
v = next((x for x in defs if x['id'] == vid), None)
|
||||
back = '<a class="cmp-btlink" style="text-decoration:none" href="/?t=lab&s=cmp">← 비교로</a>'
|
||||
style = """<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#0b0f14;color:#e5e7eb;font-family:-apple-system,'Apple SD Gothic Neo',sans-serif;font-size:14px}
|
||||
#page{max-width:760px;margin:0 auto;padding:14px 14px 40px}
|
||||
h1{font-size:17px;margin:10px 0 2px}h2{font-size:14px;margin:22px 0 8px;color:#cbd5e1}
|
||||
.sub{color:#6b7280;font-size:12px;margin:2px 0}
|
||||
.card{background:#111824;border:1px solid #1f2937;border-radius:10px;padding:12px;margin:10px 0}
|
||||
.kv{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:13px;border-bottom:1px solid #10161e;font-variant-numeric:tabular-nums}
|
||||
.kv:last-child{border-bottom:none}.kv .k{color:#6b7280;font-size:12px;white-space:nowrap}
|
||||
.muted{color:#6b7280}.empty{color:#475569;padding:14px 0;text-align:center;font-size:13px}
|
||||
.vid{background:#1e293b;color:#7dd3fc;border-radius:5px;padding:1px 6px;font-size:11px;font-weight:700}
|
||||
.bic{border-radius:5px;padding:1px 5px;font-size:10px;font-weight:700;margin-left:4px}
|
||||
.bic0{background:#334155;color:#cbd5e1}.bic1{background:#1e3a5f;color:#7dd3fc}.bic2{background:#4a2e12;color:#fbbf24}
|
||||
.cmp-btlink{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;display:inline-block}
|
||||
.note{white-space:pre-wrap;line-height:1.65;font-size:13px;color:#cbd5e1}
|
||||
ul.an{margin:6px 0;padding-left:18px;line-height:1.7;font-size:13px}
|
||||
details.row{border-top:1px solid #1f2937;padding:6px 0}summary{cursor:pointer;font-size:13px}
|
||||
details.row summary{display:flex;gap:6px;align-items:center}
|
||||
details.row summary .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
details.row summary .rt{flex:0 0 auto;font-size:12px}
|
||||
details.row .body{padding:6px 2px 2px}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px}td,th{padding:4px 6px;border-bottom:1px solid #10161e;text-align:right}
|
||||
td:first-child,th:first-child{text-align:left}
|
||||
/* 판단근거 칩 (거래내역) — 본 대시보드와 동일 줄바꿈 레이아웃 */
|
||||
.chks{display:flex;flex-wrap:wrap;gap:5px;margin:4px 0 2px}
|
||||
.chk{background:#0f151c;border:1px solid #1f2937;border-radius:6px;padding:2px 7px;font-size:11px;display:inline-block}
|
||||
.chk .cd{color:#6b7280;margin-left:5px}
|
||||
.chk-i{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}
|
||||
.chk-paths{flex-basis:100%;margin-top:5px;border-top:1px dashed #283347;padding-top:6px}
|
||||
.path-cap{font-size:10px;color:#64748b;margin-bottom:5px;cursor:pointer}
|
||||
.path-line{display:flex;gap:8px;align-items:flex-start;padding:6px 0}
|
||||
.path-line + .path-line{border-top:1px dashed #1f2937}
|
||||
.path-line .path-h{flex:0 0 72px;font-size:11px;font-weight:700;padding-top:3px}
|
||||
.path-line .path-items{display:flex;flex-wrap:wrap;gap:5px;min-width:0}
|
||||
#chk-toast{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center}
|
||||
#chk-toast .ct-card{background:#141b24;border:1px solid #2952cc;border-radius:14px 14px 0 0;padding:16px 18px 22px;max-width:520px;width:100%;box-shadow:0 -4px 24px rgba(0,0,0,.5)}
|
||||
#chk-toast .ct-t{font-size:15px;font-weight:700;color:#fff;margin-bottom:8px}
|
||||
#chk-toast .ct-d{font-size:13px;line-height:1.6;color:#cbd5e1;white-space:pre-line;max-height:55vh;overflow-y:auto}
|
||||
#chk-toast .ct-x{margin-top:14px;width:100%;background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:10px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
</style>"""
|
||||
if not v:
|
||||
return (f'<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">'
|
||||
f'<meta name="viewport" content="width=device-width,initial-scale=1">{style}</head>'
|
||||
f'<body><div id="page">{back}<div class="empty">전략 {_esc(vid)}을 찾을 수 없어요 (삭제됐을 수 있음)</div></div></body></html>')
|
||||
params = v.get('params') or {}
|
||||
bm = _bear_mode(params)
|
||||
pp, tp = vmod.variant_paths(vid)
|
||||
pf = Portfolio.load(pp, tp)
|
||||
s = pf.summary()
|
||||
ret = s.get('total_return_pct', 0) or 0
|
||||
today = datetime.now(config.KST).strftime('%Y%m%d')
|
||||
bench = benchmark.compare(ret, benchmark.norm_date(pf.created_at), today)
|
||||
|
||||
# 순위·마킹·관심종목 (비교 스냅샷)
|
||||
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 = {c['id']: n + 1 for n, c in enumerate(ranked)}.get(vid)
|
||||
watching = next((c.get('watching', []) or [] for c in cv if c.get('id') == vid), [])
|
||||
icon = {vv: ic for ic, vv in _load_favs().items()}.get(vid, '')
|
||||
|
||||
# 모의평가(백테스트 등록 근거) — 비교탭과 동일 매칭(swept만)
|
||||
btres = _load(config.STATE_DIR / 'backtest_results.json', None) or {}
|
||||
swept = btres.get('swept_params') or []
|
||||
key = _pkey({k: params[k] for k in swept if k in params} if swept else params)
|
||||
bt, _rk, _seen = None, 0, set()
|
||||
for r in btres.get('results', []):
|
||||
kk = _pkey(r['params'])
|
||||
if kk in _seen:
|
||||
continue
|
||||
_seen.add(kk); _rk += 1
|
||||
if kk == key:
|
||||
bt = {**r, 'rank': _rk}
|
||||
break
|
||||
|
||||
# 수익률 이력 (id+파라미터해시 매칭 — id 재사용 오염 방지) + KOSPI 같은 구간
|
||||
ph = hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest()[:8]
|
||||
eh = _load(config.STATE_DIR / 'equity_history.json', {}) or {}
|
||||
series = [(d, e.get('ret', 0)) for d in sorted(eh) for e in eh[d]
|
||||
if e.get('id') == vid and e.get('ph') == ph]
|
||||
kospi = (_load(config.STATE_DIR / 'index_history.json', {}) or {}).get('KOSPI', {})
|
||||
idx_series = []
|
||||
base = kospi.get(series[0][0]) if series else None
|
||||
for d, _ in series:
|
||||
c = kospi.get(d)
|
||||
idx_series.append(round((c / base - 1) * 100, 2) if (c and base) else None)
|
||||
|
||||
# 종합 코멘트 (결정론 — LLM 미경유)
|
||||
notes = []
|
||||
rk_s = f'#{rank}위/{len(ranked)}' if rank else '순위 산정 전'
|
||||
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> — '
|
||||
+ ('지수를 이기고 있어요' if ak > 0 else '아직 지수에 못 미쳐요' if ak < 0 else '지수와 동률'))
|
||||
nt, npos = s.get('closed_trades', 0) or 0, s.get('open_positions', 0) or 0
|
||||
if not nt and not npos:
|
||||
notes.append('아직 거래가 없어요 — 진입 조건 충족을 기다리는 중')
|
||||
else:
|
||||
if npos:
|
||||
notes.append(f'현재 {npos}종목 보유 중 (주식 {s.get("held_value", 0):,}원)')
|
||||
if nt:
|
||||
notes.append(f'청산 {nt}건 · 승률 {s.get("win_rate_pct")}%'
|
||||
+ (' — 표본이 적어 아직 참고만' if nt < 5 else ''))
|
||||
if bt:
|
||||
tr_, te_ = bt['train'].get('total_return_pct', 0), bt['test'].get('total_return_pct', 0)
|
||||
if (tr_ - te_) > 40:
|
||||
notes.append('⚠ 백테스트 학습-검증 격차가 커요 — 과최적 가능성, 라이브 성적을 더 지켜봐야')
|
||||
notes_html = '<ul class="an">' + ''.join(f'<li>{x}</li>' for x in notes) + '</ul>'
|
||||
|
||||
# 모의평가 카드
|
||||
if bt:
|
||||
wins = ' / '.join((f"{w['ret']:+g}%" if w.get('ret') is not None else '—') for w in bt.get('windows', []))
|
||||
worst = bt.get('worst_ret')
|
||||
bt_html = (
|
||||
_kvline('전체 순위', f'#{bt["rank"]}위 (고유 조합 기준)')
|
||||
+ _kvline('검증 수익 / 학습 수익', f'{bt["test"].get("total_return_pct"):+g}% / {bt["train"].get("total_return_pct"):+g}%')
|
||||
+ _kvline('3분할 구간 수익', wins or '—')
|
||||
+ _kvline('최악 구간', f'{worst:+g}%' if worst is not None else '—')
|
||||
+ _kvline('검증 MDD / 승률 / PF',
|
||||
f'{bt["test"].get("mdd_pct")}% · {bt["test"].get("win_rate_pct")}% · {bt["test"].get("profit_factor")}')
|
||||
+ '<div class="sub">과거 데이터 모의평가 — 다음날 시가 체결·슬리피지 반영, 절대수익이 아닌 상대 비교용</div>')
|
||||
else:
|
||||
bt_html = '<div class="empty">백테스트 기록 없음 (등록 이후 스윕 미실행)</div>'
|
||||
|
||||
# 파라미터 표
|
||||
prow = ''
|
||||
for k in sorted(params):
|
||||
hlp = _TUNE_HELP.get(k, {})
|
||||
prow += (f'<tr><td>{_esc(hlp.get("t", k))}<br><span class="muted">{_esc(k)}</span></td>'
|
||||
f'<td>{params[k]:g}</td></tr>')
|
||||
mf_t, mf_b = _market_fit(params)
|
||||
watch_html = ''.join(
|
||||
f'<div class="kv"><span class="k">{_esc(it.get("name", ""))}</span>'
|
||||
f'<span class="muted">{_esc(it.get("reason", ""))[:34]}</span></div>'
|
||||
for it in watching) or '<div class="empty">노리는 종목 없음</div>'
|
||||
rc = '#22c55e' if ret >= 0 else '#ef4444'
|
||||
alpha_html = _alpha_inline(bench)
|
||||
|
||||
return f"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{_esc(vid)} 전략 상세</title>{style}</head><body><div id="page">
|
||||
{back}
|
||||
<h1><span class="vid">{rank or "—"}위</span> <span class="muted">{_vid_disp(vid)}</span> {_esc(_market_fit_words(params))}
|
||||
<span class="bic bic{bm}">{_BEAR_WORD[bm]}</span> {icon}</h1>
|
||||
<div class="sub">{_esc(_market_fit_summary(params))} · 약세장대처: {_BEAR_NAME[bm]}</div>
|
||||
|
||||
<div class="card">{notes_html}</div>
|
||||
|
||||
<h2>📈 수익률 추이</h2>
|
||||
<div class="card">{_ret_chart_svg(series, idx_series)}</div>
|
||||
|
||||
<h2>현재 성적</h2>
|
||||
<div class="card">
|
||||
{_kvline('평가자산', f'{s.get("equity", 0):,}원')}
|
||||
{_kvline('수익률', f'<b style="color:{rc}">{ret:+.2f}%</b>')}
|
||||
{_kvline('예수금 / 주식', f'{s.get("cash", 0):,} / {s.get("held_value", 0):,}')}
|
||||
{_kvline('실현손익', f'{s.get("realized_pnl", 0):,}원')}
|
||||
{_kvline('거래 / 승률', f'{s.get("closed_trades", 0)}건 · {s.get("win_rate_pct")}%')}
|
||||
{_kvline('MDD / 보유', f'{s.get("max_drawdown_pct", 0)}% · {s.get("open_positions", 0)}종목')}
|
||||
{_kvline('지수대비(알파)', alpha_html)}
|
||||
</div>
|
||||
|
||||
<h2>모의평가 (등록 근거)</h2>
|
||||
<div class="card">{bt_html}</div>
|
||||
|
||||
<h2>전략 성격 — 어떤 시장에 강한가</h2>
|
||||
<div class="card"><div class="note">{_esc(mf_b)}</div></div>
|
||||
|
||||
<h2>약세장대처 — {_BEAR_NAME[bm]}</h2>
|
||||
<div class="card"><div class="note">{_esc(_BEAR_DESC[bm])}</div></div>
|
||||
|
||||
<h2>파라미터 ({len(params)}개)</h2>
|
||||
<div class="card"><table><tr><th>항목</th><th>값</th></tr>{prow}</table></div>
|
||||
|
||||
<h2>보유·관심·거래</h2>
|
||||
<div class="card">
|
||||
<details class="row"><summary>📦 보유종목 ({s.get("open_positions", 0)})</summary>{_pos_table(pf)}</details>
|
||||
<details class="row"><summary>🎯 관심종목 ({len(watching)})</summary>{watch_html}</details>
|
||||
<details class="row"><summary>📋 거래내역 ({_trade_count(tp)}건 · {s.get("closed_trades", 0)}청산)</summary>{_trades_table(_read_trades(tp))}</details>
|
||||
</div>
|
||||
<div class="sub" style="margin-top:18px">수익률 이력은 매 거래일 EOD 기준 1점씩 누적 · 분석은 규칙 기반(LLM 미경유)</div>
|
||||
</div>
|
||||
{_CHK_HELP_JS}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _engine_status(snap, asset_line) -> tuple[str, str]:
|
||||
"""스캔 엔진 헬스 → (점 클래스 ok/idle/bad, 토스트 상세). 장중 최근스캔=초록, 장중 멈춤=빨강, 장외=대기."""
|
||||
now = datetime.now(config.KST)
|
||||
@@ -1157,6 +1416,14 @@ def _pos_table(pf) -> str:
|
||||
return rows or '<div class="empty">보유 종목 없음</div>'
|
||||
|
||||
|
||||
def _trade_count(path) -> int:
|
||||
"""거래로그(jsonl) 총 건수 (매수+매도)."""
|
||||
try:
|
||||
return sum(1 for ln in path.read_text().splitlines() if ln.strip())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _read_trades(path, limit=30) -> list:
|
||||
"""거래로그(jsonl) 최근 limit건, 최신순."""
|
||||
out = []
|
||||
@@ -1468,6 +1735,7 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
||||
.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}}
|
||||
#judge-view{{background:#141b24;border:1px solid #2a3142;color:#e5e7eb;border-radius:6px;padding:3px 6px;font-size:10px;max-width:88%}}
|
||||
.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)}}
|
||||
@@ -1594,9 +1862,8 @@ input[name="tab"],input[name="subtab"]{{display:none}}
|
||||
<h2>시장 동향</h2>{market_html}
|
||||
</section>
|
||||
<section class="panel" id="p-lab">
|
||||
<nav class="subtabs"><label for="sub-cmp">비교</label><label for="sub-bt">백테스트</label></nav>
|
||||
<div class="subpanel" id="ps-cmp">{cmp_html}</div>
|
||||
<div class="subpanel" id="ps-bt"><h2>백테스트 순위표</h2>{bt_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>
|
||||
</section>
|
||||
<div class=sub style="margin-top:20px">60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)</div>
|
||||
</div>
|
||||
@@ -1625,6 +1892,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if path == '/strategy':
|
||||
vid = (urllib.parse.parse_qs(parsed.query).get('id') or [''])[0]
|
||||
body = _strategy_page(vid).encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if path not in ('/', '/index.html'):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
@@ -1633,6 +1910,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = render_html(view=_view).encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
# 캐시 금지 — Safari 휴리스틱 캐싱이 옛 HTML/JS를 재사용해 탭 복원 로직이 구버전으로 돌던 문제 방지
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
Reference in New Issue
Block a user