auto: 일일 백업 2026-06-10 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-10 02:00:02 +09:00
parent 0ba61d4d7c
commit 9cc7043490
13 changed files with 695 additions and 119 deletions
+397 -77
View File
@@ -19,7 +19,7 @@ import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import config
from . import config, engine
from .portfolio import Portfolio
BIND_HOST = ''
@@ -81,6 +81,7 @@ REFRESH_JS = """
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;});
@@ -227,20 +228,128 @@ def _pkey(params) -> str:
_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'}
'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 (판단근거 표시 공용)."""
return ''.join(
f'<span class="chk" style="color:{_check_color(c.get("ok"))}">'
f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}<span class="cd">{_esc(c.get("detail"))}</span></span>'
for c in (checks or [])
)
"""신호 체크리스트 → 칩 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=''):
@@ -248,6 +357,11 @@ def _kpi(label, value, sub=''):
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>'
@@ -256,6 +370,32 @@ def _signed(v, suffix='억'):
_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."""
@@ -306,12 +446,14 @@ def _market_panel() -> str:
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]
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'
@@ -324,8 +466,9 @@ def _market_panel() -> str:
rise, fall, steady = r.get('rise', 0), r.get('fall', 0), r.get('steady', 0)
total = rise + fall + steady
breadth = (rise / total * 100) if total else 0
ok = total and (rise / total) >= config.MARKET_BREADTH_MIN
vlabel, vcolor = ('양호', '#22c55e') if ok else ('약세', '#ef4444')
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))]
@@ -338,15 +481,20 @@ def _market_panel() -> str:
f'<div class="kv"><span class="k">ADR</span><span>{adr_s}</span></div>'
f'<div class="kv"><span class="k">상승 / 하락</span><span><span style="color:#22c55e">{rise:,}</span> / <span style="color:#ef4444">{fall:,}</span> (보합 {steady:,})</span></div>'
f'<div class="kv"><span class="k">상한 / 하한</span><span>{r.get("upper",0):,} / {r.get("lower",0):,}</span></div>'
f'<div class="kv"><span class="k">상승비율</span><span>{breadth:.1f}% · {vlabel} (기준 {config.MARKET_BREADTH_MIN*100:.0f}%)</span></div>'
f'<div class="kv"><span class="k">상승비율</span><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>'
)
note = ('<div class="sub" style="margin-top:6px">EOD(장 마감 후 수집) 데이터 · 신규 매수 필터에만 반영 '
'· 순매수 단위 억원</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
@@ -364,6 +512,7 @@ def _tune_panel() -> str:
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>'
)
@@ -395,17 +544,112 @@ def _bt_alpha_row(res: dict) -> str:
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("train","?")} → 검증 {sp.get("test","?")} · '
f'{res.get("count")}개 조합 · 수급반영 {res.get("flow_used")} · '
f'생성 {res.get("generated_at","")[:16].replace("T"," ")}</div>'
'<div class="sub" style="color:#b45309">⚠ 절대수익보다 <b>상대순위·검증기간 일관성</b>으로 판단 '
'(수급 중립·종가체결·현 워치리스트 기준)</div>')
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>'
@@ -416,35 +660,26 @@ def _backtest_panel() -> str:
if st.get('state') == 'error' else '')
ctrl = (err + '<form method="post" action="/run_sweep" style="margin:0 0 10px">'
'<button type="submit" class="btn-sweep">↻ 다시 계산 (스윕 재실행 · 약 1~2분)</button></form>')
sortbar = (
'<div class="sortbar" id="bt-sortbar">'
'<button data-sort="te">검증수익<span class="ar"></span></button>'
'<button data-sort="tr">학습수익<span class="ar"></span></button>'
'<button data-sort="mdd">MDD<span class="ar"></span></button>'
'<button data-sort="wr">승률<span class="ar"></span></button>'
'<button data-sort="pf">손익비<span class="ar"></span></button>'
'</div>'
)
from . import variants as vmod
registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()}
main_p = config.load_params()
main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p})
rows = ''
for i, r in enumerate(res['results'][:40]):
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="tag">메인</span>' if is_main else ''
cmp_badge = (' <span class="tag" style="background:#0e2a4d;color:#bfdbfe">비교군</span>'
if vid else '')
if vid: # 비교군 등록됨 → 제거 (메인 중복이라도 정리 가능)
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: # 메인은 비교 탭에 항상 포함 → 추가 불가
elif is_main:
cmp_btn = '<div class="bt-mainnote">메인 — 비교 탭에 항상 포함</div>'
else:
cmp_btn = (f'<form method="post" action="/variants_add">{hidden}'
@@ -452,20 +687,14 @@ def _backtest_panel() -> str:
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 # 학습≫검증 → 과최적 의심
overfit = (trret - teret) > 40
of_tag = ' <span class="tag" style="background:#7c2d12;color:#fed7aa">과최적?</span>' if overfit else ''
rank = f'<span class="muted">#{i+1}</span>'
# 정렬용 수치 (없는 값은 최하위로 — MDD는 낮을수록 좋아 큰값, 나머지는 작은값)
mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor')
d_te = teret if isinstance(teret, (int, float)) else -999
d_tr = trret if isinstance(trret, (int, float)) else -999
d_mdd = mdd if isinstance(mdd, (int, float)) else 9999
d_wr = wr if isinstance(wr, (int, float)) else -1
d_pf = 9e9 if pf == 'inf' else (pf if isinstance(pf, (int, float)) else -1)
rows += (
f'<details class="row" data-te="{d_te}" data-tr="{d_tr}" data-mdd="{d_mdd}" '
f'data-wr="{d_wr}" data-pf="{d_pf}"><summary>{rank} '
f'<span class="nm">{_esc(_plabel(p))}</span>{main_badge}{cmp_badge}'
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>'
@@ -474,13 +703,49 @@ def _backtest_panel() -> str:
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>'
)
return head + ctrl + sortbar + f'<div id="bt-rows">{rows}</div>' + _BT_SORT_JS
# 중복(동일 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:
@@ -512,14 +777,22 @@ def _compare_panel() -> str:
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="tag">메인</span>' if is_main else ''
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" '
@@ -534,7 +807,7 @@ def _compare_panel() -> str:
)
rows += (
f'<details class="row"{" open" if i == 0 else ""}><summary>'
f'<span class="muted">#{i+1}</span> <span class="nm">{_esc(v["name"])}</span>{tag}'
f'<span class="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>'
@@ -545,10 +818,37 @@ def _compare_panel() -> str:
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 head + rows
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:
@@ -565,21 +865,21 @@ def render_html() -> str:
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'
# ---- KPI ----
# ---- 계좌 요약 (한 줄씩 나열) ----
wr = s.get('win_rate_pct')
kpis = (
_kpi('평가자산', f"{s.get('equity', 0):,}", f"초기 {s.get('initial', 0):,}") +
_kpi('총보유(주식)', f"{s.get('held_value', 0):,}") +
_kpi('예수금', f"{s.get('cash', 0):,}") +
f'<div class="kpi"><div class="kpi-label">수익률</div>'
f'<div class="kpi-val" style="color:{ret_color}">{ret:+.2f}%</div></div>' +
_kpi('실현손익', f"{s.get('realized_pnl', 0):,}") +
_kpi('승률', f"{s.get('win_rate_pct')}%" if s.get('win_rate_pct') is not None else '',
f"{s.get('closed_trades', 0)}건 청산") +
_kpi('MDD', f"{s.get('max_drawdown_pct', 0)}%") +
_kpi('보유', f"{s.get('open_positions', 0)}종목")
_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 {}
@@ -587,12 +887,11 @@ def render_html() -> str:
b = (bench.get('indices') or {}).get(idx, {})
a, ip = b.get('alpha'), b.get('pct')
if a is None:
kpis += _kpi(f'{lbl} 대비', '', '지수 데이터 없음')
kpis += _kvline(f'{lbl} 대비(알파)', ' <span class="muted">(지수 데이터 없음)</span>')
else:
ac = '#22c55e' if a >= 0 else '#ef4444'
kpis += (f'<div class="kpi"><div class="kpi-label">{lbl} 대비(알파)</div>'
f'<div class="kpi-val" style="color:{ac}">{a:+.2f}%p</div>'
f'<div class="kpi-sub">지수 {ip:+.2f}%</div></div>')
kpis += _kvline(f'{lbl} 대비(알파)',
f'<b style="color:{ac}">{a:+.2f}%p</b> <span class="muted">(지수 {ip:+.2f}%)</span>')
# ---- 보유 포지션 ----
pos_rows = ''
@@ -734,10 +1033,10 @@ h2{{font-size:15px;margin:22px 0 8px;border-bottom:1px solid #1f2937;padding-bot
h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
.badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}}
.row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}}
.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;align-items:center;gap:8px;font-size:13px}}
.row>summary{{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}}
.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums}}
.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}}
@@ -753,6 +1052,24 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
.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}}
@@ -827,16 +1144,16 @@ input[name="tab"],input[name="subtab"]{{display:none}}
<input type="radio" name="tab" id="tab-watch">
<input type="radio" name="tab" id="tab-market">
<input type="radio" name="tab" id="tab-lab">
<input type="radio" name="subtab" id="sub-tune" checked>
<input type="radio" name="subtab" id="sub-cmp" checked>
<input type="radio" name="subtab" id="sub-bt">
<input type="radio" name="subtab" id="sub-cmp">
<input type="radio" name="subtab" id="sub-tune">
<header class="hd">
<h1>🪎 레이 자동매매 시뮬레이션</h1>
<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=kpis>{kpis}</div>
<div class="card" style="margin-bottom:18px">{kpis}</div>
<h2>보유 포지션</h2>{pos_table}
<h2>최근 거래</h2>{tr_table}
</section>
@@ -853,16 +1170,17 @@ input[name="tab"],input[name="subtab"]{{display:none}}
<h2>시장 동향</h2>{market_html}
</section>
<section class="panel" id="p-lab">
<nav class="subtabs"><label for="sub-tune">튜닝</label><label for="sub-bt">백테스트</label><label for="sub-cmp">비교</label></nav>
<div class="subpanel" id="ps-tune"><h2>파라미터 튜닝</h2>{tune_html}</div>
<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-cmp"><h2>병렬 페이퍼 비교</h2>{cmp_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>"""
@@ -902,7 +1220,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('Location', '/?t=lab&s=bt')
self.end_headers()
return
if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove'):
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'):
@@ -948,6 +1266,8 @@ class Handler(BaseHTTPRequestHandler):
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)