c749e66460
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2297 lines
136 KiB
Python
2297 lines
136 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 hashlib
|
||
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, engine
|
||
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.href=(window._simURL?window._simURL():location.href);}
|
||
else{reset();}
|
||
});
|
||
})();</script>
|
||
"""
|
||
|
||
# 탭 복원(?t=) + 저장 배너 + 자동새로고침(60s) — 펼침(details)·스크롤을 sessionStorage로 보존해
|
||
# 전체 리로드여도 보던 화면이 유지되게 한다 (자동·당겨서·수동 새로고침 공통).
|
||
REFRESH_JS = """
|
||
<script>(function(){
|
||
var TABS=['lab','watch','market'],SUBS=['bt','cmp','day'];
|
||
var p=new URLSearchParams(location.search);
|
||
var t=p.get('t'),s=p.get('s'),vw=p.get('view');
|
||
if(SUBS.indexOf(t)>=0){s=t;t='lab';} /* 레거시 ?t=bt/cmp 하위호환 */
|
||
if(t==='acct'||t==='tune')t='lab'; /* 삭제된 탭 하위호환 */
|
||
if(t){var r=document.getElementById('tab-'+t); if(r)r.checked=true;}
|
||
if(s){var rs=document.getElementById('sub-'+s); if(rs)rs.checked=true;}
|
||
/* 탭 라디오 ↔ URL 동기화 — 어떤 새로고침(당겨서/브라우저/reload)이든 보던 탭으로 돌아오게 */
|
||
function simURL(){
|
||
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
|
||
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
|
||
var u='/?t='+(cur||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'')+((cur==='watch'&&vw)?'&view='+encodeURIComponent(vw):'');
|
||
if(cur==='lab'&&sub==='day'){var dd=new URLSearchParams(location.search).get('d');
|
||
if(dd)u+='&d='+encodeURIComponent(dd);} /* 거래목록 선택 날짜 보존 */
|
||
return u;
|
||
}
|
||
window._simURL=simURL;
|
||
function syncUrl(){try{history.replaceState(null,'',simURL());}catch(e){}}
|
||
document.querySelectorAll('input[name="tab"],input[name="subtab"]').forEach(function(r2){r2.addEventListener('change',syncUrl);});
|
||
syncUrl();
|
||
function banner(msg,color){var b=document.createElement('div');b.className='saved-banner';
|
||
if(color)b.style.background=color;b.textContent=msg;document.body.appendChild(b);
|
||
setTimeout(function(){b.remove();},2800);}
|
||
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');
|
||
|
||
/* ---- 펼침·스크롤 보존: 새로고침해도 보던 화면 유지 ---- */
|
||
function keyOf(d){
|
||
var sec=d.closest('.subpanel,.panel'); var sm=d.querySelector(':scope>summary');
|
||
return (sec?sec.id:'?')+'|'+(sm?sm.textContent.trim().slice(0,80):'');
|
||
}
|
||
function saveState(){
|
||
var open=[];
|
||
document.querySelectorAll('details[open]').forEach(function(d){open.push(keyOf(d));});
|
||
try{sessionStorage.setItem('simOpen',JSON.stringify(open));
|
||
sessionStorage.setItem('simScroll',String(window.scrollY||0));}catch(e){}
|
||
}
|
||
(function restore(){
|
||
var open;try{open=JSON.parse(sessionStorage.getItem('simOpen')||'[]');}catch(e){open=[];}
|
||
if(open.length){
|
||
var set={};open.forEach(function(k){set[k]=1;});
|
||
document.querySelectorAll('details').forEach(function(d){ if(set[keyOf(d)])d.open=true; });
|
||
}
|
||
var y=parseFloat(sessionStorage.getItem('simScroll')||'0');
|
||
if(y>0)requestAnimationFrame(function(){window.scrollTo(0,y);});
|
||
})();
|
||
document.addEventListener('toggle',function(){saveState();},true);
|
||
window.addEventListener('pagehide',saveState);
|
||
|
||
setInterval(function(){
|
||
if(document.getElementById('chk-toast')) return; /* 설명 토스트 열려 있으면 새로고침 멈춤 */
|
||
var ae=document.activeElement;
|
||
if(ae&&(ae.tagName==='INPUT'||ae.tagName==='TEXTAREA')) return; /* 입력 중 멈춤 */
|
||
saveState();
|
||
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||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'')+((cur==='watch'&&vw)?'&view='+encodeURIComponent(vw):'');
|
||
},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)
|
||
|
||
|
||
def _vid_disp(vid) -> str:
|
||
"""고유번호 표시용 — 'v37' → '#37' (내부 id·URL은 raw 유지)."""
|
||
v = str(vid or '')
|
||
return '#' + v[1:] if v[:1] == 'v' and v[1:].isdigit() else v
|
||
|
||
|
||
_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',
|
||
'RSI_OVERBOUGHT': 'RSI↑', 'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD',
|
||
'RISK_PER_TRADE_PCT': 'RISK'}
|
||
|
||
|
||
def _plabel(params) -> str:
|
||
return '·'.join(f'{_PSHORT.get(k, k)}{v:g}' for k, v in (params or {}).items())
|
||
|
||
|
||
# 평가 항목 설명 — 관심종목/판단근거 칩의 ⓘ 버튼 클릭 시 토스트로 표시 (쉬운 말로)
|
||
_CHK_HELP = {
|
||
'추세': {'t': '추세 (20일선 위)', 'd': '현재가가 최근 20일 평균값(20일 이동평균선)보다 위에 있는지 봐요. 위에 있으면 최근 한 달 흐름이 상승 쪽이라는 뜻이에요. 자동매매는 떨어지는 칼을 잡지 않으려고, 추세가 위로 향한 종목만 매수 후보로 봅니다.'},
|
||
'정배열': {'t': '정배열 (5일선 > 20일선)', 'd': '단기 평균(5일선)이 장기 평균(20일선)보다 위에 있는 상태예요. 최근 5일 기세가 한 달 평균보다 강하다 = 상승에 가속이 붙은 모양이에요. 정배열이면 추세가 살아있다고 봅니다.'},
|
||
'수급': {'t': '수급 (외국인·기관 순매수)', 'd': '최근 5일 동안 외국인과 기관이 이 종목을 순매수(더 많이 사들임)했는지 봐요. 돈이 큰 큰손이 사면 주가가 잘 받쳐지는 경향이 있어요. 외국인·기관 둘 중 하나라도 순매수면 통과예요. (숫자 단위는 천 주)'},
|
||
'애널': {'t': '애널리스트 의견', 'd': '증권사 애널리스트들의 종합 의견이에요. 보통 5점 만점에 높을수록 매수 추천이에요. ETF처럼 분석 대상이 아닌 종목은 데이터가 없어서 그냥 통과 처리해요.'},
|
||
'투자의견': {'t': '투자의견 (5점 만점)', 'd': '증권사 애널리스트들의 평균 투자의견 점수예요. 5점에 가까울수록 "사라"는 의견이 많다는 뜻이고, 보통 4점 이상이면 전문가들이 긍정적으로 본다고 판단해요.'},
|
||
'상승여력': {'t': '상승여력 (목표가 대비)', 'd': '애널리스트 평균 목표주가가 지금 가격보다 몇 % 높은지예요. +20%면 전문가들이 20% 더 오를 여지를 본다는 의미예요. 이미 목표가 근처라 여력이 적으면 매력이 줄어요.'},
|
||
'목표가리비전': {'t': '목표가 리비전 (상향/하향)', 'd': '최근 애널리스트들이 목표주가를 올리는 중인지 내리는 중인지 봐요. "상향"이면 회사 전망이 좋아지는 방향이라 긍정 신호예요. "하향"이면 반대고요.'},
|
||
'어닝서프라이즈': {'t': '어닝 서프라이즈', 'd': '가장 최근 실적이 시장이 예상했던 것(컨센서스)보다 좋았는지(+) 나빴는지(−)예요. 예상보다 잘 나오면(서프라이즈 +) 주가에 긍정적으로 작용하는 경우가 많아요.'},
|
||
'시장강세': {'t': '시장 강세 (장 전체 분위기)', 'd': '그날 시장 전체 분위기(상승비율+지수추세+당일등락 조합)예요. 시장이 무너지는 날엔 좋은 종목도 같이 빠지기 쉬워서 참고하는데, 이 항목 하나로 매수가 막히는 건 아니에요 — 전략의 "약세장대처" 모드에 따라 갈려요. 회피=약세면 안 삼 · 안정(기본)=돌파만 삼 · 적극=눌림목도 삼. 그래서 약세(!)인데도 적극 전략은 정상적으로 매수할 수 있어요.'},
|
||
'과열': {'t': '과열 아님 (회전율)', 'd': '그날 거래 회전율(거래량을 상장주식 수로 나눈 값)이 평소 평균보다 지나치게(3배 이상) 높지 않은지 봐요. 너무 과열되면 단기 꼭대기일 위험이 커서, 그럴 땐 신규매수를 잠깐 보류해요.'},
|
||
'돌파': {'t': '돌파 (전고점)', 'd': '현재가가 최근 고점을 뚫고 올라갔는지예요. 전고점 돌파는 새로운 상승의 출발 신호로 봐요. 거래량·회전율 급증이 함께 나오면 "진짜 돌파"로 판단해 바로 매수합니다.'},
|
||
'거래량': {'t': '거래량 급증', 'd': '그날 거래량이 평소 평균보다 크게 많은지 봐요. 거래가 확 늘었다는 건 사람들의 관심이 몰렸다는 뜻이라, 돌파에 힘이 실렸는지 확인하는 신호예요.'},
|
||
'회전율': {'t': '회전율 급증', 'd': '회전율(거래량 ÷ 상장주식 수)이 평소보다 크게 늘었는지예요. 거래량 급증과 함께 보면 돌파의 신뢰도를 높여줘요. 데이터가 없으면 그냥 통과 처리해요.'},
|
||
'눌림목': {'t': '눌림목 도달', 'd': '강한 종목이 잠깐 쉬며 적정 매수가까지 내려왔는지 봐요. 적정가는 "20일선"과 "전일 종가 − 하루 변동폭(ATR)" 중 더 높은 값이에요. 비싸게 쫓아 사지 않고, 추세는 살아있되 잠깐 눌린 자리에서 싸게 담으려는 지정가 진입 자리예요.'},
|
||
'RSI': {'t': 'RSI (과매수·과매도)', 'd': '최근 오른 폭과 내린 폭을 견줘 0~100으로 나타낸 지표예요. 70~80 위면 단기 과열(너무 많이 올랐다), 20~30 아래면 과매도(너무 많이 빠졌다)로 봐요. 돌파 매수는 과열이면 추격을 피하고, 눌림목 매수는 과매도(아직 떨어지는 중)면 반등을 기다려요.'},
|
||
'손절선': {'t': '손절선', 'd': '이 가격 아래로 내려가면 손실을 확정하고 파는 기준선이에요. 진입가에서 변동폭(ATR)이나 최근 스윙 저점을 빼서 정해요. 트레일링이 켜지면 고점을 따라 위로 올라가며 번 수익을 지켜요.'},
|
||
}
|
||
|
||
|
||
def _chk_key(label) -> str:
|
||
"""체크 항목 라벨 → 설명 키 (라벨에 숫자·괄호가 섞여 있어 접두사로 매칭)."""
|
||
l = (label or '').strip()
|
||
for pre, key in (('추세', '추세'), ('정배열', '정배열'), ('수급', '수급'), ('애널', '애널'),
|
||
('투자의견', '투자의견'), ('상승여력', '상승여력'), ('목표가', '목표가리비전'),
|
||
('어닝', '어닝서프라이즈'), ('시장', '시장강세'), ('과열', '과열'),
|
||
('돌파', '돌파'), ('거래량', '거래량'), ('회전율', '회전율'), ('눌림목', '눌림목'),
|
||
('RSI', 'RSI'), ('손절', '손절선')):
|
||
if l.startswith(pre):
|
||
return key
|
||
return ''
|
||
|
||
|
||
def _chips(checks) -> str:
|
||
"""신호 체크리스트 → 칩 HTML (판단근거 표시 공용). 각 항목에 ⓘ 버튼.
|
||
|
||
진입경로 칩(돌파 3종 / 눌림목)은 'OR 그룹' 박스로 묶어 표시 — 둘 중 하나만 충족하면
|
||
매수라는 구조가 화면에서 보이게. 그룹 라벨의 ✓/✗는 그 경로 전체 충족 여부."""
|
||
checks = checks or []
|
||
_BRK = ('돌파(전고점)', '거래량 급증', '회전율 급증', '회전율')
|
||
_PUL = ('눌림목 도달',)
|
||
|
||
def chip(c):
|
||
k = _chk_key(c.get('label'))
|
||
info = f'<button type="button" class="chk-i" data-k="{k}" aria-label="설명">ⓘ</button>' if k else ''
|
||
ok = c.get('ok')
|
||
mark, color = _check_mark(ok), _check_color(ok)
|
||
# 시장 강세는 그 자체로 매수를 막는 필수조건이 아니라 약세장대처 모드로 분기되는
|
||
# 소프트 게이트 — 미충족이어도 빨간 ✗(위반 인상) 대신 노란 ! (참고)
|
||
if c.get('label') == '시장 강세' and ok is False:
|
||
mark, color = '!', '#eab308'
|
||
return (
|
||
f'<span class="chk" style="color:{color}">'
|
||
f'{mark} {_esc(c.get("label"))}'
|
||
f'<span class="cd">{_esc(c.get("detail"))}</span>{info}</span>'
|
||
)
|
||
|
||
base = [c for c in checks if c.get('label') not in _BRK + _PUL]
|
||
brk = [c for c in checks if c.get('label') in _BRK]
|
||
pul = [c for c in checks if c.get('label') in _PUL]
|
||
out = ''.join(chip(c) for c in base)
|
||
if brk or pul:
|
||
hint = ('매수 진입은 두 경로 중 하나만 충족하면 돼요.\n'
|
||
'① 돌파: 전고점 돌파 + 거래량·회전율 급증이 모두 충족되면 즉시 매수\n'
|
||
'② 눌림목: 추세는 살아있는데 적정가까지 내려오면 매수\n'
|
||
'각 줄 앞의 ✓/✗는 그 경로 전체의 충족 여부예요. 위쪽 칩들은 필수 조건.')
|
||
|
||
def path_line(title, items):
|
||
oks = [c.get('ok') for c in items]
|
||
ok = all(v in (True, None) for v in oks) and any(v is True for v in oks)
|
||
mark, color = ('✓', '#22c55e') if ok else ('✗', '#ef4444')
|
||
return (f'<div class="path-line">'
|
||
f'<span class="path-h" style="color:{color}">{mark} {title}</span>'
|
||
f'<span class="path-items">{"".join(chip(c) for c in items)}</span></div>')
|
||
|
||
lines = ''
|
||
if brk:
|
||
lines += path_line('① 돌파', brk)
|
||
if pul:
|
||
lines += path_line('② 눌림목', pul)
|
||
out += (f'<div class="chk-paths">'
|
||
f'<div class="path-cap" data-title="진입경로 (둘 중 하나)" data-detail="{_esc(hint)}">'
|
||
f'진입경로 — 둘 중 하나만 충족하면 매수 ⓘ</div>{lines}</div>')
|
||
return out
|
||
|
||
|
||
# 전략 튜닝 탭 파라미터 설명 — 같은 ⓘ 토스트 핸들러 재사용 (data-k = 파라미터 key)
|
||
_TUNE_HELP = {
|
||
'SMA_SHORT': {'t': '단기 이동평균 (일)', 'd': '단기 추세를 보는 평균선 기간이에요. 5면 최근 5일 평균이에요. 짧을수록 가격 변화에 더 민감하게(빠르게) 반응해요. "정배열(단기선>장기선)" 판단에 쓰여요.'},
|
||
'SMA_LONG': {'t': '장기 이동평균 (일)', 'd': '큰 추세를 보는 평균선 기간이에요. 20이면 최근 20일(약 한 달) 평균이에요. "추세(이 선 위인지)"와 "정배열"의 기준선이에요. 길수록 큰 흐름만 봐요.'},
|
||
'ATR_PERIOD': {'t': 'ATR 기간 (일)', 'd': 'ATR은 하루에 가격이 보통 얼마나 출렁이는지(평균 변동폭)예요. 이 기간만큼의 변동폭을 평균내요. 손절·눌림목·트레일링 폭을 계산하는 데 쓰여요.'},
|
||
'BREAKOUT_LOOKBACK': {'t': '돌파 고점 구간 (일)', 'd': '"전고점 돌파"를 따질 때 며칠간의 최고가를 기준으로 할지예요. 20이면 최근 20일 고점을 넘어야 돌파로 봐요. 길수록 더 의미 있는 고점을 요구해요.'},
|
||
'SWING_LOW_LOOKBACK': {'t': '스윙 저점 구간 (일)', 'd': '손절선을 잡을 때 참고하는 최근 저점 구간이에요. 이 기간의 저점 근처를 손절 후보로 써서, 의미 있는 지지선 아래로 빠지면 정리해요.'},
|
||
'VOLUME_AVG_PERIOD': {'t': '거래량 평균 구간 (일)', 'd': '"거래량 급증"을 판단할 때 비교 기준이 되는 평소 평균 거래량을 며칠로 낼지예요. 이 평균보다 확 늘면 관심이 몰린 걸로 봐요.'},
|
||
'FLOW_DAYS': {'t': '수급 합산 (일)', 'd': '외국인·기관 순매수를 며칠치 합쳐서 볼지예요. 5면 최근 5일 합계예요. 길수록 하루 변동이 아닌 꾸준한 큰손 매수를 봐요.'},
|
||
'VOLUME_BREAKOUT_MULT': {'t': '돌파 거래량 배수', 'd': '돌파로 인정하려면 그날 거래량이 평소 평균의 몇 배 이상이어야 하는지예요. 2면 평소 2배 넘게 거래돼야 "진짜 돌파"로 봐요. 높일수록 조건이 까다로워져요.'},
|
||
'TURNOVER_BREAKOUT_MULT': {'t': '돌파 회전율 배수', 'd': '돌파 인정에 필요한 회전율(거래 활발도) 배수예요. 거래량 배수와 함께 봐서 돌파에 힘이 실렸는지 확인해요.'},
|
||
'TURNOVER_OVERHEAT_MULT': {'t': '과열 보류 회전율 배수', 'd': '회전율이 평소의 몇 배를 넘으면 "과열"로 보고 신규매수를 보류할지예요. 3이면 평소 3배 넘게 과열되면 단기 꼭대기 위험으로 잠깐 쉬어요.'},
|
||
'PULLBACK_ATR_MULT': {'t': '눌림목 ATR 배수', 'd': '눌림목 매수가를 "전일 종가 − (변동폭 ATR × 이 배수)"로 잡아요. 클수록 더 깊이 떨어진 자리에서 사려고 해요 — 더 싸게 사지만 기회는 줄어들어요.'},
|
||
'ENTRY_TRANCHES': {'t': '분할매수 횟수', 'd': '한 종목 목표 비중을 몇 번에 나눠 살지예요. 3이면 1/3씩 세 번에 걸쳐 들어가요. 한 번에 다 사지 않아 평균 단가 위험을 줄여요.'},
|
||
'ADD_ATR_MULT': {'t': '추격매수 ATR 배수', 'd': '추가 매수(추격)할 때, 직전 매수가보다 "변동폭 ATR × 이 배수"만큼 더 오르면 다음 분할을 사요. 추세가 계속될 때만 따라 올라타는 기준이에요.'},
|
||
'RSI_OVERBOUGHT': {'t': 'RSI 과열 컷 (돌파)', 'd': '돌파 매수할 때 RSI가 이 값 이상이면 단기 과열로 보고 추격을 보류해요. 78이면 RSI 78 이상일 때 안 사요. 낮출수록 더 깐깐하게(덜) 사고, 너무 높이면 과열 종목도 사게 돼요.'},
|
||
'RSI_OVERSOLD': {'t': 'RSI 과매도 컷 (눌림목)', 'd': '눌림목 매수할 때 RSI가 이 값 미만이면 아직 떨어지는 중(과매도)으로 보고 진입을 미뤄요. 25면 RSI 25 미만일 때 기다려요. "떨어지는 칼을 잡지 않으려는" 안전장치예요.'},
|
||
'STOP_ATR_MULT': {'t': '손절 ATR 배수', 'd': '손절선을 "매수가 − (변동폭 ATR × 이 배수)"로 잡아요. 2면 하루 변동폭의 2배까지 빠지면 손절이에요. 클수록 여유롭게 두지만(잘 안 잘림) 손실은 커질 수 있어요.'},
|
||
'RR_RATIO': {'t': '손익비 (목표 ÷ 손절)', 'd': '목표 수익이 손절 위험의 몇 배인지예요. 2면 1 잃을 위험에 2 벌 목표를 둬요. 높일수록 큰 수익을 노리지만 목표 도달은 어려워져요.'},
|
||
'SCALE_OUT_FRAC': {'t': '분할매도 익절 비율', 'd': '목표가에 닿으면 보유 수량 중 몇 %를 먼저 익절할지예요. 0.5면 절반을 팔아 수익을 챙기고, 나머지는 더 끌고 가며 트레일링으로 관리해요.'},
|
||
'TRAIL_ATR_MULT': {'t': '트레일링 ATR 배수', 'd': '익절 후 남은 물량의 "따라가는 손절선"을 고점에서 "변동폭 ATR × 이 배수"만큼 아래에 둬요. 주가가 오르면 같이 올라가고, 떨어지면 그 선에서 정리해요.'},
|
||
'TRAIL_ATR_MULT_TIGHT': {'t': '트레일링 강화 배수', 'd': '수익이 충분히 났을 때 트레일링 폭을 더 좁게(타이트하게) 조이는 배수예요. 번 것을 더 꼭 지키려고 고점에 더 가깝게 따라붙어요.'},
|
||
'MAX_HOLD_DAYS': {'t': '시간손절 (보유 최대 일)', 'd': '한 종목을 산 뒤 이 일수(달력 기준)가 지나도 진전이 없으면(트레일링 시작 전이고 진입가도 못 넘김) 정리해서 자본을 다른 기회로 돌려요. 20이면 약 한 달이에요. 0이면 시간손절을 끕니다.'},
|
||
'ANALYST_MIN_UPSIDE_PCT': {'t': '최소 상승여력 (%)', 'd': '매수 후보가 되려면 애널리스트 목표가까지 최소 몇 %의 여력이 있어야 하는지예요. 이보다 낮으면(이미 목표가 근처면) 매력이 부족하다고 봐요.'},
|
||
'ANALYST_SELL_OPINION_MAX': {'t': '매도의견 컷 (1~5)', 'd': '애널리스트 평균 의견 점수가 이 값 이하로 낮으면(매도에 가까우면) 매수 후보에서 빼요. 전문가가 부정적인 종목을 거르는 방향성 안전장치예요.'},
|
||
'MARKET_BREADTH_MIN': {'t': '시장 상승비율 하한', 'd': '그날 오른 종목 비율이 이 값 미만이면 "약세장"으로 봐요. 0.15면 15%예요. 약세장에선 바닥잡기 매수는 미루고 강한 돌파만 허용해요.'},
|
||
'BEAR_ENTRY_MODE': {'t': '약세장 매수 모드', 'd': '시장이 약세 국면(혼조·약세·급락)일 때 어떻게 살지예요. 0=완전 현금화(돌파도 안 사고 무조건 쉼) · 1=돌파만 허용(강하게 치고 오르는 종목만, 바닥잡기는 안 함) · 2=눌림목도 허용(약세장에도 적정가 오면 저가 매수). 숫자가 클수록 약세장에 적극적이에요. 기본 1.'},
|
||
'MAX_POSITIONS': {'t': '동시 보유 최대 종목', 'd': '한 번에 들고 갈 수 있는 최대 종목 수예요. 분산은 되지만 너무 많으면 관리가 어려워요. 이 수에 차면 신규매수를 멈춰요. "종목당 균등비중 = 자산 ÷ 이 수"의 기준이기도 해요.'},
|
||
'RISK_PER_TRADE_PCT': {'t': '종목당 리스크 비율', 'd': '한 종목에서 감수할 손실(손절까지 갔을 때)을 자산의 몇 %로 제한할지예요. 0.01이면 1%. 수량 = 자산 × 이 비율 ÷ 손절폭으로 정해서, 손절폭이 큰(변동성 큰) 종목은 자동으로 적게 사요. 종목마다 위험을 균등하게 맞춰 큰 손실 한 방을 막아요. 0이면 끄고 균등비중으로 사요.'},
|
||
}
|
||
|
||
|
||
_CHK_HELP_JS = ("""
|
||
<script>(function(){
|
||
var H = __HELP_JSON__;
|
||
var box=null;
|
||
function close(){ if(box){box.remove();box=null;} }
|
||
function show(title, body){
|
||
close();
|
||
box=document.createElement('div'); box.id='chk-toast';
|
||
var card=document.createElement('div'); card.className='ct-card';
|
||
var t=document.createElement('div'); t.className='ct-t'; t.textContent=title;
|
||
var d=document.createElement('div'); d.className='ct-d'; d.textContent=body;
|
||
var x=document.createElement('button'); x.type='button'; x.className='ct-x'; x.textContent='닫기';
|
||
card.appendChild(t); card.appendChild(d); card.appendChild(x);
|
||
box.appendChild(card); document.body.appendChild(box);
|
||
box.addEventListener('click', function(ev){ if(ev.target===box||ev.target===x) close(); });
|
||
}
|
||
document.addEventListener('click', function(e){
|
||
var dd=e.target.closest('[data-detail]');
|
||
if(dd){
|
||
e.preventDefault(); e.stopPropagation();
|
||
var det=dd.getAttribute('data-detail')||''; var ttl=dd.getAttribute('data-title');
|
||
if(ttl){ show(ttl, det); }
|
||
else { var nl=det.indexOf('\\n'); show(nl>0?det.slice(0,nl):'정보', nl>0?det.slice(nl+1):det); }
|
||
return;
|
||
}
|
||
var b=e.target.closest('.chk-i');
|
||
if(!b) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
var h=H[b.getAttribute('data-k')]; if(!h) return;
|
||
show(h.t, h.d);
|
||
});
|
||
})();</script>
|
||
""").replace('__HELP_JSON__', json.dumps({**_CHK_HELP, **_TUNE_HELP}, ensure_ascii=False))
|
||
|
||
|
||
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>'
|
||
|
||
|
||
FAV_PATH = config.STATE_DIR / 'favorites.json'
|
||
|
||
# 관심 전략 마킹 아이콘 — 하나당 전략 1개만 (다른 전략에 쓰면 옮겨짐). 순서 = 우선순위(관심종목 탭 기본 시각).
|
||
FAV_ICONS = ['⭐', '🔥', '💎', '🎯', '👀', '🚀']
|
||
|
||
|
||
def _load_favs() -> dict:
|
||
"""{아이콘: vid} — 아이콘 하나당 전략 1개. 구버전(list)은 순서대로 마이그레이션."""
|
||
try:
|
||
d = json.loads(FAV_PATH.read_text())
|
||
except Exception:
|
||
return {}
|
||
if isinstance(d, list):
|
||
return {ic: v for ic, v in zip(FAV_ICONS, d)}
|
||
return {ic: v for ic, v in d.items() if ic in FAV_ICONS}
|
||
|
||
|
||
def _save_favs(favs: dict):
|
||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||
FAV_PATH.write_text(json.dumps(favs, ensure_ascii=False))
|
||
|
||
|
||
def _kvline(k, v):
|
||
"""한 줄 항목(라벨 좌 / 값 우). v는 신뢰 HTML(색상·muted 등 허용)."""
|
||
return f'<div class="kv"><span class="k">{k}</span><span>{v}</span></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
|
||
|
||
_mkt_cache = {'data': None, 'ts': 0.0}
|
||
_MKT_TTL = 60.0
|
||
|
||
|
||
def _get_live_market() -> dict | None:
|
||
"""장중 실시간 시장통계(상승하락·ADR·투자자 순매수) — 네이버 m.stock, 60s 캐시. 실패 시 None."""
|
||
now = time.time()
|
||
if _mkt_cache['data'] and now - _mkt_cache['ts'] < _MKT_TTL:
|
||
return _mkt_cache['data']
|
||
try:
|
||
sys.path.insert(0, str(config.SCRIPTS))
|
||
import market_indicators_sync as mis
|
||
out = {}
|
||
for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
|
||
rr = mis.fetch_market(sym, lab)
|
||
if rr:
|
||
rr['date'] = rr.get('bizdate') or ''
|
||
out[sym] = rr
|
||
if out:
|
||
_mkt_cache['data'] = out
|
||
_mkt_cache['ts'] = now
|
||
return out
|
||
except Exception as e:
|
||
sys.stderr.write(f'[sim_web] 실시간 시장통계 fetch 실패: {e}\n')
|
||
return None
|
||
|
||
|
||
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()
|
||
live = _get_live_market() # 세션 무관 항상 최신(네이버는 장 마감 후에도 당일 최종값 제공), 실패 시만 EOD 폴백
|
||
regimes = engine.market_regime_map() # 6단계 국면 (매수판단과 동일 소스)
|
||
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], **live[k]} if live and k in live else 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
|
||
grade = regimes.get(k, 'mixed')
|
||
r_em, r_lbl, r_buy, r_desc, r_color = engine.REGIME[grade]
|
||
vlabel, vcolor = f'{r_em} {r_lbl}', r_color
|
||
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}%</span></div>'
|
||
f'<div class="kv"><span class="k">국면</span><span style="color:{r_color}"><b>{vlabel}</b></span></div>'
|
||
f'<div class="sub" style="margin:2px 0 4px">{r_desc}{" · 신규매수 허용" if r_buy else ""}</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>'
|
||
)
|
||
if live:
|
||
live_lbl = '장중 실시간' if engine.is_market_session() else '직전 장 마감 기준'
|
||
note = f'<div class="sub" style="margin-top:6px">{live_lbl}(네이버 m.stock 실시간) · 순매수 단위 억원</div>'
|
||
else:
|
||
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'<button type="button" class="chk-i" data-k="{k}" aria-label="설명">ⓘ</button>'
|
||
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 _market_fit(p: dict) -> tuple[str, str]:
|
||
"""조합(설정값 세트)이 어떤 시장에서 강세/약세인지 — 파라미터 성격 기반 결정론적 설명."""
|
||
sma = p.get('SMA_LONG', 20); rr = p.get('RR_RATIO', 2.0); stop = p.get('STOP_ATR_MULT', 2.0)
|
||
vol = p.get('VOLUME_BREAKOUT_MULT', 1.5); pull = p.get('PULLBACK_ATR_MULT', 1.0)
|
||
risk = p.get('RISK_PER_TRADE_PCT', 0.01); hold = p.get('MAX_HOLD_DAYS', 0)
|
||
tags, strong, weak = [], [], []
|
||
tags.append('크게 먹으려 오래 들고가는 편' if rr >= 2.5 else
|
||
'조금씩 빨리 먹고 빠지는 편' if rr <= 1.5 else '적당히 균형 잡힌 편')
|
||
if sma <= 15: tags.append('시세에 빠르게 반응')
|
||
elif sma >= 40: tags.append('큰 흐름만 천천히')
|
||
if risk >= 0.02: tags.append('한 종목에 크게 베팅')
|
||
elif risk <= 0.005: tags.append('조심조심 적게 베팅')
|
||
|
||
if vol <= 1.3:
|
||
strong.append('거래가 활발하고 쭉쭉 오르는 장 — 치고 올라갈 때 잘 따라붙어요')
|
||
weak.append('거래도 뜸하고 옆으로 횡보하는 장 — 오르는 척하다 도로 빠질 때 속아요')
|
||
elif vol >= 2.0:
|
||
strong.append('확실하게 크게 터지며 오르는 장')
|
||
weak.append('조용한 장 — 살 만한 신호가 잘 안 나와서 기회가 적어요')
|
||
if rr >= 2.5:
|
||
strong.append('한 방향으로 길게 쭉 가는 장 — 오래 들고 크게 먹어요')
|
||
weak.append('올랐다 내렸다 왔다갔다 하는 장 — 목표에 닿기 전에 꺾여요')
|
||
elif rr <= 1.5:
|
||
strong.append('출렁출렁 자주 움직이는 장 — 조금씩 빨리 먹기 좋아요')
|
||
weak.append('한 방향으로 크게 가는 장 — 너무 일찍 팔아서 아쉬워요')
|
||
if stop <= 1.5:
|
||
strong.append('완만하게 꾸준히 오르는 장 — 손실을 작게 끊어요')
|
||
weak.append('출렁임이 심한 장 — 자꾸 손절에 걸려서 잘려요')
|
||
elif stop >= 2.5:
|
||
strong.append('흔들려도 결국 오르는 장 — 출렁임을 버텨내요')
|
||
weak.append('확 빠지는 급락장 — 한 번에 크게 손해 봐요')
|
||
if pull >= 1.5:
|
||
strong.append('올랐다 잠깐 쉬며 내려주는 장 — 싸게 살 기회가 생겨요')
|
||
weak.append('쉬지 않고 곧장 오르기만 하는 장 — 살 자리를 안 줘서 못 사요')
|
||
if risk >= 0.02:
|
||
strong.append('쭉 오르는 장 — 크게 베팅한 만큼 수익도 커요')
|
||
weak.append('내리거나 출렁이는 장 — 손실도 그만큼 커서 계좌가 휘청여요')
|
||
if sma <= 15:
|
||
weak.append('자잘하게 흔들리기만 하는 장 — 빠른 신호가 자꾸 헛다리 짚어요')
|
||
elif sma >= 40:
|
||
weak.append('빠르게 휙휙 바뀌는 장 — 굼떠서 한발 늦게 들어가요')
|
||
if hold and hold <= 10:
|
||
strong.append('짧게 끝나는 장 — 돈을 빨리 돌려 다음 기회로 넘어가요')
|
||
|
||
def _dedup(lst):
|
||
seen, out = set(), []
|
||
for x in lst:
|
||
if x not in seen:
|
||
seen.add(x); out.append(x)
|
||
return out
|
||
strong, weak = _dedup(strong), _dedup(weak)
|
||
body = ('한마디로: ' + ', '.join(tags) + ' 스타일이에요.\n\n'
|
||
'💪 이런 장에서 잘 벌어요\n' + '\n'.join('· ' + x for x in strong) +
|
||
'\n\n⚠️ 이런 장에선 약해요\n' + '\n'.join('· ' + x for x in weak) +
|
||
'\n\n※ 설정값 성격으로 따져본 거예요 — 참고만 하세요')
|
||
return '🌐 어떤 시장에 강한가', body
|
||
|
||
|
||
def _fit_tokens(p: dict) -> tuple[str, list[str], str]:
|
||
"""조합 성격 토큰 (market, 수식어들, 스타일) — 긴 문장/짧은 나열 공용."""
|
||
rr = p.get('RR_RATIO', 2.0); risk = p.get('RISK_PER_TRADE_PCT', 0.01)
|
||
sma = p.get('SMA_LONG', 20); stop = p.get('STOP_ATR_MULT', 2.0)
|
||
vol = p.get('VOLUME_BREAKOUT_MULT', 1.5); pull = p.get('PULLBACK_ATR_MULT', 1.0)
|
||
style = '추세추종' if rr >= 2.5 else '단타' if rr <= 1.5 else '균형'
|
||
adj = []
|
||
if sma <= 15: adj.append('빠른')
|
||
elif sma >= 40: adj.append('느린')
|
||
if risk >= 0.02: adj.append('공격')
|
||
elif risk <= 0.005: adj.append('보수')
|
||
if risk >= 0.02 or vol <= 1.3: market = '상승장'
|
||
elif rr >= 2.5: market = '추세장'
|
||
elif pull >= 1.5: market = '조정장'
|
||
elif rr <= 1.5 or stop <= 1.5: market = '출렁이는 장'
|
||
elif stop >= 2.5: market = '변동성 장'
|
||
else: market = '보통 장'
|
||
return market, adj, style
|
||
|
||
|
||
def _market_fit_summary(p: dict) -> str:
|
||
"""조합 성격을 한 줄로 — 백테스트 탭 행 제목·유형 라벨용. 예: '상승장에 강한 빠른·공격 단타형'."""
|
||
market, adj, style = _fit_tokens(p)
|
||
style_full = ('·'.join(adj) + ' ' if adj else '') + style + '형'
|
||
return f'{market}에 강한 {style_full}'
|
||
|
||
|
||
def _market_fit_words(p: dict) -> str:
|
||
"""짧은 단어 나열 — 비교탭 행 제목·드롭다운용. 예: '상승장·빠른·공격·추세추종'."""
|
||
market, adj, style = _fit_tokens(p)
|
||
m = {'출렁이는 장': '출렁장', '변동성 장': '변동장', '보통 장': '보통장'}.get(market, market)
|
||
return '·'.join([m] + adj + [style])
|
||
|
||
|
||
# 약세장 모드 단어 칩 — 비교군 행·드롭다운에서 한눈에 구분 (긴 접미사 대신).
|
||
_BEAR_WORD = {0: '회피', 1: '안정', 2: '적극'}
|
||
_BEAR_NAME = {0: '회피 (현금화)', 1: '안정 (돌파만·기본)', 2: '적극 (눌림허용)'}
|
||
_BEAR_DESC = {
|
||
0: '이 전략은 시장이 약세 국면(혼조·약세·급락)이면 아무것도 안 사고 현금을 지켜요.',
|
||
1: '이 전략은 시장이 약세 국면이어도 강하게 치고 오르는 돌파 종목만 사요. 바닥잡기(눌림목 매수)는 안 해요.',
|
||
2: '이 전략은 시장이 약세 국면이어도 눌림목 저가매수까지 해요. 셋 중 가장 적극적이에요.',
|
||
}
|
||
_BEAR_LEGEND = '\n\n회피=약세장 매수 안 함 · 안정=돌파만 매수(기본) · 적극=눌림목 저가매수도 함'
|
||
|
||
|
||
def _bear_mode(p) -> int:
|
||
"""변이 params의 약세장 모드 — 없으면 기본 1(돌파만)."""
|
||
try:
|
||
return int((p or {}).get('BEAR_ENTRY_MODE', 1))
|
||
except Exception:
|
||
return 1
|
||
|
||
|
||
def _bear_chip_html(p) -> str:
|
||
"""약세모드 단어 칩 (클릭 시 설명 토스트)."""
|
||
m = _bear_mode(p)
|
||
return (f'<span class="bic bic{m}" data-title="약세장대처 — {_BEAR_NAME[m]}" '
|
||
f'data-detail="{_esc(_BEAR_DESC[m] + _BEAR_LEGEND)}">{_BEAR_WORD[m]}</span>')
|
||
|
||
|
||
def _backtest_panel() -> str:
|
||
"""백테스트 결과 — 유형별(세부 라벨) 대표(1위) 조합 요약. 비교군은 이 대표들로 자동 구성되므로
|
||
수동 추가/제거 없이 읽기 전용 요약만 보여준다."""
|
||
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("test","?")} · {res.get("count")}개 조합 · '
|
||
f'순위=3분할 최악구간 수익(꾸준함) · 다음날 시가 체결+슬리피지 0.2%</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;display:inline">'
|
||
'<button type="submit" class="btn-sweep">↻ 다시 계산</button></form>')
|
||
main_p = config.load_params()
|
||
main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p})
|
||
min_tr = res.get('min_trades', 0) or 0
|
||
|
||
def _sk(r):
|
||
te = r['test']
|
||
enough = (te.get('trades', 0) or 0) >= min_tr
|
||
worst = r.get('worst_ret')
|
||
return (1 if enough else 0,
|
||
worst if worst is not None else -999,
|
||
te.get('total_return_pct', -999) or -999)
|
||
|
||
def _row_html(r, rank_label):
|
||
p, te, tr = r['params'], r['test'], r['train']
|
||
is_main = _pkey(p) == main_pkey
|
||
main_badge = ' <span class="badge-ic" title="메인 — 현재 적용된 튜닝">⭐</span>' if is_main else ''
|
||
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 ''
|
||
mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor')
|
||
mf_t, mf_b = _market_fit(p)
|
||
mf_sum = _market_fit_summary(p)
|
||
wins_row = ''
|
||
if r.get('windows'):
|
||
parts = []
|
||
for wnd in r['windows']:
|
||
wret = wnd.get('ret')
|
||
wc = '#22c55e' if (wret or 0) >= 0 else '#ef4444'
|
||
parts.append(f'<span style="color:{wc}">{wret:+g}%</span>' if wret is not None else '—')
|
||
wins_row = (f'<div class="kv"><span class="k">구간별 검증(3분할)</span>'
|
||
f'<span>{" / ".join(parts)}</span></div>')
|
||
worst = r.get('worst_ret')
|
||
worst_h = f'<span class="muted">최악 {worst:+g}%</span> · ' if worst is not None else ''
|
||
return (
|
||
f'<details class="row"><summary><span class="muted">{rank_label}</span> '
|
||
f'<span class="nm">{_esc(mf_sum)}</span>{main_badge}'
|
||
f'<span class="rt">{worst_h}<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'{wins_row}'
|
||
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="kv"><span class="k">파라미터</span><span class="muted">{_esc(_plabel(p))}</span></div>'
|
||
f'<button type="button" class="mfit" data-title="{_esc(mf_t)}" data-detail="{_esc(mf_b)}">🌐 어떤 시장에 강한가</button>'
|
||
f'</div></details>'
|
||
)
|
||
|
||
# 중복(동일 params) 제거 → 세부 유형(라벨)별 대표(1위)만 추려 요약
|
||
uniq = {}
|
||
for r in res['results']:
|
||
uniq.setdefault(_pkey(r['params']), r)
|
||
by_label = {}
|
||
for r in uniq.values():
|
||
lbl = _market_fit_summary(r['params'])
|
||
if lbl not in by_label or _sk(r) > _sk(by_label[lbl]):
|
||
by_label[lbl] = r
|
||
|
||
# 시장 성격별 그룹 (각 그룹 안은 꾸준함 순)
|
||
by_market = {}
|
||
for lbl, r in by_label.items():
|
||
by_market.setdefault(lbl.split('에 강한')[0], []).append(r)
|
||
|
||
note = (f'<div class="sub" style="margin:2px 0 10px">유형별({len(by_label)}종) 대표 조합 — '
|
||
f'각 유형 1위만 요약 · 비교군 반영은 수동(코디에게 요청) (총 고유 {len(uniq)}조합)<br>'
|
||
f'※ 약세장 모드(기본/현금화/눌림허용)는 백테스트에 영향이 없어(시장필터 중립) 여기선 안 갈라져요 — '
|
||
f'비교군에서만 유형당 3종으로 라이브 검증</div>')
|
||
|
||
# 유형별 보기 (시장 성격 그룹 — 접이식, 접힌 줄에 그룹 1위 수익 표시)
|
||
type_rows = ''
|
||
for mkt in sorted(by_market, key=lambda m: max(_sk(r) for r in by_market[m]), reverse=True):
|
||
grp = sorted(by_market[mkt], key=_sk, reverse=True)
|
||
best = grp[0]
|
||
bret = best['test'].get('total_return_pct', 0)
|
||
bw = best.get('worst_ret')
|
||
bc = '#22c55e' if (bret or 0) >= 0 else '#ef4444'
|
||
bw_h = f'<span class="muted">최악 {bw:+g}%</span> · ' if bw is not None else ''
|
||
rows_html = ''.join(_row_html(r, f'#{j + 1}') for j, r in enumerate(grp))
|
||
type_rows += (
|
||
f'<details class="row bt-grp"><summary>'
|
||
f'<span class="nm">{_esc(mkt)}에 강한 유형 <span class="muted">({len(grp)}종)</span></span>'
|
||
f'<span class="rt"><span class="muted">1위</span> {bw_h}<b style="color:{bc}">{bret:+g}%</b></span></summary>'
|
||
f'<div class="body">{rows_html}</div></details>'
|
||
)
|
||
|
||
# 순위별 보기 (전체 36종 통합 꾸준함 순)
|
||
flat = sorted(by_label.values(), key=_sk, reverse=True)
|
||
rank_rows = ''.join(_row_html(r, f'#{i + 1}') for i, r in enumerate(flat))
|
||
|
||
viewbar = ('<div class="bt-viewbar" id="bt-viewbar">'
|
||
'<button type="button" data-v="type">유형별로 보기</button>'
|
||
'<button type="button" data-v="rank">순위별로 보기</button></div>')
|
||
toggle_js = """
|
||
<script>(function(){
|
||
var bar=document.getElementById('bt-viewbar'); if(!bar)return;
|
||
function set(v){
|
||
document.getElementById('btv-type').style.display=(v==='type')?'':'none';
|
||
document.getElementById('btv-rank').style.display=(v==='rank')?'':'none';
|
||
bar.querySelectorAll('button').forEach(function(b){b.classList.toggle('active',b.dataset.v===v);});
|
||
try{sessionStorage.setItem('btView',v);}catch(e){}
|
||
}
|
||
bar.addEventListener('click',function(e){var b=e.target.closest('button');if(b)set(b.dataset.v);});
|
||
var v='type';try{v=sessionStorage.getItem('btView')||'type';}catch(e){}
|
||
set(v);
|
||
})();</script>"""
|
||
return (head + ctrl + note + viewbar
|
||
+ f'<div id="btv-type">{type_rows}</div>'
|
||
+ f'<div id="btv-rank" style="display:none">{rank_rows}</div>'
|
||
+ toggle_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')
|
||
# 스캔 시점 '노리는 종목' (variants_compare.json) — id별 매핑
|
||
watch_map = {c['id']: c.get('watching', [])
|
||
for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', [])}
|
||
# 라이브 거래일수 — equity_history.json 에 거래일마다 (id,ph) 1점씩 → 등장한 거래일 수 (백테스트 제외)
|
||
_eh = _load(config.STATE_DIR / 'equity_history.json', {}) or {}
|
||
live_days: dict = {}
|
||
for _d, _items in _eh.items():
|
||
for _e in _items:
|
||
live_days.setdefault((_e.get('id'), _e.get('ph')), set()).add(_d)
|
||
|
||
def _row(rid, name, params, pf, tpath):
|
||
s = pf.summary()
|
||
return {'id': rid, 'name': name, 'params': params, 'summary': s, 'pf': pf, 'tpath': tpath,
|
||
'benchmark': benchmark.compare(s.get('total_return_pct'),
|
||
benchmark.norm_date(pf.created_at), today)}
|
||
|
||
# 변이 계좌 수집 (기준전략 폐지 — 변이만 비교)
|
||
favs = _load_favs()
|
||
icon_of = {v: ic for ic, v in favs.items()} # vid → 아이콘 (전략당 1개)
|
||
# 백테스트 성적 맵 — 각 변이가 어떤 성적으로 등록됐는지 (params 정규화 키 → 순위·최악·검증)
|
||
bt_map = {}
|
||
_btres = _load(config.STATE_DIR / 'backtest_results.json', None) or {}
|
||
_seen = set()
|
||
_rank = 0
|
||
for r in _btres.get('results', []):
|
||
kk = _pkey(r['params'])
|
||
if kk in _seen:
|
||
continue
|
||
_seen.add(kk)
|
||
_rank += 1
|
||
bt_map[kk] = {'rank': _rank, 'worst': r.get('worst_ret'),
|
||
'test': r['test'].get('total_return_pct')}
|
||
# 백테스트 매칭은 스윕 대상 파라미터만으로 — 약세모드 등 백테스트 중립 파라미터(BEAR_ENTRY_MODE)가
|
||
# 변이 params에 붙어 있어도 같은 백테스트 행에 매칭되게 (모드 3종은 백테스트 성적이 동일).
|
||
_swept = _btres.get('swept_params') or []
|
||
|
||
def _btkey(p):
|
||
p = p or {}
|
||
return _pkey({k: p[k] for k in _swept if k in p} if _swept else p)
|
||
|
||
# 비교군 내부 순위 (1~N) — 등록된 변이들끼리만 백테스트 순위 재매김 (전체 순위는 쌍둥이 제외로 번호가 건너뜀)
|
||
_vkeys = {_btkey(v.get('params') or {}) for v in defs} # set — 모드 3종이 같은 키를 공유
|
||
_border = sorted([k for k in _vkeys if k in bt_map], key=lambda k: bt_map[k]['rank'])
|
||
bt_grank = {k: i + 1 for i, k in enumerate(_border)}
|
||
rows_data = []
|
||
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), tp))
|
||
# 표시 순서는 고정(메인→변이 등록순) — 새로고침해도 위치 안 바뀜. 순위(#N)만 수익률로 매겨 라벨 표시.
|
||
_ranked = sorted(rows_data, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
|
||
rank_of = {c['id']: n + 1 for n, c in enumerate(_ranked)}
|
||
|
||
scanned = _load(config.LAST_SCAN_PATH, {}).get('scanned_at', '')[:16].replace('T', ' ')
|
||
head = (f'<div class="sub">실시간 누적 비교 · 마지막 스캔 {scanned or "—"} '
|
||
f'· 전략 {len(rows_data)}개</div>')
|
||
bt_btn = ('<label for="sub-bt" class="cmp-btlink" role="button" tabindex="0" '
|
||
'title="백테스트 순위표 보기">📊 모의결과보기</label>')
|
||
day_btn = ('<label for="sub-day" class="cmp-btlink" role="button" tabindex="0" '
|
||
'title="날짜별 거래 종목">📋 거래목록</label>')
|
||
title = (f'<div class="cmp-title"><h2>병렬 페이퍼 비교</h2>'
|
||
f'<div class="cmp-acts">{bt_btn}{day_btn}</div></div>')
|
||
rows = ''
|
||
for i, v in enumerate(rows_data):
|
||
s = v['summary']
|
||
ret = s.get('total_return_pct', 0) or 0
|
||
rc = '#22c55e' if ret >= 0 else '#ef4444'
|
||
fav_ic = icon_of.get(v['id'], '')
|
||
tag = (f' <button type="button" class="fav-b{"" if fav_ic else " fav-none"}" '
|
||
f'data-vid="{_esc(v["id"])}" data-vd="{_vid_disp(v["id"])}" data-cur="{fav_ic}" '
|
||
f'title="관심 전략 마킹 — 아이콘 선택">{fav_ic or "🏷"}</button>')
|
||
mp = v.get('params') or {}
|
||
mf_t, mf_b = _market_fit(mp)
|
||
mf_sum = _market_fit_summary(mp)
|
||
mf_w = _market_fit_words(mp)
|
||
bm = _bear_mode(mp)
|
||
_ph = hashlib.md5(json.dumps(mp, sort_keys=True).encode()).hexdigest()[:8]
|
||
ndays = len(live_days.get((v['id'], _ph), ()))
|
||
w = watch_map.get(v['id'], [])
|
||
_STL = {'BUY': '🟢 매수', 'ADD': '🟢 추격', 'WAIT': '🟡 대기'}
|
||
watch_html = ''.join(
|
||
f'<div class="kv"><span class="k">{_STL.get(it.get("state"), it.get("state"))} {_esc(it.get("name"))}</span>'
|
||
f'<span class="muted">{_esc(it.get("reason",""))[:28]}</span></div>'
|
||
for it in w) or '<div class="empty">노리는 종목 없음</div>'
|
||
reset_btn = (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>')
|
||
del_btn = (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>')
|
||
det_btn = (f'<a href="/strategy?id={_esc(v["id"])}" class="btn-mini" '
|
||
f'style="text-decoration:none">📈 자세히보기</a>')
|
||
actions = f'<div class="cmp-row-actions">{det_btn}{reset_btn}{del_btn}</div>'
|
||
vid = v['id']
|
||
_vk = _btkey(v.get('params') or {})
|
||
bt = bt_map.get(_vk)
|
||
gr = bt_grank.get(_vk)
|
||
# 평가순 정렬 키 = 고유번호 오름차순 (#1→#108) — 번호 자체가 평가순 부여라 그대로가 가장 직관적.
|
||
# bt 미매칭 변이만 뒤로. (등수×1000+번호 방식은 #1·#37·#38… 로 보여 뒤섞인 인상 — 2026-06-11 단순화)
|
||
_vnum = int(vid[1:]) if vid[1:].isdigit() else 999
|
||
bt_attr = _vnum if (bt and gr) else 900000 + _vnum
|
||
if bt and gr:
|
||
bw = f"{bt['worst']:+g}%" if bt['worst'] is not None else '—'
|
||
bt_row = (f'<div class="kv nb"><span class="k">모의평가</span>'
|
||
f'<span><b>{gr}위</b>/{len(bt_grank)}</span></div>'
|
||
f'<div class="kv"><span class="k"></span>'
|
||
f'<span class="muted">최악구간 {bw} · 검증 {bt["test"]:+g}%</span></div>')
|
||
else:
|
||
bt_row = ''
|
||
rows += (
|
||
f'<details class="row" data-reg="{i}" data-rank="{rank_of[v["id"]]}" data-bt="{bt_attr}" '
|
||
f'data-held="{s.get("open_positions", 0)}" data-trades="{s.get("closed_trades", 0)}"><summary>'
|
||
f'<span class="vid">{rank_of[v["id"]]}위</span>{_bear_chip_html(mp)} <span class="muted">{_vid_disp(vid)}</span> '
|
||
f'<span class="nm">{_esc(mf_w)}</span>{tag}'
|
||
f'<span class="rt"><span class="muted">🛒{s.get("open_positions", 0)}</span> '
|
||
f'<b style="color:{rc}">{ret:+g}%</b></span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k">유형</span><span>{_esc(mf_sum)}'
|
||
f'<button type="button" class="chk-i" data-title="{_esc(mf_t)}" '
|
||
f'data-detail="{_esc(mf_b)}">ⓘ</button></span></div>'
|
||
f'<div class="kv"><span class="k">약세장대처</span><span><b>{_BEAR_NAME[bm]}</b>'
|
||
f'<button type="button" class="chk-i" data-title="약세장대처 — {_BEAR_NAME[bm]}" '
|
||
f'data-detail="{_esc(_BEAR_DESC[bm] + _BEAR_LEGEND)}">ⓘ</button></span></div>'
|
||
f'<div class="kv"><span class="k">운영 기간</span><span>{ndays or "—"}거래일째 <span class="muted">(백테스트 제외)</span></span></div>'
|
||
f'<div class="kv"><span class="k">평가자산</span><span>{s.get("equity",0):,}원</span></div>'
|
||
f'<div class="kv"><span class="k">예수금 / 주식</span><span>{s.get("cash",0):,} / {s.get("held_value",0):,}</span></div>'
|
||
f'<div class="kv"><span class="k">수익률</span><span style="color:{rc}">{ret:+g}%</span></div>'
|
||
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'{bt_row}'
|
||
f'<details class="row"><summary><span class="nm">📦 보유종목 ({s.get("open_positions",0)})</span></summary>'
|
||
f'<div class="body">{_pos_table(v["pf"])}</div></details>'
|
||
f'<details class="row"><summary><span class="nm">📋 거래내역 ({_trade_count(v["tpath"])}건 · {s.get("closed_trades",0)}청산)</span></summary>'
|
||
f'<div class="body">{_trades_table(_read_trades(v["tpath"]))}</div></details>'
|
||
f'<details class="row"><summary><span class="nm">🎯 관심종목 ({len(w)})</span></summary>'
|
||
f'<div class="body">{watch_html}</div></details>'
|
||
f'{actions}'
|
||
f'</div></details>'
|
||
)
|
||
sortbar = ('<div class="cmp-sortbar" id="cmp-sortbar">'
|
||
'<button type="button" data-s="rank">손익순</button>'
|
||
'<button type="button" data-s="bt">평가순</button>'
|
||
'<button type="button" data-s="held">보유순</button>'
|
||
'<button type="button" data-s="trades">거래순</button></div>')
|
||
sort_js = """
|
||
<script>(function(){
|
||
var bar=document.getElementById('cmp-sortbar'),box=document.getElementById('cmp-rows');
|
||
if(!bar||!box)return;
|
||
function num(el,a){return parseFloat(el.getAttribute(a))||0;}
|
||
var CMP={rank:function(a,b){return num(a,'data-rank')-num(b,'data-rank');},
|
||
bt:function(a,b){return num(a,'data-bt')-num(b,'data-bt');},
|
||
held:function(a,b){return num(b,'data-held')-num(a,'data-held');},
|
||
trades:function(a,b){return num(b,'data-trades')-num(a,'data-trades');}};
|
||
function apply(m){
|
||
var f=CMP[m]; if(!f)return;
|
||
Array.prototype.slice.call(box.children).sort(f).forEach(function(r){box.appendChild(r);});
|
||
bar.querySelectorAll('button').forEach(function(b){b.classList.toggle('active',b.dataset.s===m);});
|
||
try{sessionStorage.setItem('cmpSort',m);}catch(e){}
|
||
}
|
||
bar.addEventListener('click',function(e){var b=e.target.closest('button');if(b)apply(b.dataset.s);});
|
||
var m='rank';try{m=sessionStorage.getItem('cmpSort')||'rank';}catch(e){}
|
||
if(!CMP[m])m='rank';
|
||
apply(m);
|
||
})();</script>"""
|
||
fav_js = ("""
|
||
<script>(function(){
|
||
var FAVS=__FAVS__, ICONS=__ICONS__;
|
||
function post(vid, icon){
|
||
fetch('/fav_set',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||
body:'id='+encodeURIComponent(vid)+'&icon='+encodeURIComponent(icon)})
|
||
.then(function(){location.href=(window._simURL?window._simURL():location.href);});
|
||
}
|
||
document.addEventListener('click',function(e){
|
||
var b=e.target.closest('.fav-b'); if(!b) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
var vid=b.getAttribute('data-vid'), cur=b.getAttribute('data-cur')||'';
|
||
var vd=b.getAttribute('data-vd')||vid;
|
||
var old=document.getElementById('chk-toast'); if(old) old.remove();
|
||
var box=document.createElement('div'); box.id='chk-toast';
|
||
var card=document.createElement('div'); card.className='ct-card';
|
||
var t=document.createElement('div'); t.className='ct-t';
|
||
t.textContent=vd+' 마킹 아이콘 — 아이콘 하나당 전략 1개 (이미 쓰인 아이콘을 고르면 옮겨와요)';
|
||
card.appendChild(t);
|
||
var grid=document.createElement('div'); grid.className='fav-grid';
|
||
ICONS.forEach(function(ic){
|
||
var owner=FAVS[ic];
|
||
var btn=document.createElement('button'); btn.type='button'; btn.className='fav-opt';
|
||
var vds=function(x){return /^v\\d+$/.test(x||'')?'#'+x.slice(1):x;};
|
||
var sub=(owner===vid)?'현재':(owner?vds(owner)+' 사용중':'');
|
||
btn.innerHTML=ic+(sub?'<small>'+sub+'</small>':'');
|
||
if(owner===vid) btn.classList.add('on');
|
||
btn.addEventListener('click',function(){post(vid, ic);});
|
||
grid.appendChild(btn);
|
||
});
|
||
if(cur){
|
||
var off=document.createElement('button'); off.type='button'; off.className='fav-opt fav-off';
|
||
off.textContent='해제'; off.addEventListener('click',function(){post(vid,'');});
|
||
grid.appendChild(off);
|
||
}
|
||
card.appendChild(grid);
|
||
var x=document.createElement('button'); x.type='button'; x.className='ct-x'; x.textContent='닫기';
|
||
x.addEventListener('click',function(){box.remove();});
|
||
card.appendChild(x);
|
||
box.appendChild(card); document.body.appendChild(box);
|
||
box.addEventListener('click',function(ev){if(ev.target===box)box.remove();});
|
||
}, true);
|
||
})();</script>"""
|
||
.replace('__FAVS__', json.dumps(favs, ensure_ascii=False))
|
||
.replace('__ICONS__', json.dumps(FAV_ICONS, ensure_ascii=False)))
|
||
return title + head + sortbar + f'<div id="cmp-rows">{rows}</div>' + sort_js + fav_js
|
||
|
||
|
||
def _ret_chart_svg(series, idx_series) -> str:
|
||
"""일별 누적수익률 라인차트 — 전략(실선) vs KOSPI(회색 점선, 같은 구간 등락률).
|
||
|
||
series=[(yyyymmdd, ret%)], idx_series=[ret%|None] (series와 같은 길이)."""
|
||
if not series:
|
||
return '<div class="empty">아직 수익률 이력이 없어요 — 매 거래일 하나씩 쌓입니다</div>'
|
||
w, h = 720, 230
|
||
pl, pr, pt, pb = 50, 14, 16, 28
|
||
rets = [r for _, r in series]
|
||
vals = rets + [r for r in idx_series if r is not None] + [0]
|
||
lo, hi = min(vals), max(vals)
|
||
span = (hi - lo) or 1.0
|
||
lo -= span * 0.15; hi += span * 0.15; span = hi - lo
|
||
n = len(series)
|
||
|
||
def X(i): return pl + (w - pl - pr) * (i / max(n - 1, 1))
|
||
def Y(v): return pt + (h - pt - pb) * (1 - (v - lo) / span)
|
||
|
||
color = '#22c55e' if rets[-1] >= 0 else '#ef4444'
|
||
parts = [f'<svg viewBox="0 0 {w} {h}" style="width:100%;height:auto" xmlns="http://www.w3.org/2000/svg">']
|
||
# 0% 기준선 + y축 라벨
|
||
parts.append(f'<line x1="{pl}" y1="{Y(0):.1f}" x2="{w-pr}" y2="{Y(0):.1f}" stroke="#334155" stroke-dasharray="4 4"/>')
|
||
for v in {round(hi - span * 0.15, 1), 0.0, round(lo + span * 0.15, 1)}:
|
||
parts.append(f'<text x="{pl-6}" y="{Y(v)+4:.1f}" text-anchor="end" font-size="11" fill="#64748b">{v:+g}%</text>')
|
||
# KOSPI 점선
|
||
ipts = [(i, r) for i, r in enumerate(idx_series) if r is not None]
|
||
if len(ipts) >= 2:
|
||
d = ' '.join(f'{X(i):.1f},{Y(r):.1f}' for i, r in ipts)
|
||
parts.append(f'<polyline points="{d}" fill="none" stroke="#64748b" stroke-width="1.5" stroke-dasharray="5 4"/>')
|
||
# 전략 실선 + 점
|
||
if n >= 2:
|
||
d = ' '.join(f'{X(i):.1f},{Y(r):.1f}' for i, r in enumerate(rets))
|
||
parts.append(f'<polyline points="{d}" fill="none" stroke="{color}" stroke-width="2.5"/>')
|
||
for i, r in enumerate(rets):
|
||
parts.append(f'<circle cx="{X(i):.1f}" cy="{Y(r):.1f}" r="3" fill="{color}"/>')
|
||
parts.append(f'<text x="{X(n-1):.1f}" y="{Y(rets[-1])-8:.1f}" text-anchor="end" font-size="12" '
|
||
f'font-weight="700" fill="{color}">{rets[-1]:+g}%</text>')
|
||
# x축 날짜 (처음·끝, 5개 이상이면 중간도)
|
||
ticks = sorted({0, n - 1} | ({n // 2} if n >= 5 else set()))
|
||
for i in ticks:
|
||
dt = series[i][0]
|
||
parts.append(f'<text x="{X(i):.1f}" y="{h-8}" text-anchor="middle" font-size="11" '
|
||
f'fill="#64748b">{dt[4:6]}/{dt[6:8]}</text>')
|
||
parts.append('</svg>')
|
||
legend = (f'<div class="sub" style="margin-top:2px"><span style="color:{color}">━ 이 전략</span>'
|
||
+ (' · <span style="color:#64748b">┅ KOSPI(같은 구간)</span>' if len(ipts) >= 2 else '') + '</div>')
|
||
return ''.join(parts) + legend
|
||
|
||
|
||
def _strategy_page(vid: str) -> str:
|
||
"""전략 1개 자세히보기 — 수익률 차트 + 분석. /strategy?id=vN"""
|
||
from . import variants as vmod, benchmark
|
||
defs = vmod.load_variants()
|
||
v = next((x for x in defs if x['id'] == vid), None)
|
||
back = '<a class="cmp-btlink" style="text-decoration:none" href="/?t=lab&s=cmp">← 비교로</a>'
|
||
style = """<style>
|
||
*{box-sizing:border-box}body{margin:0;background:#0b0f14;color:#e5e7eb;font-family:-apple-system,'Apple SD Gothic Neo',sans-serif;font-size:14px}
|
||
#page{max-width:760px;margin:0 auto;padding:14px 14px 40px}
|
||
h1{font-size:17px;margin:10px 0 2px}h2{font-size:14px;margin:22px 0 8px;color:#cbd5e1}
|
||
.sub{color:#6b7280;font-size:12px;margin:2px 0}
|
||
.card{background:#111824;border:1px solid #1f2937;border-radius:10px;padding:12px;margin:10px 0}
|
||
.kv{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:13px;border-bottom:1px solid #10161e;font-variant-numeric:tabular-nums}
|
||
.kv:last-child{border-bottom:none}.kv .k{color:#6b7280;font-size:12px;white-space:nowrap}
|
||
.muted{color:#6b7280}.empty{color:#475569;padding:14px 0;text-align:center;font-size:13px}
|
||
.vid{background:#1e293b;color:#7dd3fc;border-radius:5px;padding:1px 6px;font-size:11px;font-weight:700}
|
||
.bic{border-radius:5px;padding:1px 5px;font-size:10px;font-weight:700;margin-left:4px}
|
||
.bic0{background:#334155;color:#cbd5e1}.bic1{background:#1e3a5f;color:#7dd3fc}.bic2{background:#4a2e12;color:#fbbf24}
|
||
.cmp-btlink{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;display:inline-block}
|
||
.note{white-space:pre-wrap;line-height:1.65;font-size:13px;color:#cbd5e1}
|
||
ul.an{margin:6px 0;padding-left:18px;line-height:1.7;font-size:13px}
|
||
details.row{border-top:1px solid #1f2937;padding:6px 0}summary{cursor:pointer;font-size:13px}
|
||
details.row summary{display:flex;gap:6px;align-items:center}
|
||
details.row summary .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
details.row summary .rt{flex:0 0 auto;font-size:12px}
|
||
details.row .body{padding:6px 2px 2px}
|
||
table{width:100%;border-collapse:collapse;font-size:12px}td,th{padding:4px 6px;border-bottom:1px solid #10161e;text-align:right}
|
||
td:first-child,th:first-child{text-align:left}
|
||
/* 판단근거 칩 (거래내역) — 본 대시보드와 동일 줄바꿈 레이아웃 */
|
||
.chks{display:flex;flex-wrap:wrap;gap:5px;margin:4px 0 2px}
|
||
.chk{background:#0f151c;border:1px solid #1f2937;border-radius:6px;padding:2px 7px;font-size:11px;display:inline-block}
|
||
.chk .cd{color:#6b7280;margin-left:5px}
|
||
.chk-i{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}
|
||
.chk-paths{flex-basis:100%;margin-top:5px;border-top:1px dashed #283347;padding-top:6px}
|
||
.path-cap{font-size:10px;color:#64748b;margin-bottom:5px;cursor:pointer}
|
||
.path-line{display:flex;gap:8px;align-items:flex-start;padding:6px 0}
|
||
.path-line + .path-line{border-top:1px dashed #1f2937}
|
||
.path-line .path-h{flex:0 0 72px;font-size:11px;font-weight:700;padding-top:3px}
|
||
.path-line .path-items{display:flex;flex-wrap:wrap;gap:5px;min-width:0}
|
||
#chk-toast{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center}
|
||
#chk-toast .ct-card{background:#141b24;border:1px solid #2952cc;border-radius:14px 14px 0 0;padding:16px 18px 22px;max-width:520px;width:100%;box-shadow:0 -4px 24px rgba(0,0,0,.5)}
|
||
#chk-toast .ct-t{font-size:15px;font-weight:700;color:#fff;margin-bottom:8px}
|
||
#chk-toast .ct-d{font-size:13px;line-height:1.6;color:#cbd5e1;white-space:pre-line;max-height:55vh;overflow-y:auto}
|
||
#chk-toast .ct-x{margin-top:14px;width:100%;background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:10px;font-size:13px;font-weight:600;cursor:pointer}
|
||
</style>"""
|
||
if not v:
|
||
return (f'<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">'
|
||
f'<meta name="viewport" content="width=device-width,initial-scale=1">{style}</head>'
|
||
f'<body><div id="page">{back}<div class="empty">전략 {_esc(vid)}을 찾을 수 없어요 (삭제됐을 수 있음)</div></div></body></html>')
|
||
params = v.get('params') or {}
|
||
bm = _bear_mode(params)
|
||
pp, tp = vmod.variant_paths(vid)
|
||
pf = Portfolio.load(pp, tp)
|
||
s = pf.summary()
|
||
ret = s.get('total_return_pct', 0) or 0
|
||
today = datetime.now(config.KST).strftime('%Y%m%d')
|
||
bench = benchmark.compare(ret, benchmark.norm_date(pf.created_at), today)
|
||
|
||
# 순위·마킹·관심종목 (비교 스냅샷)
|
||
cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', []) if c.get('id') != 'main']
|
||
ranked = sorted(cv, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
|
||
rank = {c['id']: n + 1 for n, c in enumerate(ranked)}.get(vid)
|
||
watching = next((c.get('watching', []) or [] for c in cv if c.get('id') == vid), [])
|
||
icon = {vv: ic for ic, vv in _load_favs().items()}.get(vid, '')
|
||
|
||
# 모의평가(백테스트 등록 근거) — 비교탭과 동일 매칭(swept만)
|
||
btres = _load(config.STATE_DIR / 'backtest_results.json', None) or {}
|
||
swept = btres.get('swept_params') or []
|
||
key = _pkey({k: params[k] for k in swept if k in params} if swept else params)
|
||
bt, _rk, _seen = None, 0, set()
|
||
for r in btres.get('results', []):
|
||
kk = _pkey(r['params'])
|
||
if kk in _seen:
|
||
continue
|
||
_seen.add(kk); _rk += 1
|
||
if kk == key:
|
||
bt = {**r, 'rank': _rk}
|
||
break
|
||
|
||
# 수익률 이력 (id+파라미터해시 매칭 — id 재사용 오염 방지) + KOSPI 같은 구간
|
||
ph = hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest()[:8]
|
||
eh = _load(config.STATE_DIR / 'equity_history.json', {}) or {}
|
||
series = [(d, e.get('ret', 0)) for d in sorted(eh) for e in eh[d]
|
||
if e.get('id') == vid and e.get('ph') == ph]
|
||
kospi = (_load(config.STATE_DIR / 'index_history.json', {}) or {}).get('KOSPI', {})
|
||
idx_series = []
|
||
base = kospi.get(series[0][0]) if series else None
|
||
for d, _ in series:
|
||
c = kospi.get(d)
|
||
idx_series.append(round((c / base - 1) * 100, 2) if (c and base) else None)
|
||
|
||
# 종합 코멘트 (결정론 — LLM 미경유)
|
||
notes = []
|
||
rk_s = f'#{rank}위/{len(ranked)}' if rank else '순위 산정 전'
|
||
notes.append(f'라이브 {len(series) or 1}거래일째(백테스트 제외) · 현재 {rk_s} · 누적 <b>{ret:+.2f}%</b>')
|
||
ak = (bench.get('KOSPI') or {}).get('alpha')
|
||
if ak is not None:
|
||
notes.append(f'같은 기간 KOSPI 대비 <b>{ak:+.2f}%p</b> — '
|
||
+ ('지수를 이기고 있어요' if ak > 0 else '아직 지수에 못 미쳐요' if ak < 0 else '지수와 동률'))
|
||
nt, npos = s.get('closed_trades', 0) or 0, s.get('open_positions', 0) or 0
|
||
if not nt and not npos:
|
||
notes.append('아직 거래가 없어요 — 진입 조건 충족을 기다리는 중')
|
||
else:
|
||
if npos:
|
||
notes.append(f'현재 {npos}종목 보유 중 (주식 {s.get("held_value", 0):,}원)')
|
||
if nt:
|
||
notes.append(f'청산 {nt}건 · 승률 {s.get("win_rate_pct")}%'
|
||
+ (' — 표본이 적어 아직 참고만' if nt < 5 else ''))
|
||
if bt:
|
||
tr_, te_ = bt['train'].get('total_return_pct', 0), bt['test'].get('total_return_pct', 0)
|
||
if (tr_ - te_) > 40:
|
||
notes.append('⚠ 백테스트 학습-검증 격차가 커요 — 과최적 가능성, 라이브 성적을 더 지켜봐야')
|
||
notes_html = '<ul class="an">' + ''.join(f'<li>{x}</li>' for x in notes) + '</ul>'
|
||
|
||
# 모의평가 카드
|
||
if bt:
|
||
wins = ' / '.join((f"{w['ret']:+g}%" if w.get('ret') is not None else '—') for w in bt.get('windows', []))
|
||
worst = bt.get('worst_ret')
|
||
bt_html = (
|
||
_kvline('전체 순위', f'#{bt["rank"]}위 (고유 조합 기준)')
|
||
+ _kvline('검증 수익 / 학습 수익', f'{bt["test"].get("total_return_pct"):+g}% / {bt["train"].get("total_return_pct"):+g}%')
|
||
+ _kvline('3분할 구간 수익', wins or '—')
|
||
+ _kvline('최악 구간', f'{worst:+g}%' if worst is not None else '—')
|
||
+ _kvline('검증 MDD / 승률 / PF',
|
||
f'{bt["test"].get("mdd_pct")}% · {bt["test"].get("win_rate_pct")}% · {bt["test"].get("profit_factor")}')
|
||
+ '<div class="sub">과거 데이터 모의평가 — 다음날 시가 체결·슬리피지 반영, 절대수익이 아닌 상대 비교용</div>')
|
||
else:
|
||
bt_html = '<div class="empty">백테스트 기록 없음 (등록 이후 스윕 미실행)</div>'
|
||
|
||
# 파라미터 표
|
||
prow = ''
|
||
for k in sorted(params):
|
||
hlp = _TUNE_HELP.get(k, {})
|
||
prow += (f'<tr><td>{_esc(hlp.get("t", k))}<br><span class="muted">{_esc(k)}</span></td>'
|
||
f'<td>{params[k]:g}</td></tr>')
|
||
mf_t, mf_b = _market_fit(params)
|
||
watch_html = ''.join(
|
||
f'<div class="kv"><span class="k">{_esc(it.get("name", ""))}</span>'
|
||
f'<span class="muted">{_esc(it.get("reason", ""))[:34]}</span></div>'
|
||
for it in watching) or '<div class="empty">노리는 종목 없음</div>'
|
||
rc = '#22c55e' if ret >= 0 else '#ef4444'
|
||
alpha_html = _alpha_inline(bench)
|
||
|
||
return f"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{_esc(vid)} 전략 상세</title>{style}</head><body><div id="page">
|
||
{back}
|
||
<h1><span class="vid">{rank or "—"}위</span> <span class="muted">{_vid_disp(vid)}</span> {_esc(_market_fit_words(params))}
|
||
<span class="bic bic{bm}">{_BEAR_WORD[bm]}</span> {icon}</h1>
|
||
<div class="sub">{_esc(_market_fit_summary(params))} · 약세장대처: {_BEAR_NAME[bm]}</div>
|
||
|
||
<div class="card">{notes_html}</div>
|
||
|
||
<h2>📈 수익률 추이</h2>
|
||
<div class="card">{_ret_chart_svg(series, idx_series)}</div>
|
||
|
||
<h2>현재 성적</h2>
|
||
<div class="card">
|
||
{_kvline('평가자산', f'{s.get("equity", 0):,}원')}
|
||
{_kvline('수익률', f'<b style="color:{rc}">{ret:+.2f}%</b>')}
|
||
{_kvline('예수금 / 주식', f'{s.get("cash", 0):,} / {s.get("held_value", 0):,}')}
|
||
{_kvline('실현손익', f'{s.get("realized_pnl", 0):,}원')}
|
||
{_kvline('거래 / 승률', f'{s.get("closed_trades", 0)}건 · {s.get("win_rate_pct")}%')}
|
||
{_kvline('MDD / 보유', f'{s.get("max_drawdown_pct", 0)}% · {s.get("open_positions", 0)}종목')}
|
||
{_kvline('지수대비(알파)', alpha_html)}
|
||
</div>
|
||
|
||
<h2>모의평가 (등록 근거)</h2>
|
||
<div class="card">{bt_html}</div>
|
||
|
||
<h2>전략 성격 — 어떤 시장에 강한가</h2>
|
||
<div class="card"><div class="note">{_esc(mf_b)}</div></div>
|
||
|
||
<h2>약세장대처 — {_BEAR_NAME[bm]}</h2>
|
||
<div class="card"><div class="note">{_esc(_BEAR_DESC[bm])}</div></div>
|
||
|
||
<h2>파라미터 ({len(params)}개)</h2>
|
||
<div class="card"><table><tr><th>항목</th><th>값</th></tr>{prow}</table></div>
|
||
|
||
<h2>보유·관심·거래</h2>
|
||
<div class="card">
|
||
<details class="row"><summary>📦 보유종목 ({s.get("open_positions", 0)})</summary>{_pos_table(pf)}</details>
|
||
<details class="row"><summary>🎯 관심종목 ({len(watching)})</summary>{watch_html}</details>
|
||
<details class="row"><summary>📋 거래내역 ({_trade_count(tp)}건 · {s.get("closed_trades", 0)}청산)</summary>{_trades_table(_read_trades(tp))}</details>
|
||
</div>
|
||
<div class="sub" style="margin-top:18px">수익률 이력은 매 거래일 EOD 기준 1점씩 누적 · 분석은 규칙 기반(LLM 미경유)</div>
|
||
</div>
|
||
{_CHK_HELP_JS}
|
||
</body></html>"""
|
||
|
||
|
||
_DAY_CAL_JS = """<script>(function(){
|
||
var TD=__DATES__, SEL='__SEL__', TODAY='__TODAY__';
|
||
var tset={}; TD.forEach(function(d){tset[d]=1;});
|
||
var box=document.getElementById('day-cal'); if(!box)return;
|
||
var cur=(SEL||TD[0]||'').slice(0,7); /* 표시 중인 YYYY-MM */
|
||
var WD=['일','월','화','수','목','금','토'];
|
||
function pad(n){return ('0'+n).slice(-2);}
|
||
function render(){
|
||
var p=cur.split('-'), y=+p[0], m=+p[1];
|
||
var startDow=new Date(y,m-1,1).getDay(), ndays=new Date(y,m,0).getDate();
|
||
var minYM=(TD[TD.length-1]||'').slice(0,7), maxYM=(TD[0]||'').slice(0,7);
|
||
var h='<div class="cal-hd">'
|
||
+'<button type="button" data-nav="-1"'+(cur<=minYM?' disabled':'')+'>‹</button>'
|
||
+'<span>'+y+'.'+pad(m)+'</span>'
|
||
+'<button type="button" data-nav="1"'+(cur>=maxYM?' disabled':'')+'>›</button></div>';
|
||
h+='<div class="cal-grid">';
|
||
WD.forEach(function(w){h+='<div class="cal-wd">'+w+'</div>';});
|
||
for(var i=0;i<startDow;i++)h+='<div></div>';
|
||
for(var d=1;d<=ndays;d++){
|
||
var ds=y+'-'+pad(m)+'-'+pad(d);
|
||
var cls='cal-d'+(ds===TODAY?' tdy':'');
|
||
if(tset[ds]){h+='<a class="'+cls+' on'+(ds===SEL?' sel':'')+'" data-d="'+ds+'" href="/?t=lab&s=day&d='+ds+'">'+d+'</a>';}
|
||
else{h+='<span class="'+cls+' off">'+d+'</span>';}
|
||
}
|
||
h+='</div>';
|
||
box.innerHTML=h;
|
||
box.querySelectorAll('[data-nav]').forEach(function(b){
|
||
b.addEventListener('click',function(){
|
||
if(b.disabled)return;
|
||
var p=cur.split('-'), nm=+p[1]+(+b.dataset.nav), ny=+p[0];
|
||
if(nm<1){nm=12;ny--;} if(nm>12){nm=1;ny++;}
|
||
cur=ny+'-'+pad(nm); render();
|
||
});
|
||
});
|
||
box.querySelectorAll('.cal-d.on').forEach(function(a){
|
||
a.addEventListener('click',function(e){ e.preventDefault(); pick(a.getAttribute('data-d')); });
|
||
});
|
||
}
|
||
var loading=false;
|
||
function pick(ds){
|
||
/* 날짜 선택 — 달력 닫지 않고 거래내역만 fetch 갱신. 로딩 표시로 진행 상태를 보여줌 */
|
||
if(loading)return; loading=true;
|
||
var url='/?t=lab&s=day&d='+ds;
|
||
var clbl=document.getElementById('day-sel-label');
|
||
if(clbl)clbl.innerHTML=ds+' <span class="day-load">⏳ 불러오는 중…</span>';
|
||
var rowsEl=document.getElementById('day-rows');
|
||
if(rowsEl)rowsEl.style.opacity='.35'; /* 갱신 중 흐리게 */
|
||
SEL=ds; render(); /* 선택 하이라이트 즉시 이동(반응성) */
|
||
fetch(url).then(function(r){return r.text();}).then(function(html){
|
||
var doc=new DOMParser().parseFromString(html,'text/html');
|
||
['day-rows','day-sub'].forEach(function(id){
|
||
var nw=doc.getElementById(id), cu=document.getElementById(id);
|
||
if(nw&&cu)cu.innerHTML=nw.innerHTML;
|
||
});
|
||
var lbl=doc.getElementById('day-sel-label');
|
||
if(lbl&&clbl)clbl.innerHTML=lbl.innerHTML;
|
||
if(rowsEl)rowsEl.style.opacity='';
|
||
try{history.replaceState(null,'',url);}catch(e){}
|
||
loading=false;
|
||
}).catch(function(){
|
||
if(clbl)clbl.innerHTML=ds+' <span class="day-load" style="color:#ef4444">불러오기 실패 · 다시 탭</span>';
|
||
if(rowsEl)rowsEl.style.opacity='';
|
||
loading=false;
|
||
});
|
||
}
|
||
render();
|
||
var cb=document.getElementById('day-cal-close');
|
||
if(cb)cb.addEventListener('click',function(){
|
||
var w=document.getElementById('day-cal-wrap'); if(w)w.open=false;
|
||
});
|
||
})();</script>"""
|
||
|
||
|
||
def _day_trades_panel(sel_date: str | None = None) -> str:
|
||
"""전 전략(변이)이 거래한 종목 — 날짜별 매수/매도 집계. sel_date=YYYY-MM-DD (없으면 최근 거래일)."""
|
||
from . import variants as vmod
|
||
# 순위맵 (비교 스냅샷) — 전략번호 옆 현재 순위
|
||
cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', []) if c.get('id') != 'main']
|
||
ranked = sorted(cv, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
|
||
rank_map = {c['id']: n + 1 for n, c in enumerate(ranked)}
|
||
|
||
# 전 전략 거래로그 1회 로드 — 날짜 목록 + 선택일 집계 동시 처리
|
||
all_rows = [] # (date, vid, record)
|
||
for v in vmod.load_variants():
|
||
_, tp = vmod.variant_paths(v['id'])
|
||
if not tp.exists():
|
||
continue
|
||
for ln in tp.read_text().splitlines():
|
||
if not ln.strip():
|
||
continue
|
||
try:
|
||
r = json.loads(ln)
|
||
except Exception:
|
||
continue
|
||
d = str(r.get('ts', ''))[:10]
|
||
if d:
|
||
all_rows.append((d, v['id'], r))
|
||
|
||
dates = sorted({d for d, _, _ in all_rows}, reverse=True) # 최신순
|
||
back = '<label for="sub-cmp" class="cmp-btlink" role="button" tabindex="0">← 비교로</label>'
|
||
if not dates:
|
||
return (f'<h2 class="cmp-title">📋 거래목록 {back}</h2>'
|
||
'<div class="empty">아직 거래 기록이 없어요 — 스캔이 진입 조건을 기다리는 중</div>')
|
||
if sel_date not in dates:
|
||
sel_date = dates[0] # 기본 = 최근 거래일
|
||
|
||
# 달력형 날짜 선택 — 거래일만 밝게·클릭가능, 거래 없는 날은 어둡게. 월 이동은 JS(새로고침 X).
|
||
is_today = sel_date == datetime.now(config.KST).strftime('%Y-%m-%d')
|
||
cal_js = (_DAY_CAL_JS.replace('__DATES__', json.dumps(dates))
|
||
.replace('__SEL__', sel_date)
|
||
.replace('__TODAY__', datetime.now(config.KST).strftime('%Y-%m-%d')))
|
||
picker = (f'<details class="day-cal-wrap" id="day-cal-wrap"><summary>'
|
||
f'📅 <b id="day-sel-label">{sel_date}{" (오늘)" if is_today else ""}</b> '
|
||
f'<span class="muted">▾ 날짜 선택</span>'
|
||
f'</summary><div id="day-cal" class="day-cal"></div>'
|
||
f'<button type="button" id="day-cal-close" class="day-cal-close">닫기</button>'
|
||
f'{cal_js}</details>')
|
||
head = (f'<h2 class="cmp-title"><span>📋 거래목록</span>'
|
||
f'<div class="cmp-acts">{back}</div></h2>{picker}')
|
||
|
||
# 선택일 집계: (code, side) → {name, vids[], prices[], pnls[]}
|
||
agg: dict = {}
|
||
for d, vid, r in all_rows:
|
||
if d != sel_date:
|
||
continue
|
||
key = (r.get('code'), r.get('side'))
|
||
a = agg.setdefault(key, {'name': r.get('name', ''), 'vids': [],
|
||
'prices': [], 'pnls': [], 'reason': r.get('reason', ''),
|
||
'kinds': {}, 'time': r.get('ts', '')[11:16]})
|
||
a['vids'].append(vid)
|
||
if r.get('price'):
|
||
a['prices'].append(r['price'])
|
||
if r.get('side') == 'SELL':
|
||
if r.get('pnl_pct') is not None:
|
||
a['pnls'].append(r['pnl_pct'])
|
||
rsn = r.get('reason', '') or ''
|
||
kind = ('익절' if '익절' in rsn else '손절' if '손절' in rsn
|
||
else '추세이탈' if '이탈' in rsn else '청산')
|
||
a['kinds'][kind] = a['kinds'].get(kind, 0) + 1
|
||
for a in agg.values():
|
||
a['n'] = len(a['vids'])
|
||
|
||
if not agg:
|
||
return head + '<div class="empty">이 날은 거래가 없어요</div>'
|
||
|
||
# (종목, 방향) 단위로 한 아이템 — 같은 종목에 매수·매도 동시면 2개 아이템으로 분리.
|
||
# 정렬: 매도(청산) 먼저, 그다음 전략 수 많은 순.
|
||
_SIDE = {'BUY': ('매수', '🟢', '#22c55e'), 'SELL': ('매도', '🔴', '#ef4444')}
|
||
order = sorted(agg.items(),
|
||
key=lambda kv: (0 if kv[0][1] == 'SELL' else 1, -kv[1]['n']))
|
||
n_buy = sum(1 for (_, side) in agg if side == 'BUY')
|
||
n_sell = sum(1 for (_, side) in agg if side == 'SELL')
|
||
sub = (f'<div class="sub" id="day-sub">매수 {n_buy} · 매도 {n_sell} 아이템 (전 전략 합산, 종목·방향별 구분)</div>')
|
||
rows = ''
|
||
for (code, side), a in order:
|
||
label, emo, color = _SIDE.get(side, (side, '', '#9ca3af'))
|
||
ps = a['prices']
|
||
prng = (f'{min(ps):,}' if len(set(ps)) == 1 else f'{min(ps):,}~{max(ps):,}') if ps else '—'
|
||
extra = ''
|
||
kind_badge = ''
|
||
kind_row = ''
|
||
if side == 'SELL':
|
||
if a['pnls']:
|
||
avg = sum(a['pnls']) / len(a['pnls'])
|
||
pc = '#22c55e' if avg >= 0 else '#ef4444'
|
||
extra = f' · <span style="color:{pc}">평균 {avg:+.1f}%</span>'
|
||
# 익절/손절 분류 — 매도 사유 기반. 요약엔 대표 1개, 본문엔 전체 내역
|
||
_KC = {'익절': '#22c55e', '손절': '#ef4444', '추세이탈': '#eab308', '청산': '#9ca3af'}
|
||
kinds = sorted(a['kinds'].items(), key=lambda kv: -kv[1])
|
||
if kinds:
|
||
top = kinds[0][0]
|
||
kind_badge = f'<span class="day-kind" style="color:{_KC[top]};border-color:{_KC[top]}">{top}</span> '
|
||
parts = ' · '.join(f'<span style="color:{_KC[k]}">{k} {c}</span>' for k, c in kinds)
|
||
kind_row = (f'<div class="kv"><span class="k">청산 유형</span><span>{parts}</span></div>')
|
||
vids = sorted(a['vids'], key=lambda x: rank_map.get(x, 999))
|
||
vid_str = ' '.join(
|
||
f'<a href="/strategy?id={x}" class="vchip">{_vid_disp(x)}'
|
||
+ (f'<span class="vr">{rank_map[x]}위</span>' if x in rank_map else '') + '</a>'
|
||
for x in vids)
|
||
rows += (
|
||
f'<details class="row"><summary>'
|
||
f'<span class="nm">{_esc(a["name"])}</span>'
|
||
f'<span class="rt">{kind_badge}<span class="day-badge" style="color:{color};border-color:{color}">'
|
||
f'{emo} {label} <b>{a["n"]}</b></span></span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k" style="color:{color}">{emo} {label} <b>{a["n"]}</b>개 전략</span>'
|
||
f'<span>{prng}원{extra}</span></div>'
|
||
f'{kind_row}'
|
||
f'<div class="kv"><span class="k muted" style="font-size:11px">{a["time"]} · 사유</span>'
|
||
f'<span class="muted" style="font-size:11px">{_esc(a["reason"])[:30]}</span></div>'
|
||
f'<div class="vchips">{vid_str}</div>'
|
||
f'</div></details>')
|
||
return head + sub + f'<div id="day-rows">{rows}</div>'
|
||
|
||
|
||
def _engine_status(snap, asset_line) -> tuple[str, str]:
|
||
"""스캔 엔진 헬스 → (점 클래스 ok/idle/bad, 토스트 상세). 장중 최근스캔=초록, 장중 멈춤=빨강, 장외=대기."""
|
||
now = datetime.now(config.KST)
|
||
session = engine.is_market_session(now)
|
||
ts = snap.get('scanned_at')
|
||
age_min, last_str = None, '—'
|
||
if ts:
|
||
try:
|
||
dt = datetime.fromisoformat(ts)
|
||
age_min = (now - dt).total_seconds() / 60
|
||
last_str = dt.strftime('%m-%d %H:%M:%S')
|
||
except Exception:
|
||
pass
|
||
asset = asset_line
|
||
if session:
|
||
if age_min is not None and age_min <= 12:
|
||
return 'ok', (f"🟢 시뮬 가동 중\n장중 · 5분 주기 자동 스캔\n"
|
||
f"마지막 스캔 {last_str} ({age_min:.0f}분 전)\n{asset}")
|
||
agetxt = f'{age_min:.0f}분 전' if age_min is not None else '기록 없음'
|
||
return 'bad', (f"🔴 스캔 멈춤 의심\n장중인데 마지막 스캔이 {agetxt}입니다.\n"
|
||
f"sim-scan launchd 점검이 필요할 수 있어요.\n{asset}")
|
||
return 'idle', (f"🟡 대기 중 (장 마감/장외)\n다음 개장(평일 09:00)에 자동 재개돼요.\n"
|
||
f"마지막 스캔 {last_str}\n{asset}")
|
||
|
||
|
||
def _pos_table(pf) -> str:
|
||
"""보유 포지션 펼치기 테이블 (내 계좌·비교 변이 공용)."""
|
||
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
|
||
costv = int(round(entry * qty))
|
||
trail = ' <span class="tag">트레일링</span>' if p.get('trailing_on') else ''
|
||
tr_n, tr_max = p.get('tranches', 1), config.ENTRY_TRANCHES
|
||
scaled = ' <span class="tag">일부익절</span>' if p.get('scaled_out') else ''
|
||
entry_at = _esc((p.get('entry_at') or '')[:16].replace('T', ' '))
|
||
rows += (
|
||
f'<details class="row"><summary>'
|
||
f'<span class="nm">{_esc(p["name"])} <span class="muted" style="font-weight:400">{qty:,}주</span></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>{costv:,}원</span></div>'
|
||
f'<div class="kv"><span class="k">평가금액</span><span>{evalv:,}원</span></div>'
|
||
f'<div class="kv"><span class="k">평가손익</span><span style="color:{pc}">{pnl_amt:+,}원 ({pnl:+.1f}%)</span></div>'
|
||
f'<div class="kv"><span class="k">손절</span><span>{p["stop"]:,}원</span></div>'
|
||
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>'
|
||
)
|
||
return rows or '<div class="empty">보유 종목 없음</div>'
|
||
|
||
|
||
def _trade_count(path) -> int:
|
||
"""거래로그(jsonl) 총 건수 (매수+매도)."""
|
||
try:
|
||
return sum(1 for ln in path.read_text().splitlines() if ln.strip())
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def _read_trades(path, limit=30) -> list:
|
||
"""거래로그(jsonl) 최근 limit건, 최신순."""
|
||
out = []
|
||
if path.exists():
|
||
for line in path.read_text().splitlines()[-limit:]:
|
||
try:
|
||
out.append(json.loads(line))
|
||
except Exception:
|
||
pass
|
||
out.reverse()
|
||
return out
|
||
|
||
|
||
def _trades_table(trades) -> str:
|
||
"""거래내역 펼치기 테이블 (내 계좌·비교 변이 공용)."""
|
||
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>'
|
||
)
|
||
return tr_rows or '<div class="empty">거래 없음</div>'
|
||
|
||
|
||
def _decision_card(d, watch_n: int = 0, held_n: int = 0, acc_detail: str = '') -> str:
|
||
"""관심종목 탭 종목 카드 (일반·제외목록 공용). watch_n/held_n = 이 종목을 관심/보유 중인 계좌 수.
|
||
acc_detail 이 있으면 👁🛒 클릭 시 '어느 전략이 보는지' 목록 토스트."""
|
||
chips = _chips(d.get('checks', []))
|
||
src = '/'.join(ORIGIN_LABEL.get(s, s) for s in d.get('sources', []))
|
||
acc = ''
|
||
if watch_n or held_n:
|
||
parts = ([f'👁{watch_n}'] if watch_n else []) + ([f'🛒{held_n}'] if held_n else [])
|
||
attrs = (f' data-title="{_esc(d.get("name"))} — 보는 전략" data-detail="{_esc(acc_detail)}"'
|
||
if acc_detail else '')
|
||
acc = f' <span class="muted acc-n"{attrs}>{" ".join(parts)}</span>'
|
||
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>')
|
||
return (
|
||
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>{acc}'
|
||
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>'
|
||
)
|
||
|
||
|
||
def render_html(view: str | None = None, day_date: str | None = None,
|
||
tab: str | None = None, sub: str | None = None) -> str:
|
||
snap = _load(config.LAST_SCAN_PATH, {})
|
||
from . import variants as vmod
|
||
_cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', [])
|
||
if c.get('id') != 'main'] # 구버전 스냅샷의 main 잔재 무시 (기준전략 폐지)
|
||
_ranked = sorted(_cv, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
|
||
rank_map = {c.get('id'): n + 1 for n, c in enumerate(_ranked)}
|
||
fav_map = _load_favs() # {아이콘: vid}
|
||
icon_of = {v: ic for ic, v in fav_map.items()} # vid → 아이콘
|
||
favs = [fav_map[ic] for ic in FAV_ICONS if fav_map.get(ic) in rank_map] # 아이콘 순 = 우선순위
|
||
|
||
# 관심종목 탭 시각 — ?view 지정 > 첫 마킹(아이콘 순) > 현재 1위. (기준전략 폐지 — 2026-06-10)
|
||
view = (view or '').strip()
|
||
if view in ('main', 'M') or view not in rank_map:
|
||
view = favs[0] if favs else (_ranked[0]['id'] if _ranked else '')
|
||
view_label = _vid_disp(view) if view else '기준판단'
|
||
judge_snap = snap # 폴백: params.json 판단 스냅샷
|
||
if view:
|
||
try:
|
||
js = engine.judge_view(view)
|
||
if js:
|
||
judge_snap = js
|
||
except Exception:
|
||
pass
|
||
scanned = snap.get('scanned_at', '')[:19].replace('T', ' ')
|
||
n_acc = len(_cv)
|
||
rets = [c['summary'].get('total_return_pct', 0) or 0 for c in _cv]
|
||
asset_line = (f'전략 {n_acc}개 운영 · 평균 {sum(rets)/len(rets):+.2f}% · 1위 {max(rets):+.2f}%'
|
||
if rets else '운영 전략 없음')
|
||
hcls, hdetail = _engine_status(snap, asset_line)
|
||
|
||
# ---- 판단표 (state 그룹) ----
|
||
from . import universe as umod
|
||
decisions = judge_snap.get('decisions', [])
|
||
|
||
# 종목별 — 이 종목을 관심(매수후보)/보유 중인 계좌(메인+변이) 수 (variants_compare 스냅샷 기준)
|
||
watch_n, held_n, buy_n = {}, {}, {}
|
||
watchers, holders = {}, {} # code → [(id, state, reason)] / [id] — 👁 클릭 토스트용
|
||
for c in _cv:
|
||
acc_id = c.get('id')
|
||
for it in c.get('watching', []) or []:
|
||
cd = it.get('code')
|
||
watch_n[cd] = watch_n.get(cd, 0) + 1
|
||
watchers.setdefault(cd, []).append((acc_id, it.get('state'), it.get('reason', '')))
|
||
if it.get('state') in ('BUY', 'ADD'):
|
||
buy_n[cd] = buy_n.get(cd, 0) + 1
|
||
for cd in c.get('held', []) or []:
|
||
held_n[cd] = held_n.get(cd, 0) + 1
|
||
holders.setdefault(cd, []).append(acc_id)
|
||
|
||
def _acc_sort(vid):
|
||
return int(vid[1:]) if vid.startswith('v') and vid[1:].isdigit() else 999
|
||
|
||
_view_dec = {d.get('code'): d for d in decisions}
|
||
_STN = {'BUY': '🟢 매수 직전', 'ADD': '🟢 추격 대기', 'WAIT': '🟡 대기',
|
||
'HOLD': '📦 보유', 'SKIP': '⚪ 매수 안 함', 'NODATA': '⚪ 데이터 없음'}
|
||
|
||
def _acc_detail(code):
|
||
"""이 종목을 보유/관심 중인 전략 목록 — 👁🛒 클릭 토스트 본문.
|
||
맨 위에 현재 시각 전략의 판단을 항상 표시 (목록에 없으면 왜 빠졌는지 보이게)."""
|
||
lines = []
|
||
vd = _view_dec.get(code)
|
||
if view and vd:
|
||
rk = rank_map.get(view)
|
||
rk_s = f'({rk}위)' if rk else ''
|
||
st = vd.get('state')
|
||
lines.append(f'▶ 시각 {_vid_disp(view)}{rk_s} — {_STN.get(st, st)}: '
|
||
f'{(vd.get("reason") or "")[:38]}')
|
||
lines.append('')
|
||
hs = sorted(holders.get(code) or [], key=_acc_sort)
|
||
if hs:
|
||
lines.append('🛒 보유 ' + str(len(hs)) + '개: '
|
||
+ ', '.join(f'{_vid_disp(h)}({rank_map[h]}위)' if h in rank_map else _vid_disp(h)
|
||
for h in hs))
|
||
ws = sorted(watchers.get(code) or [], key=lambda x: _acc_sort(x[0]))
|
||
if ws:
|
||
if lines:
|
||
lines.append('')
|
||
lines.append(f'👁 관심 {len(ws)}개 전략:')
|
||
for vid, st, rsn in ws:
|
||
em = '🟢' if st in ('BUY', 'ADD') else '🟡'
|
||
rk = rank_map.get(vid)
|
||
rk_s = f'({rk}위)' if rk else ''
|
||
pre = '▶ ' if vid == view else ''
|
||
lines.append(f'{pre}{em} {_vid_disp(vid)}{rk_s} — {(rsn or "")[:38]}')
|
||
return '\n'.join(lines)
|
||
|
||
# 상태 그룹 = 전체 전략(메인+변이) 합산 — 한 계좌라도 보유→보유 / 매수직전→매수 / 대기→대기.
|
||
# 아무 계좌도 안 보면 기준전략(M) 판정. 카드 체크리스트·사유는 기준전략 시각 유지(합산 정보는 사유 앞에 표시).
|
||
by_state = {}
|
||
excluded = [] # 매매 안 하는 종목(ETF/ETN 등) → 하단 제외목록
|
||
for d in decisions:
|
||
if umod.is_etf(d.get('name', '')):
|
||
excluded.append(d)
|
||
continue
|
||
code = d.get('code')
|
||
if held_n.get(code):
|
||
st = 'HOLD'
|
||
elif buy_n.get(code):
|
||
st = 'BUY'
|
||
elif watch_n.get(code):
|
||
st = 'WAIT'
|
||
else:
|
||
st = d.get('state', 'NODATA')
|
||
if st != d.get('state'):
|
||
bits = []
|
||
if held_n.get(code):
|
||
bits.append(f'{held_n[code]}개 전략 보유')
|
||
if buy_n.get(code):
|
||
bits.append(f'{buy_n[code]}개 전략 매수 직전')
|
||
elif watch_n.get(code):
|
||
bits.append(f'{watch_n[code]}개 전략 매수 대기')
|
||
d = {**d, 'reason': ' · '.join(bits) + f' — {view_label}: {d.get("reason", "")}'}
|
||
by_state.setdefault(st, []).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:
|
||
judge_html += _decision_card(d, watch_n.get(d.get('code'), 0), held_n.get(d.get('code'), 0),
|
||
_acc_detail(d.get('code')))
|
||
|
||
# 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시
|
||
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>'
|
||
)
|
||
|
||
# 매매 안 하는 종목(ETF/ETN 등) → 하단 접이식 제외목록
|
||
if excluded:
|
||
judge_html += (
|
||
f'<details class="row" style="margin-top:16px"><summary>'
|
||
f'<span class="badge" style="background:#374151">🚫 매매 제외 (ETF·ETN 등)</span> {len(excluded)}'
|
||
f'<span class="bt-type-d" style="display:inline;margin-left:6px">스캔은 하되 매수 안 함</span></summary>'
|
||
f'<div class="body">' + ''.join(
|
||
_decision_card(d, watch_n.get(d.get('code'), 0), held_n.get(d.get('code'), 0),
|
||
_acc_detail(d.get('code')))
|
||
for d in excluded) + '</div></details>'
|
||
)
|
||
|
||
# 관심종목 탭 시각 선택 드롭다운 (M + 변이 전체)
|
||
def _rk(vid):
|
||
r = rank_map.get(vid)
|
||
return f'{r}위 ' if r else ''
|
||
opts = ''
|
||
for vv in vmod.load_variants():
|
||
_sel = ' selected' if view == vv['id'] else ''
|
||
_fav = f'{icon_of[vv["id"]]} ' if vv['id'] in icon_of else ''
|
||
_vp = vv.get('params') or {}
|
||
opts += (f'<option value="{_esc(vv["id"])}"{_sel}>{_fav}{_rk(vv["id"])}{_vid_disp(vv["id"])} '
|
||
f'[{_BEAR_WORD[_bear_mode(_vp)]}] · {_esc(_market_fit_words(_vp))}</option>')
|
||
view_sel = (f'<div class="sub" style="margin:4px 0 8px">체크리스트 시각: '
|
||
f'<select id="judge-view" '
|
||
f'onchange="location.href=\'/?t=watch&view=\'+encodeURIComponent(this.value)">{opts}</select></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()
|
||
bt_html = _backtest_panel()
|
||
cmp_html = _compare_panel()
|
||
day_html = _day_trades_panel(day_date)
|
||
|
||
# 활성 탭/서브탭을 서버에서 미리 checked — JS가 로드 후 바꾸며 생기던 깜빡임 제거.
|
||
tab = tab if tab in ('lab', 'watch', 'market') else 'lab'
|
||
sub = sub if sub in ('cmp', 'bt', 'day') else 'cmp'
|
||
ck = {f'tab-{tab}': ' checked', f'sub-{sub}': ' checked'}
|
||
def _ck(rid):
|
||
return ck.get(rid, '')
|
||
|
||
return f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
|
||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||
<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;flex-wrap:nowrap;align-items:center;gap:8px;font-size:13px;overflow:hidden}}
|
||
.row>summary::-webkit-details-marker{{display:none}}
|
||
.row>summary .nm{{font-weight:600;flex:1 1 auto;min-width:0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
|
||
.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums;flex:0 0 auto;white-space:nowrap;font-size:11px}}
|
||
.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.nb{{border-bottom:none;padding-bottom:0}}
|
||
.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}}
|
||
.acc-n[data-detail]{{cursor:pointer;text-decoration:underline dotted #475569;text-underline-offset:3px}}
|
||
#chk-toast .ct-d{{max-height:55vh;overflow-y:auto}}
|
||
.chk-i{{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}}
|
||
.chk-paths{{flex-basis:100%;margin-top:5px;border-top:1px dashed #283347;padding-top:6px}}
|
||
.path-cap{{font-size:10px;color:#64748b;margin-bottom:5px;cursor:pointer}}
|
||
.path-line{{display:flex;gap:8px;align-items:flex-start;padding:6px 0}}
|
||
.path-line + .path-line{{border-top:1px dashed #1f2937}}
|
||
.path-line .path-h{{flex:0 0 72px;font-size:11px;font-weight:700;padding-top:3px}}
|
||
.path-line .path-items{{display:flex;flex-wrap:wrap;gap:5px;min-width:0}}
|
||
.hdot{{display:inline-block;width:11px;height:11px;border-radius:50%;margin-left:6px;cursor:pointer;vertical-align:middle;background:#6b7280}}
|
||
.hdot.ok{{background:#22c55e;box-shadow:0 0 6px #22c55e}}
|
||
.hdot.idle{{background:#eab308}}
|
||
.hdot.bad{{background:#ef4444;box-shadow:0 0 7px #ef4444;animation:hdot-bl 1s infinite}}
|
||
@keyframes hdot-bl{{50%{{opacity:.35}}}}
|
||
.mfit{{display:block;width:100%;margin:8px 0 2px;background:#0e2236;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:8px;padding:8px;font-size:12px;font-weight:600;cursor:pointer}}
|
||
.bt-type{{margin:18px 0 8px;font-size:14px;font-weight:700;border-bottom:1px solid #1f2937;padding-bottom:5px}}
|
||
.bt-type .bt-type-d{{display:block;font-size:11px;font-weight:400;color:#9ca3af;margin-top:2px}}
|
||
.cmp-title{{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 6px}}
|
||
.cmp-title h2{{margin:0;flex:0 0 auto}}
|
||
.cmp-acts{{display:flex;flex-wrap:wrap;gap:6px;align-items:center;flex:1 1 auto;justify-content:flex-end}}
|
||
.cmp-acts form{{margin:0}}
|
||
.day-badge{{border:1px solid;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:600;white-space:nowrap}}
|
||
.day-kind{{border:1px solid;border-radius:6px;padding:2px 7px;font-size:10px;font-weight:600;white-space:nowrap}}
|
||
.vchips{{display:flex;flex-wrap:wrap;gap:5px;margin:6px 0 2px}}
|
||
.vchip{{display:inline-flex;align-items:center;gap:3px;background:#1e293b;color:#7dd3fc;border:1px solid #334155;border-radius:6px;padding:2px 7px;font-size:11px;font-weight:600;text-decoration:none}}
|
||
.vchip .vr{{color:#94a3b8;font-size:9px;font-weight:500}}
|
||
.cmp-clear{{background:#3a1518;color:#fca5a5;border:1px solid #7f1d1d;border-radius:6px;padding:4px 9px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap}}
|
||
.cmp-btlink{{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:6px;padding:4px 9px;font-size:11px;font-weight:600;cursor:pointer;display:inline-block;white-space:nowrap}}
|
||
#judge-view{{background:#141b24;border:1px solid #2a3142;color:#e5e7eb;border-radius:6px;padding:3px 6px;font-size:10px;max-width:88%}}
|
||
.day-cal-wrap{{margin:8px 0 4px;background:#111824;border:1px solid #1f2937;border-radius:10px;max-width:300px}}
|
||
.day-cal-wrap>summary{{cursor:pointer;padding:8px 10px;font-size:12px;list-style:none}}
|
||
.day-cal-wrap>summary::-webkit-details-marker{{display:none}}
|
||
.day-cal{{padding:2px 10px 8px}}
|
||
.cal-hd{{display:flex;justify-content:space-between;align-items:center;margin:2px 0 5px}}
|
||
.cal-hd button{{background:#1e293b;border:1px solid #334155;color:#7dd3fc;border-radius:5px;padding:2px 12px;font-size:13px;cursor:pointer}}
|
||
.cal-hd button:disabled{{opacity:.3;cursor:default}}
|
||
.cal-hd span{{font-weight:700;font-size:12px}}
|
||
.cal-grid{{display:grid;grid-template-columns:repeat(7,1fr);gap:2px}}
|
||
.cal-wd{{text-align:center;font-size:9px;color:#64748b;padding:1px 0}}
|
||
.cal-d{{position:relative;text-align:center;padding:5px 0;border-radius:5px;font-size:11px;font-variant-numeric:tabular-nums}}
|
||
.cal-d.on{{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;font-weight:700;text-decoration:none;cursor:pointer}}
|
||
.cal-d.off{{color:#2c3848}}
|
||
.cal-d.sel{{background:#2563eb;color:#fff;border-color:#60a5fa;box-shadow:0 0 0 1px #60a5fa}}
|
||
.cal-d.tdy::after{{content:'';position:absolute;bottom:2px;left:50%;transform:translateX(-50%);width:3px;height:3px;border-radius:50%;background:#7dd3fc}}
|
||
.cal-d.tdy.sel::after{{background:#fff}}
|
||
.day-cal-close{{display:block;width:calc(100% - 20px);margin:4px 10px 10px;background:#1e293b;border:1px solid #334155;color:#cbd5e1;border-radius:6px;padding:7px;font-size:12px;cursor:pointer}}
|
||
.day-load{{font-size:11px;color:#7dd3fc;font-weight:400}}
|
||
#day-rows{{transition:opacity .12s}}
|
||
.fav-b{{background:none;border:none;font-size:13px;cursor:pointer;padding:0 2px;line-height:1;flex:0 0 auto}}
|
||
.fav-none{{opacity:.35;filter:grayscale(1)}}
|
||
.fav-grid{{display:flex;flex-wrap:nowrap;overflow-x:auto;gap:8px;margin:10px 0;padding-bottom:4px}}
|
||
.fav-opt{{display:flex;flex-direction:column;align-items:center;gap:2px;background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px 10px;font-size:18px;cursor:pointer;min-width:52px;flex:0 0 auto}}
|
||
.fav-opt small{{font-size:9px;color:#94a3b8;white-space:nowrap}}
|
||
.fav-opt.on{{border-color:#7dd3fc;background:#0c2436}}
|
||
.fav-opt.fav-off{{font-size:12px;color:#fca5a5;justify-content:center}}
|
||
.vid{{flex:0 0 auto;background:#1e293b;color:#7dd3fc;border-radius:5px;padding:1px 6px;font-size:10px;font-weight:700;font-variant-numeric:tabular-nums}}
|
||
.bic{{flex:0 0 auto;border-radius:5px;padding:1px 5px;font-size:9px;font-weight:700;margin-left:3px}}
|
||
.bic[data-detail]{{cursor:pointer}}
|
||
.bic0{{background:#334155;color:#cbd5e1}}
|
||
.bic1{{background:#1e3a5f;color:#7dd3fc}}
|
||
.bic2{{background:#4a2e12;color:#fbbf24}}
|
||
.cmp-sortbar{{display:flex;gap:6px;margin:0 0 10px}}
|
||
.cmp-sortbar button{{flex:1;background:#141b24;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:11px;font-weight:600;padding:7px 4px;cursor:pointer}}
|
||
.cmp-sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||
.badge-ic{{flex:0 0 auto;font-size:12px;line-height:1}}
|
||
#chk-toast{{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center}}
|
||
#chk-toast .ct-card{{background:#141b24;border:1px solid #2952cc;border-radius:14px 14px 0 0;padding:16px 18px 22px;max-width:520px;width:100%;box-shadow:0 -4px 24px rgba(0,0,0,.5);animation:ct-up .18s ease-out}}
|
||
@keyframes ct-up{{from{{transform:translateY(100%)}}to{{transform:translateY(0)}}}}
|
||
#chk-toast .ct-t{{font-size:15px;font-weight:700;color:#fff;margin-bottom:8px}}
|
||
#chk-toast .ct-d{{font-size:13px;line-height:1.6;color:#cbd5e1;white-space:pre-line}}
|
||
#chk-toast .ct-x{{margin-top:14px;width:100%;background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:10px;font-size:13px;font-weight:600;cursor:pointer}}
|
||
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-lab:checked~.hd label[for="tab-lab"],#tab-watch:checked~.hd label[for="tab-watch"],#tab-market:checked~.hd label[for="tab-market"]{{background:#1e3a8a;color:#fff;border-color:#2952cc}}
|
||
.panel{{display:none}}
|
||
#tab-lab:checked~#p-lab,#tab-watch:checked~#p-watch,#tab-market:checked~#p-market{{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-cmp:checked~#p-lab label[for="sub-cmp"],#sub-bt:checked~#p-lab label[for="sub-bt"]{{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:7px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;width:auto}}
|
||
.bt-viewbar{{display:flex;gap:6px;margin:0 0 12px}}
|
||
.bt-viewbar button{{flex:1;background:#141b24;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:12px;font-weight:600;padding:8px 6px;cursor:pointer}}
|
||
.bt-viewbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||
.bt-grp{{border-color:#2a3850}}
|
||
.bt-grp>summary .nm{{font-size:13px;font-weight:700}}
|
||
.bt-grp>.body{{padding-top:8px}}
|
||
.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;justify-content:flex-start;gap:6px;margin-top:10px;opacity:.55}}
|
||
.cmp-row-actions form{{flex:0 0 auto;margin:0}}
|
||
.cmp-row-actions .btn-mini{{width:auto;padding:3px 8px;font-size:10px}}
|
||
.btn-mini{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:7px;padding:9px 11px;font-size:12px;font-weight:600;cursor:pointer;width:100%}}
|
||
.btn-mini.btn-warn{{background:#3f2d0a;border-color:#b45309;color:#fcd34d}}
|
||
.btn-mini.btn-danger{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}}
|
||
#sub-cmp:checked~#p-lab #ps-cmp,#sub-bt:checked~#p-lab #ps-bt,#sub-day:checked~#p-lab #ps-day{{display:block}}
|
||
.btn-apply{{background:#15803d;color:#fff;border:1px solid #16a34a;border-radius:8px;padding:9px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}}
|
||
.tabs label{{font-size:12px;padding:9px 4px}}
|
||
.prow{{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid #161e29}}
|
||
.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-lab"{_ck('tab-lab')}>
|
||
<input type="radio" name="tab" id="tab-watch"{_ck('tab-watch')}>
|
||
<input type="radio" name="tab" id="tab-market"{_ck('tab-market')}>
|
||
<input type="radio" name="subtab" id="sub-cmp"{_ck('sub-cmp')}>
|
||
<input type="radio" name="subtab" id="sub-bt"{_ck('sub-bt')}>
|
||
<input type="radio" name="subtab" id="sub-day"{_ck('sub-day')}>
|
||
<header class="hd">
|
||
<h1> 레이 자동매매 시뮬레이션 <span id="health" class="hdot {hcls}" data-detail="{_esc(hdetail)}" role="button" tabindex="0" title="시뮬 상태 보기"></span></h1>
|
||
<div class=sub>마지막 스캔 {scanned or '—'} · 시장 {mkt_h}</div>
|
||
<nav class="tabs"><label for="tab-lab">전략</label><label for="tab-watch">관심종목 ({len(decisions)})</label><label for="tab-market">시장</label></nav>
|
||
</header>
|
||
<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">상태 그룹은 전체 전략(메인+변이) 합산 — 한 전략이라도 보유/매수대기면 그 그룹으로 · 체크리스트는 기준전략(M) 시각 · 👁관심 🛒보유 전략 수</div>
|
||
<div class="sub">종목명 일부만 입력해도 후보가 떠요 · 후보를 누르면 추가 · 자산(비하이브 워치·관심·보유)은 자동 편입 · 삭제는 카드의 ✕ (수동만) · 보유 중이면 삭제해도 청산까지 추적</div>
|
||
{view_sel}
|
||
{judge_html}
|
||
</section>
|
||
<section class="panel" id="p-market">
|
||
<h2>시장 동향</h2>{market_html}
|
||
</section>
|
||
<section class="panel" id="p-lab">
|
||
<div class="subpanel" id="ps-cmp">{cmp_html}</div>
|
||
<div class="subpanel" id="ps-bt"><h2 class="cmp-title">백테스트 순위표 <label for="sub-cmp" class="cmp-btlink" role="button" tabindex="0">← 비교로</label></h2>{bt_html}</div>
|
||
<div class="subpanel" id="ps-day">{day_html}</div>
|
||
</section>
|
||
<div class=sub style="margin-top:20px">60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)</div>
|
||
</div>
|
||
{PTR_HTML}
|
||
{REFRESH_JS}
|
||
{_WATCH_SEARCH_JS}
|
||
{_CHK_HELP_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 == '/strategy':
|
||
vid = (urllib.parse.parse_qs(parsed.query).get('id') or [''])[0]
|
||
body = _strategy_page(vid).encode('utf-8')
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||
self.send_header('Cache-Control', 'no-store')
|
||
self.send_header('Content-Length', str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
return
|
||
if path not in ('/', '/index.html'):
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
return
|
||
_qs = urllib.parse.parse_qs(parsed.query)
|
||
_view = (_qs.get('view') or ['main'])[0]
|
||
_dd = (_qs.get('d') or [None])[0]
|
||
_tab = (_qs.get('t') or [None])[0]
|
||
_sub = (_qs.get('s') or [None])[0]
|
||
# 레거시 ?t=bt/cmp/day (서브탭이 t로 옴) 하위호환 — REFRESH_JS와 동일 규칙
|
||
if _tab in ('bt', 'cmp', 'day'):
|
||
_sub, _tab = _tab, 'lab'
|
||
if _tab in ('acct', 'tune'):
|
||
_tab = 'lab'
|
||
body = render_html(view=_view, day_date=_dd, tab=_tab, sub=_sub).encode('utf-8')
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||
# 캐시 금지 — Safari 휴리스틱 캐싱이 옛 HTML/JS를 재사용해 탭 복원 로직이 구버전으로 돌던 문제 방지
|
||
self.send_header('Cache-Control', 'no-store')
|
||
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 == '/fav_set':
|
||
# 관심 전략 마킹 — {아이콘: vid}, 아이콘 하나당 전략 1개 (다른 전략에 쓰면 옮겨옴)
|
||
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()}
|
||
vid = (form.get('id') or '').strip()
|
||
icon = (form.get('icon') or '').strip()
|
||
favs = {ic: v for ic, v in _load_favs().items() if v != vid} # 전략당 아이콘 1개
|
||
if vid and icon in FAV_ICONS:
|
||
favs[icon] = vid # 기존 주인 교체(이동)
|
||
_save_favs(favs)
|
||
self.send_response(303)
|
||
self.send_header('Location', '/?t=lab&s=cmp')
|
||
self.end_headers()
|
||
return
|
||
if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove', '/variants_clear_all'):
|
||
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=bt&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'])
|
||
elif self.path == '/variants_clear_all':
|
||
vmod.clear() # 등록 변이 전부 삭제 (메인은 variants.json에 없어 보존)
|
||
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()
|