M1 │ 스캐너 최소 거래량 100만→10만주 │ kosdaq_scanner.py │
Some checks failed
CI/CD Pipeline / test-backend (push) Has been cancelled
CI/CD Pipeline / test-frontend (push) Has been cancelled
CI/CD Pipeline / build-and-push (push) Has been cancelled

├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤
  │ M2  │ ADX 히스테리시스 18/22 (플리핑 방지)                │ mean_reversion_strategy.py     │
  ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤
  │ M3  │ 분할매도 최소 수익 1.0%→0.5%                        │ mean_reversion_strategy.py     │
  ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤
  │ M4  │ ORB 갭 필터 4% + 레인지 폭 0.3% 최소                │ day_trading_strategy.py        │
  ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤
  │ M5  │ 쿨다운 스트릭 부분매도 시 미변경                    │ daytrading/position_manager.py │
  ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤
  │ M6  │ MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지) │ mdd_manager.py                 │
  ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤
  │ M7  │ RSI Wilder 스무딩으로 통일                          │ mean_reversion_strategy.py
This commit is contained in:
2026-04-03 11:41:52 +09:00
parent eda5d76af9
commit 9d096ddd57
22 changed files with 299 additions and 132 deletions

View File

@@ -185,18 +185,18 @@ async def check_sector_concentration(
- reason: 거부 이유 (성공 시 None)
"""
# 진입 종목의 섹터 조회
# [S7] 섹터 정보 없으면 "기타"로 분류하여 집중도 체크 적용
sector = await get_stock_sector(stock_code, is_domestic=True)
if not sector:
# 섹터 정보 없으면 필터 통과 (정보 부족 시 기본적으로 허용)
return True, None
sector = "기타"
# 같은 섹터 종목들의 섹터 정보 조회
# [S7] 보유 종목도 섹터 미확인 시 "기타" 적용
sector_counts: Dict[str, int] = {}
for holding in portfolio_holdings:
if holding.quantity > 0:
h_sector = await get_stock_sector(holding.stock_code, is_domestic=True)
if h_sector:
sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1
h_sector = await get_stock_sector(holding.stock_code, is_domestic=True) or "기타"
sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1
current_count = sector_counts.get(sector, 0)
if current_count >= max_per_sector:

View File

@@ -99,6 +99,7 @@ class OpeningRange:
low: float
total_volume: int
avg_vol_per_min: float # 분당 평균 거래량 (ORB 기간)
open_price: float = 0.0 # M4: 시가 (첫 ORB 봉의 open, gap 필터용)
@dataclass
@@ -253,11 +254,14 @@ def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[Openin
if len(orb) < 5:
return None
total_vol = sum(c.volume for c in orb)
# M4: 시가 = ORB 기간 첫 봉의 open (gap 필터용)
sorted_orb = sorted(orb, key=lambda c: c.time)
return OpeningRange(
high=max(c.high for c in orb),
low=min(c.low for c in orb),
total_volume=total_vol,
avg_vol_per_min=total_vol / len(orb),
open_price=sorted_orb[0].open,
)
@@ -302,6 +306,22 @@ def check_orb_signal(
if not orb or current_price <= 0:
return None
# M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부
# prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용
# orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주
if orb.open_price > 0:
_gap_ratio = abs(current_price - orb.open_price) / orb.open_price
if _gap_ratio > 0.04:
logger.debug("ORB skip %s: gap ratio %.2f%% > 4%%", stock_code, _gap_ratio * 100)
return None
# M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험
if orb.low > 0:
_range_width = (orb.high - orb.low) / orb.low
if _range_width < 0.003: # 최소 0.3% 범위
logger.debug("ORB skip %s: range width %.3f%% < 0.3%%", stock_code, _range_width * 100)
return None
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
return None

View File

@@ -73,8 +73,20 @@ async def check_and_exit_positions() -> list[dict]:
return exits
# 일일 손실 한도 초과 시 전체 강제 청산
if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl):
logger.warning("[DayExit] 일일 손실 한도 초과 → 전체 강제 청산")
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
unrealized_pnl = 0.0
for _code, _pos in day_trading_state.positions.items():
_entry = _pos.get("entry_price", 0)
_qty = _pos.get("quantity", 0)
_cur = _pos.get("current_price", _entry)
if _entry > 0 and _qty > 0:
unrealized_pnl += (_cur - _entry) * _qty
total_daily_pnl = day_trading_state.daily_pnl + unrealized_pnl
if is_daily_loss_exceeded(day_trading_state.last_total_assets, total_daily_pnl):
logger.warning(
f"[DayExit] 일일 손실 한도 초과 → 전체 강제 청산 "
f"(실현={day_trading_state.daily_pnl:.0f}, 미실현={unrealized_pnl:.0f})"
)
return await force_close_all(reason="일일 손실 한도 초과")
# EOD 강제 청산 (15:20)
@@ -131,10 +143,13 @@ async def _check_single_position(stock_code: str) -> dict | None:
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완)
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price
new_highest = max(current_price, candle_high, highest_price)
if new_highest > highest_price:
async with day_trading_state.trade_lock:
if stock_code in day_trading_state.positions:
async with day_trading_state.trade_lock:
if stock_code in day_trading_state.positions:
if new_highest > highest_price:
day_trading_state.positions[stock_code]["highest_price"] = new_highest
# [S5] 미실현 손익 계산용 현재가 갱신
day_trading_state.positions[stock_code]["current_price"] = current_price
if new_highest > highest_price:
highest_price = new_highest
# 청산 신호 체크
@@ -231,7 +246,7 @@ async def _execute_exit(
day_trading_state.positions[stock_code]["quantity"] = remaining
day_trading_state.positions[stock_code]["target_price"] = float("inf")
day_trading_state.positions[stock_code]["partial_sold"] = True
day_trading_state.update_pnl(profit_loss)
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
if is_full_exit:
@@ -295,6 +310,7 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]:
list[dict]: 청산 실행 결과 목록
"""
results = []
failed_codes: list[str] = []
codes = list(day_trading_state.positions.keys())
for stock_code in codes:
@@ -319,6 +335,33 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]:
except Exception as e:
logger.error(f"[DayExit] 강제 청산 오류 ({stock_code}): {e}", exc_info=True)
failed_codes.append(stock_code)
# S4 fix: 실패 종목 1회 재시도
if failed_codes:
logger.warning(f"[DayExit] 강제 청산 실패 {len(failed_codes)}건 — 2초 후 재시도: {failed_codes}")
await asyncio.sleep(2.0)
still_failed: list[str] = []
for stock_code in failed_codes:
pos = day_trading_state.get_position(stock_code)
if not pos:
continue
try:
price_resp = await korea_investment_api.get_current_price(stock_code)
price = float(
price_resp.get("output", {}).get("stck_prpr", 0) or 0
) if price_resp else 0.0
if price <= 0:
price = float(pos.get("entry_price", 0))
result = await _execute_exit(stock_code, pos, price, reason)
if result:
results.append(result)
await asyncio.sleep(0.3)
except Exception as e:
logger.critical(f"[DayExit] 강제 청산 재시도 실패 ({stock_code}): {e} — 포지션 미청산 위험!", exc_info=True)
still_failed.append(stock_code)
if still_failed:
logger.critical(f"[DayExit] 최종 미청산 종목: {still_failed} — 수동 청산 필요!")
logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})")
return results

