diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 2387385..1530c2c 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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", ] diff --git a/backend/app/api/signal.py b/backend/app/api/signal.py new file mode 100644 index 0000000..be24382 --- /dev/null +++ b/backend/app/api/signal.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index a19a059..31cd38f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/schemas/backtest.py b/backend/app/schemas/backtest.py index 5cc1523..9d47840 100644 --- a/backend/app/schemas/backtest.py +++ b/backend/app/schemas/backtest.py @@ -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 diff --git a/backend/app/services/backtest/worker.py b/backend/app/services/backtest/worker.py index d8a228e..bdca866 100644 --- a/backend/app/services/backtest/worker.py +++ b/backend/app/services/backtest/worker.py @@ -44,8 +44,12 @@ def _run_backtest_job(backtest_id: int) -> None: db.commit() logger.info(f"Backtest {backtest_id} started") - # Run backtest - engine = BacktestEngine(db) + # 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) # Update status to completed diff --git a/backend/jobs/__init__.py b/backend/jobs/__init__.py index b8829b1..020b5f0 100644 --- a/backend/jobs/__init__.py +++ b/backend/jobs/__init__.py @@ -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", ] diff --git a/backend/jobs/kjb_signal_job.py b/backend/jobs/kjb_signal_job.py new file mode 100644 index 0000000..4d9805c --- /dev/null +++ b/backend/jobs/kjb_signal_job.py @@ -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() diff --git a/backend/jobs/scheduler.py b/backend/jobs/scheduler.py index 2837b07..3be9f4d 100644 --- a/backend/jobs/scheduler.py +++ b/backend/jobs/scheduler.py @@ -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."""