feat: wire KJB into backtest worker, add Signal API, add scheduler job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-19 15:16:13 +09:00
parent 8d1a2f7937
commit 3c969fc53c
8 changed files with 194 additions and 3 deletions

View File

@ -6,6 +6,7 @@ from app.api.market import router as market_router
from app.api.backtest import router as backtest_router
from app.api.snapshot import router as snapshot_router
from app.api.data_explorer import router as data_explorer_router
from app.api.signal import router as signal_router
__all__ = [
"auth_router",
@ -16,4 +17,5 @@ __all__ = [
"backtest_router",
"snapshot_router",
"data_explorer_router",
"signal_router",
]

56
backend/app/api/signal.py Normal file
View File

@ -0,0 +1,56 @@
"""
KJB Signal API endpoints.
"""
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.signal import Signal
from app.schemas.signal import SignalResponse
router = APIRouter(prefix="/api/signal", tags=["signal"])
@router.get("/kjb/today", response_model=List[SignalResponse])
async def get_today_signals(
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get today's KJB trading signals."""
today = date.today()
signals = (
db.query(Signal)
.filter(Signal.date == today)
.order_by(Signal.signal_type, Signal.ticker)
.all()
)
return signals
@router.get("/kjb/history", response_model=List[SignalResponse])
async def get_signal_history(
current_user: CurrentUser,
db: Session = Depends(get_db),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
ticker: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
):
"""Get historical KJB signals."""
query = db.query(Signal)
if start_date:
query = query.filter(Signal.date >= start_date)
if end_date:
query = query.filter(Signal.date <= end_date)
if ticker:
query = query.filter(Signal.ticker == ticker)
signals = (
query.order_by(Signal.date.desc(), Signal.ticker)
.limit(limit)
.all()
)
return signals

View File

@ -10,6 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api import (
auth_router, admin_router, portfolio_router, strategy_router,
market_router, backtest_router, snapshot_router, data_explorer_router,
signal_router,
)
# Configure logging
@ -112,6 +113,7 @@ app.include_router(market_router)
app.include_router(backtest_router)
app.include_router(snapshot_router)
app.include_router(data_explorer_router)
app.include_router(signal_router)
@app.get("/health")

View File

@ -27,7 +27,7 @@ class BacktestStatus(str, Enum):
class BacktestCreate(BaseModel):
"""Request to create a new backtest."""
strategy_type: str = Field(..., description="multi_factor, quality, or value_momentum")
strategy_type: str = Field(..., description="multi_factor, quality, value_momentum, or kjb")
strategy_params: Dict[str, Any] = Field(default_factory=dict)
start_date: date
end_date: date

View File

@ -44,7 +44,11 @@ def _run_backtest_job(backtest_id: int) -> None:
db.commit()
logger.info(f"Backtest {backtest_id} started")
# Run backtest
# Run backtest - route KJB to DailyBacktestEngine
if backtest.strategy_type == "kjb":
from app.services.backtest.daily_engine import DailyBacktestEngine
engine = DailyBacktestEngine(db)
else:
engine = BacktestEngine(db)
engine.run(backtest_id)

View File

@ -3,8 +3,10 @@ Background jobs module.
"""
from jobs.scheduler import scheduler, start_scheduler, stop_scheduler
from jobs.collection_job import run_daily_collection, run_backfill
from jobs.kjb_signal_job import run_kjb_signals
__all__ = [
"scheduler", "start_scheduler", "stop_scheduler",
"run_daily_collection", "run_backfill",
"run_kjb_signals",
]

View File

@ -0,0 +1,109 @@
"""
Daily KJB signal generation job.
"""
import logging
from datetime import date, timedelta
import pandas as pd
from app.core.database import SessionLocal
from app.models.stock import Stock, Price
from app.models.signal import Signal, SignalType, SignalStatus
from app.services.strategy.kjb import KJBSignalGenerator
logger = logging.getLogger(__name__)
def run_kjb_signals():
"""
Generate KJB trading signals for today.
Called by scheduler at 18:15 KST (after price collection).
"""
logger.info("Starting KJB signal generation")
db = SessionLocal()
try:
today = date.today()
signal_gen = KJBSignalGenerator()
stocks = (
db.query(Stock)
.filter(Stock.market_cap.isnot(None))
.order_by(Stock.market_cap.desc())
.limit(30)
.all()
)
tickers = [s.ticker for s in stocks]
name_map = {s.ticker: s.name for s in stocks}
lookback_start = today - timedelta(days=90)
kospi_prices = (
db.query(Price)
.filter(Price.ticker == "069500")
.filter(Price.date >= lookback_start, Price.date <= today)
.order_by(Price.date)
.all()
)
if not kospi_prices:
logger.warning("No KOSPI data available for signal generation")
return
kospi_df = pd.DataFrame([
{"date": p.date, "close": float(p.close)}
for p in kospi_prices
]).set_index("date")
signals_created = 0
for ticker in tickers:
stock_prices = (
db.query(Price)
.filter(Price.ticker == ticker)
.filter(Price.date >= lookback_start, Price.date <= today)
.order_by(Price.date)
.all()
)
if len(stock_prices) < 21:
continue
stock_df = pd.DataFrame([{
"date": p.date,
"open": float(p.open),
"high": float(p.high),
"low": float(p.low),
"close": float(p.close),
"volume": int(p.volume),
} for p in stock_prices]).set_index("date")
signals = signal_gen.generate_signals(stock_df, kospi_df)
if today in signals.index and signals.loc[today, "buy"]:
close_price = stock_df.loc[today, "close"]
reason_parts = []
if signals.loc[today, "breakout"]:
reason_parts.append("breakout")
if signals.loc[today, "large_candle"]:
reason_parts.append("large_candle")
signal = Signal(
date=today,
ticker=ticker,
name=name_map.get(ticker),
signal_type=SignalType.BUY,
entry_price=close_price,
target_price=round(close_price * 1.05, 2),
stop_loss_price=round(close_price * 0.97, 2),
reason=", ".join(reason_parts),
status=SignalStatus.ACTIVE,
)
db.add(signal)
signals_created += 1
db.commit()
logger.info(f"KJB signal generation complete: {signals_created} buy signals")
except Exception as e:
logger.exception(f"KJB signal generation failed: {e}")
finally:
db.close()

View File

@ -11,6 +11,7 @@ KST = ZoneInfo("Asia/Seoul")
from jobs.snapshot_job import create_daily_snapshots
from jobs.collection_job import run_daily_collection, run_financial_collection
from jobs.kjb_signal_job import run_kjb_signals
logger = logging.getLogger(__name__)
@ -66,6 +67,21 @@ def configure_jobs():
)
logger.info("Configured daily_snapshots job at 18:30 KST")
# KJB signal generation at 18:15 (after daily collection, before snapshot)
scheduler.add_job(
run_kjb_signals,
trigger=CronTrigger(
hour=18,
minute=15,
day_of_week='mon-fri',
timezone=KST,
),
id='kjb_daily_signals',
name='Generate KJB trading signals',
replace_existing=True,
)
logger.info("Configured kjb_daily_signals job at 18:15 KST")
def start_scheduler():
"""Start the scheduler."""