zephyrdark 932b46c5fe feat: add KJBSignalGenerator for daily buy/sell signal detection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:10:53 +09:00

100 lines
3.3 KiB
Python

"""
Kim Jong-bong (KJB) strategy implementation.
Signal-based short-term trading strategy:
- Universe: market cap top 30, daily trading value >= 200B KRW
- Entry: relative strength > KOSPI + breakout or large candle
- Exit: stop-loss -3%, take-profit +5%/+10%, trailing stop
"""
from datetime import date
from decimal import Decimal
from typing import Dict, List, Optional
import pandas as pd
from sqlalchemy.orm import Session
from app.services.strategy.base import BaseStrategy
from app.schemas.strategy import StockFactor, StrategyResult, UniverseFilter
from app.services.factor_calculator import FactorCalculator
class KJBSignalGenerator:
"""
Generates daily buy/sell signals based on KJB rules.
Pure computation - no DB access. Takes DataFrames as input.
"""
def calculate_relative_strength(
self,
stock_df: pd.DataFrame,
kospi_df: pd.DataFrame,
lookback: int = 10,
) -> pd.Series:
"""
RS = (stock return / market return) * 100
RS > 100 means stock outperforms market.
"""
stock_ret = stock_df["close"].pct_change(lookback)
kospi_ret = kospi_df["close"].pct_change(lookback)
# Align on common index
aligned = pd.DataFrame({
"stock_ret": stock_ret,
"kospi_ret": kospi_ret,
}).dropna()
rs = pd.Series(dtype=float, index=stock_df.index)
for idx in aligned.index:
market_ret = aligned.loc[idx, "kospi_ret"]
stock_r = aligned.loc[idx, "stock_ret"]
if abs(market_ret) < 1e-10:
rs[idx] = 100.0 if abs(stock_r) < 1e-10 else (200.0 if stock_r > 0 else 0.0)
else:
rs[idx] = (stock_r / market_ret) * 100
return rs
def detect_breakout(
self,
stock_df: pd.DataFrame,
lookback: int = 20,
) -> pd.Series:
"""Close > highest high of previous lookback days."""
prev_high = stock_df["high"].rolling(lookback).max().shift(1)
return stock_df["close"] > prev_high
def detect_large_candle(
self,
stock_df: pd.DataFrame,
pct_threshold: float = 0.05,
vol_multiplier: float = 1.5,
) -> pd.Series:
"""
Daily return >= 5% AND volume >= 1.5x 20-day average.
"""
daily_return = stock_df["close"].pct_change()
avg_volume = stock_df["volume"].rolling(20).mean()
volume_ratio = stock_df["volume"] / avg_volume
return (daily_return >= pct_threshold) & (volume_ratio >= vol_multiplier)
def generate_signals(
self,
stock_df: pd.DataFrame,
kospi_df: pd.DataFrame,
rs_lookback: int = 10,
breakout_lookback: int = 20,
) -> pd.DataFrame:
"""
Buy when: RS > 100 AND (breakout OR large candle)
"""
rs = self.calculate_relative_strength(stock_df, kospi_df, rs_lookback)
breakout = self.detect_breakout(stock_df, breakout_lookback)
large_candle = self.detect_large_candle(stock_df)
signals = pd.DataFrame(index=stock_df.index)
signals["rs"] = rs
signals["breakout"] = breakout.fillna(False)
signals["large_candle"] = large_candle.fillna(False)
signals["buy"] = (rs > 100) & (breakout.fillna(False) | large_candle.fillna(False))
signals["buy"] = signals["buy"].fillna(False)
return signals