Files
openclaw/agents/stock/workspace/sim/signals.py
T
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

365 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""매수/매도 판단 — 결정론적 규칙. LLM 미경유.
방향(애널·펀더) 약한 게이트 + 가점 → 타이밍(수급·기술)으로 진입/청산.
각 판단은 dashboard 용 체크리스트(checks)를 함께 반환한다.
"""
from __future__ import annotations
import math
from . import config, indicators as ind_mod
def compute_indicators(series: list[dict]) -> dict | None:
"""series(어제까지+오늘) → 지표 묶음. 데이터 부족 시 None."""
closes = [c['close'] for c in series]
sma_long = ind_mod.sma(closes, config.SMA_LONG)
sma_short = ind_mod.sma(closes, config.SMA_SHORT)
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]
turns = [c['turnover_rate'] for c in hist[-config.VOLUME_AVG_PERIOD:]
if c.get('turnover_rate')]
turnover_avg = sum(turns) / len(turns) if turns else None
est_float = None
for c in reversed(hist):
tr, v = c.get('turnover_rate'), c.get('volume')
if tr and v:
est_float = v / (tr / 100.0)
break
turnover_today = (series[-1]['volume'] / est_float * 100.0) if est_float else None
return {
'price': closes[-1],
'prev_close': closes[-2] if len(closes) >= 2 else closes[-1],
'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),
'turnover_today': turnover_today,
'turnover_avg': turnover_avg,
}
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차 스크린 — 비싼 수급·애널 호출 전 게이트.
기본: 현재가>20일선 & 정배열(5>20). 보강: ①20일선 우상향 ②5>중기선(60) — 데이터 있을 때만."""
if not (ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']):
return False
if getattr(config, 'TREND_SLOPE_GATE', 1) and ind.get('sma_long_slope_up') is False:
return False # ① 장기선이 꺾여 내려오면 제외 (게이트 ON일 때만)
sm = ind.get('sma_mid')
if getattr(config, 'MID_ARRAY_GATE', 1) and sm is not None and ind['sma_short'] <= sm:
return False # ② 단기선이 중기선 아래면(역배열) 제외 (게이트 ON일 때만)
return True
def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]:
"""손절 = max(진입−ATR×2, 스윙저점) → 둘 중 위쪽(덜 손해). 목표 = 진입 + (진입−손절)×RR."""
atr_stop = entry - atr * config.STOP_ATR_MULT
stop = max(atr_stop, recent_low) if recent_low else atr_stop
stop = min(stop, entry - 1) # 손절은 진입가보다 아래여야 함
target = entry + (entry - stop) * config.RR_RATIO
return int(round(stop)), int(round(target))
def _analyst_checks(analyst: dict | None, price: float) -> tuple[bool, list[dict], int]:
"""애널 약한 게이트 + 가점. 반환: (게이트 통과, checks, 가점)."""
checks: list[dict] = []
bonus = 0
if not analyst:
checks.append({'label': '애널', 'ok': None, 'detail': '데이터 없음(통과)'})
return True, checks, 0
gate_ok = True
op = analyst.get('opinion')
if op is not None:
sell = op <= config.ANALYST_SELL_OPINION_MAX
checks.append({'label': '투자의견', 'ok': not sell,
'detail': f'{op:.1f}/5' + (' 매도권' if sell else '')})
if sell:
gate_ok = False
up = analyst.get('upside_pct')
if up is not None:
ok = up >= config.ANALYST_MIN_UPSIDE_PCT
checks.append({'label': '상승여력', 'ok': ok, 'detail': f'{up:+.0f}%'})
if not ok:
gate_ok = False
if analyst.get('revision_up'):
checks.append({'label': '목표가 리비전', 'ok': True, 'detail': '상향 '})
bonus += 1
if analyst.get('surprise_pos'):
checks.append({'label': '어닝 서프라이즈', 'ok': True, 'detail': '+ '})
bonus += 1
return gate_ok, checks, bonus
def trend_checks(ind: dict) -> list[dict]:
"""추세 게이트 체크리스트(trend_ok와 동일 4조건 + 상대강도) — 매수 판단·제외 카드 공용.
라벨은 실제 적용된 이평 일수(config)로 표기 (변이마다 SMA_LONG 10/20/40 다름)."""
price = ind['price']
_S, _L, _M = config.SMA_SHORT, config.SMA_LONG, config.SMA_MID
slope = ind.get('sma_long_slope_up')
sm = ind.get('sma_mid')
rel = ind.get('rel_strength')
slope_gate = getattr(config, 'TREND_SLOPE_GATE', 1)
mid_gate = getattr(config, 'MID_ARRAY_GATE', 1)
# 게이트 꺼진 조건은 ok=None(참고) — 통과/탈락에 영향 없음을 표시
if not slope_gate:
slope_check = {'label': f'추세 기울기({_L}일선↑)', 'ok': None,
'detail': '게이트 끔(완화)' + ('' if slope is None else f" · 실제 {'우상향' if slope else '하향'}")}
else:
slope_check = {'label': f'추세 기울기({_L}일선↑)', 'ok': None if slope is None else slope,
'detail': '데이터 없음(통과)' if slope is None else ('우상향' if slope else '하향')}
if not mid_gate:
mid_check = {'label': f'중기 정배열({_S}>{_M})', 'ok': None,
'detail': '게이트 끔(완화)' + ('' if sm is None else f" · {ind['sma_short']:,.0f} / {sm:,.0f}")}
else:
mid_check = {'label': f'중기 정배열({_S}>{_M})', 'ok': None if sm is None else ind['sma_short'] > sm,
'detail': '데이터 없음(통과)' if sm is None else f"{ind['sma_short']:,.0f} / {sm:,.0f}"}
return [
{'label': f'추세({_L}일선 위)', 'ok': price > ind['sma_long'],
'detail': f"{price:,} vs {ind['sma_long']:,.0f}"},
{'label': f'정배열({_S}>{_L})', 'ok': ind['sma_short'] > ind['sma_long'],
'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"},
slope_check, mid_check,
{'label': '상대강도(시장대비)', 'ok': None if rel is None else rel > 0,
'detail': '데이터 없음(통과)' if rel is None else f'{rel:+.1f}%p'},
]
def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> dict:
"""미보유 종목 매수 판단. flow/analyst 는 engine 이 trend 통과 후 주입."""
price = ind['price']
checks: list[dict] = list(trend_checks(ind)) # 1. 추세 게이트 (제외 카드와 공유)
# 2. 수급
fnet = flow.get('foreign', 0) if flow else 0
inet = flow.get('institution', 0) if flow else 0
supply_ok = fnet > 0 or inet > 0
checks.append({'label': f'수급({config.FLOW_DAYS}일 외/기)', 'ok': supply_ok,
'detail': f'{fnet:+,} · 기 {inet:+,} (천주)'})
# 3. 애널 게이트+가점
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 supply_ok:
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
# 4. 과열 보류 — 회전율이 평균 대비 과도하면 돌파·눌림목 불문 신규매수 보류
# 장중엔 부분 누적 거래량을 경과시간 비례로 하루치 환산(vol_time_frac, engine 주입·백테스트는 1.0)
vfrac = ind.get('vol_time_frac') or 1.0
vol_today = ind['today_volume'] / vfrac
t_today, t_avg = ind.get('turnover_today'), ind.get('turnover_avg')
if t_today is not None:
t_today = t_today / vfrac
proj = ' (환산)' if vfrac < 1.0 else ''
overheated = bool(t_today and t_avg and t_today >= t_avg * config.TURNOVER_OVERHEAT_MULT)
if t_today is not None and t_avg:
checks.append({'label': '과열 아님(회전율)', 'ok': not overheated,
'detail': f"{t_today:.2f}%{proj} vs 평균 {t_avg:.2f}% (보류 {config.TURNOVER_OVERHEAT_MULT:.0f}배↑)"})
if overheated:
return {**base, 'state': 'WAIT',
'reason': f'과열(회전율 {t_today:.1f}% ≥ 평균×{config.TURNOVER_OVERHEAT_MULT:.0f}) — 신규매수 보류'}
# 5. 매수 경로 — 돌파 우선 (전고점 + 거래량 급증 + 회전율 급증 모두 충족)
price_break = bool(ind['recent_high'] and price > ind['recent_high'])
vol_mult_ok = bool(ind['volume_avg'] and vol_today >= ind['volume_avg'] * config.VOLUME_BREAKOUT_MULT)
turn_ok = (t_today is None or not t_avg) or (t_today >= t_avg * config.TURNOVER_BREAKOUT_MULT)
breakout = price_break and vol_mult_ok and turn_ok
checks.append({'label': '돌파(전고점)', 'ok': price_break,
'detail': f"고점 {ind['recent_high']:,.0f}" if ind['recent_high'] else ''})
checks.append({'label': '거래량 급증', 'ok': vol_mult_ok,
'detail': f"{vol_today:,.0f}{proj} vs 평균 {ind['volume_avg']:,.0f}" if ind['volume_avg'] else ''})
if t_today is not None and t_avg:
checks.append({'label': '회전율 급증', 'ok': bool(turn_ok),
'detail': f"{t_today:.2f}%{proj} vs 평균 {t_avg:.2f}%"})
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': '거래량·회전율 동반 전고점 돌파'}
# 6. 눌림목 지정가
limit = max(ind['sma_long'], ind['prev_close'] - ind['atr'] * config.PULLBACK_ATR_MULT)
limit = int(round(limit))
pulled = price <= limit
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,
'reason': f'눌림목 지정가 {limit:,} 도달'}
# 6-b. 즉시매수 — 추세·정배열·수급·애널 다 통과했는데 돌파·눌림목 둘 다 아니면(=안 내려오는 상승 종목),
# 강세장·RSI 정상이면 현재가에 바로 매수. 꾸준히 오르기만 하는 좋은 종목 놓침 방지 (실험 토글).
if getattr(config, 'IMMEDIATE_ENTRY', 0) and market_ok and not rsi_overbought:
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'immediate',
'buy_price': price, 'reason': '조건 충족 — 즉시매수(눌림 안 기다림)'}
return {**base, 'state': 'WAIT', 'watch_price': limit,
'reason': f'눌림목 대기 — 지정가 {limit:,}'}
def evaluate_holding(position, ind, flow, analyst, held_days=None, market_ok=True) -> dict:
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 시간손절 → 분할익절(scale_out) → 추격매수(add) → 유지.
position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환.
held_days: 보유 경과 일수(달력). None이면 시간손절 미적용(엔진·백테스트가 산정해 전달).
market_ok: 시장 강세 여부(물타기 허용 판단용, 백테스트는 중립 True).
"""
price = ind['price']
entry = position['entry_price']
stop = position['stop']
target = position['target']
peak = max(position.get('peak', entry), price)
trailing_on = position.get('trailing_on', False)
scaled_out = position.get('scaled_out', False)
tranches = position.get('tranches', 1)
last_add = position.get('last_add_price', entry)
if trailing_on:
atarget = (analyst or {}).get('target_price')
mult = config.TRAIL_ATR_MULT_TIGHT if (atarget and price >= atarget) else config.TRAIL_ATR_MULT
stop = max(stop, int(round(peak - ind['atr'] * mult)))
fnet = flow.get('foreign', 0) if flow else 0
inet = flow.get('institution', 0) if flow else 0
supply_ok = fnet > 0 or inet > 0
trend_up = price > ind['sma_long'] and ind['sma_short'] > ind['sma_long']
add_trigger = int(round(last_add + ind['atr'] * config.ADD_ATR_MULT))
can_add = tranches < config.ENTRY_TRANCHES and not trailing_on and not scaled_out
checks = [
{'label': '손절선', 'ok': price > stop, 'detail': f'{stop:,} (현재 {price:,})'},
{'label': '목표/트레일링', 'ok': None,
'detail': ('트레일링 ON' if trailing_on else
('일부익절 완료' if scaled_out else f'목표 {target:,}'))},
{'label': f'추세({config.SMA_LONG}일선)', 'ok': price >= ind['sma_long'],
'detail': f"{ind['sma_long']:,.0f}"},
{'label': '수급(외/기)', 'ok': supply_ok, 'detail': f'{fnet:+,} · 기 {inet:+,}'},
{'label': f'분할({tranches}/{config.ENTRY_TRANCHES})', 'ok': None,
'detail': (f'추가 트리거 {add_trigger:,}' if can_add else '추가 종료')},
]
upd = {'stop': stop, 'peak': peak, 'trailing_on': trailing_on, 'scaled_out': scaled_out}
pnl_pct = (price / entry - 1) * 100
base = {'code': position['code'], 'name': position['name'], 'sources': position.get('sources', []),
'price': price, 'checks': checks, 'action': None, 'state': 'HOLD',
'position_update': upd, 'pnl_pct': pnl_pct}
# 0. 물타기(애버리징) — 켜진 변이만. 하락 시 손절 대신 추가매수로 평단↓ (한도·강세장·비급락 조건).
# 당일 단일 급락(-CRASH%↓)은 물타기 금지→손절 우선(그림 #3), 약세장도 금지(그림 #4).
avg_on = getattr(config, 'AVERAGING_ENTRY', 0)
if avg_on:
prev_close = ind.get('prev_close') or price
today_chg = (price / prev_close - 1) if prev_close else 0
crash = today_chg <= -getattr(config, 'CRASH_DROP_PCT', 0.10)
avg_trigger = last_add * (1 - getattr(config, 'AVERAGING_DROP_PCT', 0.10))
budget_left = tranches < config.ENTRY_TRANCHES
# 트레일링/익절 전 + 한도 남음 + 강세장 + 비급락 + 직전매수가 -X% 도달 → 물타기
if (budget_left and market_ok and not crash and not trailing_on and not scaled_out
and price <= avg_trigger):
return {**base, 'state': 'ADD', 'action': 'add', 'add_kind': 'down',
'reason': f'물타기 — {tranches + 1}/{config.ENTRY_TRANCHES}차 (평단↓, -{config.AVERAGING_DROP_PCT*100:.0f}%)'}
# 물타기 한도 소진(또는 급락/약세장)인데 손절선 깨짐 → 최종 손절
# (한도 남았는데 급락이면 아래 일반 손절로 자연 처리)
# 1. 손절·추세이탈 — 전량
if price <= stop:
reason = '트레일링 익절' if (trailing_on and price > entry) else '손절'
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': reason}
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
if config.SCALE_OUT_FRAC > 0:
upd['scaled_out'] = True
return {**base, 'state': 'SELL', 'action': 'scale_out',
'sell_frac': config.SCALE_OUT_FRAC,
'reason': f'목표 도달 — {config.SCALE_OUT_FRAC * 100:.0f}% 분할익절'}
return {**base, 'reason': '목표 도달 — 트레일링 전환'}
# 3. 추격매수 — 트랜치 여유 & 추세 지속 & 수급 & 직전 진입가 + ATR 돌파
if can_add and trend_up and supply_ok and price >= add_trigger:
return {**base, 'state': 'ADD', 'action': 'add',
'reason': f'추격매수 — {tranches + 1}/{config.ENTRY_TRANCHES}차 (추세 지속)'}
return {**base, 'reason': '보유 유지'}