View File

@@ -153,20 +153,26 @@ class DayTradingState:
self.portfolio_cache_dirty = True
return pos
def update_pnl(self, realized: float) -> None:
def update_pnl(self, realized: float, is_full_close: bool = True) -> None:
"""실현 손익 누적 및 연속 손실 카운트 업데이트.
손실 시 loss_streak_today 증가, 이익 시 리셋.
3연패 이상이면 is_cooldown_active()가 True 반환.
연속 손실 카운트는 전량 매도(is_full_close=True)일 때만 업데이트.
분할 매도(is_full_close=False)나 손익분기(realized==0)는 카운트에 영향 없음.
Args:
realized: 실현 손익 (원). 양수=이익, 음수=손실
is_full_close: 전량 매도 여부. False이면 streak 카운트 미변경
"""
self.daily_pnl += realized
if not is_full_close:
return
if realized < 0:
self.loss_streak_today += 1
else:
# 이익 발생 시 연속 손실 카운트 리셋
elif realized > 0:
# 이익 발생 시 연속 손실 카운트 리셋 (손익분기 realized==0은 중립 — 리셋하지 않음)
self.loss_streak_today = 0
def get_position(self, stock_code: str) -> dict | None:

View File

@@ -18,8 +18,9 @@ logger = logging.getLogger(__name__)
# 가격 필터 (CLAUDE.md 섹션 6.4)
MIN_PRICE_KRW = 5_000
# 전일 거래량 최솟값: 100만주 미만 종목 제외 (유동성 부족)
MIN_DAILY_VOLUME = 1_000_000
# 전일 거래량 최솟값: 10만주 미만 종목 제외 (유동성 부족)
# market_analyzer.py와 동일 수준. LIQUIDITY_PERCENTILE=0.80 이 추가 필터 역할.
MIN_DAILY_VOLUME = 100_000
# 거래대금 상위 20% 임계값 (하위 80% 제외)
LIQUIDITY_PERCENTILE = 0.80
@@ -155,8 +156,13 @@ class KosdaqScanner:
pct_vs_lower = (td.close - bb_lower) / bb_lower * 100
volume_ratio = td.volume / volume_ma20
rsi_score = max(0.0, (25.0 - rsi14) / 25.0)
bb_score = min(max(0.0, (0.0 - pct_vs_lower) / 3.0), 1.0) # 상한 1.0 — 폭락 종목 점수 왜곡 방지
# [S8] RSI 점수: config의 MEAN_REVERSION_RSI_THRESHOLD(46.0) 기준으로 정규화
from app.core.config import settings as _settings
_rsi_threshold = _settings.MEAN_REVERSION_RSI_THRESHOLD # 46.0
rsi_score = max(0.0, (_rsi_threshold - rsi14) / _rsi_threshold)
# [S8] BB 점수: CLAUDE.md 6.2 기준 -3%~+1.5% 구간 보상
# pct_vs_lower <= -3%: 최대 점수 1.0, +1.5% 이상: 점수 0.0
bb_score = min(max(0.0, (1.5 - pct_vs_lower) / 4.5), 1.0)
volume_score = min(volume_ratio, 2.0)
total_score = rsi_score * 0.5 + bb_score * 0.3 + volume_score * 0.2
@@ -169,8 +175,8 @@ class KosdaqScanner:
rsi_turning = rsi_prev is not None and rsi14 > rsi_prev
reason_parts = []
if rsi14 <= 25:
reason_parts.append(f"RSI({rsi14:.1f})≤25")
if rsi14 <= _rsi_threshold:
reason_parts.append(f"RSI({rsi14:.1f})≤{_rsi_threshold:.0f}")
if pct_vs_lower <= 0:
reason_parts.append(f"BB하단({pct_vs_lower:.1f}%)")
if volume_ratio >= 1.5:

