Some checks failed
Deploy to Production / deploy (push) Failing after 6m46s
- 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)
372 lines
14 KiB
Python
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
|