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:
parent
8d1a2f7937
commit
3c969fc53c
@ -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
56
backend/app/api/signal.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
109
backend/jobs/kjb_signal_job.py
Normal file
109
backend/jobs/kjb_signal_job.py
Normal 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()
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user