9cc7043490
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1324 lines
82 KiB
Python
1324 lines
82 KiB
Python
"""시뮬 대시보드 — 별도 읽기전용 웹서버 (포트 18792).
|
||
|
||
sim state 파일(last_scan.json·portfolio.json·trades.jsonl)만 읽어 렌더. 키움 호출 없음.
|
||
외부 노출은 NAS reverse proxy(sim.hyowons.net → mac:18792) 경유. Tailnet 내부망 한정, 인증 없음.
|
||
|
||
python3 -m sim.sim_web serve # launchd 상시
|
||
python3 -m sim.sim_web render # 1회 HTML stdout (디버깅)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
from . import config, 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.reload();}
|
||
else{reset();}
|
||
});
|
||
})();</script>
|
||
"""
|
||
|
||
# 탭 복원(?t=) + 저장 배너(?saved=1) + 자동새로고침(튜닝 탭/입력 중엔 멈춤, 현재 탭 유지)
|
||
REFRESH_JS = """
|
||
<script>(function(){
|
||
var TABS=['acct','watch','market','lab'],SUBS=['tune','bt','cmp'];
|
||
var p=new URLSearchParams(location.search);
|
||
var t=p.get('t'),s=p.get('s');
|
||
if(SUBS.indexOf(t)>=0){s=t;t='lab';} /* 레거시 ?t=bt/cmp/tune 하위호환 */
|
||
if(t){var r=document.getElementById('tab-'+t); if(r)r.checked=true;}
|
||
if(s){var rs=document.getElementById('sub-'+s); if(rs)rs.checked=true;}
|
||
function banner(msg,color){var b=document.createElement('div');b.className='saved-banner';
|
||
if(color)b.style.background=color;b.textContent=msg;document.body.appendChild(b);
|
||
setTimeout(function(){b.remove();},2800);}
|
||
if(p.get('saved')) banner('저장됨 — 다음 스캔부터 적용');
|
||
if(p.get('wadd')) banner('추가됨: '+p.get('wadd')+' — 다음 스캔부터 평가');
|
||
if(p.get('wdup')) banner('이미 목록에 있어요: '+p.get('wdup'),'#eab308');
|
||
if(p.get('werr')) banner('추가 실패: "'+p.get('werr')+'" 종목을 못 찾았어요','#ef4444');
|
||
if(p.get('wrem')) banner('삭제됨: '+p.get('wrem'),'#6b7280');
|
||
setInterval(function(){
|
||
if(document.getElementById('chk-toast')) return; /* 설명 토스트 열려 있으면 새로고침 멈춤 */
|
||
if(document.getElementById('tab-lab').checked&&document.getElementById('sub-tune').checked) return;
|
||
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
|
||
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
|
||
location.href='/?t='+(cur||'acct')+(cur==='lab'?'&s='+(sub||'tune'):'');
|
||
},60000);
|
||
})();</script>
|
||
"""
|
||
|
||
# 관심종목 탭 종목 검색 — 입력(디바운스)·검색 버튼·Enter로 /api/stock_search 후보 칩. 칩 클릭=폼 POST로 추가.
|
||
_WATCH_SEARCH_JS = """
|
||
<script>(function(){
|
||
var inp=document.getElementById('wq'),btn=document.getElementById('wq-btn'),box=document.getElementById('wq-results');
|
||
if(!inp||!box)return;
|
||
var tmr;
|
||
function esc(s){return (''+(s||'')).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||
function run(){
|
||
var q=inp.value.trim();
|
||
if(!q){box.classList.add('hidden');box.innerHTML='';return;}
|
||
fetch('/api/stock_search?q='+encodeURIComponent(q)).then(function(r){return r.json();}).then(function(d){
|
||
var res=(d&&d.results)||[];
|
||
box.classList.remove('hidden');
|
||
if(!res.length){box.innerHTML='<div class="wsearch-empty">일치하는 종목이 없어요</div>';return;}
|
||
box.innerHTML=res.map(function(it){
|
||
return '<form method="post" action="/watch_add" class="wsearch-hit-f">'+
|
||
'<input type="hidden" name="q" value="'+esc(it.code)+'">'+
|
||
'<button type="submit" class="wsearch-hit">'+esc(it.name||it.code)+
|
||
' <span class="muted">'+esc(it.code)+'</span></button></form>';
|
||
}).join('');
|
||
}).catch(function(){box.classList.add('hidden');});
|
||
}
|
||
btn.addEventListener('click',run);
|
||
inp.addEventListener('input',function(){clearTimeout(tmr);tmr=setTimeout(run,300);});
|
||
inp.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();clearTimeout(tmr);run();}});
|
||
})();</script>
|
||
"""
|
||
|
||
|
||
# 백테스트 순위표 클라이언트 정렬 — 행의 data-* 수치로 재배치. 상태는 sessionStorage 유지.
|
||
# 기본 방향: best-first (검증·학습·승률·손익비 내림차순 / MDD 오름차순). 같은 항목 재클릭 시 토글.
|
||
_BT_SORT_JS = """
|
||
<script>(function(){
|
||
var bar=document.getElementById('bt-sortbar'),box=document.getElementById('bt-rows');
|
||
if(!bar||!box)return;
|
||
var st;try{st=JSON.parse(sessionStorage.getItem('bt_sort'))}catch(e){}
|
||
if(!st||!st.key)st={key:'te',asc:false};
|
||
function apply(){
|
||
var rows=[].slice.call(box.querySelectorAll('.row'));
|
||
rows.sort(function(a,b){
|
||
var va=parseFloat(a.getAttribute('data-'+st.key)),vb=parseFloat(b.getAttribute('data-'+st.key));
|
||
return st.asc?va-vb:vb-va;
|
||
});
|
||
rows.forEach(function(r){box.appendChild(r);});
|
||
[].forEach.call(bar.children,function(btn){
|
||
var on=btn.getAttribute('data-sort')===st.key;
|
||
btn.classList.toggle('active',on);
|
||
var ar=btn.querySelector('.ar');if(ar)ar.textContent=on?(st.asc?' ↑':' ↓'):'';
|
||
});
|
||
}
|
||
[].forEach.call(bar.children,function(btn){
|
||
btn.addEventListener('click',function(){
|
||
var k=btn.getAttribute('data-sort');
|
||
if(st.key===k)st.asc=!st.asc;else{st.key=k;st.asc=(k==='mdd');}
|
||
try{sessionStorage.setItem('bt_sort',JSON.stringify(st));}catch(e){}
|
||
apply();
|
||
});
|
||
});
|
||
apply();
|
||
})();</script>
|
||
"""
|
||
|
||
|
||
def _load(path, default):
|
||
try:
|
||
return json.loads(path.read_text())
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
# ---- 스윕(백테스트 재계산) 백그라운드 트리거 ----
|
||
# sweep은 ~80초라 동기 응답 불가 → subprocess 백그라운드 + 진행상태 파일로 표시.
|
||
SWEEP_STATUS = config.STATE_DIR / 'sweep_status.json'
|
||
_WORKSPACE = config.STATE_DIR.parent.parent # `-m sim` 실행 cwd
|
||
|
||
|
||
def _sweep_status() -> dict:
|
||
return _load(SWEEP_STATUS, {})
|
||
|
||
|
||
def _write_sweep_status(d: dict):
|
||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||
SWEEP_STATUS.write_text(json.dumps(d, ensure_ascii=False))
|
||
|
||
|
||
def _trigger_sweep():
|
||
"""실행 중이 아니면 백그라운드로 `python3 -m sim sweep`. 진행상태를 파일에 남김."""
|
||
st = _sweep_status()
|
||
if st.get('state') == 'running':
|
||
try: # 5분 넘게 running이면 stale로 보고 재시작 허용
|
||
started = datetime.fromisoformat(st.get('started', ''))
|
||
if (datetime.now(config.KST) - started).total_seconds() < 300:
|
||
return
|
||
except Exception:
|
||
pass
|
||
_write_sweep_status({'state': 'running', 'started': datetime.now(config.KST).isoformat()})
|
||
|
||
def _run():
|
||
try:
|
||
p = subprocess.Popen([sys.executable, '-m', 'sim', 'sweep'], cwd=str(_WORKSPACE),
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||
_, err = p.communicate()
|
||
if p.returncode == 0:
|
||
_write_sweep_status({'state': 'done', 'finished': datetime.now(config.KST).isoformat()})
|
||
else:
|
||
_write_sweep_status({'state': 'error', 'finished': datetime.now(config.KST).isoformat(),
|
||
'msg': (err or b'').decode('utf-8', 'ignore')[-300:]})
|
||
except Exception as e:
|
||
_write_sweep_status({'state': 'error', 'finished': datetime.now(config.KST).isoformat(),
|
||
'msg': str(e)})
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
|
||
def _check_mark(ok):
|
||
return {True: '✓', False: '✗', None: '·'}.get(ok, '·')
|
||
|
||
|
||
def _check_color(ok):
|
||
return {True: '#22c55e', False: '#ef4444', None: '#9ca3af'}.get(ok, '#9ca3af')
|
||
|
||
|
||
def _esc(v):
|
||
return html.escape(str(v if v is not None else ''))
|
||
|
||
|
||
def _num(s):
|
||
"""폼 문자열 → 정수면 int, 아니면 float (변이 params 타입 보존)."""
|
||
f = float(s)
|
||
return int(f) if f == int(f) else f
|
||
|
||
|
||
def _pkey(params) -> str:
|
||
"""params 비교 키 — 값을 float로 정규화 (int 3 vs float 3.0 매칭 일치)."""
|
||
return json.dumps({k: float(v) for k, v in (params or {}).items()}, sort_keys=True)
|
||
|
||
|
||
_PSHORT = {'SMA_LONG': 'SMA', 'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'PULLBACK_ATR_MULT': 'P',
|
||
'VOLUME_BREAKOUT_MULT': 'V', 'TURNOVER_BREAKOUT_MULT': 'TB', 'TURNOVER_OVERHEAT_MULT': 'OH',
|
||
'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': '그날 시장 전체에서 오른 종목의 비율이에요. 오른 종목이 기준(15%) 이상이면 "강세장"으로 봐요. 시장이 무너지는 날엔 좋은 종목도 같이 빠지기 쉬워서, 약세장에선 바닥잡기 매수는 잠깐 미루고 강한 돌파만 허용해요.'},
|
||
'과열': {'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 (판단근거 표시 공용). 각 항목에 ⓘ 버튼 — 클릭 시 설명 토스트."""
|
||
out = []
|
||
for c in (checks or []):
|
||
k = _chk_key(c.get('label'))
|
||
info = f'<button type="button" class="chk-i" data-k="{k}" aria-label="설명">ⓘ</button>' if k else ''
|
||
out.append(
|
||
f'<span class="chk" style="color:{_check_color(c.get("ok"))}">'
|
||
f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}'
|
||
f'<span class="cd">{_esc(c.get("detail"))}</span>{info}</span>'
|
||
)
|
||
return ''.join(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>'
|
||
|
||
|
||
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 _market_fit_summary(p: dict) -> str:
|
||
"""조합 성격을 한 줄로 — 행 제목용. 예: '상승장에 강한 빠른·공격 단타형'."""
|
||
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('보수')
|
||
style_full = ('·'.join(adj) + ' ' if adj else '') + style
|
||
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 = '보통 장'
|
||
bear = p.get('BEAR_ENTRY_MODE', 1)
|
||
suffix = ' · 약세현금화' if bear == 0 else ' · 약세눌림허용' if bear == 2 else ''
|
||
return f'{market}에 강한 {style_full}{suffix}'
|
||
|
||
|
||
def _strategy_type(p: dict) -> str:
|
||
"""조합을 매매 스타일(손익비)로 대분류 — 추세추종형/균형형/단타형. (리스크 성향은 행 제목에 표시)"""
|
||
rr = p.get('RR_RATIO', 2.0)
|
||
if rr >= 2.5:
|
||
return 'trend'
|
||
if rr <= 1.5:
|
||
return 'scalp'
|
||
return 'swing'
|
||
|
||
|
||
_BT_TYPES = [
|
||
('trend', '📈 추세추종형', '손익비 높게(RR 2.5↑) — 큰 추세를 끝까지 먹는 형, 강한 상승장 특화'),
|
||
('swing', '⚖️ 균형형', '손익비 중간(RR 2.0) — 적당히 먹고 나오는 무난한 중도'),
|
||
('scalp', '⚡ 단타·회전형', '손익비 낮게(RR 1.5↓) — 짧게 먹고 빠르게 회전, 출렁이는 장 특화'),
|
||
]
|
||
_BT_TOPN = 8
|
||
|
||
|
||
def _backtest_panel() -> str:
|
||
"""백테스트 스윕 순위표 — 검증기간(out-of-sample) 성적순. 행마다 '이 튜닝 적용'."""
|
||
res = _load(config.STATE_DIR / 'backtest_results.json', None)
|
||
if not res or not res.get('results'):
|
||
return ('<div class="empty">스윕 미실행 — 터미널에서 <code>python3 -m sim sweep</code> 실행 후 표시됩니다.</div>')
|
||
sp = res.get('split', {})
|
||
head = f'<div class="sub">검증 {sp.get("test","?")} · {res.get("count")}개 조합</div>'
|
||
st = _sweep_status()
|
||
if st.get('state') == 'running':
|
||
ctrl = ('<div class="sweep-bar"><span class="spin"></span>'
|
||
'스윕 계산 중… (약 1~2분 소요 · 끝나면 자동 갱신)</div>'
|
||
'<script>setTimeout(function(){location.href="/?t=lab&s=bt"},7000);</script>')
|
||
else:
|
||
err = ('<div class="sub" style="color:#ef4444">⚠ 직전 스윕 실패 — 로그 확인 필요</div>'
|
||
if st.get('state') == 'error' else '')
|
||
ctrl = (err + '<form method="post" action="/run_sweep" style="margin:0 0 10px">'
|
||
'<button type="submit" class="btn-sweep">↻ 다시 계산 (스윕 재실행 · 약 1~2분)</button></form>')
|
||
from . import variants as vmod
|
||
registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()}
|
||
main_p = config.load_params()
|
||
main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p})
|
||
min_tr = res.get('min_trades', 0) or 0
|
||
|
||
def _row_html(r, rank_label):
|
||
p, te, tr = r['params'], r['test'], r['train']
|
||
hidden = ''.join(f'<input type="hidden" name="{k}" value="{v:g}">' for k, v in p.items())
|
||
pk = _pkey(p)
|
||
vid = registered.get(pk)
|
||
is_main = pk == main_pkey
|
||
main_badge = ' <span class="badge-ic" title="메인 — 현재 적용된 튜닝">⭐</span>' if is_main else ''
|
||
cmp_badge = ' <span class="badge-ic" title="비교군에 등록됨">🔖</span>' if vid else ''
|
||
if vid:
|
||
cmp_btn = (f'<form method="post" action="/variants_remove">'
|
||
f'<input type="hidden" name="id" value="{vid}">'
|
||
f'<input type="hidden" name="back" value="bt">'
|
||
f'<button type="submit" class="btn-mini btn-danger">− 비교군에서 제거</button></form>')
|
||
elif is_main:
|
||
cmp_btn = '<div class="bt-mainnote">메인 — 비교 탭에 항상 포함</div>'
|
||
else:
|
||
cmp_btn = (f'<form method="post" action="/variants_add">{hidden}'
|
||
f'<button type="submit" class="btn-addcmp">+ 비교군에 추가</button></form>')
|
||
teret = te.get('total_return_pct', 0)
|
||
trret = tr.get('total_return_pct', 0)
|
||
tc = '#22c55e' if teret >= 0 else '#ef4444'
|
||
overfit = (trret - teret) > 40
|
||
of_tag = ' <span class="tag" style="background:#7c2d12;color:#fed7aa">과최적?</span>' if overfit else ''
|
||
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)
|
||
return (
|
||
f'<details class="row"><summary><span class="muted">{rank_label}</span> '
|
||
f'<span class="nm">{_esc(mf_sum)}</span>{main_badge}{cmp_badge}'
|
||
f'<span class="rt"><b style="color:{tc}">검증 {teret:+g}%</b></span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k">검증 수익 / 학습 수익</span>'
|
||
f'<span><b style="color:{tc}">{teret:+g}%</b> / {trret:+g}%{of_tag}</span></div>'
|
||
f'{_bt_alpha_row(te)}'
|
||
f'<div class="kv"><span class="k">검증 MDD</span><span>{mdd}%</span></div>'
|
||
f'<div class="kv"><span class="k">검증 거래수 / 승률</span><span>{te.get("trades")}건 · {wr}%</span></div>'
|
||
f'<div class="kv"><span class="k">검증 손익비(PF)</span><span>{pf}</span></div>'
|
||
f'<div class="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 class="bt-actions">'
|
||
f'<form method="post" action="/save_params">{hidden}'
|
||
f'<button type="submit" class="btn-apply">이 튜닝 적용</button></form>'
|
||
f'{cmp_btn}'
|
||
f'</div></div></details>'
|
||
)
|
||
|
||
# 중복(동일 params) 제거 → 타입 분류 → 타입별 검증수익 상위 N
|
||
uniq = {}
|
||
for r in res['results']:
|
||
uniq.setdefault(_pkey(r['params']), r)
|
||
|
||
def _sk(r):
|
||
te = r['test']
|
||
enough = (te.get('trades', 0) or 0) >= min_tr
|
||
return (1 if enough else 0, te.get('total_return_pct', -999) or -999)
|
||
|
||
grouped = {k: [] for k, _, _ in _BT_TYPES}
|
||
for r in uniq.values():
|
||
grouped.setdefault(_strategy_type(r['params']), []).append(r)
|
||
|
||
# 비교군 등록 조합 — 성적순위와 무관하게 항상 맨 위에 표시
|
||
reg_items = sorted([r for r in uniq.values() if _pkey(r['params']) in registered],
|
||
key=_sk, reverse=True)
|
||
cmp_section = ''
|
||
if reg_items:
|
||
cmp_section = ('<h3 class="bt-type">🔖 비교군 등록 조합'
|
||
f'<span class="bt-type-d">현재 병렬 비교 중인 {len(reg_items)}개 (성적순위 무관 항상 표시)</span></h3>')
|
||
for j, r in enumerate(reg_items):
|
||
cmp_section += _row_html(r, f'#{j + 1}')
|
||
|
||
sections = (f'<div class="sub" style="margin:2px 0 10px">베팅 성향 타입별 검증수익 상위 {_BT_TOPN}개씩 · '
|
||
f'비교군 추가는 각 행에서 (총 고유 {len(uniq)}조합)</div>')
|
||
for tk, tlabel, tdesc in _BT_TYPES:
|
||
grp = sorted(grouped.get(tk, []), key=_sk, reverse=True)[:_BT_TOPN]
|
||
if not grp:
|
||
continue
|
||
sections += (f'<h3 class="bt-type">{tlabel}<span class="bt-type-d">{tdesc}</span></h3>')
|
||
for j, r in enumerate(grp):
|
||
sections += _row_html(r, f'#{j + 1}')
|
||
return head + ctrl + f'<div id="bt-rows">{cmp_section}{sections}</div>'
|
||
|
||
|
||
def _compare_panel() -> str:
|
||
"""병렬 페이퍼 비교 — 메인 + 변이들의 실시간 누적 성적.
|
||
|
||
변이 정의(variants.json) + 각 계좌(portfolio.json)에서 직접 읽어 렌더 →
|
||
추가/초기화 버튼이 다음 스캔을 기다리지 않고 즉시 반영된다.
|
||
"""
|
||
from . import variants as vmod, benchmark
|
||
defs = vmod.load_variants()
|
||
today = datetime.now(config.KST).strftime('%Y%m%d')
|
||
|
||
def _row(rid, name, params, pf):
|
||
s = pf.summary()
|
||
return {'id': rid, 'name': name, 'params': params, 'summary': s,
|
||
'benchmark': benchmark.compare(s.get('total_return_pct'),
|
||
benchmark.norm_date(pf.created_at), today)}
|
||
|
||
# 메인 + 변이 계좌를 소스에서 직접 수집
|
||
rows_data = [_row('main', '메인 (현재 튜닝)', config.load_params(), Portfolio.load())]
|
||
for v in defs:
|
||
pp, tp = vmod.variant_paths(v['id'])
|
||
rows_data.append(_row(v['id'], v.get('name', v['id']), v.get('params') or {},
|
||
Portfolio.load(pp, tp)))
|
||
rows_data.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
|
||
|
||
scanned = _load(config.LAST_SCAN_PATH, {}).get('scanned_at', '')[:16].replace('T', ' ')
|
||
head = (f'<div class="sub">실시간 누적 비교 · 마지막 스캔 {scanned or "—"} '
|
||
f'· {len(rows_data)}개 (메인 포함)</div>'
|
||
'<div class="sub" style="color:#b45309">⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. '
|
||
'기간 길수록·거래 많을수록 신뢰 · 변이는 백테스트 탭의 「+ 비교군에 추가」로 등록</div>')
|
||
clear_btn = ('' if not defs else
|
||
'<form method="post" action="/variants_clear_all" style="display:inline;margin-left:8px" '
|
||
'onsubmit="return confirm(\'메인을 제외한 비교군 변이를 전부 삭제할까요?\')">'
|
||
f'<button type="submit" class="cmp-clear">🗑 전체삭제 {len(defs)}</button></form>')
|
||
title = f'<h2 class="cmp-title">병렬 페이퍼 비교{clear_btn}</h2>'
|
||
rows = ''
|
||
for i, v in enumerate(rows_data):
|
||
s = v['summary']
|
||
ret = s.get('total_return_pct', 0) or 0
|
||
rc = '#22c55e' if ret >= 0 else '#ef4444'
|
||
is_main = v['id'] == 'main'
|
||
tag = ' <span class="badge-ic" title="메인 — 현재 적용된 튜닝">⭐</span>' if is_main else ''
|
||
plabel = '·'.join(f'{k.split("_")[0]}{val:g}' for k, val in (v.get('params') or {}).items()) or '기본값'
|
||
mp = v.get('params') or config.load_params()
|
||
mf_t, mf_b = _market_fit(mp)
|
||
mf_sum = _market_fit_summary(mp)
|
||
actions = '' if is_main else (
|
||
f'<div class="cmp-row-actions">'
|
||
f'<form method="post" action="/variants_reset_one" '
|
||
f'onsubmit="return confirm(\'이 변이 누적 성적을 0으로 초기화할까요?\')">'
|
||
f'<input type="hidden" name="id" value="{_esc(v["id"])}">'
|
||
f'<button type="submit" class="btn-mini btn-warn">성적 초기화</button></form>'
|
||
f'<form method="post" action="/variants_remove" '
|
||
f'onsubmit="return confirm(\'이 변이를 삭제할까요?\')">'
|
||
f'<input type="hidden" name="id" value="{_esc(v["id"])}">'
|
||
f'<button type="submit" class="btn-mini btn-danger">삭제</button></form>'
|
||
f'</div>'
|
||
)
|
||
rows += (
|
||
f'<details class="row"{" open" if i == 0 else ""}><summary>'
|
||
f'<span class="muted">#{i+1}</span> <span class="nm">{_esc(mf_sum)}</span>{tag}'
|
||
f'<span class="rt"><b style="color:{rc}">{ret:+g}%</b></span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k">평가자산</span><span>{s.get("equity",0):,}원</span></div>'
|
||
f'<div class="kv"><span class="k">예수금 / 주식</span><span>{s.get("cash",0):,} / {s.get("held_value",0):,}</span></div>'
|
||
f'<div class="kv"><span class="k">수익률</span><span style="color:{rc}">{ret:+g}%</span></div>'
|
||
f'<div class="kv"><span class="k">실현손익</span><span>{s.get("realized_pnl",0):,}원</span></div>'
|
||
f'<div class="kv"><span class="k">거래 / 승률</span><span>{s.get("closed_trades",0)}건 · {s.get("win_rate_pct")}%</span></div>'
|
||
f'<div class="kv"><span class="k">MDD / 보유</span><span>{s.get("max_drawdown_pct",0)}% · {s.get("open_positions",0)}종목</span></div>'
|
||
f'<div class="kv"><span class="k">지수대비(알파)</span><span>{_alpha_inline(v.get("benchmark") or {})}</span></div>'
|
||
f'<div class="kv"><span class="k">파라미터</span><span class="muted">{_esc(plabel)}</span></div>'
|
||
f'<button type="button" class="mfit" data-title="{_esc(mf_t)}" data-detail="{_esc(mf_b)}">🌐 어떤 시장에 강한가</button>'
|
||
f'{actions}'
|
||
f'</div></details>'
|
||
)
|
||
return title + head + rows
|
||
|
||
|
||
def _engine_status(snap, summ) -> 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 = (f"자산 {summ.get('equity', 0):,}원 · 보유 {summ.get('open_positions', 0)}종목 "
|
||
f"· 현금 {summ.get('cash', 0):,}원")
|
||
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 render_html() -> str:
|
||
snap = _load(config.LAST_SCAN_PATH, {})
|
||
pf = Portfolio.load() # 계좌 진실원 — 스캔 스냅샷 없어도 현재 상태 표시
|
||
trades = []
|
||
if config.TRADES_PATH.exists():
|
||
for line in config.TRADES_PATH.read_text().splitlines()[-30:]:
|
||
try:
|
||
trades.append(json.loads(line))
|
||
except Exception:
|
||
pass
|
||
trades.reverse()
|
||
|
||
s = pf.summary()
|
||
scanned = snap.get('scanned_at', '')[:19].replace('T', ' ')
|
||
hcls, hdetail = _engine_status(snap, s)
|
||
ret = s.get('total_return_pct', 0) or 0
|
||
ret_color = '#22c55e' if ret >= 0 else '#ef4444'
|
||
|
||
# ---- 계좌 요약 (한 줄씩 나열) ----
|
||
wr = s.get('win_rate_pct')
|
||
kpis = (
|
||
_kvline('평가자산', f"{s.get('equity', 0):,}원 <span class=\"muted\">(초기 {s.get('initial', 0):,})</span>") +
|
||
_kvline('총보유(주식)', f"{s.get('held_value', 0):,}원") +
|
||
_kvline('예수금', f"{s.get('cash', 0):,}원") +
|
||
_kvline('수익률', f'<b style="color:{ret_color}">{ret:+.2f}%</b>') +
|
||
_kvline('실현손익', f"{s.get('realized_pnl', 0):,}원") +
|
||
_kvline('승률', f"{wr}% <span class=\"muted\">({s.get('closed_trades', 0)}건 청산)</span>" if wr is not None else '—') +
|
||
_kvline('MDD', f"{s.get('max_drawdown_pct', 0)}%") +
|
||
_kvline('보유', f"{s.get('open_positions', 0)}종목")
|
||
)
|
||
# 기준지수 대비(알파) — 시작일 대비 같은 기간 지수 매수후보유와 비교
|
||
bench = snap.get('benchmark', {}) or {}
|
||
for idx, lbl in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
|
||
b = (bench.get('indices') or {}).get(idx, {})
|
||
a, ip = b.get('alpha'), b.get('pct')
|
||
if a is None:
|
||
kpis += _kvline(f'{lbl} 대비(알파)', '— <span class="muted">(지수 데이터 없음)</span>')
|
||
else:
|
||
ac = '#22c55e' if a >= 0 else '#ef4444'
|
||
kpis += _kvline(f'{lbl} 대비(알파)',
|
||
f'<b style="color:{ac}">{a:+.2f}%p</b> <span class="muted">(지수 {ip:+.2f}%)</span>')
|
||
|
||
# ---- 보유 포지션 ----
|
||
pos_rows = ''
|
||
for p in pf.positions.values():
|
||
entry, qty = p['entry_price'], p['qty']
|
||
cur = p.get('cur_price', entry)
|
||
pnl = (cur / entry - 1) * 100
|
||
pnl_amt = int(round((cur - entry) * qty))
|
||
pc = '#22c55e' if pnl >= 0 else '#ef4444'
|
||
evalv = cur * qty
|
||
trail = ' <span class="tag">트레일링</span>' if p.get('trailing_on') else ''
|
||
tr_n, tr_max = p.get('tranches', 1), config.ENTRY_TRANCHES
|
||
scaled = ' <span class="tag">일부익절</span>' if p.get('scaled_out') else ''
|
||
entry_at = _esc((p.get('entry_at') or '')[:16].replace('T', ' '))
|
||
pos_rows += (
|
||
f'<details class="row"><summary>'
|
||
f'<span class="nm">{_esc(p["name"])}</span>'
|
||
f'<span class="rt">{cur:,} <b style="color:{pc}">{pnl:+.1f}%</b></span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k">수량</span><span>{qty:,}주 <span class="muted">({tr_n}/{tr_max}분할)</span>{scaled}</span></div>'
|
||
f'<div class="kv"><span class="k">평단</span><span>{entry:,.0f}원</span></div>'
|
||
f'<div class="kv"><span class="k">평가금액</span><span>{evalv:,}원</span></div>'
|
||
f'<div class="kv"><span class="k">평가손익</span><span style="color:{pc}">{pnl_amt:+,}원 ({pnl:+.1f}%)</span></div>'
|
||
f'<div class="kv"><span class="k">손절</span><span>{p["stop"]:,}원</span></div>'
|
||
f'<div class="kv"><span class="k">목표</span><span>{p["target"]:,}원{trail}</span></div>'
|
||
f'<div class="kv"><span class="k">진입사유</span><span>{_esc(p.get("entry_reason",""))}</span></div>'
|
||
f'<div class="kv"><span class="k">진입시각</span><span>{entry_at}</span></div>'
|
||
f'</div></details>'
|
||
)
|
||
pos_table = pos_rows or '<div class="empty">보유 종목 없음</div>'
|
||
|
||
# ---- 판단표 (state 그룹) ----
|
||
decisions = snap.get('decisions', [])
|
||
by_state = {}
|
||
for d in decisions:
|
||
by_state.setdefault(d.get('state', 'NODATA'), []).append(d)
|
||
|
||
judge_html = ''
|
||
for st in STATE_ORDER:
|
||
items = by_state.get(st, [])
|
||
if not items:
|
||
continue
|
||
label, color = STATE_BADGE.get(st, (st, '#888'))
|
||
judge_html += f'<h3 class="grp"><span class="badge" style="background:{color}">{label}</span> {len(items)}</h3>'
|
||
for d in items:
|
||
chips = _chips(d.get('checks', []))
|
||
src = '/'.join(ORIGIN_LABEL.get(s, s) for s in d.get('sources', []))
|
||
price = d.get('price')
|
||
price_h = f'{price:,}' if isinstance(price, (int, float)) else '—'
|
||
pnl_h = f' · <span class="muted">{d["pnl_pct"]:+.1f}%</span>' if d.get('pnl_pct') is not None else ''
|
||
del_btn = (f'<form method="post" action="/watch_remove" class="card-del" '
|
||
f'onsubmit="return confirm(\'관찰목록에서 삭제할까요? (보유 중이면 청산까지는 계속 추적)\')">'
|
||
f'<input type="hidden" name="code" value="{_esc(d.get("code"))}">'
|
||
f'<button type="submit" title="관찰목록에서 삭제">✕</button></form>')
|
||
judge_html += (
|
||
f'<div class="card"><div class="card-h">'
|
||
f'<b>{_esc(d.get("name"))}</b> <span class="muted">{_esc(d.get("code"))} · {_esc(src)}</span>'
|
||
f'<span class="price">{price_h}{pnl_h}</span>{del_btn}</div>'
|
||
f'<div class="reason">{_esc(d.get("reason",""))}</div>'
|
||
f'<div class="chks">{chips}</div></div>'
|
||
)
|
||
|
||
# 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시
|
||
from . import universe as umod
|
||
wl = umod._load_watchlist()
|
||
decided = {d.get('code') for d in decisions}
|
||
pending = [(c, e) for c, e in wl.items() if c not in decided]
|
||
if pending:
|
||
judge_html += '<h3 class="grp"><span class="badge" style="background:#6b7280">스캔 대기</span> {}</h3>'.format(len(pending))
|
||
for code, e in pending:
|
||
src = ORIGIN_LABEL.get(e.get('origin', ''), e.get('origin', ''))
|
||
del_btn = (f'<form method="post" action="/watch_remove" class="card-del" '
|
||
f'onsubmit="return confirm(\'관찰목록에서 삭제할까요?\')">'
|
||
f'<input type="hidden" name="code" value="{_esc(code)}">'
|
||
f'<button type="submit" title="관찰목록에서 삭제">✕</button></form>')
|
||
judge_html += (
|
||
f'<div class="card"><div class="card-h">'
|
||
f'<b>{_esc(e.get("name") or code)}</b> <span class="muted">{_esc(code)} · {_esc(src)}</span>'
|
||
f'<span class="price muted">다음 스캔 대기</span>{del_btn}</div></div>'
|
||
)
|
||
|
||
mkt = snap.get('market_ok', {})
|
||
mkt_h = ' · '.join(f'{k} {"양호" if v else "약세"}' for k, v in mkt.items()) or '—'
|
||
market_html = _market_panel()
|
||
tune_html = _tune_panel()
|
||
bt_html = _backtest_panel()
|
||
cmp_html = _compare_panel()
|
||
|
||
# ---- 거래내역 ----
|
||
tr_rows = ''
|
||
for r in trades:
|
||
ts = _esc(r["ts"][5:16].replace('T', ' '))
|
||
if r['side'] == 'BUY':
|
||
st_line = (f'<div class="kv"><span class="k">손절/목표</span>'
|
||
f'<span>{r["stop"]:,} / {r["target"]:,}</span></div>') if r.get('stop') else ''
|
||
tr_rows += (
|
||
f'<details class="row"><summary>'
|
||
f'<span style="color:#22c55e;font-weight:600">매수</span> <span class="nm">{_esc(r["name"])}</span>'
|
||
f'<span class="rt muted">{ts}</span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k">체결</span><span>{r["qty"]}주 @ {r["price"]:,}원</span></div>'
|
||
f'<div class="kv"><span class="k">매수금액</span><span>{r.get("cost",0):,}원</span></div>'
|
||
f'<div class="kv"><span class="k">사유</span><span>{_esc(r.get("reason",""))}</span></div>'
|
||
f'{st_line}'
|
||
+ (f'<div class="kv"><span class="k">판단근거</span></div><div class="chks">{_chips(r.get("signals"))}</div>'
|
||
if r.get('signals') else '')
|
||
+ '</div></details>'
|
||
)
|
||
else:
|
||
pc = '#22c55e' if r.get('pnl', 0) >= 0 else '#ef4444'
|
||
tr_rows += (
|
||
f'<details class="row"><summary>'
|
||
f'<span style="color:#ef4444;font-weight:600">매도</span> <span class="nm">{_esc(r["name"])}</span>'
|
||
f'<span class="rt"><b style="color:{pc}">{r.get("pnl_pct",0):+.1f}%</b> <span class="muted">{ts}</span></span></summary>'
|
||
f'<div class="body">'
|
||
f'<div class="kv"><span class="k">체결</span><span>{r["qty"]}주 @ {r["price"]:,}원</span></div>'
|
||
f'<div class="kv"><span class="k">실현손익</span><span style="color:{pc}">{r.get("pnl",0):+,}원 ({r.get("pnl_pct",0):+.1f}%)</span></div>'
|
||
f'<div class="kv"><span class="k">평단</span><span>{r.get("entry_price",0):,}원</span></div>'
|
||
f'<div class="kv"><span class="k">사유</span><span>{_esc(r.get("reason",""))}</span></div>'
|
||
+ (f'<div class="kv"><span class="k">판단근거</span></div><div class="chks">{_chips(r.get("signals"))}</div>'
|
||
if r.get('signals') else '')
|
||
+ '</div></details>'
|
||
)
|
||
tr_table = tr_rows or '<div class="empty">거래 없음</div>'
|
||
|
||
return f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
|
||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||
<title>레이 시뮬 — 자동매매 판단</title>
|
||
<style>
|
||
*{{box-sizing:border-box}}
|
||
html,body{{overscroll-behavior-y:none;touch-action:manipulation}}
|
||
body{{margin:0;background:#0b0f14;color:#e5e7eb;font:14px/1.5 -apple-system,'Apple SD Gothic Neo',sans-serif;padding:14px;max-width:980px;margin:0 auto}}
|
||
h1{{font-size:16px;margin:0 0 2px}} .sub{{color:#9ca3af;font-size:11px;margin:0 0 8px}}
|
||
.kpis{{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-bottom:18px}}
|
||
.kpi{{background:#141b24;border:1px solid #1f2937;border-radius:10px;padding:10px 12px}}
|
||
.kpi-label{{color:#9ca3af;font-size:11px}} .kpi-val{{font-size:18px;font-weight:700;margin-top:2px}}
|
||
.kpi-sub{{color:#6b7280;font-size:10px;margin-top:2px}}
|
||
h2{{font-size:15px;margin:22px 0 8px;border-bottom:1px solid #1f2937;padding-bottom:6px}}
|
||
h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
||
.badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}}
|
||
.row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}}
|
||
.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;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 .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}}
|
||
.chk-i{{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}}
|
||
.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;align-items:center}}
|
||
.cmp-clear{{background:#3a1518;color:#fca5a5;border:1px solid #7f1d1d;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;cursor:pointer}}
|
||
.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-acct:checked~.hd label[for="tab-acct"],#tab-watch:checked~.hd label[for="tab-watch"],#tab-market:checked~.hd label[for="tab-market"],#tab-lab:checked~.hd label[for="tab-lab"]{{background:#1e3a8a;color:#fff;border-color:#2952cc}}
|
||
.panel{{display:none}}
|
||
#tab-acct:checked~#p-acct,#tab-watch:checked~#p-watch,#tab-market:checked~#p-market,#tab-lab:checked~#p-lab{{display:block}}
|
||
.subtabs{{display:flex;gap:6px;margin:0 0 14px}}
|
||
.subtabs label{{flex:1;text-align:center;padding:8px 6px;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:12px;font-weight:600;cursor:pointer;background:#141b24}}
|
||
#sub-tune:checked~#p-lab label[for="sub-tune"],#sub-bt:checked~#p-lab label[for="sub-bt"],#sub-cmp:checked~#p-lab label[for="sub-cmp"]{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||
#p-lab .subpanel{{display:none}}
|
||
#p-lab .subpanel h2{{margin-top:4px}}
|
||
.sortbar{{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}}
|
||
.sortbar button{{background:#141b24;border:1px solid #1f2937;border-radius:7px;color:#9ca3af;font-size:12px;font-weight:600;padding:7px 10px;cursor:pointer;font-variant-numeric:tabular-nums}}
|
||
.sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||
.btn-sweep{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:8px;padding:10px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}}
|
||
.sweep-bar{{display:flex;align-items:center;gap:9px;background:#13233a;border:1px solid #2952cc;border-radius:8px;padding:11px 13px;margin:0 0 10px;font-size:13px;color:#cbd5e1}}
|
||
.spin{{width:14px;height:14px;border:2px solid #93c5fd;border-top-color:transparent;border-radius:50%;animation:ptr-spin .7s linear infinite;flex:none}}
|
||
.bt-actions{{display:flex;gap:8px;margin-top:8px}}
|
||
.bt-actions form{{flex:1;margin:0}}
|
||
.bt-actions button{{width:100%}}
|
||
.btn-addcmp{{background:#0e2a4d;color:#fff;border:1px solid #2952cc;border-radius:8px;padding:9px 10px;font-size:13px;font-weight:600;cursor:pointer}}
|
||
.bt-mainnote{{flex:1;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:12px;border:1px dashed #2a3142;border-radius:8px;padding:9px;text-align:center}}
|
||
.watch-add{{display:flex;gap:8px;margin:0 0 8px}}
|
||
.watch-add input{{flex:1;background:#0f151c;border:1px solid #2a3142;border-radius:8px;color:#e5e7eb;padding:9px 11px;font-size:14px;color-scheme:dark}}
|
||
.watch-add button{{flex:none;white-space:nowrap}}
|
||
.wsearch-results{{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px}}
|
||
.wsearch-results.hidden{{display:none}}
|
||
.wsearch-hit-f{{margin:0}}
|
||
.wsearch-hit{{background:#0f151c;border:1px solid #2a3142;color:#e5e7eb;border-radius:8px;padding:8px 11px;font-size:13px;cursor:pointer;font-variant-numeric:tabular-nums}}
|
||
.wsearch-hit:hover{{border-color:#2952cc;color:#bfdbfe}}
|
||
.wsearch-empty{{color:#6b7280;font-size:12px;padding:4px 2px}}
|
||
.card-del{{margin:0 0 0 8px}}
|
||
.card-del button{{background:transparent;border:1px solid #2a3142;color:#9ca3af;border-radius:6px;width:24px;height:24px;font-size:12px;line-height:1;cursor:pointer;padding:0}}
|
||
.card-del button:hover{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}}
|
||
.cmp-row-actions{{display:flex;gap:8px;margin-top:10px}}
|
||
.cmp-row-actions form{{flex:1;margin:0}}
|
||
.btn-mini{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:7px;padding:9px 11px;font-size:12px;font-weight:600;cursor:pointer;width:100%}}
|
||
.btn-mini.btn-warn{{background:#3f2d0a;border-color:#b45309;color:#fcd34d}}
|
||
.btn-mini.btn-danger{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}}
|
||
#sub-tune:checked~#p-lab #ps-tune,#sub-bt:checked~#p-lab #ps-bt,#sub-cmp:checked~#p-lab #ps-cmp{{display:block}}
|
||
.btn-apply{{background:#15803d;color:#fff;border:1px solid #16a34a;border-radius:8px;padding:9px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}}
|
||
.tabs label{{font-size:12px;padding:9px 4px}}
|
||
.prow{{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid #161e29}}
|
||
.prow label{{flex:1;font-size:13px}}
|
||
.prow input{{width:100px;background:#0f151c;border:1px solid #2a3142;border-radius:7px;color:#e5e7eb;padding:8px;font-size:14px;text-align:right;font-variant-numeric:tabular-nums;color-scheme:dark}}
|
||
.prow-actions{{margin:16px 0 6px}}
|
||
.btn-save{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;width:100%}}
|
||
.btn-reset{{background:transparent;color:#9ca3af;border:1px solid #2a3142;border-radius:9px;padding:10px;font-size:13px;cursor:pointer;margin-top:10px;width:100%}}
|
||
.saved-banner{{position:fixed;top:8px;left:50%;transform:translateX(-50%);background:#22c55e;color:#06210f;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:700;z-index:70;box-shadow:0 4px 14px rgba(0,0,0,.4)}}
|
||
#page{{transition:transform .32s cubic-bezier(.2,.8,.2,1)}}
|
||
#page.dragging{{transition:none;will-change:transform}}
|
||
#ptr{{position:fixed;top:10px;left:50%;width:38px;height:38px;margin-left:-19px;background:rgba(28,34,48,.95);border:1px solid #2a3142;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#9ca3af;opacity:0;pointer-events:none;z-index:60;box-shadow:0 4px 14px rgba(0,0,0,.35);transition:color .18s,border-color .18s,background .18s}}
|
||
#ptr .arrow{{font-size:16px;font-weight:700;line-height:1}}
|
||
#ptr .spinner{{display:none;width:16px;height:16px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%}}
|
||
#ptr.committed{{color:#22c55e;border-color:#22c55e;background:rgba(34,197,94,.18)}}
|
||
#ptr.spinning .arrow{{display:none}}
|
||
#ptr.spinning .spinner{{display:block;animation:ptr-spin .7s linear infinite}}
|
||
@keyframes ptr-spin{{from{{transform:rotate(0)}}to{{transform:rotate(360deg)}}}}
|
||
@media(max-width:560px){{
|
||
body{{padding:10px}}
|
||
h1{{font-size:16px}}
|
||
.kpis{{grid-template-columns:repeat(auto-fit,minmax(98px,1fr));gap:6px}}
|
||
.kpi{{padding:8px 10px}} .kpi-val{{font-size:16px}}
|
||
.card-h{{font-size:13px}}
|
||
}}
|
||
</style></head><body>
|
||
<div id="ptr"><span class="arrow">↓</span><span class="spinner"></span></div>
|
||
<div id="page">
|
||
<input type="radio" name="tab" id="tab-acct" checked>
|
||
<input type="radio" name="tab" id="tab-watch">
|
||
<input type="radio" name="tab" id="tab-market">
|
||
<input type="radio" name="tab" id="tab-lab">
|
||
<input type="radio" name="subtab" id="sub-cmp" checked>
|
||
<input type="radio" name="subtab" id="sub-bt">
|
||
<input type="radio" name="subtab" id="sub-tune">
|
||
<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-acct">내 계좌</label><label for="tab-watch">관심종목 ({len(decisions)})</label><label for="tab-market">시장</label><label for="tab-lab">전략</label></nav>
|
||
</header>
|
||
<section class="panel" id="p-acct">
|
||
<div class="card" style="margin-bottom:18px">{kpis}</div>
|
||
<h2>보유 포지션</h2>{pos_table}
|
||
<h2>최근 거래</h2>{tr_table}
|
||
</section>
|
||
<section class="panel" id="p-watch">
|
||
<h2>관찰 중 종목 ({len(decisions)})</h2>
|
||
<div class="watch-add">
|
||
<input type="text" id="wq" placeholder="종목명 또는 6자리 코드 검색" aria-label="종목 검색" autocomplete="off">
|
||
<button type="button" id="wq-btn" class="btn-addcmp">검색</button></div>
|
||
<div id="wq-results" class="wsearch-results hidden"></div>
|
||
<div class="sub">종목명 일부만 입력해도 후보가 떠요 · 후보를 누르면 추가 · 자산(비하이브 워치·관심·보유)은 자동 편입 · 삭제는 카드의 ✕ (수동만) · 보유 중이면 삭제해도 청산까지 추적</div>
|
||
{judge_html}
|
||
</section>
|
||
<section class="panel" id="p-market">
|
||
<h2>시장 동향</h2>{market_html}
|
||
</section>
|
||
<section class="panel" id="p-lab">
|
||
<nav class="subtabs"><label for="sub-cmp">비교</label><label for="sub-bt">백테스트</label><label for="sub-tune">튜닝</label></nav>
|
||
<div class="subpanel" id="ps-cmp">{cmp_html}</div>
|
||
<div class="subpanel" id="ps-bt"><h2>백테스트 순위표</h2>{bt_html}</div>
|
||
<div class="subpanel" id="ps-tune"><h2>파라미터 튜닝</h2>{tune_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 not in ('/', '/index.html'):
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
return
|
||
body = render_html().encode('utf-8')
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||
self.send_header('Content-Length', str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_POST(self):
|
||
if self.path == '/run_sweep':
|
||
_trigger_sweep()
|
||
self.send_response(303)
|
||
self.send_header('Location', '/?t=lab&s=bt')
|
||
self.end_headers()
|
||
return
|
||
if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove', '/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=tune&saved=1')
|
||
self.end_headers()
|
||
|
||
def _handle_variants(self):
|
||
"""비교군 관리 — 추가(백테스트 행)·성적 초기화·삭제(비교 행). 파일 IO만이라 즉시 완료."""
|
||
from . import variants as vmod
|
||
length = int(self.headers.get('Content-Length') or 0)
|
||
raw = self.rfile.read(length).decode('utf-8') if length else ''
|
||
form = {k: v[-1] for k, v in urllib.parse.parse_qs(raw).items()}
|
||
# 어느 탭에서 눌렀는지 (back=bt → 백테스트 탭 유지, 없으면 비교 탭). add는 항상 bt.
|
||
back = form.get('back')
|
||
dest = f'/?t=lab&s={back}' if back in ('bt', 'cmp') else '/?t=lab&s=cmp'
|
||
try:
|
||
if self.path == '/variants_add':
|
||
keys = [spec['key'] for spec in config.TUNABLE]
|
||
params = {k: _num(form[k]) for k in keys if k in form}
|
||
main_p = config.load_params()
|
||
is_main = params and _pkey(params) == _pkey({k: main_p[k] for k in params if k in main_p})
|
||
if params and not is_main: # 메인과 동일 조합은 중복이라 추가 X
|
||
vmod.add_variant(params)
|
||
dest = '/?t=lab&s=bt' # 백테스트 탭에서 연속 추가 가능하도록 유지
|
||
elif self.path == '/variants_reset_one' and form.get('id'):
|
||
vmod.reset_variant(form['id'])
|
||
elif self.path == '/variants_remove' and form.get('id'):
|
||
vmod.remove_variant(form['id'])
|
||
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()
|