451 lines
15 KiB
Python
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()
|