Files
openclaw/agents/stock/workspace/sim/sim_web.py
T
hyowons 69ef9c09e8 auto: 일일 백업 2026-06-09 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 02:00:01 +09:00

1004 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""시뮬 대시보드 — 별도 읽기전용 웹서버 (포트 18792).
sim state 파일(last_scan.json·portfolio.json·trades.jsonl)만 읽어 렌더. 키움 호출 없음.
외부 노출은 NAS reverse proxy(sim.hyowons.net → mac:18792) 경유. Tailnet 내부망 한정, 인증 없음.
python3 -m sim.sim_web serve # launchd 상시
python3 -m sim.sim_web render # 1회 HTML stdout (디버깅)
"""
from __future__ import annotations
import html
import json
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import config
from .portfolio import Portfolio
BIND_HOST = ''
BIND_PORT = 18792
STATE_BADGE = {
'SELL': ('매도', '#ef4444'), 'ADD': ('추격매수', '#16a34a'), 'BUY': ('매수', '#22c55e'),
'HOLD': ('보유', '#3b82f6'), 'WAIT': ('대기', '#eab308'), 'SKIP': ('제외', '#6b7280'),
'NODATA': ('데이터없음', '#374151'),
}
STATE_ORDER = ['HOLD', 'ADD', 'BUY', 'SELL', 'WAIT', 'SKIP', 'NODATA']
ORIGIN_LABEL = {'watch': '워치', 'interest': '관심', 'held': '보유', 'manual': '수동'}
# 모바일 당겨서 새로고침 (behive_web 방식 차용 — 별도 상수로 템플릿 f-string 중괄호 충돌 회피)
# #page를 손가락 따라 translateY, #ptr 원형(화살표→스피너), resistance 0.55·임계 70px. 커밋 시 reload.
PTR_HTML = """
<script>(function(){
var c=document.getElementById('page'),p=document.getElementById('ptr');
if(!c||!p)return;
var arrow=p.querySelector('.arrow'),sy=0,dy=0,pull=false,ok=false,T=70,MAX=110;
function reset(){c.classList.remove('dragging');c.style.transform='';p.style.opacity='';if(arrow)arrow.style.transform='';p.classList.remove('committed','spinning');}
document.addEventListener('touchstart',function(e){
if(window.scrollY<=0&&e.touches.length===1){sy=e.touches[0].clientY;dy=0;pull=true;ok=false;c.classList.add('dragging');}
},{passive:true});
document.addEventListener('touchmove',function(e){
if(!pull)return;dy=e.touches[0].clientY-sy;
if(dy<=0){reset();return;}
var d=dy*0.55;if(d>MAX)d=MAX;
c.style.transform='translateY('+d+'px)';
p.style.opacity=Math.min(d/25,1);
if(arrow)arrow.style.transform='rotate('+(Math.min(d/T,1)*180)+'deg)';
if(d>T){if(!ok){ok=true;p.classList.add('committed');}}
else if(ok){ok=false;p.classList.remove('committed');}
},{passive:true});
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();}
else{reset();}
});
})();</script>
"""
# 탭 복원(?t=) + 저장 배너(?saved=1) + 자동새로고침(튜닝 탭/입력 중엔 멈춤, 현재 탭 유지)
REFRESH_JS = """
<script>(function(){
var TABS=['acct','watch','market','lab'],SUBS=['tune','bt','cmp'];
var p=new URLSearchParams(location.search);
var t=p.get('t'),s=p.get('s');
if(SUBS.indexOf(t)>=0){s=t;t='lab';} /* 레거시 ?t=bt/cmp/tune 하위호환 */
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;}
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);}
if(p.get('saved')) banner('저장됨 — 다음 스캔부터 적용');
if(p.get('wadd')) banner('추가됨: '+p.get('wadd')+' — 다음 스캔부터 평가');
if(p.get('wdup')) banner('이미 목록에 있어요: '+p.get('wdup'),'#eab308');
if(p.get('werr')) banner('추가 실패: "'+p.get('werr')+'" 종목을 못 찾았어요','#ef4444');
if(p.get('wrem')) banner('삭제됨: '+p.get('wrem'),'#6b7280');
setInterval(function(){
if(document.getElementById('tab-lab').checked&&document.getElementById('sub-tune').checked) return;
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;});
location.href='/?t='+(cur||'acct')+(cur==='lab'?'&s='+(sub||'tune'):'');
},60000);
})();</script>
"""
# 관심종목 탭 종목 검색 — 입력(디바운스)·검색 버튼·Enter로 /api/stock_search 후보 칩. 칩 클릭=폼 POST로 추가.
_WATCH_SEARCH_JS = """
<script>(function(){
var inp=document.getElementById('wq'),btn=document.getElementById('wq-btn'),box=document.getElementById('wq-results');
if(!inp||!box)return;
var tmr;
function esc(s){return (''+(s||'')).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function run(){
var q=inp.value.trim();
if(!q){box.classList.add('hidden');box.innerHTML='';return;}
fetch('/api/stock_search?q='+encodeURIComponent(q)).then(function(r){return r.json();}).then(function(d){
var res=(d&&d.results)||[];
box.classList.remove('hidden');
if(!res.length){box.innerHTML='<div class="wsearch-empty">일치하는 종목이 없어요</div>';return;}
box.innerHTML=res.map(function(it){
return '<form method="post" action="/watch_add" class="wsearch-hit-f">'+
'<input type="hidden" name="q" value="'+esc(it.code)+'">'+
'<button type="submit" class="wsearch-hit">'+esc(it.name||it.code)+
' <span class="muted">'+esc(it.code)+'</span></button></form>';
}).join('');
}).catch(function(){box.classList.add('hidden');});
}
btn.addEventListener('click',run);
inp.addEventListener('input',function(){clearTimeout(tmr);tmr=setTimeout(run,300);});
inp.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();clearTimeout(tmr);run();}});
})();</script>
"""
# 백테스트 순위표 클라이언트 정렬 — 행의 data-* 수치로 재배치. 상태는 sessionStorage 유지.
# 기본 방향: best-first (검증·학습·승률·손익비 내림차순 / MDD 오름차순). 같은 항목 재클릭 시 토글.
_BT_SORT_JS = """
<script>(function(){
var bar=document.getElementById('bt-sortbar'),box=document.getElementById('bt-rows');
if(!bar||!box)return;
var st;try{st=JSON.parse(sessionStorage.getItem('bt_sort'))}catch(e){}
if(!st||!st.key)st={key:'te',asc:false};
function apply(){
var rows=[].slice.call(box.querySelectorAll('.row'));
rows.sort(function(a,b){
var va=parseFloat(a.getAttribute('data-'+st.key)),vb=parseFloat(b.getAttribute('data-'+st.key));
return st.asc?va-vb:vb-va;
});
rows.forEach(function(r){box.appendChild(r);});
[].forEach.call(bar.children,function(btn){
var on=btn.getAttribute('data-sort')===st.key;
btn.classList.toggle('active',on);
var ar=btn.querySelector('.ar');if(ar)ar.textContent=on?(st.asc?'':''):'';
});
}
[].forEach.call(bar.children,function(btn){
btn.addEventListener('click',function(){
var k=btn.getAttribute('data-sort');
if(st.key===k)st.asc=!st.asc;else{st.key=k;st.asc=(k==='mdd');}
try{sessionStorage.setItem('bt_sort',JSON.stringify(st));}catch(e){}
apply();
});
});
apply();
})();</script>
"""
def _load(path, default):
try:
return json.loads(path.read_text())
except Exception:
return default
# ---- 스윕(백테스트 재계산) 백그라운드 트리거 ----
# sweep은 ~80초라 동기 응답 불가 → subprocess 백그라운드 + 진행상태 파일로 표시.
SWEEP_STATUS = config.STATE_DIR / 'sweep_status.json'
_WORKSPACE = config.STATE_DIR.parent.parent # `-m sim` 실행 cwd
def _sweep_status() -> dict:
return _load(SWEEP_STATUS, {})
def _write_sweep_status(d: dict):
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
SWEEP_STATUS.write_text(json.dumps(d, ensure_ascii=False))
def _trigger_sweep():
"""실행 중이 아니면 백그라운드로 `python3 -m sim sweep`. 진행상태를 파일에 남김."""
st = _sweep_status()
if st.get('state') == 'running':
try: # 5분 넘게 running이면 stale로 보고 재시작 허용
started = datetime.fromisoformat(st.get('started', ''))
if (datetime.now(config.KST) - started).total_seconds() < 300:
return
except Exception:
pass
_write_sweep_status({'state': 'running', 'started': datetime.now(config.KST).isoformat()})
def _run():
try:
p = subprocess.Popen([sys.executable, '-m', 'sim', 'sweep'], cwd=str(_WORKSPACE),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
_, err = p.communicate()
if p.returncode == 0:
_write_sweep_status({'state': 'done', 'finished': datetime.now(config.KST).isoformat()})
else:
_write_sweep_status({'state': 'error', 'finished': datetime.now(config.KST).isoformat(),
'msg': (err or b'').decode('utf-8', 'ignore')[-300:]})
except Exception as e:
_write_sweep_status({'state': 'error', 'finished': datetime.now(config.KST).isoformat(),
'msg': str(e)})
threading.Thread(target=_run, daemon=True).start()
def _check_mark(ok):
return {True: '', False: '', None: '·'}.get(ok, '·')
def _check_color(ok):
return {True: '#22c55e', False: '#ef4444', None: '#9ca3af'}.get(ok, '#9ca3af')
def _esc(v):
return html.escape(str(v if v is not None else ''))
def _num(s):
"""폼 문자열 → 정수면 int, 아니면 float (변이 params 타입 보존)."""
f = float(s)
return int(f) if f == int(f) else f
def _pkey(params) -> str:
"""params 비교 키 — 값을 float로 정규화 (int 3 vs float 3.0 매칭 일치)."""
return json.dumps({k: float(v) for k, v in (params or {}).items()}, sort_keys=True)
_PSHORT = {'SMA_LONG': 'SMA', 'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'PULLBACK_ATR_MULT': 'P',
'VOLUME_BREAKOUT_MULT': 'V', 'TURNOVER_BREAKOUT_MULT': 'TB', 'TURNOVER_OVERHEAT_MULT': 'OH'}
def _plabel(params) -> str:
return '·'.join(f'{_PSHORT.get(k, k)}{v:g}' for k, v in (params or {}).items())
def _chips(checks) -> str:
"""신호 체크리스트 → 칩 HTML (판단근거 표시 공용)."""
return ''.join(
f'<span class="chk" style="color:{_check_color(c.get("ok"))}">'
f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}<span class="cd">{_esc(c.get("detail"))}</span></span>'
for c in (checks or [])
)
def _kpi(label, value, sub=''):
sub_h = f'<div class="kpi-sub">{_esc(sub)}</div>' if sub else ''
return f'<div class="kpi"><div class="kpi-label">{_esc(label)}</div><div class="kpi-val">{_esc(value)}</div>{sub_h}</div>'
def _signed(v, suffix=''):
c = '#22c55e' if (v or 0) > 0 else '#ef4444' if (v or 0) < 0 else '#9ca3af'
return f'<span style="color:{c}">{(v or 0):+,}{suffix}</span>'
_idx_cache = {'data': {}, 'ts': 0.0}
_IDX_TTL = 60.0
def _fetch_naver_index(code: str) -> dict | None:
"""네이버 지수 1개 (코스피/코스닥) closePrice·등락. 실패 시 None."""
try:
url = f'https://m.stock.naver.com/api/index/{code}/basic'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
data = json.loads(resp.read().decode('utf-8', 'ignore'))
price = str(data.get('closePrice') or '').replace(',', '')
if not price:
return None
return {'price': float(price),
'change': float(str(data.get('compareToPreviousClosePrice') or '0').replace(',', '')),
'pct': float(str(data.get('fluctuationsRatio') or '0').replace(',', ''))}
except Exception as e:
sys.stderr.write(f'[sim_web] index {code} fetch 실패: {e}\n')
return None
def _get_indices() -> dict:
"""KOSPI·KOSDAQ 지수값 (in-process 60s 캐시 — sim_web은 상시 데몬)."""
now = time.time()
if _idx_cache['data'] and now - _idx_cache['ts'] < _IDX_TTL:
return _idx_cache['data']
out = {}
for code in ('KOSPI', 'KOSDAQ'):
r = _fetch_naver_index(code)
if r:
out[code] = r
if out:
_idx_cache['data'] = out
_idx_cache['ts'] = now
return out
def _market_panel() -> str:
"""시장 탭 — market_indicators_history.jsonl에서 시장별 ADR·상승하락·투자자 순매수."""
path = config.MARKET_HISTORY
if not path.exists():
return '<div class="empty">시장 데이터 없음</div>'
by_market: dict[str, list] = {}
try:
for line in path.read_text().splitlines():
if line.strip():
r = json.loads(line)
by_market.setdefault(r.get('market', ''), []).append(r)
except Exception:
return '<div class="empty">시장 데이터 읽기 실패</div>'
idx = _get_indices()
order = ['KOSPI', 'KOSDAQ']
keys = [k for k in order if k in by_market] + [k for k in by_market if k not in order]
out = ''
for k in keys:
recs = by_market[k]
r = recs[-1]
ix = idx.get(k)
if ix:
ic = '#22c55e' if ix['change'] > 0 else '#ef4444' if ix['change'] < 0 else '#9ca3af'
idx_row = (f'<div class="kv"><span class="k">지수</span>'
f'<span><b>{ix["price"]:,.2f}</b> '
f'<span style="color:{ic}">{ix["change"]:+,.2f} ({ix["pct"]:+.2f}%)</span></span></div>')
else:
idx_row = ''
label = _esc(r.get('market_label') or k)
rise, fall, steady = r.get('rise', 0), r.get('fall', 0), r.get('steady', 0)
total = rise + fall + steady
breadth = (rise / total * 100) if total else 0
ok = total and (rise / total) >= config.MARKET_BREADTH_MIN
vlabel, vcolor = ('양호', '#22c55e') if ok else ('약세', '#ef4444')
adr = r.get('adr')
adr_s = f'{adr:.1f}' if isinstance(adr, (int, float)) else ''
recent = [rr.get('adr') for rr in recs[-5:] if isinstance(rr.get('adr'), (int, float))]
recent_s = ''.join(f'{a:.0f}' for a in recent) if recent else ''
out += (
f'<div class="card"><div class="card-h"><b>{label}</b> '
f'<span class="muted">{_esc(r.get("date",""))}</span>'
f'<span class="rt"><span class="badge" style="background:{vcolor}">{vlabel}</span></span></div>'
f'{idx_row}'
f'<div class="kv"><span class="k">ADR</span><span>{adr_s}</span></div>'
f'<div class="kv"><span class="k">상승 / 하락</span><span><span style="color:#22c55e">{rise:,}</span> / <span style="color:#ef4444">{fall:,}</span> (보합 {steady:,})</span></div>'
f'<div class="kv"><span class="k">상한 / 하한</span><span>{r.get("upper",0):,} / {r.get("lower",0):,}</span></div>'
f'<div class="kv"><span class="k">상승비율</span><span>{breadth:.1f}% · {vlabel} (기준 {config.MARKET_BREADTH_MIN*100:.0f}%)</span></div>'
f'<div class="kv"><span class="k">개인 순매수</span>{_signed(r.get("personal"))}</div>'
f'<div class="kv"><span class="k">외국인 순매수</span>{_signed(r.get("foreign"))}</div>'
f'<div class="kv"><span class="k">기관 순매수</span>{_signed(r.get("institutional"))}</div>'
f'<div class="kv"><span class="k">최근 ADR(5일)</span><span>{recent_s}</span></div>'
f'</div>'
)
note = ('<div class="sub" style="margin-top:6px">EOD(장 마감 후 수집) 데이터 · 신규 매수 필터에만 반영 '
'· 순매수 단위 억원</div>')
return out + note
def _tune_panel() -> str:
"""파라미터 튜닝 폼 — 그룹별 number 입력. 저장 시 params.json 반영(다음 스캔부터)."""
eff = config.load_params()
groups: dict[str, list] = {}
for spec in config.TUNABLE:
groups.setdefault(spec['group'], []).append(spec)
body = '<form method="post" action="/save_params">'
for gname, specs in groups.items():
body += f'<h3 class="grp">{_esc(gname)}</h3>'
for spec in specs:
k = spec['key']
body += (
f'<div class="prow"><label for="f_{k}">{_esc(spec["label"])}'
f'<span class="muted"> · 기본 {config._DEFAULTS[k]:g}</span></label>'
f'<input id="f_{k}" name="{k}" type="number" value="{eff[k]:g}" '
f'min="{spec["min"]}" max="{spec["max"]}" step="{spec.get("step",1)}" inputmode="decimal"></div>'
)
body += ('<div class="prow-actions"><button type="submit" class="btn-save">저장</button></div></form>'
'<form method="post" action="/save_params" '
'onsubmit="return confirm(\'모든 파라미터를 기본값으로 되돌릴까요?\')">'
'<input type="hidden" name="reset" value="1">'
'<button type="submit" class="btn-reset">기본값 복원</button></form>'
'<div class="sub" style="margin-top:10px">저장하면 다음 스캔(최대 15분 뒤)부터 적용 · '
'범위 벗어난 값은 자동 보정</div>')
return body
def _alpha_inline(bench: dict) -> str:
"""benchmark dict({KOSPI/KOSDAQ:{pct,alpha}}) → '코스피 +5.2%p · 코스닥 -1.1%p' 문자열."""
parts = []
for idx, lbl in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
a = (bench.get(idx) or {}).get('alpha')
if a is None:
continue
c = '#22c55e' if a >= 0 else '#ef4444'
parts.append(f'{lbl} <b style="color:{c}">{a:+g}%p</b>')
return ' · '.join(parts) if parts else '<span class="muted">지수 데이터 없음</span>'
def _bt_alpha_row(res: dict) -> str:
bench = res.get('benchmark') or {}
return (f'<div class="kv"><span class="k">지수대비(알파)</span>'
f'<span>{_alpha_inline(bench)}</span></div>')
def _backtest_panel() -> str:
"""백테스트 스윕 순위표 — 검증기간(out-of-sample) 성적순. 행마다 '이 튜닝 적용'."""
res = _load(config.STATE_DIR / 'backtest_results.json', None)
if not res or not res.get('results'):
return ('<div class="empty">스윕 미실행 — 터미널에서 <code>python3 -m sim sweep</code> 실행 후 표시됩니다.</div>')
sp = res.get('split', {})
head = (f'<div class="sub">학습 {sp.get("train","?")} → 검증 {sp.get("test","?")} · '
f'{res.get("count")}개 조합 · 수급반영 {res.get("flow_used")} · '
f'생성 {res.get("generated_at","")[:16].replace("T"," ")}</div>'
'<div class="sub" style="color:#b45309">⚠ 절대수익보다 <b>상대순위·검증기간 일관성</b>으로 판단 '
'(수급 중립·종가체결·현 워치리스트 기준)</div>')
st = _sweep_status()
if st.get('state') == 'running':
ctrl = ('<div class="sweep-bar"><span class="spin"></span>'
'스윕 계산 중… (약 1~2분 소요 · 끝나면 자동 갱신)</div>'
'<script>setTimeout(function(){location.href="/?t=lab&s=bt"},7000);</script>')
else:
err = ('<div class="sub" style="color:#ef4444">⚠ 직전 스윕 실패 — 로그 확인 필요</div>'
if st.get('state') == 'error' else '')
ctrl = (err + '<form method="post" action="/run_sweep" style="margin:0 0 10px">'
'<button type="submit" class="btn-sweep">↻ 다시 계산 (스윕 재실행 · 약 1~2분)</button></form>')
sortbar = (
'<div class="sortbar" id="bt-sortbar">'
'<button data-sort="te">검증수익<span class="ar"></span></button>'
'<button data-sort="tr">학습수익<span class="ar"></span></button>'
'<button data-sort="mdd">MDD<span class="ar"></span></button>'
'<button data-sort="wr">승률<span class="ar"></span></button>'
'<button data-sort="pf">손익비<span class="ar"></span></button>'
'</div>'
)
from . import variants as vmod
registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()}
main_p = config.load_params()
main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p})
rows = ''
for i, r in enumerate(res['results'][:40]):
p, te, tr = r['params'], r['test'], r['train']
hidden = ''.join(f'<input type="hidden" name="{k}" value="{v:g}">' for k, v in p.items())
pk = _pkey(p)
vid = registered.get(pk)
is_main = pk == main_pkey
main_badge = ' <span class="tag">메인</span>' if is_main else ''
cmp_badge = (' <span class="tag" style="background:#0e2a4d;color:#bfdbfe">비교군</span>'
if vid else '')
if vid: # 비교군 등록됨 → 제거 (메인 중복이라도 정리 가능)
cmp_btn = (f'<form method="post" action="/variants_remove">'
f'<input type="hidden" name="id" value="{vid}">'
f'<input type="hidden" name="back" value="bt">'
f'<button type="submit" class="btn-mini btn-danger"> 비교군에서 제거</button></form>')
elif is_main: # 메인은 비교 탭에 항상 포함 → 추가 불가
cmp_btn = '<div class="bt-mainnote">메인 — 비교 탭에 항상 포함</div>'
else:
cmp_btn = (f'<form method="post" action="/variants_add">{hidden}'
f'<button type="submit" class="btn-addcmp">+ 비교군에 추가</button></form>')
teret = te.get('total_return_pct', 0)
trret = tr.get('total_return_pct', 0)
tc = '#22c55e' if teret >= 0 else '#ef4444'
overfit = (trret - teret) > 40 # 학습≫검증 → 과최적 의심
of_tag = ' <span class="tag" style="background:#7c2d12;color:#fed7aa">과최적?</span>' if overfit else ''
rank = f'<span class="muted">#{i+1}</span>'
# 정렬용 수치 (없는 값은 최하위로 — MDD는 낮을수록 좋아 큰값, 나머지는 작은값)
mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor')
d_te = teret if isinstance(teret, (int, float)) else -999
d_tr = trret if isinstance(trret, (int, float)) else -999
d_mdd = mdd if isinstance(mdd, (int, float)) else 9999
d_wr = wr if isinstance(wr, (int, float)) else -1
d_pf = 9e9 if pf == 'inf' else (pf if isinstance(pf, (int, float)) else -1)
rows += (
f'<details class="row" data-te="{d_te}" data-tr="{d_tr}" data-mdd="{d_mdd}" '
f'data-wr="{d_wr}" data-pf="{d_pf}"><summary>{rank} '
f'<span class="nm">{_esc(_plabel(p))}</span>{main_badge}{cmp_badge}'
f'<span class="rt"><b style="color:{tc}">검증 {teret:+g}%</b></span></summary>'
f'<div class="body">'
f'<div class="kv"><span class="k">검증 수익 / 학습 수익</span>'
f'<span><b style="color:{tc}">{teret:+g}%</b> / {trret:+g}%{of_tag}</span></div>'
f'{_bt_alpha_row(te)}'
f'<div class="kv"><span class="k">검증 MDD</span><span>{mdd}%</span></div>'
f'<div class="kv"><span class="k">검증 거래수 / 승률</span><span>{te.get("trades")}건 · {wr}%</span></div>'
f'<div class="kv"><span class="k">검증 손익비(PF)</span><span>{pf}</span></div>'
f'<div class="bt-actions">'
f'<form method="post" action="/save_params">{hidden}'
f'<button type="submit" class="btn-apply">이 튜닝 적용</button></form>'
f'{cmp_btn}'
f'</div></div></details>'
)
return head + ctrl + sortbar + f'<div id="bt-rows">{rows}</div>' + _BT_SORT_JS
def _compare_panel() -> str:
"""병렬 페이퍼 비교 — 메인 + 변이들의 실시간 누적 성적.
변이 정의(variants.json) + 각 계좌(portfolio.json)에서 직접 읽어 렌더 →
추가/초기화 버튼이 다음 스캔을 기다리지 않고 즉시 반영된다.
"""
from . import variants as vmod, benchmark
defs = vmod.load_variants()
today = datetime.now(config.KST).strftime('%Y%m%d')
def _row(rid, name, params, pf):
s = pf.summary()
return {'id': rid, 'name': name, 'params': params, 'summary': s,
'benchmark': benchmark.compare(s.get('total_return_pct'),
benchmark.norm_date(pf.created_at), today)}
# 메인 + 변이 계좌를 소스에서 직접 수집
rows_data = [_row('main', '메인 (현재 튜닝)', config.load_params(), Portfolio.load())]
for v in defs:
pp, tp = vmod.variant_paths(v['id'])
rows_data.append(_row(v['id'], v.get('name', v['id']), v.get('params') or {},
Portfolio.load(pp, tp)))
rows_data.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
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>')
rows = ''
for i, v in enumerate(rows_data):
s = v['summary']
ret = s.get('total_return_pct', 0) or 0
rc = '#22c55e' if ret >= 0 else '#ef4444'
is_main = v['id'] == 'main'
tag = ' <span class="tag">메인</span>' if is_main else ''
plabel = '·'.join(f'{k.split("_")[0]}{val:g}' for k, val in (v.get('params') or {}).items()) or '기본값'
actions = '' if is_main else (
f'<div class="cmp-row-actions">'
f'<form method="post" action="/variants_reset_one" '
f'onsubmit="return confirm(\'이 변이 누적 성적을 0으로 초기화할까요?\')">'
f'<input type="hidden" name="id" value="{_esc(v["id"])}">'
f'<button type="submit" class="btn-mini btn-warn">성적 초기화</button></form>'
f'<form method="post" action="/variants_remove" '
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>'
f'</div>'
)
rows += (
f'<details class="row"{" open" if i == 0 else ""}><summary>'
f'<span class="muted">#{i+1}</span> <span class="nm">{_esc(v["name"])}</span>{tag}'
f'<span class="rt"><b style="color:{rc}">{ret:+g}%</b></span></summary>'
f'<div class="body">'
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>'
f'<div class="kv"><span class="k">실현손익</span><span>{s.get("realized_pnl",0):,}원</span></div>'
f'<div class="kv"><span class="k">거래 / 승률</span><span>{s.get("closed_trades",0)}건 · {s.get("win_rate_pct")}%</span></div>'
f'<div class="kv"><span class="k">MDD / 보유</span><span>{s.get("max_drawdown_pct",0)}% · {s.get("open_positions",0)}종목</span></div>'
f'<div class="kv"><span class="k">지수대비(알파)</span><span>{_alpha_inline(v.get("benchmark") or {})}</span></div>'
f'<div class="kv"><span class="k">파라미터</span><span class="muted">{_esc(plabel)}</span></div>'
f'{actions}'
f'</div></details>'
)
return head + rows
def render_html() -> str:
snap = _load(config.LAST_SCAN_PATH, {})
pf = Portfolio.load() # 계좌 진실원 — 스캔 스냅샷 없어도 현재 상태 표시
trades = []
if config.TRADES_PATH.exists():
for line in config.TRADES_PATH.read_text().splitlines()[-30:]:
try:
trades.append(json.loads(line))
except Exception:
pass
trades.reverse()
s = pf.summary()
scanned = snap.get('scanned_at', '')[:19].replace('T', ' ')
ret = s.get('total_return_pct', 0) or 0
ret_color = '#22c55e' if ret >= 0 else '#ef4444'
# ---- KPI ----
kpis = (
_kpi('평가자산', f"{s.get('equity', 0):,}", f"초기 {s.get('initial', 0):,}") +
_kpi('총보유(주식)', f"{s.get('held_value', 0):,}") +
_kpi('예수금', f"{s.get('cash', 0):,}") +
f'<div class="kpi"><div class="kpi-label">수익률</div>'
f'<div class="kpi-val" style="color:{ret_color}">{ret:+.2f}%</div></div>' +
_kpi('실현손익', f"{s.get('realized_pnl', 0):,}") +
_kpi('승률', f"{s.get('win_rate_pct')}%" if s.get('win_rate_pct') is not None else '',
f"{s.get('closed_trades', 0)}건 청산") +
_kpi('MDD', f"{s.get('max_drawdown_pct', 0)}%") +
_kpi('보유', f"{s.get('open_positions', 0)}종목")
)
# 기준지수 대비(알파) — 시작일 대비 같은 기간 지수 매수후보유와 비교
bench = snap.get('benchmark', {}) or {}
for idx, lbl in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
b = (bench.get('indices') or {}).get(idx, {})
a, ip = b.get('alpha'), b.get('pct')
if a is None:
kpis += _kpi(f'{lbl} 대비', '', '지수 데이터 없음')
else:
ac = '#22c55e' if a >= 0 else '#ef4444'
kpis += (f'<div class="kpi"><div class="kpi-label">{lbl} 대비(알파)</div>'
f'<div class="kpi-val" style="color:{ac}">{a:+.2f}%p</div>'
f'<div class="kpi-sub">지수 {ip:+.2f}%</div></div>')
# ---- 보유 포지션 ----
pos_rows = ''
for p in pf.positions.values():
entry, qty = p['entry_price'], p['qty']
cur = p.get('cur_price', entry)
pnl = (cur / entry - 1) * 100
pnl_amt = int(round((cur - entry) * qty))
pc = '#22c55e' if pnl >= 0 else '#ef4444'
evalv = cur * 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 ''
entry_at = _esc((p.get('entry_at') or '')[:16].replace('T', ' '))
pos_rows += (
f'<details class="row"><summary>'
f'<span class="nm">{_esc(p["name"])}</span>'
f'<span class="rt">{cur:,} <b style="color:{pc}">{pnl:+.1f}%</b></span></summary>'
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>{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>'
f'<div class="kv"><span class="k">목표</span><span>{p["target"]:,}{trail}</span></div>'
f'<div class="kv"><span class="k">진입사유</span><span>{_esc(p.get("entry_reason",""))}</span></div>'
f'<div class="kv"><span class="k">진입시각</span><span>{entry_at}</span></div>'
f'</div></details>'
)
pos_table = pos_rows or '<div class="empty">보유 종목 없음</div>'
# ---- 판단표 (state 그룹) ----
decisions = snap.get('decisions', [])
by_state = {}
for d in decisions:
by_state.setdefault(d.get('state', 'NODATA'), []).append(d)
judge_html = ''
for st in STATE_ORDER:
items = by_state.get(st, [])
if not items:
continue
label, color = STATE_BADGE.get(st, (st, '#888'))
judge_html += f'<h3 class="grp"><span class="badge" style="background:{color}">{label}</span> {len(items)}</h3>'
for d in items:
chips = _chips(d.get('checks', []))
src = '/'.join(ORIGIN_LABEL.get(s, s) for s in d.get('sources', []))
price = d.get('price')
price_h = f'{price:,}' if isinstance(price, (int, float)) else ''
pnl_h = f' · <span class="muted">{d["pnl_pct"]:+.1f}%</span>' if d.get('pnl_pct') is not None else ''
del_btn = (f'<form method="post" action="/watch_remove" class="card-del" '
f'onsubmit="return confirm(\'관찰목록에서 삭제할까요? (보유 중이면 청산까지는 계속 추적)\')">'
f'<input type="hidden" name="code" value="{_esc(d.get("code"))}">'
f'<button type="submit" title="관찰목록에서 삭제">✕</button></form>')
judge_html += (
f'<div class="card"><div class="card-h">'
f'<b>{_esc(d.get("name"))}</b> <span class="muted">{_esc(d.get("code"))} · {_esc(src)}</span>'
f'<span class="price">{price_h}{pnl_h}</span>{del_btn}</div>'
f'<div class="reason">{_esc(d.get("reason",""))}</div>'
f'<div class="chks">{chips}</div></div>'
)
# 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시
from . import universe as umod
wl = umod._load_watchlist()
decided = {d.get('code') for d in decisions}
pending = [(c, e) for c, e in wl.items() if c not in decided]
if pending:
judge_html += '<h3 class="grp"><span class="badge" style="background:#6b7280">스캔 대기</span> {}</h3>'.format(len(pending))
for code, e in pending:
src = ORIGIN_LABEL.get(e.get('origin', ''), e.get('origin', ''))
del_btn = (f'<form method="post" action="/watch_remove" class="card-del" '
f'onsubmit="return confirm(\'관찰목록에서 삭제할까요?\')">'
f'<input type="hidden" name="code" value="{_esc(code)}">'
f'<button type="submit" title="관찰목록에서 삭제">✕</button></form>')
judge_html += (
f'<div class="card"><div class="card-h">'
f'<b>{_esc(e.get("name") or code)}</b> <span class="muted">{_esc(code)} · {_esc(src)}</span>'
f'<span class="price muted">다음 스캔 대기</span>{del_btn}</div></div>'
)
mkt = snap.get('market_ok', {})
mkt_h = ' · '.join(f'{k} {"양호" if v else "약세"}' for k, v in mkt.items()) or ''
market_html = _market_panel()
tune_html = _tune_panel()
bt_html = _backtest_panel()
cmp_html = _compare_panel()
# ---- 거래내역 ----
tr_rows = ''
for r in trades:
ts = _esc(r["ts"][5:16].replace('T', ' '))
if r['side'] == 'BUY':
st_line = (f'<div class="kv"><span class="k">손절/목표</span>'
f'<span>{r["stop"]:,} / {r["target"]:,}</span></div>') if r.get('stop') else ''
tr_rows += (
f'<details class="row"><summary>'
f'<span style="color:#22c55e;font-weight:600">매수</span> <span class="nm">{_esc(r["name"])}</span>'
f'<span class="rt muted">{ts}</span></summary>'
f'<div class="body">'
f'<div class="kv"><span class="k">체결</span><span>{r["qty"]}주 @ {r["price"]:,}원</span></div>'
f'<div class="kv"><span class="k">매수금액</span><span>{r.get("cost",0):,}원</span></div>'
f'<div class="kv"><span class="k">사유</span><span>{_esc(r.get("reason",""))}</span></div>'
f'{st_line}'
+ (f'<div class="kv"><span class="k">판단근거</span></div><div class="chks">{_chips(r.get("signals"))}</div>'
if r.get('signals') else '')
+ '</div></details>'
)
else:
pc = '#22c55e' if r.get('pnl', 0) >= 0 else '#ef4444'
tr_rows += (
f'<details class="row"><summary>'
f'<span style="color:#ef4444;font-weight:600">매도</span> <span class="nm">{_esc(r["name"])}</span>'
f'<span class="rt"><b style="color:{pc}">{r.get("pnl_pct",0):+.1f}%</b> <span class="muted">{ts}</span></span></summary>'
f'<div class="body">'
f'<div class="kv"><span class="k">체결</span><span>{r["qty"]}주 @ {r["price"]:,}원</span></div>'
f'<div class="kv"><span class="k">실현손익</span><span style="color:{pc}">{r.get("pnl",0):+,}원 ({r.get("pnl_pct",0):+.1f}%)</span></div>'
f'<div class="kv"><span class="k">평단</span><span>{r.get("entry_price",0):,}원</span></div>'
f'<div class="kv"><span class="k">사유</span><span>{_esc(r.get("reason",""))}</span></div>'
+ (f'<div class="kv"><span class="k">판단근거</span></div><div class="chks">{_chips(r.get("signals"))}</div>'
if r.get('signals') else '')
+ '</div></details>'
)
tr_table = tr_rows or '<div class="empty">거래 없음</div>'
return f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>레이 시뮬 — 자동매매 판단</title>
<style>
*{{box-sizing:border-box}}
html,body{{overscroll-behavior-y:none;touch-action:manipulation}}
body{{margin:0;background:#0b0f14;color:#e5e7eb;font:14px/1.5 -apple-system,'Apple SD Gothic Neo',sans-serif;padding:14px;max-width:980px;margin:0 auto}}
h1{{font-size:16px;margin:0 0 2px}} .sub{{color:#9ca3af;font-size:11px;margin:0 0 8px}}
.kpis{{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-bottom:18px}}
.kpi{{background:#141b24;border:1px solid #1f2937;border-radius:10px;padding:10px 12px}}
.kpi-label{{color:#9ca3af;font-size:11px}} .kpi-val{{font-size:18px;font-weight:700;margin-top:2px}}
.kpi-sub{{color:#6b7280;font-size:10px;margin-top:2px}}
h2{{font-size:15px;margin:22px 0 8px;border-bottom:1px solid #1f2937;padding-bottom:6px}}
h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
.badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}}
.row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}}
.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;align-items:center;gap:8px;font-size:13px}}
.row>summary::-webkit-details-marker{{display:none}}
.row>summary .nm{{font-weight:600}}
.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums}}
.row>summary::after{{content:'';color:#6b7280;font-size:16px;line-height:1;transition:transform .15s}}
.row[open]>summary::after{{transform:rotate(90deg)}}
.row .body{{padding:2px 12px 10px;border-top:1px solid #161e29}}
.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}}
.empty{{color:#6b7280;font-size:13px;padding:10px 2px}}
.muted{{color:#6b7280;font-size:12px}}
.tag{{background:#1e3a8a;color:#bfdbfe;border-radius:4px;padding:0 5px;font-size:10px;margin-left:4px}}
.card{{background:#141b24;border:1px solid #1f2937;border-radius:10px;padding:10px 12px;margin-bottom:8px}}
.card-h{{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}} .card-h .price{{margin-left:auto;font-weight:600}}
.reason{{color:#cbd5e1;font-size:12px;margin:4px 0 6px}}
.chks{{display:flex;flex-wrap:wrap;gap:5px}}
.chk{{background:#0f151c;border:1px solid #1f2937;border-radius:6px;padding:2px 7px;font-size:11px}}
.chk .cd{{color:#6b7280;margin-left:5px}}
input[name="tab"],input[name="subtab"]{{display:none}}
.hd{{position:sticky;top:0;z-index:30;background:#0b0f14;padding:6px 0;border-bottom:1px solid #161e29}}
#page.dragging .hd{{position:static}}
.tabs{{display:flex;gap:6px;margin:0}}
.tabs label{{flex:1;text-align:center;padding:9px 6px;border:1px solid #1f2937;border-radius:9px;color:#9ca3af;font-size:13px;font-weight:600;cursor:pointer;background:#141b24}}
#tab-acct:checked~.hd label[for="tab-acct"],#tab-watch:checked~.hd label[for="tab-watch"],#tab-market:checked~.hd label[for="tab-market"],#tab-lab:checked~.hd label[for="tab-lab"]{{background:#1e3a8a;color:#fff;border-color:#2952cc}}
.panel{{display:none}}
#tab-acct:checked~#p-acct,#tab-watch:checked~#p-watch,#tab-market:checked~#p-market,#tab-lab:checked~#p-lab{{display:block}}
.subtabs{{display:flex;gap:6px;margin:0 0 14px}}
.subtabs label{{flex:1;text-align:center;padding:8px 6px;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:12px;font-weight:600;cursor:pointer;background:#141b24}}
#sub-tune:checked~#p-lab label[for="sub-tune"],#sub-bt:checked~#p-lab label[for="sub-bt"],#sub-cmp:checked~#p-lab label[for="sub-cmp"]{{background:#15406e;color:#fff;border-color:#2952cc}}
#p-lab .subpanel{{display:none}}
#p-lab .subpanel h2{{margin-top:4px}}
.sortbar{{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}}
.sortbar button{{background:#141b24;border:1px solid #1f2937;border-radius:7px;color:#9ca3af;font-size:12px;font-weight:600;padding:7px 10px;cursor:pointer;font-variant-numeric:tabular-nums}}
.sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}}
.btn-sweep{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:8px;padding:10px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}}
.sweep-bar{{display:flex;align-items:center;gap:9px;background:#13233a;border:1px solid #2952cc;border-radius:8px;padding:11px 13px;margin:0 0 10px;font-size:13px;color:#cbd5e1}}
.spin{{width:14px;height:14px;border:2px solid #93c5fd;border-top-color:transparent;border-radius:50%;animation:ptr-spin .7s linear infinite;flex:none}}
.bt-actions{{display:flex;gap:8px;margin-top:8px}}
.bt-actions form{{flex:1;margin:0}}
.bt-actions button{{width:100%}}
.btn-addcmp{{background:#0e2a4d;color:#fff;border:1px solid #2952cc;border-radius:8px;padding:9px 10px;font-size:13px;font-weight:600;cursor:pointer}}
.bt-mainnote{{flex:1;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:12px;border:1px dashed #2a3142;border-radius:8px;padding:9px;text-align:center}}
.watch-add{{display:flex;gap:8px;margin:0 0 8px}}
.watch-add input{{flex:1;background:#0f151c;border:1px solid #2a3142;border-radius:8px;color:#e5e7eb;padding:9px 11px;font-size:14px;color-scheme:dark}}
.watch-add button{{flex:none;white-space:nowrap}}
.wsearch-results{{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px}}
.wsearch-results.hidden{{display:none}}
.wsearch-hit-f{{margin:0}}
.wsearch-hit{{background:#0f151c;border:1px solid #2a3142;color:#e5e7eb;border-radius:8px;padding:8px 11px;font-size:13px;cursor:pointer;font-variant-numeric:tabular-nums}}
.wsearch-hit:hover{{border-color:#2952cc;color:#bfdbfe}}
.wsearch-empty{{color:#6b7280;font-size:12px;padding:4px 2px}}
.card-del{{margin:0 0 0 8px}}
.card-del button{{background:transparent;border:1px solid #2a3142;color:#9ca3af;border-radius:6px;width:24px;height:24px;font-size:12px;line-height:1;cursor:pointer;padding:0}}
.card-del button:hover{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}}
.cmp-row-actions{{display:flex;gap:8px;margin-top:10px}}
.cmp-row-actions form{{flex:1;margin:0}}
.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-tune:checked~#p-lab #ps-tune,#sub-bt:checked~#p-lab #ps-bt,#sub-cmp:checked~#p-lab #ps-cmp{{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}}
.prow label{{flex:1;font-size:13px}}
.prow input{{width:100px;background:#0f151c;border:1px solid #2a3142;border-radius:7px;color:#e5e7eb;padding:8px;font-size:14px;text-align:right;font-variant-numeric:tabular-nums;color-scheme:dark}}
.prow-actions{{margin:16px 0 6px}}
.btn-save{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;width:100%}}
.btn-reset{{background:transparent;color:#9ca3af;border:1px solid #2a3142;border-radius:9px;padding:10px;font-size:13px;cursor:pointer;margin-top:10px;width:100%}}
.saved-banner{{position:fixed;top:8px;left:50%;transform:translateX(-50%);background:#22c55e;color:#06210f;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:700;z-index:70;box-shadow:0 4px 14px rgba(0,0,0,.4)}}
#page{{transition:transform .32s cubic-bezier(.2,.8,.2,1)}}
#page.dragging{{transition:none;will-change:transform}}
#ptr{{position:fixed;top:10px;left:50%;width:38px;height:38px;margin-left:-19px;background:rgba(28,34,48,.95);border:1px solid #2a3142;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#9ca3af;opacity:0;pointer-events:none;z-index:60;box-shadow:0 4px 14px rgba(0,0,0,.35);transition:color .18s,border-color .18s,background .18s}}
#ptr .arrow{{font-size:16px;font-weight:700;line-height:1}}
#ptr .spinner{{display:none;width:16px;height:16px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%}}
#ptr.committed{{color:#22c55e;border-color:#22c55e;background:rgba(34,197,94,.18)}}
#ptr.spinning .arrow{{display:none}}
#ptr.spinning .spinner{{display:block;animation:ptr-spin .7s linear infinite}}
@keyframes ptr-spin{{from{{transform:rotate(0)}}to{{transform:rotate(360deg)}}}}
@media(max-width:560px){{
body{{padding:10px}}
h1{{font-size:16px}}
.kpis{{grid-template-columns:repeat(auto-fit,minmax(98px,1fr));gap:6px}}
.kpi{{padding:8px 10px}} .kpi-val{{font-size:16px}}
.card-h{{font-size:13px}}
}}
</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-acct" checked>
<input type="radio" name="tab" id="tab-watch">
<input type="radio" name="tab" id="tab-market">
<input type="radio" name="tab" id="tab-lab">
<input type="radio" name="subtab" id="sub-tune" checked>
<input type="radio" name="subtab" id="sub-bt">
<input type="radio" name="subtab" id="sub-cmp">
<header class="hd">
<h1>🪎 레이 자동매매 시뮬레이션</h1>
<div class=sub>마지막 스캔 {scanned or ''} · 시장 {mkt_h}</div>
<nav class="tabs"><label for="tab-acct">내 계좌</label><label for="tab-watch">관심종목 ({len(decisions)})</label><label for="tab-market">시장</label><label for="tab-lab">전략</label></nav>
</header>
<section class="panel" id="p-acct">
<div class=kpis>{kpis}</div>
<h2>보유 포지션</h2>{pos_table}
<h2>최근 거래</h2>{tr_table}
</section>
<section class="panel" id="p-watch">
<h2>관찰 중 종목 ({len(decisions)})</h2>
<div class="watch-add">
<input type="text" id="wq" placeholder="종목명 또는 6자리 코드 검색" aria-label="종목 검색" autocomplete="off">
<button type="button" id="wq-btn" class="btn-addcmp">검색</button></div>
<div id="wq-results" class="wsearch-results hidden"></div>
<div class="sub">종목명 일부만 입력해도 후보가 떠요 · 후보를 누르면 추가 · 자산(비하이브 워치·관심·보유)은 자동 편입 · 삭제는 카드의 ✕ (수동만) · 보유 중이면 삭제해도 청산까지 추적</div>
{judge_html}
</section>
<section class="panel" id="p-market">
<h2>시장 동향</h2>{market_html}
</section>
<section class="panel" id="p-lab">
<nav class="subtabs"><label for="sub-tune">튜닝</label><label for="sub-bt">백테스트</label><label for="sub-cmp">비교</label></nav>
<div class="subpanel" id="ps-tune"><h2>파라미터 튜닝</h2>{tune_html}</div>
<div class="subpanel" id="ps-bt"><h2>백테스트 순위표</h2>{bt_html}</div>
<div class="subpanel" id="ps-cmp"><h2>병렬 페이퍼 비교</h2>{cmp_html}</div>
</section>
<div class=sub style="margin-top:20px">60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)</div>
</div>
{PTR_HTML}
{REFRESH_JS}
{_WATCH_SEARCH_JS}
</body></html>"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == '/api/stock_search':
from . import universe as umod
q = (urllib.parse.parse_qs(parsed.query).get('q') or [''])[0]
try:
results = umod.search_stocks(q)
except Exception:
results = []
body = json.dumps({'results': results}, ensure_ascii=False).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
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()
return
body = render_html().encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path == '/run_sweep':
_trigger_sweep()
self.send_response(303)
self.send_header('Location', '/?t=lab&s=bt')
self.end_headers()
return
if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove'):
self._handle_variants()
return
if self.path in ('/watch_add', '/watch_remove'):
self._handle_watch()
return
if self.path != '/save_params':
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get('Content-Length') or 0)
raw = self.rfile.read(length).decode('utf-8') if length else ''
form = {k: v[-1] for k, v in urllib.parse.parse_qs(raw).items()}
try:
if form.get('reset'):
config.reset_params()
else:
config.save_params(form)
except Exception as e:
sys.stderr.write(f'[sim_web] save_params 실패: {e}\n')
self.send_response(303)
self.send_header('Location', '/?t=lab&s=tune&saved=1')
self.end_headers()
def _handle_variants(self):
"""비교군 관리 — 추가(백테스트 행)·성적 초기화·삭제(비교 행). 파일 IO만이라 즉시 완료."""
from . import variants as vmod
length = int(self.headers.get('Content-Length') or 0)
raw = self.rfile.read(length).decode('utf-8') if length else ''
form = {k: v[-1] for k, v in urllib.parse.parse_qs(raw).items()}
# 어느 탭에서 눌렀는지 (back=bt → 백테스트 탭 유지, 없으면 비교 탭). add는 항상 bt.
back = form.get('back')
dest = f'/?t=lab&s={back}' if back in ('bt', 'cmp') else '/?t=lab&s=cmp'
try:
if self.path == '/variants_add':
keys = [spec['key'] for spec in config.TUNABLE]
params = {k: _num(form[k]) for k in keys if k in form}
main_p = config.load_params()
is_main = params and _pkey(params) == _pkey({k: main_p[k] for k in params if k in main_p})
if params and not is_main: # 메인과 동일 조합은 중복이라 추가 X
vmod.add_variant(params)
dest = '/?t=lab&s=bt' # 백테스트 탭에서 연속 추가 가능하도록 유지
elif self.path == '/variants_reset_one' and form.get('id'):
vmod.reset_variant(form['id'])
elif self.path == '/variants_remove' and form.get('id'):
vmod.remove_variant(form['id'])
except Exception as e:
sys.stderr.write(f'[sim_web] variants {self.path} 실패: {e}\n')
self.send_response(303)
self.send_header('Location', dest)
self.end_headers()
def _handle_watch(self):
"""관찰목록 관리 — 수동 추가(/watch_add)·삭제(/watch_remove). 결과를 쿼리로 전달해 배너 피드백."""
from . import universe as umod
length = int(self.headers.get('Content-Length') or 0)
raw = self.rfile.read(length).decode('utf-8') if length else ''
form = {k: v[-1] for k, v in urllib.parse.parse_qs(raw).items()}
dest = '/?t=watch'
try:
if self.path == '/watch_add' and (form.get('q') or '').strip():
q = form['q'].strip()
before = set(umod._load_watchlist())
res = umod.add_manual(q)
if res is None:
dest += '&werr=' + urllib.parse.quote(q) # 종목 못 찾음
elif res['code'] in before:
dest += '&wdup=' + urllib.parse.quote(res.get('name') or res['code']) # 이미 있음
else:
dest += '&wadd=' + urllib.parse.quote(res.get('name') or res['code']) # 추가됨
elif self.path == '/watch_remove' and form.get('code'):
code = form['code']
nm = umod._load_watchlist().get(code, {}).get('name') or code
umod.remove(code)
dest += '&wrem=' + urllib.parse.quote(nm)
except Exception as e:
sys.stderr.write(f'[sim_web] watch {self.path} 실패: {e}\n')
dest += '&werr=' + urllib.parse.quote('처리 오류')
self.send_response(303)
self.send_header('Location', dest)
self.end_headers()
def log_message(self, *a):
pass
def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else 'serve'
if cmd == 'render':
sys.stdout.write(render_html())
return
server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)
print(f'sim dashboard on http://{BIND_HOST or "0.0.0.0"}:{BIND_PORT} @ {datetime.now(config.KST).isoformat()}',
flush=True)
server.serve_forever()
if __name__ == '__main__':
main()