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