69ef9c09e8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1004 lines
54 KiB
Python
1004 lines
54 KiB
Python
"""시뮬 대시보드 — 별도 읽기전용 웹서버 (포트 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{'&':'&','<':'<','>':'>','"':'"'}[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()
|