galaxis-po/backend/jobs/screening_job.py
머니페니 5a452e4714
Some checks failed
Deploy to Production / deploy (push) Failing after 12h57m55s
fix: harden screening data collection
2026-05-14 23:32:59 +09:00

451 lines
15 KiB
Python

import logging
from collections import defaultdict
from datetime import date, datetime, time as dtime, timedelta
from decimal import Decimal
from typing import Iterable
from zoneinfo import ZoneInfo
import pandas as pd
from sqlalchemy import func
from app.core.database import SessionLocal
from app.models.screening import AutoOrder, ScreeningSignal
from app.models.stock import MarketIndex, Price, Sector, Stock
logger = logging.getLogger(__name__)
LOOKBACK_CALENDAR_DAYS = 430
MIN_HISTORY_ROWS = 20
KST = ZoneInfo("Asia/Seoul")
def _now_kst() -> datetime:
return datetime.now(KST)
def _today_kst() -> date:
return _now_kst().date()
def _as_float(value, default: float = 0.0) -> float:
if value is None:
return default
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return default
def _latest_price_date(db, market: str = "KOSPI") -> date | None:
return (
db.query(func.max(Price.date))
.join(Stock, Stock.ticker == Price.ticker)
.filter(Stock.market == market)
.scalar()
)
def _price_rows_to_frame(rows: Iterable[Price]) -> pd.DataFrame:
records = [
{
"date": row.date,
"open": _as_float(row.open),
"high": _as_float(row.high),
"low": _as_float(row.low),
"close": _as_float(row.close),
"volume": int(row.volume or 0),
"trading_value": int(row.trading_value or 0),
}
for row in rows
]
if not records:
return pd.DataFrame()
df = pd.DataFrame(records).set_index("date").sort_index()
df.index = pd.to_datetime(df.index)
return df
def _load_kospi_screening_inputs(db, target_date: date):
"""Load KOSPI price/stock/sector inputs from the local DB.
This keeps production screening on the collected bulk data path instead of
making one pykrx request per ticker during the job.
"""
from app.services.strategy.kospi_screener import StockInfo
start_date = target_date - timedelta(days=LOOKBACK_CALENDAR_DAYS)
stocks = (
db.query(Stock)
.filter(Stock.market == "KOSPI")
.all()
)
stock_by_ticker = {stock.ticker: stock for stock in stocks}
if not stock_by_ticker:
return {}, {}
latest_sector_date = db.query(func.max(Sector.base_date)).scalar()
sector_by_ticker = {}
if latest_sector_date:
sector_by_ticker = {
sector.ticker: sector.sector_name
for sector in db.query(Sector).filter(Sector.base_date == latest_sector_date).all()
}
rows = (
db.query(Price)
.filter(
Price.ticker.in_(stock_by_ticker.keys()),
Price.date >= start_date,
Price.date <= target_date,
)
.order_by(Price.ticker, Price.date)
.all()
)
grouped: dict[str, list[Price]] = defaultdict(list)
for row in rows:
grouped[row.ticker].append(row)
price_data = {}
stock_info = {}
for ticker, ticker_rows in grouped.items():
if len(ticker_rows) < MIN_HISTORY_ROWS:
continue
df = _price_rows_to_frame(ticker_rows)
if pd.Timestamp(target_date) not in df.index:
continue
stock = stock_by_ticker[ticker]
sector = sector_by_ticker.get(ticker) or "미분류"
price_data[ticker] = df
stock_info[ticker] = StockInfo(
ticker=ticker,
name=stock.name,
market_cap=int(stock.market_cap or 0),
sector=sector,
)
return price_data, stock_info
def _load_kospi_index_df(db, target_date: date) -> pd.DataFrame:
start_date = target_date - timedelta(days=LOOKBACK_CALENDAR_DAYS)
rows = (
db.query(MarketIndex)
.filter(
MarketIndex.code == "1001",
MarketIndex.date >= start_date,
MarketIndex.date <= target_date,
)
.order_by(MarketIndex.date)
.all()
)
records = [
{
"date": row.date,
"open": _as_float(row.open),
"high": _as_float(row.high),
"low": _as_float(row.low),
"close": _as_float(row.close),
"volume": int(row.volume or 0),
"trading_value": int(row.trading_value or 0),
}
for row in rows
]
if not records:
return pd.DataFrame()
df = pd.DataFrame(records).set_index("date").sort_index()
df.index = pd.to_datetime(df.index)
return df
def _get_executor():
from app.core.config import get_settings
from app.services.trading.kis_executor import KISTradeExecutor
settings = get_settings()
if not settings.kis_app_key or not settings.kis_app_secret or not settings.kis_account_no:
logger.warning("KIS API not configured")
return None, settings
executor = KISTradeExecutor(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
account_no=settings.kis_account_no,
paper_trade=settings.kis_paper_trade,
)
return executor, settings
def _build_signal(signal_result, target_row, target_date: date, market_state: str) -> ScreeningSignal:
return ScreeningSignal(
screen_date=target_date,
ticker=signal_result.ticker,
name=signal_result.name,
sector=signal_result.sector,
market_cap=signal_result.market_cap,
trading_value=int(signal_result.trading_value),
is_limit_up=signal_result.is_limit_up,
daily_return=signal_result.daily_return,
trigger_low=float(target_row["low"]),
market_state=market_state,
status="watching",
)
def run_morning_screening():
"""08:30 KST - Pre-market screening from collected DB data."""
logger.info("Starting morning screening job")
db = SessionLocal()
try:
from app.services.strategy.kospi_screener import KOSPIMarketStateDetector, VolumeScreener
target_date = _latest_price_date(db, market="KOSPI")
if not target_date:
logger.warning("No KOSPI price data found, skipping screening")
return
kospi_df = _load_kospi_index_df(db, target_date)
if kospi_df.empty:
logger.warning("No KOSPI index data found, skipping screening")
return
detector = KOSPIMarketStateDetector()
market_state = detector.detect(kospi_df)
logger.info("Market state: %s target_date=%s", market_state.value, target_date)
if market_state.value == "crash":
logger.info("Market crash detected, skipping new screening")
return
price_data, stock_info = _load_kospi_screening_inputs(db, target_date)
if not price_data:
logger.warning("No screening inputs found for %s", target_date)
return
screener = VolumeScreener()
results = screener.screen(target_date, price_data, stock_info)
logger.info("Screening found %d candidates", len(results))
saved = 0
target_ts = pd.Timestamp(target_date)
for result in results:
existing = (
db.query(ScreeningSignal)
.filter(
ScreeningSignal.screen_date == target_date,
ScreeningSignal.ticker == result.ticker,
)
.first()
)
if existing:
continue
signal = _build_signal(
result,
price_data[result.ticker].loc[target_ts],
target_date,
market_state.value,
)
db.add(signal)
saved += 1
db.commit()
logger.info("Morning screening complete, saved %d signals", saved)
except Exception as e:
db.rollback()
logger.error("Morning screening failed: %s", str(e), exc_info=True)
raise
finally:
db.close()
def run_intraday_exit_check():
"""09:05~15:20 KST - Check exits and optional auto entries."""
now = _now_kst()
if not (dtime(9, 5) <= now.time() <= dtime(15, 20)):
logger.info("Outside trading window (09:05~15:20), skipping trade check")
return
logger.info("Starting intraday trade check")
db = SessionLocal()
try:
from app.services.strategy.kospi_screener import KJBScreeningSignalGenerator
executor, settings = _get_executor()
if executor is None:
return
signal_gen = KJBScreeningSignalGenerator()
positions = executor.get_positions()
position_map = {p.ticker: p for p in positions}
entered = db.query(ScreeningSignal).filter(ScreeningSignal.status == "entered").all()
for signal in entered:
pos = position_map.get(signal.ticker)
if not pos:
continue
latest_date = _latest_price_date(db, market="KOSPI") or _today_kst()
rows = (
db.query(Price)
.filter(
Price.ticker == signal.ticker,
Price.date >= (signal.screen_date - timedelta(days=30)),
Price.date <= latest_date,
)
.order_by(Price.date)
.all()
)
price_df = _price_rows_to_frame(rows)
if price_df.empty:
continue
current_price = pos.current_price
entry_price = float(signal.entry_price or pos.avg_price or 0)
stop_price = float(signal.trigger_low or 0)
partial_sold = any(
order.order_type == "sell" and order.status == "filled" and order.qty and order.qty < pos.qty
for order in signal.orders
)
exit_signal = signal_gen.check_exit(
entry_price=entry_price,
stop_price=stop_price,
current_price=current_price,
price_df=price_df,
partial_sold=partial_sold,
)
if not exit_signal:
continue
sell_qty = max(1, int(pos.qty * exit_signal["sell_ratio"]))
order = executor.place_sell_order(signal.ticker, sell_qty)
db.add(AutoOrder(
order_date=_now_kst().replace(tzinfo=None),
ticker=signal.ticker,
order_type="sell",
qty=sell_qty,
price=current_price,
order_no=order.order_no,
status="filled" if order.success else "rejected",
screening_signal_id=signal.id,
))
if order.success and exit_signal["sell_ratio"] >= 1.0:
signal.status = "closed"
signal.exit_date = _today_kst()
signal.exit_price = current_price
if not getattr(settings, "screening_auto_trade_enabled", False):
db.commit()
logger.info("Auto entry disabled; exit checks complete")
return
balance = executor.get_account_balance()
capital = balance.total_amount or balance.available_amount
watching = (
db.query(ScreeningSignal)
.filter(ScreeningSignal.status.in_(["pending", "watching"]))
.all()
)
open_tickers = set(position_map.keys())
for signal in watching:
if signal.ticker in open_tickers:
continue
latest_date = _latest_price_date(db, market="KOSPI") or _today_kst()
rows = (
db.query(Price)
.filter(
Price.ticker == signal.ticker,
Price.date >= (signal.screen_date - timedelta(days=20)),
Price.date <= latest_date,
)
.order_by(Price.date)
.all()
)
price_df = _price_rows_to_frame(rows)
entry = signal_gen.check_entry(signal.ticker, price_df, signal.screen_date)
if not entry:
continue
qty = signal_gen.calculate_position_size(
entry_price=float(entry["entry_price"]),
stop_price=float(entry["stop_price"]),
capital=capital,
risk_pct=settings.screening_risk_pct,
)
max_qty_by_order = int(settings.screening_max_order_amount / float(entry["entry_price"]))
qty = min(qty, max_qty_by_order)
if qty <= 0:
continue
order = executor.place_buy_order(signal.ticker, qty, int(entry["entry_price"]))
db.add(AutoOrder(
order_date=_now_kst().replace(tzinfo=None),
ticker=signal.ticker,
order_type="buy",
qty=qty,
price=float(entry["entry_price"]),
order_no=order.order_no,
status="filled" if order.success else "rejected",
screening_signal_id=signal.id,
))
if order.success:
signal.status = "entered"
signal.entry_date = entry["entry_date"].date() if hasattr(entry["entry_date"], "date") else entry["entry_date"]
signal.entry_price = float(entry["entry_price"])
open_tickers.add(signal.ticker)
db.commit()
logger.info("Intraday trade check complete")
except Exception as e:
db.rollback()
logger.error("Intraday trade check failed: %s", str(e), exc_info=True)
raise
finally:
db.close()
def run_closing_review():
"""15:35 KST - Post-market review and watchlist update."""
logger.info("Starting closing review")
db = SessionLocal()
try:
today = _today_kst()
stale_before = today - timedelta(days=7)
stale_signals = (
db.query(ScreeningSignal)
.filter(
ScreeningSignal.status.in_(["pending", "watching"]),
ScreeningSignal.screen_date < stale_before,
)
.all()
)
for signal in stale_signals:
signal.status = "expired"
db.commit()
total_today = db.query(ScreeningSignal).filter(ScreeningSignal.screen_date == today).count()
watching_count = db.query(ScreeningSignal).filter(ScreeningSignal.status == "watching").count()
entered_count = db.query(ScreeningSignal).filter(ScreeningSignal.status == "entered").count()
logger.info(
"Closing review complete: today=%d, watching=%d, entered=%d, expired=%d",
total_today, watching_count, entered_count, len(stale_signals),
)
except Exception as e:
db.rollback()
logger.error("Closing review failed: %s", str(e), exc_info=True)
raise
finally:
db.close()