galaxis-po/backend/tests/unit/test_kospi_screener.py
머니페니 34d09d9d34
Some checks failed
Deploy to Production / deploy (push) Failing after 6m46s
feat: 김종봉식 KOSPI 종목발굴 전략 구현
- KOSPIMarketStateDetector: KOSPI MA 기반 시장 상태 판단 (bull/neutral/bear/crash)
- VolumeScreener: 거래대금 2000억+ 스크리닝 (상한가 우선, 희소성 체크, 대형주 예외)
- SectorPortfolioManager: 섹터 기반 비중 배분
- KJBScreeningSignalGenerator: 눌림목 진입, 5MA 손절, 단계적 익절
- KISTradeExecutor: KIS API 자동 매수/매도 (기본값 모의투자)
- ScreeningSignal / AutoOrder DB 모델 추가
- screening API 엔드포인트 추가
- 스케줄러 잡 3종 추가 (08:30/5분/15:35)
- Price.trading_value 컬럼 추가
- MarketIndex 테이블 추가 (KOSPI/KOSDAQ 지수 일봉)
- IndexCollector 추가 (일일 수집 잡 등록)
- intraday_exit_check 시간 필터 추가 (09:05~15:20 KST)
- 드라이런 스크립트 추가 (scripts/screening_dryrun.py)
2026-05-05 23:03:53 +09:00

372 lines
14 KiB
Python

