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)
267 lines
9.3 KiB
Python
267 lines
9.3 KiB
Python
"""
|
|
스크리닝 드라이런 스크립트.
|
|
최근 1달치 각 영업일에 대해 시장 상태 판단 + 거래대금 종목 스크리닝 실행.
|
|
KIS API / 거래 기능 없음.
|
|
|
|
사용법:
|
|
cd backend
|
|
uv run python scripts/screening_dryrun.py
|
|
uv run python scripts/screening_dryrun.py --days 20
|
|
"""
|
|
import sys
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
import importlib.util
|
|
import logging
|
|
from datetime import date, timedelta
|
|
from typing import List, Dict
|
|
|
|
import pandas as pd
|
|
from pykrx import stock as pykrx_stock
|
|
|
|
# kospi_screener를 직접 로드 (app 모듈 체인의 DB 의존성 우회)
|
|
_screener_path = Path(__file__).parent.parent / "app" / "services" / "strategy" / "kospi_screener.py"
|
|
_spec = importlib.util.spec_from_file_location("kospi_screener", _screener_path)
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_mod)
|
|
|
|
KOSPIMarketStateDetector = _mod.KOSPIMarketStateDetector
|
|
VolumeScreener = _mod.VolumeScreener
|
|
StockInfo = _mod.StockInfo
|
|
ScreeningResult = _mod.ScreeningResult
|
|
MarketState = _mod.MarketState
|
|
|
|
logging.basicConfig(level=logging.WARNING)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_business_days(start_date: date, end_date: date) -> List[date]:
|
|
"""영업일 목록 반환 (pykrx 기준)."""
|
|
days = []
|
|
current = start_date
|
|
while current <= end_date:
|
|
if current.weekday() < 5:
|
|
days.append(current)
|
|
current += timedelta(days=1)
|
|
return days
|
|
|
|
|
|
def get_sector_map(biz_day_str: str) -> Dict[str, str]:
|
|
"""WISEindex 섹터 맵 반환."""
|
|
import requests
|
|
SECTOR_CODES = ["G25", "G35", "G50", "G40", "G10", "G20", "G55", "G30", "G15", "G45"]
|
|
sector_map = {}
|
|
for code in SECTOR_CODES:
|
|
try:
|
|
url = f"http://www.wiseindex.com/Index/GetIndexComponets?ceil_yn=0&dt={biz_day_str}&sec_cd={code}"
|
|
resp = requests.get(url, timeout=5)
|
|
data = resp.json()
|
|
for item in data.get("list", []):
|
|
sector_map[item["CMP_CD"]] = item["SEC_NM_KOR"]
|
|
except Exception:
|
|
pass
|
|
return sector_map
|
|
|
|
|
|
def run_dryrun(days: int = 30) -> pd.DataFrame:
|
|
end_date = date.today()
|
|
start_date = end_date - timedelta(days=days + 30)
|
|
|
|
end_str = end_date.strftime("%Y%m%d")
|
|
start_str = start_date.strftime("%Y%m%d")
|
|
|
|
print(f"데이터 수집 중... ({start_str} ~ {end_str})")
|
|
|
|
print("KOSPI 지수 데이터 수집...")
|
|
kospi_df = pykrx_stock.get_index_ohlcv(start_str, end_str, "1001")
|
|
kospi_df.index.name = "date"
|
|
col_count = len(kospi_df.columns)
|
|
if col_count >= 6:
|
|
kospi_df.columns = ["open", "high", "low", "close", "volume", "trading_value"] + list(kospi_df.columns[6:])
|
|
elif col_count >= 5:
|
|
kospi_df.columns = ["open", "high", "low", "close", "volume"] + list(kospi_df.columns[5:])
|
|
|
|
target_days_raw = get_business_days(end_date - timedelta(days=days), end_date)
|
|
kospi_idx_dates = set(kospi_df.index.date)
|
|
target_days = [d for d in target_days_raw if d in kospi_idx_dates]
|
|
|
|
print(f"분석 대상: {len(target_days)}일")
|
|
|
|
if not target_days:
|
|
print("분석 가능한 영업일 없음")
|
|
return pd.DataFrame()
|
|
|
|
latest_biz = target_days[-1].strftime("%Y%m%d")
|
|
print(f"KOSPI 종목 목록 수집 ({latest_biz})...")
|
|
tickers = pykrx_stock.get_market_ticker_list(latest_biz, market="KOSPI")
|
|
print(f" 총 {len(tickers)}개 종목")
|
|
|
|
print("섹터 정보 수집...")
|
|
sector_map = get_sector_map(latest_biz)
|
|
|
|
print("시가총액 수집...")
|
|
try:
|
|
cap_df = pykrx_stock.get_market_cap_by_ticker(latest_biz)
|
|
except Exception:
|
|
cap_df = pd.DataFrame()
|
|
|
|
print("종목 가격 데이터 수집 중... (시간이 걸릴 수 있습니다)")
|
|
|
|
data_start = (target_days[0] - timedelta(days=400)).strftime("%Y%m%d")
|
|
data_end = target_days[-1].strftime("%Y%m%d")
|
|
|
|
price_data: Dict[str, pd.DataFrame] = {}
|
|
stock_info: Dict[str, StockInfo] = {}
|
|
|
|
for i, ticker in enumerate(tickers):
|
|
if i % 100 == 0:
|
|
print(f" {i}/{len(tickers)} 처리 중...")
|
|
try:
|
|
df = pykrx_stock.get_market_ohlcv(data_start, data_end, ticker)
|
|
if df is None or df.empty:
|
|
continue
|
|
df.index.name = "date"
|
|
col_count = len(df.columns)
|
|
if col_count >= 7:
|
|
df.columns = ["open", "high", "low", "close", "volume", "trading_value", "change"] + list(df.columns[7:])
|
|
elif col_count >= 6:
|
|
df.columns = ["open", "high", "low", "close", "volume", "trading_value"] + list(df.columns[6:])
|
|
else:
|
|
df.columns = ["open", "high", "low", "close", "volume"] + list(df.columns[5:])
|
|
price_data[ticker] = df
|
|
|
|
market_cap = 0
|
|
if ticker in cap_df.index:
|
|
try:
|
|
market_cap = int(cap_df.at[ticker, "시가총액"])
|
|
except Exception:
|
|
pass
|
|
|
|
name = pykrx_stock.get_market_ticker_name(ticker)
|
|
sector = sector_map.get(ticker, "기타")
|
|
stock_info[ticker] = StockInfo(
|
|
ticker=ticker, name=name, market_cap=market_cap, sector=sector,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("Failed %s: %s", ticker, e)
|
|
continue
|
|
|
|
print(f" 가격 데이터 수집 완료: {len(price_data)}개 종목")
|
|
|
|
detector = KOSPIMarketStateDetector()
|
|
screener = VolumeScreener()
|
|
|
|
all_rows = []
|
|
|
|
for target_date in target_days:
|
|
target_ts = pd.Timestamp(target_date)
|
|
kospi_up_to = kospi_df[kospi_df.index <= target_ts]
|
|
|
|
if len(kospi_up_to) < 20:
|
|
continue
|
|
|
|
market_state = detector.detect(kospi_up_to)
|
|
|
|
if market_state == MarketState.crash:
|
|
all_rows.append({
|
|
"날짜": target_date.strftime("%Y-%m-%d"),
|
|
"시장상태": "급락(신규진입금지)",
|
|
"종목코드": "-",
|
|
"종목명": "-",
|
|
"섹터": "-",
|
|
"종가": 0,
|
|
"등락률(%)": 0,
|
|
"거래대금(억)": 0,
|
|
"상한가": "-",
|
|
"시총(억)": 0,
|
|
"희소성(횟수)": 0,
|
|
"신호강도": 0,
|
|
})
|
|
continue
|
|
|
|
filtered_price_data = {}
|
|
for ticker, df in price_data.items():
|
|
df_up_to = df[df.index <= target_ts]
|
|
if not df_up_to.empty and target_ts in df_up_to.index:
|
|
filtered_price_data[ticker] = df_up_to
|
|
|
|
results: List[ScreeningResult] = screener.screen(
|
|
target_date, filtered_price_data, stock_info,
|
|
)
|
|
|
|
if not results:
|
|
all_rows.append({
|
|
"날짜": target_date.strftime("%Y-%m-%d"),
|
|
"시장상태": market_state.value,
|
|
"종목코드": "(해당없음)",
|
|
"종목명": "-",
|
|
"섹터": "-",
|
|
"종가": 0,
|
|
"등락률(%)": 0,
|
|
"거래대금(억)": 0,
|
|
"상한가": "-",
|
|
"시총(억)": 0,
|
|
"희소성(횟수)": 0,
|
|
"신호강도": 0,
|
|
})
|
|
else:
|
|
for r in results:
|
|
all_rows.append({
|
|
"날짜": target_date.strftime("%Y-%m-%d"),
|
|
"시장상태": market_state.value,
|
|
"종목코드": r.ticker,
|
|
"종목명": r.name,
|
|
"섹터": r.sector,
|
|
"종가": int(r.close_price),
|
|
"등락률(%)": round(r.daily_return * 100, 2),
|
|
"거래대금(억)": round(r.trading_value / 1e8, 0),
|
|
"상한가": "✅" if r.is_limit_up else "",
|
|
"시총(억)": round(r.market_cap / 1e8, 0) if r.market_cap else 0,
|
|
"희소성(횟수)": r.frequency_count,
|
|
"신호강도": round(r.signal_strength, 2),
|
|
})
|
|
|
|
df_result = pd.DataFrame(all_rows)
|
|
return df_result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="KOSPI 종목발굴 전략 드라이런")
|
|
parser.add_argument("--days", type=int, default=30, help="최근 N일 (기본 30)")
|
|
args = parser.parse_args()
|
|
|
|
df = run_dryrun(days=args.days)
|
|
|
|
if df.empty:
|
|
print("결과 없음")
|
|
return
|
|
|
|
print("\n" + "=" * 80)
|
|
print(f"김종봉식 KOSPI 종목발굴 전략 드라이런 결과 (최근 {args.days}일)")
|
|
print("=" * 80)
|
|
|
|
state_counts = df[df["종목코드"] != "(해당없음)"]["시장상태"].value_counts()
|
|
print("\n[시장 상태 분포]")
|
|
for state, count in state_counts.items():
|
|
print(f" {state}: {count}일")
|
|
|
|
signal_df = df[~df["종목코드"].isin(["-", "(해당없음)"])].copy()
|
|
print(f"\n[신호 발생 요약]")
|
|
print(f" 총 신호 발생: {len(signal_df)}건 ({signal_df['날짜'].nunique()}일)")
|
|
|
|
if not signal_df.empty:
|
|
print(f"\n[전체 결과]")
|
|
pd.set_option("display.max_rows", 200)
|
|
pd.set_option("display.width", 120)
|
|
pd.set_option("display.max_columns", 12)
|
|
print(signal_df.to_string(index=False))
|
|
|
|
output_path = Path(__file__).parent / "screening_dryrun_result.csv"
|
|
df.to_csv(output_path, index=False, encoding="utf-8-sig")
|
|
print(f"\n결과 저장: {output_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|