View File

@@ -217,6 +217,7 @@ class MDDManager:
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
results = []
skipped = 0
for h in holdings:
stock_code = h.get("pdno", "")
stock_name = h.get("prdt_name", stock_code)
@@ -224,6 +225,7 @@ class MDDManager:
current_price = float(h.get("prpr", 0) or 0)
if quantity <= 0 or current_price <= 0:
skipped += 1
continue
sell_qty = max(int(quantity * reduce_pct), 1)
@@ -243,16 +245,9 @@ class MDDManager:
profit_loss = (current_price - avg_price) * sell_qty
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
# [BUG FIX] loss_tracker 기록 — MDD 강제매도도 연속 손실 추적 대상
try:
from app.services.loss_tracker import loss_tracker
is_loss = profit_loss_rate < 0
await loss_tracker.record_trade_result_safe(
stock_code, is_loss
)
await loss_tracker.save_to_db(stock_code=stock_code)
except Exception as lt_err:
logger.warning(f"[MDD] loss_tracker 기록 실패 ({stock_code}): {lt_err}")
# MDD 강제매도는 시스템적(systemic) 리스크 축소이며,
# 개별 종목의 전략 실패가 아니므로 loss_tracker에 기록하지 않음.
# 기록 시 양호한 종목이 MDD 축소로 인해 false ban될 수 있음.
await enqueue_trade_history({
"stock_code": stock_code,
@@ -304,7 +299,7 @@ class MDDManager:
except Exception as e:
logger.error(f"[MDD] 축소 매도 실패 ({stock_code}): {e}")
failed_count = len(holdings) - len(results)
failed_count = len(holdings) - len(results) - skipped
if failed_count > 0 and executed_key is not None:
# 매도 실패 종목이 있으면 플래그 리셋 → 다음 주기에 재시도
async with self._lock:

View File

@@ -38,6 +38,9 @@ from app.services.strategy_config_service import strategy_cfg
logger = logging.getLogger(__name__)
# M2: ADX hysteresis state — 직전 regime을 기억하여 ADX 경계선에서 잦은 전환 방지
_previous_regime: Optional["MarketRegime"] = None
class MarketRegime(Enum):
BULL = "bull"
@@ -163,11 +166,10 @@ def detect_market_regime(index_closes: List[float],
return MarketRegime.BEAR # 데이터 부족 시 보수적
# CLAUDE.md 6.1: ADX < 20 → SIDEWAYS (진입 차단)
# S3 fix: ADX 값은 먼저 계산하되, slope도 함께 확인 후 판정
_adx_value = None
if highs is not None and lows is not None:
adx = calculate_adx(highs, lows, index_closes)
if adx is not None and adx < settings.MEAN_REVERSION_ADX_SIDEWAYS:
logger.info("시장 국면: SIDEWAYS (ADX=%.1f < %.1f) — CLAUDE.md 6.1", adx, settings.MEAN_REVERSION_ADX_SIDEWAYS)
return MarketRegime.SIDEWAYS
_adx_value = calculate_adx(highs, lows, index_closes)
ma200_current = sum(index_closes[-settings.MEAN_REVERSION_MA200_PERIOD:]) / settings.MEAN_REVERSION_MA200_PERIOD
# [#10] 기울기 비교 기간을 5일→20일로 확대하여 SIDEWAYS 과다판정 방지
@@ -182,28 +184,64 @@ def detect_market_regime(index_closes: List[float],
# CLAUDE.md 6.1: 기울기 ≈ 0 → SIDEWAYS
slope_ratio = abs(ma200_current - ma200_prev) / ma200_prev if ma200_prev > 0 else 0
# S3 fix: SIDEWAYS 판정은 ADX < threshold AND slope ≈ 0 모두 충족 시에만.
# ADX < threshold이더라도 slope가 명확하면 BULL/BEAR로 분류한다.
# M2: ADX hysteresis — enter SIDEWAYS at ADX < 18, exit SIDEWAYS at ADX > 22
global _previous_regime
_ADX_ENTER_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 # 18
_ADX_EXIT_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 # 22
if _adx_value is not None:
_was_sideways = (_previous_regime == MarketRegime.SIDEWAYS)
# hysteresis: 이미 SIDEWAYS면 ADX > 22 돌파해야 탈출, 아니면 ADX < 18 이하여야 진입
_adx_triggers_sideways = (
(_was_sideways and _adx_value < _ADX_EXIT_SIDEWAYS) or
(not _was_sideways and _adx_value < _ADX_ENTER_SIDEWAYS)
)
if _adx_triggers_sideways:
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (ADX=%.1f, hysteresis=[enter<%.0f,exit>%.0f] AND slope≈0, ratio=%.5f) — CLAUDE.md 6.1",
_adx_value, _ADX_ENTER_SIDEWAYS, _ADX_EXIT_SIDEWAYS, slope_ratio)
_previous_regime = MarketRegime.SIDEWAYS
return MarketRegime.SIDEWAYS
else:
logger.info("시장 국면: ADX=%.1f이나 slope 명확(ratio=%.5f) — BULL/BEAR로 분류", _adx_value, slope_ratio)
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
_previous_regime = MarketRegime.SIDEWAYS
return MarketRegime.SIDEWAYS
is_bull = index_closes[-1] > ma200_current and slope_positive
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", "BULL" if is_bull else "BEAR", slope_ratio)
return MarketRegime.BULL if is_bull else MarketRegime.BEAR
result = MarketRegime.BULL if is_bull else MarketRegime.BEAR
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", result.value.upper(), slope_ratio)
_previous_regime = result
return result
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
"""RSI(Relative Strength Index) 계산"""
"""RSI(Relative Strength Index) 계산 — Wilder 평활법
feature_engineering.compute_rsi()와 동일한 Wilder smoothing 사용:
- 첫 period개: 단순 평균(seed)
- 이후: avg = (prev_avg * (period-1) + current) / period
"""
if len(closes) < period + 1:
return None
gains, losses = [], []
for i in range(-period, 0):
diff = closes[i] - closes[i - 1]
gains.append(max(diff, 0))
losses.append(max(-diff, 0))
# 모든 price diff 계산
diffs = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
# Seed: 첫 period개의 단순 평균
avg_gain = sum(max(d, 0) for d in diffs[:period]) / period
avg_loss = sum(max(-d, 0) for d in diffs[:period]) / period
# Wilder smoothing: 나머지 구간
for i in range(period, len(diffs)):
avg_gain = (avg_gain * (period - 1) + max(diffs[i], 0)) / period
avg_loss = (avg_loss * (period - 1) + max(-diffs[i], 0)) / period
avg_gain = sum(gains) / period
avg_loss = sum(losses) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
@@ -301,6 +339,8 @@ def check_buy_signal(
if not _adp_bb.fallback_used:
# Normal 기준 0.30 대비 비율로 스케일링
_adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0
# S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3
_adaptive_bb_scale = min(_adaptive_bb_scale, 1.3)
except Exception:
pass
@@ -462,7 +502,7 @@ def check_exit_signal(
min_profit_price = entry_price + 2.0 * atr # CLAUDE.md 6.3: 2×ATR
_profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0
# 최소 수익률 가드: 수수료(~0.4%)를 커버하지 못하면 익절하지 않음
_MIN_PROFIT_FOR_EXIT = 1.0 # 최소 1% 수익이어야 익절 (수수료+세금 0.4% 커버 + 알파)
_MIN_PROFIT_FOR_EXIT = 0.5 # 최소 0.5% 수익이어야 익절 (수수료+세금 ~0.3-0.4% 커버)
if remaining_pct > threshold and _profit_pct >= _MIN_PROFIT_FOR_EXIT:
rsi_exit = rsi >= _adaptive_rsi_partial
bb_exit = close > bb_middle

View File

@@ -246,10 +246,13 @@ class MLEnsemble:
# 탭뷸러 피처 스케일링
from sklearn.preprocessing import StandardScaler
self.scaler = StandardScaler()
X_scaled = self.scaler.fit_transform(X_tabular)
# 학습/검증 분리 (시계열이므로 뒤쪽 20%를 검증으로)
split_idx = int(len(X_scaled) * 0.8)
split_idx = int(len(X_tabular) * 0.8)
# S2 fix: scaler는 학습 데이터에만 fit하여 data leakage 방지
self.scaler.fit(X_tabular[:split_idx])
X_scaled = self.scaler.transform(X_tabular)
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
y_train, y_val = y[:split_idx], y[split_idx:]
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]

View File

@@ -37,9 +37,8 @@ from app.services.market_playbook import is_entry_allowed_by_playbook, get_posit
logger = logging.getLogger(__name__)
# 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20이지만,
# 스윙 전용으로 25개까지 허용 -- 데이트레이딩과 분리 운영)
MAX_SWING_POSITIONS = 25
# 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20)
MAX_SWING_POSITIONS = 20
async def execute_swing_entries(recommendations_response: dict, db: AsyncSession) -> list[dict]:

View File

@@ -203,6 +203,15 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0
trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None
# [S6] 트레일링스탑 고점(highest_since_entry) 매 사이클 갱신
if active_trade and latest_price > (trailing_high or 0):
trailing_high = latest_price
from app.services.db_worker import enqueue_active_trade_upsert
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"highest_since_entry": trailing_high,
})
# ── 전략별 청산 분기 ────────────────────────────────────
exit_signal = None
_strategy = active_trade.strategy_name if active_trade else "swing"