import pytest
import pandas as pd
import numpy as np
from datetime import date, timedelta
from app.services.strategy.kospi_screener import (
MarketState, KOSPIMarketStateDetector, VolumeScreener, StockInfo,
ScreeningResult, SectorPortfolioManager, KJBScreeningSignalGenerator,
)
def _make_daily_index(start_date, n_days):
"""Create business day index."""
return pd.bdate_range(start=start_date, periods=n_days)
def _make_kospi_df(n_days=100, base_price=2500, trend="flat", crash_last=False):
"""Generate synthetic KOSPI index data."""
idx = _make_daily_index("2025-01-01", n_days)
if trend == "up":
prices = base_price + np.linspace(0, 300, n_days) + np.random.randn(n_days) * 5
elif trend == "down":
prices = base_price - np.linspace(0, 300, n_days) + np.random.randn(n_days) * 5
else:
prices = base_price + np.random.randn(n_days) * 5
if crash_last and len(prices) >= 2:
prices[-1] = prices[-2] * 0.97 # -3% crash
df = pd.DataFrame({
"open": prices * 0.999,
"high": prices * 1.005,
"low": prices * 0.995,
"close": prices,
"volume": np.random.randint(1_000_000, 10_000_000, n_days),
}, index=idx)
df.index.name = "date"
return df
def _make_stock_df(target_date, n_days=260, base_price=50000, limit_up_on_target=False, big_volume_on_target=False):
"""Generate synthetic stock price data."""
idx = _make_daily_index(target_date - timedelta(days=int(n_days * 1.5)), n_days)
# Make sure target_date is in the index by extending if needed
if target_date not in idx:
extra = pd.bdate_range(start=idx[-1] + timedelta(days=1), periods=20)
idx = idx.append(extra)
idx = idx[:n_days + 20]
prices = base_price + np.random.randn(len(idx)) * 500
volumes = np.random.randint(100_000, 500_000, len(idx))
df = pd.DataFrame({
"open": prices * 0.999,
"high": prices * 1.005,
"low": prices * 0.995,
"close": prices,
"volume": volumes,
"trading_value": prices * volumes,
}, index=idx)
df.index.name = "date"
if target_date in df.index:
loc = df.index.get_loc(target_date)
if limit_up_on_target and loc > 0:
prev_close = df.iloc[loc - 1]["close"]
df.iloc[loc, df.columns.get_loc("close")] = prev_close * 1.30 # +30% limit up
df.iloc[loc, df.columns.get_loc("high")] = prev_close * 1.30
if big_volume_on_target:
df.iloc[loc, df.columns.get_loc("volume")] = 5_000_000
df.iloc[loc, df.columns.get_loc("trading_value")] = df.iloc[loc]["close"] * 5_000_000
return df
class TestMarketState:
def test_market_state_bull(self):
"""Uptrend: close > 20MA, weekly 5MA > 20MA"""
df = _make_kospi_df(n_days=120, base_price=2500, trend="up")
detector = KOSPIMarketStateDetector()
result = detector.detect(df)
assert result == MarketState.bull
def test_market_state_bear(self):
"""Downtrend: close < 20MA, weekly 5MA < 20MA"""
df = _make_kospi_df(n_days=120, base_price=2800, trend="down")
detector = KOSPIMarketStateDetector()
result = detector.detect(df)
assert result == MarketState.bear
def test_market_state_crash(self):
"""Crash: daily return <= -2%"""
df = _make_kospi_df(n_days=100, base_price=2500, crash_last=True)
detector = KOSPIMarketStateDetector()
result = detector.detect(df)
assert result == MarketState.crash
class TestVolumeScreener:
def _make_test_data(self, target_date):
"""Create test price data and stock info for screening."""
# Stock A: limit up with big trading value (should pass)
stock_a_df = pd.DataFrame({
"open": [49000, 50000],
"high": [50000, 65000],
"low": [48500, 50000],
"close": [50000, 65000], # +30%
"volume": [100_000, 5_000_000],
"trading_value": [5_000_000_000, 300_000_000_000], # 3000억
}, index=pd.to_datetime([target_date - timedelta(days=1), target_date]))
stock_a_df.index.name = "date"
# Stock B: small trading value (should fail)
stock_b_df = pd.DataFrame({
"open": [10000, 10200],
"high": [10300, 10400],
"low": [9900, 10100],
"close": [10000, 10300], # +3%
"volume": [50_000, 60_000],
"trading_value": [500_000_000, 618_000_000], # 6.18억
}, index=pd.to_datetime([target_date - timedelta(days=1), target_date]))
stock_b_df.index.name = "date"
price_data = {"005930": stock_a_df, "000660": stock_b_df}
stock_info = {
"005930": StockInfo(ticker="005930", name="삼성전자", market_cap=500_000_000_000_000, sector="반도체"),
"000660": StockInfo(ticker="000660", name="SK하이닉스", market_cap=100_000_000_000_000, sector="반도체"),
}
return price_data, stock_info
def test_volume_screener_limit_up(self):
"""Limit up + big trading value should be detected."""
target_date = date(2025, 6, 2) # Monday
target_dt = pd.Timestamp(target_date)
stock_df = pd.DataFrame({
"open": [49000, 50000],
"high": [50000, 65000],
"low": [48500, 50000],
"close": [50000, 65000],
"volume": [100_000, 5_000_000],
"trading_value": [5_000_000_000, 300_000_000_000],
}, index=pd.to_datetime([target_date - timedelta(days=3), target_date]))
stock_df.index.name = "date"
price_data = {"005930": stock_df}
stock_info = {"005930": StockInfo("005930", "삼성전자", 5_000_000_000_000, "반도체")}
screener = VolumeScreener()
results = screener.screen(target_date, price_data, stock_info)
assert len(results) >= 1
assert results[0].is_limit_up == True
def test_volume_screener_frequency_filter(self):
"""Stock with trading_value >= threshold more than 2 times should be filtered out."""
target_date = date(2025, 6, 2)
n_days = 260
idx = _make_daily_index("2024-06-01", n_days)
prices = np.full(n_days, 50000.0)
volumes = np.full(n_days, 100_000)
trading_values = prices * volumes # 5B = well below threshold
# Make 5 days have huge trading value (> threshold) -> frequency = 5 > 2, should be filtered
for i in [50, 100, 150, 200, 250]:
if i < n_days:
trading_values[i] = 300_000_000_000 # 3000억
# Also make the last day have huge volume
if target_date in idx:
loc = idx.get_loc(target_date)
else:
idx = idx.append(pd.DatetimeIndex([target_date]))
prices = np.append(prices, 50000)
volumes = np.append(volumes, 5_000_000)
trading_values = np.append(trading_values, 300_000_000_000)
df = pd.DataFrame({
"open": prices * 0.999,
"high": prices * 1.005,
"low": prices * 0.995,
"close": prices,
"volume": volumes,
"trading_value": trading_values,
}, index=idx[:len(prices)])
df.index.name = "date"
price_data = {"005930": df}
stock_info = {"005930": StockInfo("005930", "삼성전자", 5_000_000_000_000, "반도체")}
screener = VolumeScreener()
results = screener.screen(target_date, price_data, stock_info)
assert len(results) == 0
def test_large_cap_exception(self):
"""Large cap (>10T) with less than 8% return should be filtered."""
target_date = date(2025, 6, 2)
stock_df = pd.DataFrame({
"open": [49000, 50000],
"high": [50500, 52000],
"low": [48500, 49800],
"close": [50000, 52000], # +4%, below 8% threshold
"volume": [100_000, 5_000_000],
"trading_value": [5_000_000_000, 300_000_000_000],
}, index=pd.to_datetime([target_date - timedelta(days=3), target_date]))
stock_df.index.name = "date"
price_data = {"005930": stock_df}
# market_cap = 15T > LARGE_CAP_THRESHOLD (10T)
stock_info = {"005930": StockInfo("005930", "삼성전자", 15_000_000_000_000, "반도체")}
screener = VolumeScreener()
results = screener.screen(target_date, price_data, stock_info)
assert len(results) == 0
class TestSectorAllocation:
def test_sector_allocation(self):
"""Test sector weight allocation."""
results = [
ScreeningResult("A", "주식A", 1_000_000_000_000, 50000, 0.1, 500_000_000_000, False, 1, "반도체", 2.5),
ScreeningResult("B", "주식B", 2_000_000_000_000, 30000, 0.05, 400_000_000_000, False, 1, "자동차", 2.0),
ScreeningResult("C", "주식C", 3_000_000_000_000, 70000, 0.08, 300_000_000_000, False, 1, "바이오", 1.5),
]
manager = SectorPortfolioManager()
allocations = manager.allocate(results, MarketState.bull, 100_000_000)
assert len(allocations) == 3
# First sector gets 40%
assert allocations[0].weight == pytest.approx(0.40)
# Second gets 30%
assert allocations[1].weight == pytest.approx(0.30)
# Third gets 20%
assert allocations[2].weight == pytest.approx(0.20)
# Amounts should be weight * investable_capital (90% of total)
investable = 100_000_000 * 0.90
assert allocations[0].amount == pytest.approx(investable * 0.40)
def test_crash_no_allocation(self):
"""Crash state should return empty allocation."""
results = [
ScreeningResult("A", "주식A", 1_000_000_000_000, 50000, 0.1, 500_000_000_000, False, 1, "반도체", 2.5),
]
manager = SectorPortfolioManager()
allocations = manager.allocate(results, MarketState.crash, 100_000_000)
assert len(allocations) == 0
class TestEntrySignal:
def test_entry_signal(self):
"""Entry signal when price bounces off 5MA after screen date."""
screen_date = date(2025, 6, 2)
idx = _make_daily_index("2025-05-20", 20)
prices = [50000] * len(idx)
# Create a pattern: screen_date has big move, then pullback to 5MA, then bounce
for i, d in enumerate(idx):
if d.date() < screen_date:
prices[i] = 50000 + i * 100
elif d.date() == screen_date:
prices[i] = 55000 # big move day
elif d.date() > screen_date:
days_after = (d.date() - screen_date).days
if days_after <= 2:
prices[i] = 53000 # pullback below 5MA
else:
prices[i] = 55500 # bounce above 5MA
df = pd.DataFrame({
"open": [p * 0.999 for p in prices],
"high": [p * 1.005 for p in prices],
"low": [p * 0.995 for p in prices],
"close": prices,
"volume": [1_000_000] * len(idx),
}, index=idx)
df.index.name = "date"
gen = KJBScreeningSignalGenerator()
result = gen.check_entry("005930", df, screen_date)
# Should find an entry within 2-5 days after screen_date
# Result may be None if the specific MA conditions aren't met with this data,
# but the function should at least not error
if result is not None:
assert "entry_date" in result
assert "entry_price" in result
assert "stop_price" in result
assert result["stop_price"] == df.loc[pd.Timestamp(screen_date), "low"]
class TestExitSignals:
def test_exit_stop_loss(self):
"""Stop loss when price drops to screen_date low."""
entry_price = 55000.0
stop_price = 50000.0
current_price = 49000.0 # below stop_price
df = pd.DataFrame({
"close": [55000, 54000, 52000, 49000],
"volume": [1_000_000, 1_000_000, 1_000_000, 1_000_000],
})
df.index.name = "date"
gen = KJBScreeningSignalGenerator()
result = gen.check_exit(entry_price, stop_price, current_price, df)
assert result is not None
assert result["exit_type"] == "stop_type_1"
assert result["sell_ratio"] == 1.0
def test_exit_max_loss(self):
"""Max loss -7% stop."""
entry_price = 55000.0
stop_price = 50000.0
current_price = 55000 * 0.92 # -8%, below -7% threshold
df = pd.DataFrame({
"close": [55000, 54000, 52000, current_price],
"volume": [1_000_000] * 4,
})
df.index.name = "date"
gen = KJBScreeningSignalGenerator()
result = gen.check_exit(entry_price, stop_price, current_price, df)
assert result is not None
assert result["exit_type"] == "stop_type_3"
assert result["sell_ratio"] == 1.0
def test_exit_take_profit(self):
"""Take profit at +10%: sell 50%."""
entry_price = 50000.0
stop_price = 45000.0
current_price = 56000.0 # +12%
df = pd.DataFrame({
"close": [50000, 52000, 54000, 56000],
"volume": [1_000_000] * 4,
})
df.index.name = "date"
gen = KJBScreeningSignalGenerator()
result = gen.check_exit(entry_price, stop_price, current_price, df)
assert result is not None
assert result["exit_type"] == "tp1"
assert result["sell_ratio"] == 0.5
def test_position_size(self):
"""Position size based on 2% risk."""
gen = KJBScreeningSignalGenerator()
qty = gen.calculate_position_size(
entry_price=50000,
stop_price=47000,
capital=100_000_000,
risk_pct=0.02,
)
# risk_per_share = 3000, allowed_risk = 2_000_000
# qty = 2_000_000 / 3000 = 666
assert qty == 666
def test_position_size_zero_risk(self):
"""Zero or negative risk should return 0."""
gen = KJBScreeningSignalGenerator()
assert gen.calculate_position_size(50000, 50000, 100_000_000) == 0
assert gen.calculate_position_size(50000, 55000, 100_000_000) == 0