auto: 일일 백업 2026-06-10 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user