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
+5 -5
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -50,9 +50,9 @@
| `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 |
| `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790``https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요 |
| `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code` |
| `sim-scan` | 평일 0915:30 매 15분 (`StartInterval` 900s, 엔진이 장외/휴장 self-skip) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유. 방향=애널 약한게이트+가점, 타이밍=수급·기술. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 네이버 지수 60s 캐시 fetch(유일한 네트워크) |
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 4핵심(RR·STOP_ATR·SMA_LONG·PULLBACK_ATR) 81조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 종가체결·현 워치리스트(생존편향) 절대수익 아닌 **상대순위**로만. 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 |
| `sim-scan` | 평일 09:0015:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map``engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 26개. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) |
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 조합당 ~2.3s → 1152조합 ≈ 44분(GRID 곱셈 폭발 주의). ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 |
휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등.
+18 -4
View File
@@ -14,9 +14,22 @@ from __future__ import annotations
import math
import sqlite3
import sys
from datetime import datetime
from . import config, signals
def _cal_days(entry_date: str | None, cur_date: str) -> int | None:
"""'YYYYMMDD' 사이 달력 일수 — 시간손절용. 파싱 실패 시 None."""
if not entry_date:
return None
try:
d0 = datetime.strptime(entry_date, '%Y%m%d')
d1 = datetime.strptime(cur_date, '%Y%m%d')
return max(0, (d1 - d0).days)
except Exception:
return None
sys.path.insert(0, str(config.SCRIPTS))
import daily_candles_cache as dcc # noqa: E402
@@ -111,7 +124,8 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
dates_seen = [c['date'] for c in h[:i + 1]]
flow = _flow_net_upto(flows.get(code), dates_seen)
pos = positions[code]
dec = signals.evaluate_holding(pos, ind, flow, None)
dec = signals.evaluate_holding(pos, ind, flow, None,
held_days=_cal_days(pos.get('entry_date'), day))
pos.update(dec['position_update'])
act = dec['action']
if act in ('sell', 'scale_out'):
@@ -170,18 +184,18 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
if dec['action'] == 'buy':
fill = dec['buy_price']
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
eq = equity(day_prices)
tranche_val = (eq / config.MAX_POSITIONS) / max(1, config.ENTRY_TRANCHES)
tranche_val = signals.target_value(eq, fill, stop) / max(1, config.ENTRY_TRANCHES)
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty < 1 or cost > cash:
continue
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
cash -= cost
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
'last_add_price': fill}
'last_add_price': fill, 'entry_date': day}
eq = equity(day_prices)
if eq > peak_eq:
+16 -1
View File
@@ -28,11 +28,15 @@ SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%)
# ---- 포지션 사이징 ----
MAX_POSITIONS = 8 # 동시 보유 최대 종목수
# 종목당 목표 비중 = 자산 / MAX_POSITIONS
# 종목당 목표 비중 = 자산 / MAX_POSITIONS (= 균등비중 상한)
RISK_PER_TRADE_PCT = 0.01 # 리스크 기반 사이징: 종목당 손실 위험 = 자산 × 이 값. 수량 = 자산×이값 ÷ (진입가−손절가), 균등비중 상한으로 캡. 0=끔(순수 균등비중)
# ---- 지표 기간 ----
SMA_SHORT = 5
SMA_LONG = 20
SMA_MID = 60 # 중기 추세선 — 정배열 확인(5>20>60). 데이터 부족 시 게이트 미적용
TREND_SLOPE_DAYS = 5 # 20일선 기울기 판정: 현재 20일선 vs N일 전 20일선(우상향 여부)
REL_STRENGTH_DAYS = 20 # 상대강도: 종목 N일 수익률 − 시장지수 N일 수익률 (라이브 전용)
ATR_PERIOD = 14
BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간
SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간
@@ -44,6 +48,9 @@ VOLUME_BREAKOUT_MULT = 1.5 # 돌파 매수: 거래량 ≥ 평균 × 이
TURNOVER_BREAKOUT_MULT = 1.5 # 돌파 확인: 오늘 회전율 ≥ 20일 평균 회전율 × 이 배수 (데이터 없으면 통과)
TURNOVER_OVERHEAT_MULT = 3.0 # 과열 보류: 오늘 회전율 ≥ 평균 × 이 배수면 신규매수 보류 (돌파 배수보다 높게)
PULLBACK_ATR_MULT = 1.0 # 눌림목 지정가 = 전일종가 − ATR × 이 배수 (20일선과 비교 후 위쪽)
RSI_PERIOD = 14 # RSI 계산 기간 (고정 상수 — 튜닝 비노출)
RSI_OVERBOUGHT = 78.0 # 돌파 매수 시 RSI ≥ 이 값이면 과열 추격으로 보고 보류
RSI_OVERSOLD = 25.0 # 눌림목 매수 시 RSI < 이 값이면 낙하 중(떨어지는 칼)으로 보고 보류
# ---- 분할매수·추격매수 ----
ENTRY_TRANCHES = 2 # 종목당 목표비중을 이 횟수로 나눠 진입 (1=전량 1회). 1차 = 목표/이 값
@@ -55,6 +62,7 @@ RR_RATIO = 2.0 # 목표 = 진입가 + (진입가−손절) ×
SCALE_OUT_FRAC = 0.0 # 분할매도: 목표 도달 시 보유량의 이 비율 익절 후 잔량 트레일링 (0=분할매도 끔, 목표 도달 시 전량 트레일링 전환)
TRAIL_ATR_MULT = 1.5 # 트레일링: 고점 − ATR × 이 배수
TRAIL_ATR_MULT_TIGHT = 1.0 # 컨센 목표가 부근에서 트레일링 강화
MAX_HOLD_DAYS = 20 # 시간손절: 보유 N일(달력) 경과 & 미진전(트레일링 전 + 진입가 이하)이면 청산해 자본 회전. 0=끔
# ---- 애널리스트 게이트 (약한 게이트) ----
ANALYST_MIN_UPSIDE_PCT = 5.0 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외
@@ -62,6 +70,8 @@ ANALYST_SELL_OPINION_MAX = 2.5 # 투자의견(1매도~5매수) 평균이 이
# ---- 시장 필터 (소프트) ----
MARKET_BREADTH_MIN = 0.15 # 해당 시장 당일 상승비율 < 이 값이면 그 시장 신규매수 보류
MARKET_BREADTH_STRONG = 0.60 # 상승비율 ≥ 이 값이면 지수가 20일선 아래여도 강세 인정 (지수추세 소프트 완화)
BEAR_ENTRY_MODE = 1 # 약세 국면(혼조·약세·급락) 신규매수: 0=완전 현금화(돌파도 보류) / 1=돌파만 허용 / 2=눌림목도 허용
# ---- 운영 시간 (KST) ----
MARKET_OPEN = (9, 0)
@@ -87,15 +97,20 @@ TUNABLE = [
{'key': 'PULLBACK_ATR_MULT', 'label': '눌림목 ATR 배수', 'type': 'float', 'min': 0.0, 'max': 3.0, 'step': 0.1, 'group': '매수'},
{'key': 'ENTRY_TRANCHES', 'label': '분할매수 횟수', 'type': 'int', 'min': 1, 'max': 5, 'step': 1, 'group': '매수'},
{'key': 'ADD_ATR_MULT', 'label': '추격매수 ATR 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매수'},
{'key': 'RSI_OVERBOUGHT', 'label': 'RSI 과열 컷(돌파)', 'type': 'float', 'min': 50.0, 'max': 95.0, 'step': 1.0, 'group': '매수'},
{'key': 'RSI_OVERSOLD', 'label': 'RSI 과매도 컷(눌림목)', 'type': 'float', 'min': 5.0, 'max': 50.0, 'step': 1.0, 'group': '매수'},
{'key': 'STOP_ATR_MULT', 'label': '손절 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
{'key': 'RR_RATIO', 'label': '손익비(목표/손절)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매도'},
{'key': 'SCALE_OUT_FRAC', 'label': '분할매도 익절 비율', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.05, 'group': '매도'},
{'key': 'TRAIL_ATR_MULT', 'label': '트레일링 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
{'key': 'TRAIL_ATR_MULT_TIGHT', 'label': '트레일링 강화 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매도'},
{'key': 'MAX_HOLD_DAYS', 'label': '시간손절(보유 최대 일)', 'type': 'int', 'min': 0, 'max': 120, 'step': 1, 'group': '매도'},
{'key': 'ANALYST_MIN_UPSIDE_PCT', 'label': '최소 상승여력(%)', 'type': 'float', 'min': -50.0, 'max': 100.0, 'step': 1.0, 'group': '애널·시장'},
{'key': 'ANALYST_SELL_OPINION_MAX', 'label': '매도의견 컷(1~5)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '애널·시장'},
{'key': 'MARKET_BREADTH_MIN', 'label': '시장 상승비율 하한', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.01, 'group': '애널·시장'},
{'key': 'BEAR_ENTRY_MODE', 'label': '약세장 매수(0현금/1돌파/2눌림)', 'type': 'int', 'min': 0, 'max': 2, 'step': 1, 'group': '애널·시장'},
{'key': 'MAX_POSITIONS', 'label': '동시 보유 최대 종목', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '포지션'},
{'key': 'RISK_PER_TRADE_PCT', 'label': '종목당 리스크 비율', 'type': 'float', 'min': 0.0, 'max': 0.1, 'step': 0.005, 'group': '포지션'},
]
# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용)
+138 -11
View File
@@ -7,12 +7,94 @@ from __future__ import annotations
import json
import sys
import time
from datetime import datetime, timedelta
from . import config, data, signals, universe
from . import benchmark, config, data, signals, universe
from .portfolio import Portfolio
def _held_days(position, now) -> int | None:
"""보유 경과 일수(달력) — entry_at 기준. 시간손절 판단용. 파싱 실패 시 None."""
ts = position.get('entry_at')
if not ts:
return None
try:
return max(0, (now - datetime.fromisoformat(ts)).days)
except Exception:
return None
def _index_trend_ok() -> dict:
"""지수별 추세 — 지수 종가가 자기 SMA_LONG 위면 True. 데이터 부족 시 True(비차단)."""
out = {}
for mk in ('KOSPI', 'KOSDAQ'):
store = benchmark.series(mk)
if not store:
continue
closes = [v for _, v in sorted(store.items())]
if len(closes) < config.SMA_LONG + 1:
out[mk] = True
continue
sma = sum(closes[-config.SMA_LONG:]) / config.SMA_LONG
out[mk] = closes[-1] >= sma
return out
def _index_return(mk: str, days: int):
"""지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None."""
s = sorted(benchmark.series(mk).items())
if len(s) > days and s[-(days + 1)][1]:
return (s[-1][1] / s[-(days + 1)][1] - 1) * 100
return None
def _index_daily_change() -> dict:
"""지수별 당일 등락률(%) — 전일 종가 대비. 데이터 부족 시 키 없음."""
out = {}
for mk in ('KOSPI', 'KOSDAQ'):
s = sorted(benchmark.series(mk).items())
if len(s) >= 2 and s[-2][1]:
out[mk] = (s[-1][1] / s[-2][1] - 1) * 100
return out
# 시장 국면 6단계 — (이모지, 라벨, 매수허용, 설명, 색상). market_ok·시장탭 배지 공용.
REGIME = {
'strong': ('🔥', '강세', True, '상승 종목 많고 지수도 20일선 위 — 추세 양호', '#16a34a'),
'rebound': ('📈', '반등', True, '상승 종목 많지만 지수는 아직 20일선 아래 — 약세 탈출 시도', '#22c55e'),
'mild': ('🌤', '완만 상승', True, '상승이 우위지만 강하진 않음', '#65a30d'),
'mixed': ('', '혼조', False, '상승·하락 비슷 — 방향 불명, 신규매수 보류', '#6b7280'),
'weak': ('📉', '약세', False, '하락 종목 우위 — 신규매수 보류', '#ef4444'),
'crash': ('❄️', '급락', False, '대부분 하락 + 지수 급락 — 신규매수 보류', '#b91c1c'),
}
def market_regime_map() -> dict:
"""시장별 국면(6단계) — 상승비율(폭) + 지수추세(방향) + 당일등락(강도) 조합.
상승비율 항상 최신(네이버, 실패 시 EOD). market_ok·시장탭 배지가 공용으로 사용."""
breadth = _live_breadth()
if breadth is None:
breadth = _eod_breadth()
chg = _index_daily_change()
trend = _index_trend_ok()
out = {}
for mk, v in breadth.items():
c = chg.get(mk)
up = trend.get(mk, True)
if v < config.MARKET_BREADTH_MIN: # <15%
out[mk] = 'crash' if (c is not None and c <= -2.0) else 'weak'
elif v >= config.MARKET_BREADTH_STRONG: # ≥60%
out[mk] = 'strong' if up else 'rebound'
elif v >= 0.40:
out[mk] = 'mild'
elif v >= 0.25:
out[mk] = 'mixed'
else:
out[mk] = 'weak'
return out
def _load_jsonl_last_per_market(path) -> dict[str, dict]:
out: dict[str, dict] = {}
if not path.exists():
@@ -28,22 +110,52 @@ def _load_jsonl_last_per_market(path) -> dict[str, dict]:
return out
def market_ok_map() -> dict[str, bool]:
"""시장별 신규매수 허용 여부 — 당일 상승비율 기반 소프트 필터 (EOD 데이터라 보수적)."""
_LIVE_BREADTH_CACHE: dict[str, float] | None = None
_LIVE_BREADTH_AT: float = 0.0
_LIVE_BREADTH_TTL = 60.0
def _live_breadth() -> dict[str, float] | None:
"""장중 실시간 상승비율(rise/total) — 네이버 m.stock 1콜/시장. 60s 캐시, 실패 시 None."""
global _LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT
now = time.time()
if _LIVE_BREADTH_CACHE is not None and (now - _LIVE_BREADTH_AT) < _LIVE_BREADTH_TTL:
return _LIVE_BREADTH_CACHE
try:
sys.path.insert(0, str(config.SCRIPTS))
import market_indicators_sync as mis
out: dict[str, float] = {}
for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
r = mis.fetch_market(sym, lab)
total = (r.get('rise', 0) + r.get('fall', 0) + r.get('steady', 0)) if r else 0
if not total:
return None
out[sym] = r['rise'] / total
_LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT = out, now
return out
except Exception:
return None
def _eod_breadth() -> dict[str, float]:
"""장외 폴백 — 마지막 EOD 누적 상승비율."""
latest = _load_jsonl_last_per_market(config.MARKET_HISTORY)
out: dict[str, bool] = {}
out: dict[str, float] = {}
for label, rec in latest.items():
rise = rec.get('rise', 0)
fall = rec.get('fall', 0)
steady = rec.get('steady', 0)
total = rise + fall + steady
breadth = rise / total if total else 1.0
total = rec.get('rise', 0) + rec.get('fall', 0) + rec.get('steady', 0)
breadth = rec.get('rise', 0) / total if total else 1.0
key = 'KOSPI' if '코스피' in (rec.get('market_label') or '') or label == 'KOSPI' else \
'KOSDAQ' if '코스닥' in (rec.get('market_label') or '') or label == 'KOSDAQ' else label
out[key] = breadth >= config.MARKET_BREADTH_MIN
out[key] = breadth
return out
def market_ok_map() -> dict[str, bool]:
"""시장별 신규매수 허용 여부 — 6단계 국면(market_regime_map) 기반.
강세·반등·완만상승 = 매수 허용 / 혼조·약세·급락 = 보류."""
return {mk: REGIME[g][2] for mk, g in market_regime_map().items()}
def _is_holiday(d: datetime) -> bool:
try:
hol = json.loads(config.HOLIDAYS_PATH.read_text())
@@ -101,6 +213,7 @@ def _gather(extra_codes=None):
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')}
decisions: list[dict] = []
buys: list[dict] = []
sells: list[dict] = []
@@ -125,7 +238,8 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
pf.mark(code, cur_price)
flow = data.flow_net(data.investor_flow(code))
anl = data.analyst(code, cur_price)
dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl)
dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl,
held_days=_held_days(pf.positions[code], now))
pf.apply_position_update(code, dec['position_update'])
dec.update({k: pf.positions[code].get(k) for k in
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
@@ -154,6 +268,13 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
continue
# ---- 미보유: 추세 스크린 → 수급·애널 → 매수 판단 ----
if universe.is_etf(name):
decisions.append({
'code': code, 'name': name, 'sources': sources, 'price': cur_price,
'state': 'SKIP', 'reason': 'ETF — 자동매매 제외(방향성 애널 부재)', 'action': None,
'checks': [],
})
continue
if not signals.trend_ok(ind):
decisions.append({
'code': code, 'name': name, 'sources': sources, 'price': cur_price,
@@ -171,6 +292,12 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
anl = data.analyst(code, cur_price)
market = cmkt.get(code, '')
m_ok = mkt_ok.get(market, True)
# 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률
n = config.REL_STRENGTH_DAYS
closes_c = [c['close'] for c in series]
if len(closes_c) > n and closes_c[-(n + 1)] and idx_ret.get(market) is not None:
stock_ret = (closes_c[-1] / closes_c[-(n + 1)] - 1) * 100
ind['rel_strength'] = round(stock_ret - idx_ret[market], 2)
dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok)
if dec['action'] == 'buy' and pf.can_open():
+2 -2
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import json
from datetime import datetime
from . import config
from . import config, signals
def _now_iso() -> str:
@@ -83,7 +83,7 @@ class Portfolio:
"""신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None."""
if code in self.positions or not self.can_open():
return None
full_target = self.target_position_value()
full_target = signals.target_value(self.equity_estimate(), fill_price, stop)
tranche_val = full_target / max(1, config.ENTRY_TRANCHES)
qty = self._qty_for(fill_price, tranche_val)
if qty < 1:
+82 -6
View File
@@ -18,6 +18,13 @@ def compute_indicators(series: list[dict]) -> dict | None:
atr = ind_mod.atr(series, config.ATR_PERIOD)
if sma_long is None or sma_short is None or atr is None:
return None
# 중기선(60) — 데이터 부족 시 None
sma_mid = ind_mod.sma(closes, config.SMA_MID)
# 20일선 기울기(우상향?) — N일 전 20일선과 비교. 데이터 부족 시 None
slope_days = config.TREND_SLOPE_DAYS
sma_long_prev = (ind_mod.sma(closes[:-slope_days], config.SMA_LONG)
if len(closes) > config.SMA_LONG + slope_days else None)
sma_long_slope_up = (sma_long > sma_long_prev) if sma_long_prev is not None else None
# 회전율 — 오늘 봉(라이브 시세)엔 turnover_rate 없음 → 최근 봉의 거래량/회전율로 유통주식 추정해 환산
hist = series[:-1]
@@ -38,7 +45,11 @@ def compute_indicators(series: list[dict]) -> dict | None:
'today_volume': series[-1]['volume'],
'sma_short': sma_short,
'sma_long': sma_long,
'sma_mid': sma_mid,
'sma_long_slope_up': sma_long_slope_up,
'rel_strength': None, # 라이브 engine이 주입(종목−지수 N일 수익률). backtest는 None(중립)
'atr': atr,
'rsi': ind_mod.rsi(closes, config.RSI_PERIOD),
'recent_high': ind_mod.recent_high(series, config.BREAKOUT_LOOKBACK),
'recent_low': ind_mod.recent_low(series, config.SWING_LOW_LOOKBACK),
'volume_avg': ind_mod.volume_avg(series, config.VOLUME_AVG_PERIOD),
@@ -47,9 +58,28 @@ def compute_indicators(series: list[dict]) -> dict | None:
}
def target_value(equity: float, fill_price: float, stop: float) -> float:
"""종목당 목표 진입금액 — 리스크 기반(자산×RISK% ÷ 손절폭)을 균등비중(자산/MAX_POSITIONS) 상한으로 캡.
RISK_PER_TRADE_PCT=0 또는 손절 정보 없으면 균등비중으로 폴백. 엔진·백테스트 공용(같은 두뇌)."""
cap = equity / max(1, config.MAX_POSITIONS)
risk_pct = getattr(config, 'RISK_PER_TRADE_PCT', 0) or 0
if risk_pct > 0 and stop and fill_price > stop:
risk_value = (equity * risk_pct) / (fill_price - stop) * fill_price
return min(cap, risk_value)
return cap
def trend_ok(ind: dict) -> bool:
"""추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트."""
return ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']
"""추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트.
기본: 현재가>20일선 & 정배열(5>20). 보강: ①20일선 우상향 ②5>중기선(60) — 데이터 있을 때만."""
if not (ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']):
return False
if ind.get('sma_long_slope_up') is False: # ① 20일선이 꺾여 내려오면 제외
return False
sm = ind.get('sma_mid')
if sm is not None and ind['sma_short'] <= sm: # ② 단기선이 중기선 아래면(역배열) 제외
return False
return True
def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]:
@@ -102,6 +132,19 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
'detail': f"{price:,} vs {ind['sma_long']:,.0f}"})
checks.append({'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'],
'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"})
# 1-b. 추세 질 보강 — 기울기 / 중기선 / 상대강도 (데이터 없으면 통과 표시)
slope = ind.get('sma_long_slope_up')
checks.append({'label': '추세 기울기(20일선↑)',
'ok': None if slope is None else slope,
'detail': '데이터 없음(통과)' if slope is None else ('우상향' if slope else '하향')})
sm = ind.get('sma_mid')
checks.append({'label': '중기 정배열(5>60)',
'ok': None if sm is None else ind['sma_short'] > sm,
'detail': '데이터 없음(통과)' if sm is None else f"{ind['sma_short']:,.0f} / {sm:,.0f}"})
rel = ind.get('rel_strength')
checks.append({'label': '상대강도(시장대비)',
'ok': None if rel is None else rel > 0,
'detail': '데이터 없음(통과)' if rel is None else f'{rel:+.1f}%p'})
# 2. 수급
fnet = flow.get('foreign', 0) if flow else 0
@@ -114,14 +157,16 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
gate_ok, acks, bonus = _analyst_checks(analyst, price)
checks.extend(acks)
# 시장 강약 — 소프트 필터(차단 X). 약세장이면 눌림목 진입만 보류, 돌파 진입은 허용
checks.append({'label': '시장 강세', 'ok': market_ok,
'detail': '강세' if market_ok else '약세 — 돌파만 허용'})
base = {'code': code, 'name': name, 'sources': sources, 'price': price,
'checks': checks, 'action': None, 'buy_path': None, 'buy_price': None,
'plan_stop': None, 'plan_target': None, 'bonus': bonus}
if not gate_ok:
return {**base, 'state': 'SKIP', 'reason': '애널 게이트 제외'}
if not market_ok:
return {**base, 'state': 'WAIT', 'reason': '시장 약세 — 신규매수 보류'}
if not supply_ok:
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
@@ -151,7 +196,21 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
else:
checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'})
# RSI — 돌파 과열 추격 / 눌림목 낙하 진입 방지 (데이터 없으면 통과)
rsi = ind.get('rsi')
rsi_overbought = rsi is not None and rsi >= config.RSI_OVERBOUGHT
rsi_oversold = rsi is not None and rsi < config.RSI_OVERSOLD
checks.append({'label': 'RSI',
'ok': None if rsi is None else not (rsi_overbought or rsi_oversold),
'detail': '데이터 없음(통과)' if rsi is None else f'{rsi:.0f}'})
if breakout:
if not market_ok and config.BEAR_ENTRY_MODE == 0:
return {**base, 'state': 'WAIT',
'reason': '약세장 — 완전 현금화(돌파도 보류)'}
if rsi_overbought:
return {**base, 'state': 'WAIT',
'reason': f'돌파했지만 RSI 과열({rsi:.0f}{config.RSI_OVERBOUGHT:g}) — 추격 보류'}
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'breakout',
'buy_price': price, 'reason': '거래량·회전율 동반 전고점 돌파'}
@@ -162,6 +221,15 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
checks.append({'label': '눌림목 도달', 'ok': pulled,
'detail': f'지정가 {limit:,} (현재 {price:,})'})
if pulled:
if not market_ok and config.BEAR_ENTRY_MODE != 2:
# 약세장에선 바닥 잡기식 눌림목 진입 보류 (모드 2=눌림목 허용이면 통과)
mode_msg = '완전 현금화' if config.BEAR_ENTRY_MODE == 0 else '돌파만 허용'
return {**base, 'state': 'WAIT', 'watch_price': limit,
'reason': f'약세장 — 눌림목({limit:,}) 보류 ({mode_msg})'}
if rsi_oversold:
# 과매도 = 아직 떨어지는 칼 — 반등 확인 전 진입 보류
return {**base, 'state': 'WAIT', 'watch_price': limit,
'reason': f'눌림목이지만 RSI 과매도({rsi:.0f} < {config.RSI_OVERSOLD:g}) — 낙하 중 보류'}
# 현재가가 이미 지정가 이하 → 시장에서 현재가(체결가)로 매수
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'pullback',
'buy_price': price, 'watch_price': limit,
@@ -171,10 +239,11 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
'reason': f'눌림목 대기 — 지정가 {limit:,}'}
def evaluate_holding(position, ind, flow, analyst) -> dict:
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 분할익절(scale_out) → 추격매수(add) → 유지.
def evaluate_holding(position, ind, flow, analyst, held_days=None) -> dict:
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 시간손절 → 분할익절(scale_out) → 추격매수(add) → 유지.
position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환.
held_days: 보유 경과 일수(달력). None이면 시간손절 미적용(엔진·백테스트가 산정해 전달).
"""
price = ind['price']
entry = position['entry_price']
@@ -225,6 +294,13 @@ def evaluate_holding(position, ind, flow, analyst) -> dict:
if price < ind['sma_long'] and fnet < 0 and inet < 0:
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': '추세·수급 이탈'}
# 1-b. 시간손절 — N일 보유했는데 트레일링 전이고 진입가도 못 넘으면(진전 없음) 청산해 자본 회전
max_hold = getattr(config, 'MAX_HOLD_DAYS', 0) or 0
if (held_days is not None and max_hold > 0 and held_days >= max_hold
and not trailing_on and not scaled_out and price <= entry):
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0,
'reason': f'시간손절 — {held_days}일 보유·진전 없음(자본 회전)'}
# 2. 분할매도 — 목표 도달 & 미익절. 일부 익절 후 잔량은 트레일링으로 전환
if target and price >= target and not scaled_out:
upd['trailing_on'] = True
+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)
+17 -9
View File
@@ -15,13 +15,18 @@ from datetime import datetime
from . import backtest, config, universe
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외).
# SMA_LONG 5단계로 추세 속도 다양화 + 돌파 강도 추가. 5×3×3×3×2 = 270 조합.
# 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소.
# base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합.
GRID = {
'SMA_LONG': [10, 20, 40, 60, 120], # 추세 속도 — 빠른/느린 (다양성 핵심)
'SMA_LONG': [10, 20, 40], # 추세 속도 — 빠른/느린 (다양성 핵심)
'RR_RATIO': [1.5, 2.0, 3.0], # 손익비
'STOP_ATR_MULT': [1.5, 2.0, 3.0], # 손절 폭
'PULLBACK_ATR_MULT': [0.5, 1.0, 1.5], # 눌림 깊이
'STOP_ATR_MULT': [1.5, 2.0], # 손절 폭
'PULLBACK_ATR_MULT': [1.0, 1.5], # 눌림 깊이
'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도
'RSI_OVERBOUGHT': [75, 88], # 돌파 과열 컷 (엄격/느슨)
'RSI_OVERSOLD': [20, 35], # 눌림목 과매도 컷 (느슨/엄격)
'MAX_HOLD_DAYS': [0, 20], # 시간손절 끔/20일
'RISK_PER_TRADE_PCT': [0.01, 0.02], # 종목당 리스크 1%/2%
}
MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로
@@ -87,9 +92,12 @@ if __name__ == '__main__':
print('스윕 실패:', res)
else:
print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']}")
print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} | {'학습수익%':>8} {'검증수익%':>8} {'검증MDD%':>8} {'검증거래':>6} {'승률%':>6}")
print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | "
f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}")
for r in res['results'][:12]:
p, te, tr = r['params'], r['test'], r['train']
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} | "
f"{tr['total_return_pct']:>8} {te['total_return_pct']:>8} {te['mdd_pct']:>8} "
f"{te['trades']:>6} {str(te['win_rate_pct']):>6}")
p, te = r['params'], r['test']
rsi = f"{p.get('RSI_OVERBOUGHT','-')}/{p.get('RSI_OVERSOLD','-')}"
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} "
f"{rsi:>8} {p.get('MAX_HOLD_DAYS','-'):>5} {p.get('RISK_PER_TRADE_PCT','-'):>5} | "
f"{te['total_return_pct']:>8} {te['mdd_pct']:>8} "
f"{te['trades']:>5} {str(te['win_rate_pct']):>6}")
+11
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import json
import re
import sys
from datetime import datetime
@@ -18,6 +19,16 @@ sys.path.insert(0, str(config.SCRIPTS))
import kiwoom_client as kc # noqa: E402
WATCHLIST_PATH = config.WORKSPACE / 'state' / 'behive_watchlist.json'
# ETF/ETN 운용사 브랜드 프리픽스 — 자동매수 후보에서 제외 (방향성=애널 컨센서스가 ETF엔 없어 전략 부정합)
_ETF_PREFIX = re.compile(
r'^(KODEX|TIGER|KBSTAR|ARIRANG|HANARO|ACE|SOL|PLUS|RISE|KOSEF|TIMEFOLIO|히어로즈|FOCUS|TREX|BNK|WON|마이다스|에셋플러스|KIWOOM|파워|마이티)',
re.IGNORECASE)
def is_etf(name: str) -> bool:
"""종목명 운용사 프리픽스로 ETF/ETN 판별 (보유분 청산은 별도라 매수 후보 필터 전용)."""
return bool(name and _ETF_PREFIX.match(name.strip()))
INTERESTS_PATH = config.WORKSPACE / 'state' / 'behive_interests.json'
SIM_WATCHLIST_PATH = config.STATE_DIR / 'watchlist.json' # sim 누적 관찰목록
+2 -1
View File
@@ -35,7 +35,8 @@ def variant_paths(vid: str):
def _label(params: dict) -> str:
short = {'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'SMA_LONG': 'SMA',
'PULLBACK_ATR_MULT': 'P', 'VOLUME_BREAKOUT_MULT': 'V',
'TURNOVER_OVERHEAT_MULT': 'OH'}
'TURNOVER_OVERHEAT_MULT': 'OH', 'RSI_OVERBOUGHT': 'RSI↑',
'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD', 'RISK_PER_TRADE_PCT': 'RISK'}
return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items())
@@ -22,3 +22,5 @@
{"date": "2026-06-05", "market": "KOSPI", "market_label": "코스피", "rise": 225, "upper": 1, "fall": 672, "lower": 0, "steady": 26, "adr": 33.63, "personal": 42240, "foreign": -27638, "institutional": -13809, "captured_at": "2026-06-05T21:00:05.007446+09:00"}
{"date": "2026-06-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 75, "upper": 7, "fall": 1634, "lower": 1, "steady": 27, "adr": 5.02, "personal": -1245, "foreign": 2976, "institutional": -1466, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
{"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
{"date": "2026-06-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1435, "upper": 6, "fall": 258, "lower": 1, "steady": 44, "adr": 556.37, "personal": -5112, "foreign": 3126, "institutional": 1996, "captured_at": "2026-06-09T21:00:02.663326+09:00"}
{"date": "2026-06-09", "market": "KOSPI", "market_label": "코스피", "rise": 774, "upper": 1, "fall": 133, "lower": 1, "steady": 15, "adr": 578.36, "personal": -6167, "foreign": -20064, "institutional": 25048, "captured_at": "2026-06-09T21:00:02.663326+09:00"}
@@ -76,3 +76,5 @@
{"date": "2026-06-08", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21100, "buy_amt": 253200, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "000660", "name": "SK하이닉스", "buy_qty": 1, "buy_avg": 1970000, "buy_amt": 1970000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 290, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
{"date": "2026-06-09", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22000, "sell_amt": 264000, "pl_amt": 9570, "cmsn_tax": 30, "prft_rt": 3.76, "collected_at": "2026-06-09T23:19:23.494299+09:00"}
{"date": "2026-06-09", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22070, "sell_amt": 264840, "pl_amt": 11610, "cmsn_tax": 30, "prft_rt": 4.59, "collected_at": "2026-06-09T23:19:23.494299+09:00"}