Files
hyowons eaa276b26d feat(sim): 물타기(애버리징) 토글 — 하락 시 손절 대신 평단↓ 추가매수
- config: AVERAGING_ENTRY·AVERAGING_DROP_PCT(0.10)·CRASH_DROP_PCT(0.10)
- signals.evaluate_holding: market_ok 인자 + 물타기 분기 (직전매수가 -10% 하락 시 ADD-down, 한도/강세장/비급락 조건, 당일 -10%급락은 손절·약세장 금지)
- engine·backtest: add_kind='down'이면 손절선 낮은 평단 기준 재설정(up은 위로 래칫 유지), engine은 market_ok 전달
- 첫 매수는 기존 검증 진입 그대로(우량주만 물타기)
- 백테스트: 즉시매수와 반대로 유리(10/20/60 수익·승률↑) → 라이브 변이 3개(v301/302/303) 등록, 총 223
- 웹 '물타기' 칩 + 설명 토스트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:30:07 +09:00

186 lines
12 KiB
Python

"""sim 엔진 설정 — 상수·경로. 기본값은 여기, 런타임 튜닝은 state/sim/params.json 오버라이드."""
from __future__ import annotations
import json
from pathlib import Path
from zoneinfo import ZoneInfo
KST = ZoneInfo('Asia/Seoul')
WORKSPACE = Path(__file__).resolve().parent.parent # agents/stock/workspace
SCRIPTS = WORKSPACE / 'scripts'
STATE_DIR = WORKSPACE / 'state' / 'sim'
HOLIDAYS_PATH = WORKSPACE / 'state' / 'market_holidays.json'
MARKET_HISTORY = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
# sim state 파일
PORTFOLIO_PATH = STATE_DIR / 'portfolio.json'
TRADES_PATH = STATE_DIR / 'trades.jsonl'
LAST_SCAN_PATH = STATE_DIR / 'last_scan.json'
# 보유종목 universe 수집 대상 계좌 (본인만)
OWNER_ACCOUNT_LABELS = ['일반', 'ISA']
# ---- 자본·체결·비용 ----
INITIAL_CAPITAL = 100_000_000 # 가상 자본 (기초 예수금 1억)
COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%)
SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%)
BT_SLIPPAGE = 0.002 # 백테스트 슬리피지 (편도 0.2% — 매수는 비싸게, 매도는 싸게. 라이브는 호가 체결이라 미적용)
# ---- 포지션 사이징 ----
MAX_POSITIONS = 8 # 동시 보유 최대 종목수
# 종목당 목표 비중 = 자산 / 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일 수익률 (라이브 전용)
TREND_SLOPE_GATE = 1 # 추세 게이트 ① 장기선 우상향 요구 (1=요구/0=완화). 보수성 완화 변이 실험용
MID_ARRAY_GATE = 1 # 추세 게이트 ② 5일선>중기선(60) 요구 (1=요구/0=완화)
IMMEDIATE_ENTRY = 0 # 즉시매수: 조건 통과 종목이 돌파·눌림목 둘 다 아니어도 강세장이면 현재가 즉시 매수 (1=켜기). 꾸준한 상승 종목 놓침 방지 실험용
AVERAGING_ENTRY = 0 # 물타기(애버리징): 보유 중 직전매수가 대비 하락 시 손절 대신 추가매수로 평단↓ (1=켜기). 종목당 10% 한도 내, 급락·약세장 제외
AVERAGING_DROP_PCT = 0.10 # 물타기 트리거 — 직전 매수가 대비 이 비율 하락 시 추가 (0.10=-10%마다)
CRASH_DROP_PCT = 0.10 # 당일 단일 하락이 이 비율 이상이면 물타기 말고 손절 (그림 #3, 0.10=-10%↓)
ATR_PERIOD = 14
BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간
SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간
VOLUME_AVG_PERIOD = 20
FLOW_DAYS = 5 # 수급 합산 일수 (외국인·기관 N일 순매수)
# ---- 매수 ----
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차 = 목표/이 값
ADD_ATR_MULT = 0.5 # 추격매수: 직전 진입가 + ATR × 이 배수 돌파 & 추세 지속 시 다음 트랜치 추가
# ---- 매도 ----
STOP_ATR_MULT = 2.0 # 손절 = 진입가 − ATR × 이 배수 (스윙저점과 비교 후 위쪽)
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 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외
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)
MARKET_CLOSE = (15, 30)
# ==== 런타임 튜닝 ====
# 웹 튜닝 화면이 노출·수정하는 파라미터. state/sim/params.json 에 저장되며 config import 시 위 기본값을 덮어쓴다.
# 엔진은 매 스캔마다 새 프로세스라 저장 즉시(다음 스캔부터) 반영된다.
PARAMS_PATH = STATE_DIR / 'params.json'
TUNABLE = [
{'key': 'SMA_SHORT', 'label': '단기 이동평균(일)', 'type': 'int', 'min': 2, 'max': 60, 'step': 1, 'group': '추세'},
{'key': 'SMA_LONG', 'label': '장기 이동평균(일)', 'type': 'int', 'min': 5, 'max': 200, 'step': 1, 'group': '추세'},
{'key': 'TREND_SLOPE_GATE', 'label': '장기선 우상향 요구', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '추세'},
{'key': 'MID_ARRAY_GATE', 'label': '중기 정배열(5>60) 요구', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '추세'},
{'key': 'IMMEDIATE_ENTRY', 'label': '즉시매수(눌림 안 기다림)', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '매수'},
{'key': 'AVERAGING_ENTRY', 'label': '물타기(하락 시 추가매수)', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '매도'},
{'key': 'AVERAGING_DROP_PCT', 'label': '물타기 하락 트리거', 'type': 'float', 'min': 0.03, 'max': 0.3, 'step': 0.01, 'group': '매도'},
{'key': 'ATR_PERIOD', 'label': 'ATR 기간(일)', 'type': 'int', 'min': 5, 'max': 60, 'step': 1, 'group': '변동성·기간'},
{'key': 'BREAKOUT_LOOKBACK', 'label': '돌파 고점 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'},
{'key': 'SWING_LOW_LOOKBACK', 'label': '스윙 저점 구간(일)', 'type': 'int', 'min': 3, 'max': 60, 'step': 1, 'group': '변동성·기간'},
{'key': 'VOLUME_AVG_PERIOD', 'label': '거래량 평균 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'},
{'key': 'FLOW_DAYS', 'label': '수급 합산(일)', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '변동성·기간'},
{'key': 'VOLUME_BREAKOUT_MULT', 'label': '돌파 거래량 배수', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매수'},
{'key': 'TURNOVER_BREAKOUT_MULT', 'label': '돌파 회전율 배수', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매수'},
{'key': 'TURNOVER_OVERHEAT_MULT', 'label': '과열 보류 회전율 배수', 'type': 'float', 'min': 1.5, 'max': 10.0, 'step': 0.1, 'group': '매수'},
{'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': '포지션'},
]
# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용)
_DEFAULTS = {spec['key']: globals()[spec['key']] for spec in TUNABLE}
def _coerce(spec: dict, v):
"""문자열/숫자를 spec 타입으로 변환 + min/max clamp. 실패 시 None."""
try:
v = int(float(v)) if spec['type'] == 'int' else float(v)
except (TypeError, ValueError):
return None
return max(spec['min'], min(spec['max'], v))
def load_params() -> dict:
"""현재 유효 파라미터 {key: value} — 저장값(clamp) 우선, 없으면 기본값."""
try:
saved = json.loads(PARAMS_PATH.read_text())
except Exception:
saved = {}
out = {}
for spec in TUNABLE:
k = spec['key']
cv = _coerce(spec, saved[k]) if k in saved else None
out[k] = cv if cv is not None else _DEFAULTS[k]
return out
def save_params(raw: dict) -> dict:
"""raw(폼 문자열 dict) 검증·clamp 후 params.json 병합 저장."""
try:
cur = json.loads(PARAMS_PATH.read_text())
except Exception:
cur = {}
for spec in TUNABLE:
k = spec['key']
if k in raw and str(raw[k]).strip() != '':
cv = _coerce(spec, raw[k])
if cv is not None:
cur[k] = cv
STATE_DIR.mkdir(parents=True, exist_ok=True)
PARAMS_PATH.write_text(json.dumps(cur, ensure_ascii=False, indent=2))
return cur
def reset_params():
"""저장된 오버라이드 삭제 → 전부 기본값."""
if PARAMS_PATH.exists():
PARAMS_PATH.unlink()
def _apply_overrides():
"""import 시 유효 파라미터를 모듈 전역에 반영 (엔진·신호가 config.X 로 참조)."""
g = globals()
for k, v in load_params().items():
g[k] = v
_apply_overrides()