auto: 일일 백업 2026-06-10 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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` | 평일 09–15: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:00–15: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` 등.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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': '포지션'},
|
||||
]
|
||||
|
||||
# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 누적 관찰목록
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user