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
+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