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.backtest import router as backtest_router
|
||||||
from app.api.snapshot import router as snapshot_router
|
from app.api.snapshot import router as snapshot_router
|
||||||
from app.api.data_explorer import router as data_explorer_router
|
from app.api.data_explorer import router as data_explorer_router
|
||||||
|
from app.api.signal import router as signal_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"auth_router",
|
"auth_router",
|
||||||
@ -16,4 +17,5 @@ __all__ = [
|
|||||||
"backtest_router",
|
"backtest_router",
|
||||||
"snapshot_router",
|
"snapshot_router",
|
||||||
"data_explorer_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 (
|
from app.api import (
|
||||||
auth_router, admin_router, portfolio_router, strategy_router,
|
auth_router, admin_router, portfolio_router, strategy_router,
|
||||||
market_router, backtest_router, snapshot_router, data_explorer_router,
|
market_router, backtest_router, snapshot_router, data_explorer_router,
|
||||||
|
signal_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@ -112,6 +113,7 @@ app.include_router(market_router)
|
|||||||
app.include_router(backtest_router)
|
app.include_router(backtest_router)
|
||||||
app.include_router(snapshot_router)
|
app.include_router(snapshot_router)
|
||||||
app.include_router(data_explorer_router)
|
app.include_router(data_explorer_router)
|
||||||
|
app.include_router(signal_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class BacktestStatus(str, Enum):
|
|||||||
|
|
||||||
class BacktestCreate(BaseModel):
|
class BacktestCreate(BaseModel):
|
||||||
"""Request to create a new backtest."""
|
"""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)
|
strategy_params: Dict[str, Any] = Field(default_factory=dict)
|
||||||
start_date: date
|
start_date: date
|
||||||
end_date: date
|
end_date: date
|
||||||
|
|||||||
@ -44,8 +44,12 @@ def _run_backtest_job(backtest_id: int) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Backtest {backtest_id} started")
|
logger.info(f"Backtest {backtest_id} started")
|
||||||
|
|
||||||
# Run backtest
|
# Run backtest - route KJB to DailyBacktestEngine
|
||||||
engine = BacktestEngine(db)
|
if backtest.strategy_type == "kjb":
|
||||||
|
from app.services.backtest.daily_engine import DailyBacktestEngine
|
||||||
|
engine = DailyBacktestEngine(db)
|
||||||
|
else:
|
||||||
|
engine = BacktestEngine(db)
|
||||||
engine.run(backtest_id)
|
engine.run(backtest_id)
|
||||||
|
|
||||||
# Update status to completed
|
# Update status to completed
|
||||||
|
|||||||
@ -3,8 +3,10 @@ Background jobs module.
|
|||||||
"""
|
"""
|
||||||
from jobs.scheduler import scheduler, start_scheduler, stop_scheduler
|
from jobs.scheduler import scheduler, start_scheduler, stop_scheduler
|
||||||
from jobs.collection_job import run_daily_collection, run_backfill
|
from jobs.collection_job import run_daily_collection, run_backfill
|
||||||
|
from jobs.kjb_signal_job import run_kjb_signals
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"scheduler", "start_scheduler", "stop_scheduler",
|
"scheduler", "start_scheduler", "stop_scheduler",
|
||||||
"run_daily_collection", "run_backfill",
|
"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.snapshot_job import create_daily_snapshots
|
||||||
from jobs.collection_job import run_daily_collection, run_financial_collection
|
from jobs.collection_job import run_daily_collection, run_financial_collection
|
||||||
|
from jobs.kjb_signal_job import run_kjb_signals
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -66,6 +67,21 @@ def configure_jobs():
|
|||||||
)
|
)
|
||||||
logger.info("Configured daily_snapshots job at 18:30 KST")
|
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():
|
def start_scheduler():
|
||||||
"""Start the scheduler."""
|
"""Start the scheduler."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user