From 0b66eae8475b24a4556563cbdb6e3248f4845040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Thu, 14 May 2026 07:52:28 +0900 Subject: [PATCH] feat: improve KJB screening workflow --- backend/app/api/screening.py | 109 ++++- backend/app/core/config.py | 3 + backend/jobs/screening_job.py | 486 +++++++++++++++------- backend/tests/unit/test_kospi_screener.py | 59 +++ frontend/src/app/screening/page.tsx | 241 +++++++++-- frontend/src/components/ui/textarea.tsx | 2 +- 6 files changed, 707 insertions(+), 193 deletions(-) diff --git a/backend/app/api/screening.py b/backend/app/api/screening.py index 432fd88..7d0044e 100644 --- a/backend/app/api/screening.py +++ b/backend/app/api/screening.py @@ -74,9 +74,14 @@ async def get_watchlist( async def execute_screening( current_user: CurrentUser, db: Session = Depends(get_db), + execute_orders: bool = Query(False, description="true일 때만 KIS 주문 전송"), ): from app.core.config import get_settings from app.services.trading.kis_executor import KISTradeExecutor + from app.models.stock import Price + from app.services.strategy.kospi_screener import KJBScreeningSignalGenerator + from jobs.screening_job import _latest_price_date, _price_rows_to_frame + from datetime import datetime, timedelta settings = get_settings() if not settings.kis_app_key or not settings.kis_app_secret: @@ -84,11 +89,11 @@ async def execute_screening( watching = ( db.query(ScreeningSignal) - .filter(ScreeningSignal.status == "watching") + .filter(ScreeningSignal.status.in_(["pending", "watching"])) .all() ) if not watching: - return {"message": "No watching signals to execute", "orders": []} + return {"message": "No watching signals to execute", "orders": [], "execute_orders": execute_orders} executor = KISTradeExecutor( app_key=settings.kis_app_key, @@ -97,22 +102,93 @@ async def execute_screening( paper_trade=settings.kis_paper_trade, ) + balance = executor.get_account_balance() + capital = balance.total_amount or balance.available_amount + if capital <= 0: + raise HTTPException(status_code=400, detail="KIS account balance unavailable") + + signal_gen = KJBScreeningSignalGenerator() + latest_date = _latest_price_date(db, market="KOSPI") or date.today() + held_tickers = {position.ticker for position in executor.get_positions()} + results = [] for signal in watching: - if not signal.entry_price: + if signal.ticker in held_tickers: + results.append({ + "ticker": signal.ticker, + "success": False, + "status": "skipped", + "message": "Already held", + }) continue - qty = 1 # placeholder - actual sizing handled by screening_job - order = executor.place_buy_order( - ticker=signal.ticker, - qty=qty, - price=int(signal.entry_price), + + 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: + results.append({ + "ticker": signal.ticker, + "success": False, + "status": "waiting", + "message": "Entry condition not met", + }) + continue + + entry_price = float(entry["entry_price"]) + stop_price = float(entry["stop_price"]) + qty = signal_gen.calculate_position_size( + entry_price=entry_price, + stop_price=stop_price, + capital=capital, + risk_pct=settings.screening_risk_pct, + ) + max_qty_by_order = int(settings.screening_max_order_amount / entry_price) + qty = min(qty, max_qty_by_order) + if qty <= 0: + results.append({ + "ticker": signal.ticker, + "success": False, + "status": "skipped", + "entry_price": entry_price, + "stop_price": stop_price, + "qty": 0, + "message": "Calculated quantity is zero", + }) + continue + + signal.entry_date = entry["entry_date"].date() if hasattr(entry["entry_date"], "date") else entry["entry_date"] + signal.entry_price = entry_price + + if not execute_orders: + signal.status = "watching" + results.append({ + "ticker": signal.ticker, + "success": True, + "status": "planned", + "entry_price": entry_price, + "stop_price": stop_price, + "qty": qty, + "risk_amount": (entry_price - stop_price) * qty, + "message": "Dry-run only. Add execute_orders=true to place KIS order.", + }) + continue + + order = executor.place_buy_order(ticker=signal.ticker, qty=qty, price=int(entry_price)) auto_order = AutoOrder( - order_date=date.today(), + order_date=datetime.now(), ticker=signal.ticker, order_type="buy", qty=qty, - price=signal.entry_price, + price=entry_price, order_no=order.order_no, status="filled" if order.success else "rejected", screening_signal_id=signal.id, @@ -120,15 +196,26 @@ async def execute_screening( db.add(auto_order) if order.success: signal.status = "entered" + held_tickers.add(signal.ticker) results.append({ "ticker": signal.ticker, "success": order.success, + "status": "ordered" if order.success else "rejected", "order_no": order.order_no, + "entry_price": entry_price, + "stop_price": stop_price, + "qty": qty, "message": order.message, }) db.commit() - return {"message": f"Executed {len(results)} orders", "orders": results} + ordered_count = sum(1 for item in results if item.get("status") == "ordered") + planned_count = sum(1 for item in results if item.get("status") == "planned") + return { + "message": f"ordered={ordered_count}, planned={planned_count}, total={len(results)}", + "execute_orders": execute_orders, + "orders": results, + } @router.get("/api/trading/orders", response_model=List[AutoOrderResponse]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c3c9eb4..95f3c97 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,6 +32,9 @@ class Settings(BaseSettings): kis_app_secret: str = "" kis_account_no: str = "" kis_paper_trade: bool = True + screening_auto_trade_enabled: bool = False + screening_risk_pct: float = 0.02 + screening_max_order_amount: int = 5_000_000 dart_api_key: str = "" krx_openapi_key: str = "" diff --git a/backend/jobs/screening_job.py b/backend/jobs/screening_job.py index 7eb103c..f72f601 100644 --- a/backend/jobs/screening_job.py +++ b/backend/jobs/screening_job.py @@ -1,81 +1,227 @@ import logging -from datetime import date, datetime, time as dtime +from collections import defaultdict +from datetime import date, datetime, time as dtime, timedelta +from decimal import Decimal +from typing import Iterable + +import pandas as pd +from sqlalchemy import func from app.core.database import SessionLocal -from app.models.screening import ScreeningSignal, AutoOrder +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 + + +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.""" + """08:30 KST - Pre-market screening from collected DB data.""" logger.info("Starting morning screening job") db = SessionLocal() try: - from pykrx import stock as pykrx_stock - import pandas as pd - from app.services.strategy.kospi_screener import ( - KOSPIMarketStateDetector, VolumeScreener, StockInfo, - ) + from app.services.strategy.kospi_screener import KOSPIMarketStateDetector, VolumeScreener - today = date.today() - yesterday = pykrx_stock.get_nearest_business_day_in_a_week(today.isoformat().replace("-", ""), prev=True) - target_date = date(int(yesterday[:4]), int(yesterday[4:6]), int(yesterday[6:8])) + target_date = _latest_price_date(db, market="KOSPI") + if not target_date: + logger.warning("No KOSPI price data found, skipping screening") + return - # Get KOSPI index data for market state - kospi_df = pykrx_stock.get_index_ohlcv( - (today.replace(year=today.year - 1)).isoformat().replace("-", ""), - today.isoformat().replace("-", ""), - "1001", - ) - kospi_df.index.name = "date" - kospi_df.columns = ["open", "high", "low", "close", "volume", "trading_value"] + 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", market_state.value) + logger.info("Market state: %s target_date=%s", market_state.value, target_date) if market_state.value == "crash": - logger.info("Market crash detected, skipping screening") + logger.info("Market crash detected, skipping new screening") return - # Get all KOSPI tickers - tickers = pykrx_stock.get_market_ticker_list(yesterday, market="KOSPI") - - price_data = {} - stock_info = {} - for ticker in tickers: - try: - df = pykrx_stock.get_market_ohlcv( - (target_date.replace(year=target_date.year - 1)).isoformat().replace("-", ""), - yesterday, - ticker, - ) - if df.empty: - continue - df.index.name = "date" - df.columns = ["open", "high", "low", "close", "volume", "trading_value", - "change"] if len(df.columns) == 7 else df.columns - price_data[ticker] = df - - cap = pykrx_stock.get_market_cap(yesterday, yesterday, ticker) - if not cap.empty: - market_cap = int(cap.iloc[0].get("시가총액", 0)) - else: - market_cap = 0 - - name = pykrx_stock.get_market_ticker_name(ticker) - stock_info[ticker] = StockInfo( - ticker=ticker, name=name, market_cap=market_cap, sector="기타", - ) - except Exception as e: - logger.warning("Failed to fetch data for %s: %s", ticker, str(e)) - continue + 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) @@ -88,132 +234,170 @@ def run_morning_screening(): if existing: continue - target_row = price_data[result.ticker].loc[target_date] if target_date in price_data[result.ticker].index else None - trigger_low = float(target_row["low"]) if target_row is not None else None - - signal = ScreeningSignal( - screen_date=target_date, - ticker=result.ticker, - name=result.name, - sector=result.sector, - market_cap=result.market_cap, - trading_value=int(result.trading_value), - is_limit_up=result.is_limit_up, - daily_return=result.daily_return, - trigger_low=trigger_low, - market_state=market_state.value, - status="pending", + 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", len(results)) + 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 (every 5 min) - Check exit conditions for held positions.""" + """09:05~15:20 KST - Check exits and optional auto entries.""" now = datetime.now() if not (dtime(9, 5) <= now.time() <= dtime(15, 20)): - logger.info("Outside trading window (09:05~15:20), skipping exit check") + logger.info("Outside trading window (09:05~15:20), skipping trade check") return - logger.info("Starting intraday exit check") + logger.info("Starting intraday trade check") db = SessionLocal() try: - entered = ( - db.query(ScreeningSignal) - .filter(ScreeningSignal.status == "entered") - .all() - ) - if not entered: - logger.info("No entered positions to check") - return - - from app.core.config import get_settings - from app.services.trading.kis_executor import KISTradeExecutor from app.services.strategy.kospi_screener import KJBScreeningSignalGenerator - settings = get_settings() - if not settings.kis_app_key: - logger.warning("KIS API not configured, skipping exit check") + executor, settings = _get_executor() + if executor is None: return - 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, - ) - + signal_gen = KJBScreeningSignalGenerator() positions = executor.get_positions() position_map = {p.ticker: p for p in positions} - signal_gen = KJBScreeningSignalGenerator() - + entered = db.query(ScreeningSignal).filter(ScreeningSignal.status == "entered").all() for signal in entered: pos = position_map.get(signal.ticker) if not pos: continue - current_price = pos.current_price - entry_price = float(signal.entry_price) if signal.entry_price else 0 - stop_price = float(signal.trigger_low) if signal.trigger_low else 0 - - import pandas as pd - empty_df = pd.DataFrame( - {"close": [current_price], "volume": [0]}, + latest_date = _latest_price_date(db, market="KOSPI") or date.today() + 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() ) - empty_df["ma_5"] = current_price - empty_df["volume_ma_20"] = 0 + 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=empty_df, + price_df=price_df, + partial_sold=partial_sold, ) - if exit_signal: - sell_qty = int(pos.qty * exit_signal["sell_ratio"]) - if sell_qty > 0: - order = executor.place_sell_order( - ticker=signal.ticker, - qty=sell_qty, - ) - auto_order = AutoOrder( - order_date=datetime.now(), - 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, - ) - db.add(auto_order) + if not exit_signal: + continue - if exit_signal["sell_ratio"] >= 1.0: - signal.status = "closed" - signal.exit_date = date.today() - signal.exit_price = current_price + 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=datetime.now(), + 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 = date.today() + signal.exit_price = current_price - logger.info( - "Exit signal for %s: %s, sell_qty=%d", - signal.ticker, exit_signal["exit_type"], sell_qty, - ) + 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 date.today() + 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=datetime.now(), + 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 exit check complete") + logger.info("Intraday trade check complete") except Exception as e: db.rollback() - logger.error("Intraday exit check failed: %s", str(e), exc_info=True) + logger.error("Intraday trade check failed: %s", str(e), exc_info=True) + raise finally: db.close() @@ -225,44 +409,32 @@ def run_closing_review(): try: today = date.today() - pending_signals = ( + stale_before = today - timedelta(days=7) + stale_signals = ( db.query(ScreeningSignal) .filter( - ScreeningSignal.status == "pending", - ScreeningSignal.screen_date >= today.replace(day=today.day - 5) if today.day > 5 else today, + ScreeningSignal.status.in_(["pending", "watching"]), + ScreeningSignal.screen_date < stale_before, ) .all() ) - - for signal in pending_signals: - signal.status = "watching" + for signal in stale_signals: + signal.status = "expired" db.commit() - # Summary for logging - 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() - ) + 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", - total_today, watching_count, entered_count, + "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() diff --git a/backend/tests/unit/test_kospi_screener.py b/backend/tests/unit/test_kospi_screener.py index 26d3ffa..e0ee2a9 100644 --- a/backend/tests/unit/test_kospi_screener.py +++ b/backend/tests/unit/test_kospi_screener.py @@ -369,3 +369,62 @@ class TestExitSignals: gen = KJBScreeningSignalGenerator() assert gen.calculate_position_size(50000, 50000, 100_000_000) == 0 assert gen.calculate_position_size(50000, 55000, 100_000_000) == 0 + + +class TestScreeningJobInputs: + def test_load_kospi_screening_inputs_uses_latest_sector(self, db): + """DB 기반 운영 스크리닝 입력은 최신 섹터를 StockInfo에 반영한다.""" + from app.models.stock import Price, Sector, Stock + from jobs.screening_job import _load_kospi_screening_inputs + + target_date = date(2025, 6, 30) + db.add(Stock( + ticker="005930", + name="삼성전자", + market="KOSPI", + market_cap=500_000_000_000_000, + base_date=target_date, + )) + db.add(Stock( + ticker="000660", + name="SK하이닉스", + market="KOSDAQ", + market_cap=100_000_000_000_000, + base_date=target_date, + )) + db.add(Sector( + ticker="005930", + sector_code="G45", + company_name="삼성전자", + sector_name="반도체", + base_date=target_date, + )) + + for i, day in enumerate(pd.bdate_range(end=target_date, periods=25)): + db.add(Price( + ticker="005930", + date=day.date(), + open=50_000 + i, + high=51_000 + i, + low=49_000 + i, + close=50_500 + i, + volume=1_000_000, + trading_value=50_500_000_000 + i, + )) + db.add(Price( + ticker="000660", + date=day.date(), + open=100_000 + i, + high=101_000 + i, + low=99_000 + i, + close=100_500 + i, + volume=1_000_000, + trading_value=100_500_000_000 + i, + )) + db.commit() + + price_data, stock_info = _load_kospi_screening_inputs(db, target_date) + + assert set(price_data.keys()) == {"005930"} + assert stock_info["005930"].sector == "반도체" + assert pd.Timestamp(target_date) in price_data["005930"].index diff --git a/frontend/src/app/screening/page.tsx b/frontend/src/app/screening/page.tsx index 6da4ee5..41eeeb3 100644 --- a/frontend/src/app/screening/page.tsx +++ b/frontend/src/app/screening/page.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/dialog'; import { api } from '@/lib/api'; import { toast } from 'sonner'; -import { ScanSearch, Star, History, Settings2, ListFilter, AlertTriangle } from 'lucide-react'; +import { ScanSearch, Star, History, Settings2, ListFilter, AlertTriangle, Briefcase, Wallet } from 'lucide-react'; // ────────────────────────────────────────────────────────── // Types @@ -69,24 +69,63 @@ interface AutoOrder { created_at: string; } +interface TradingPosition { + ticker: string; + name: string; + qty: number; + avg_price: number; + current_price: number; + pnl_amount: number; + pnl_rate: number; +} + +interface TradingBalance { + total_amount: number; + available_amount: number; + stock_amount: number; + pnl_amount: number; +} + +interface ExecuteResult { + message: string; + execute_orders: boolean; + orders: Array<{ + ticker: string; + success: boolean; + status: string; + entry_price?: number; + stop_price?: number; + qty?: number; + risk_amount?: number; + message?: string; + }>; +} + // ────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────── -const fmt = (v: number | null | undefined, decimals = 2) => - v !== null && v !== undefined ? v.toFixed(decimals) : '-'; - const fmtKRW = (v: number | null | undefined) => v !== null && v !== undefined ? new Intl.NumberFormat('ko-KR').format(v) : '-'; +const fmtPct = (v: number | null | undefined) => + v !== null && v !== undefined ? `${(v * 100).toFixed(2)}%` : '-'; + const statusBadge = (status: string) => { const map: Record = { pending: 'bg-yellow-100 text-yellow-800', watching: 'bg-blue-100 text-blue-800', entered: 'bg-green-100 text-green-800', exited: 'bg-gray-100 text-gray-800', + closed: 'bg-gray-100 text-gray-800', + expired: 'bg-slate-100 text-slate-700', + filled: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', cancelled: 'bg-red-100 text-red-800', + planned: 'bg-purple-100 text-purple-800', + waiting: 'bg-blue-100 text-blue-800', + skipped: 'bg-slate-100 text-slate-700', }; return map[status] ?? 'bg-muted text-muted-foreground'; }; @@ -128,7 +167,7 @@ function TodaySignalsTab({ signals, loading }: { signals: ScreeningSignal[]; loa {s.sector ?? '-'} = 0 ? 'text-green-600' : 'text-red-600'}`}> - {s.daily_return !== null ? `${fmt(s.daily_return)}%` : '-'} + {fmtPct(s.daily_return)} {fmtKRW(s.trigger_low)} @@ -180,7 +219,7 @@ function HistoryTab({ signals, loading }: { signals: ScreeningSignal[]; loading:

{s.ticker}

= 0 ? 'text-green-600' : 'text-red-600'}`}> - {s.daily_return !== null ? `${fmt(s.daily_return)}%` : '-'} + {fmtPct(s.daily_return)} {fmtKRW(s.entry_price)} {fmtKRW(s.exit_price)} @@ -227,7 +266,7 @@ function WatchlistTab({ items, loading }: { items: WatchlistItem[]; loading: boo {item.screen_date} = 0 ? 'text-green-600' : 'text-red-600'}`}> - {item.daily_return !== null ? `${fmt(item.daily_return)}%` : '-'} + {fmtPct(item.daily_return)} {item.trading_value !== null ? (item.trading_value / 100000000).toFixed(1) : '-'} @@ -289,6 +328,54 @@ function AutoOrdersTab({ orders, loading }: { orders: AutoOrder[]; loading: bool ); } +function PositionsTab({ positions, loading }: { positions: TradingPosition[]; loading: boolean }) { + if (loading) return ; + if (positions.length === 0) { + return ( +
+ +

KIS 보유 포지션이 없습니다.

+

KIS 계정이 설정되지 않았거나 현재 보유 종목이 없습니다.

+
+ ); + } + return ( +
+ + + + + + + + + + + + + {positions.map((p) => ( + + + + + + + + + ))} + +
종목수량평균단가현재가평가손익수익률
+

{p.name || p.ticker}

+

{p.ticker}

+
{fmtKRW(p.qty)}{fmtKRW(p.avg_price)}{fmtKRW(p.current_price)}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {fmtKRW(p.pnl_amount)} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {fmtPct((p.pnl_rate ?? 0) / 100)} +
+
+ ); +} + // ────────────────────────────────────────────────────────── // Main page // ────────────────────────────────────────────────────────── @@ -300,37 +387,48 @@ export default function ScreeningPage() { const [history, setHistory] = useState([]); const [watchlist, setWatchlist] = useState([]); const [autoOrders, setAutoOrders] = useState([]); + const [positions, setPositions] = useState([]); + const [balance, setBalance] = useState(null); const [todayLoading, setTodayLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [watchlistLoading, setWatchlistLoading] = useState(false); const [ordersLoading, setOrdersLoading] = useState(false); + const [positionsLoading, setPositionsLoading] = useState(false); const [executeConfirmOpen, setExecuteConfirmOpen] = useState(false); + const [executeOrders, setExecuteOrders] = useState(false); const [executing, setExecuting] = useState(false); + const [executeResult, setExecuteResult] = useState(null); const loadData = useCallback(async () => { setTodayLoading(true); setHistoryLoading(true); setWatchlistLoading(true); setOrdersLoading(true); + setPositionsLoading(true); const results = await Promise.allSettled([ api.get('/api/screening/today'), api.get('/api/screening/history'), api.get('/api/screening/watchlist'), - api.get('/api/screening/auto-orders'), + api.get('/api/trading/orders'), + api.get<{ positions: TradingPosition[] }>('/api/trading/positions'), + api.get('/api/trading/balance'), ]); if (results[0].status === 'fulfilled') setTodaySignals(results[0].value); if (results[1].status === 'fulfilled') setHistory(results[1].value); if (results[2].status === 'fulfilled') setWatchlist(results[2].value); if (results[3].status === 'fulfilled') setAutoOrders(results[3].value); + if (results[4].status === 'fulfilled') setPositions(results[4].value.positions); + if (results[5].status === 'fulfilled') setBalance(results[5].value); setTodayLoading(false); setHistoryLoading(false); setWatchlistLoading(false); setOrdersLoading(false); + setPositionsLoading(false); }, []); useEffect(() => { @@ -343,8 +441,9 @@ export default function ScreeningPage() { const handleExecute = async () => { setExecuting(true); try { - const result = await api.post<{ message: string; orders: unknown[] }>('/api/screening/execute'); - toast.success(result.message ?? '자동 주문 실행 완료'); + const result = await api.post(`/api/screening/execute?execute_orders=${executeOrders}`); + setExecuteResult(result); + toast.success(result.message ?? (executeOrders ? '주문 실행 완료' : '진입 조건 확인 완료')); setExecuteConfirmOpen(false); await loadData(); } catch (err) { @@ -354,6 +453,12 @@ export default function ScreeningPage() { } }; + const openExecuteConfirm = (orders: boolean) => { + setExecuteOrders(orders); + setExecuteResult(null); + setExecuteConfirmOpen(true); + }; + if (pageLoading) { return ( @@ -383,15 +488,23 @@ export default function ScreeningPage() { 김종봉 전략 기반 실시간 종목 스크리닝 및 자동 주문 관리

- +
+ + +
{/* KPI row */} @@ -406,6 +519,78 @@ export default function ScreeningPage() { ))} +
+ + +

+ 총 평가금액 +

+

{balance ? fmtKRW(balance.total_amount) : '-'}

+
+
+ + +

주문 가능금액

+

{balance ? fmtKRW(balance.available_amount) : '-'}

+
+
+ + +

주식 평가금액

+

{balance ? fmtKRW(balance.stock_amount) : '-'}

+
+
+ + +

평가손익

+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + {balance ? fmtKRW(balance.pnl_amount) : '-'} +

+
+
+
+ + {executeResult && ( + + + + {executeResult.execute_orders ? '실주문 실행 결과' : '진입 조건 확인 결과'} + + + +

{executeResult.message}

+
+ + + + + + + + + + + + + {executeResult.orders.map((o, idx) => ( + + + + + + + + + ))} + +
종목상태진입가손절가수량메시지
{o.ticker} + {o.status} + {fmtKRW(o.entry_price)}{fmtKRW(o.stop_price)}{o.qty ?? '-'}{o.message ?? '-'}
+
+
+
+ )} + {/* Tabs */} @@ -427,6 +612,10 @@ export default function ScreeningPage() { 자동 주문 + + + 보유 포지션 + @@ -442,6 +631,9 @@ export default function ScreeningPage() { + + + @@ -452,19 +644,20 @@ export default function ScreeningPage() { - 자동 주문 실행 확인 + {executeOrders ? '실주문 실행 확인' : '진입 조건 확인'} - 워치리스트({watchlist.length}종목)에 대해 KIS API를 통해 실제 매수 주문이 실행됩니다. - 실수로 실행하면 실제 거래가 발생합니다. 계속하시겠습니까? + {executeOrders + ? `워치리스트(${watchlist.length}종목)에 대해 KIS API 실제 매수 주문을 실행합니다. 실수로 실행하면 실제 거래가 발생합니다.` + : `워치리스트(${watchlist.length}종목)의 진입 조건과 포지션 사이징만 계산합니다. 주문은 전송되지 않습니다.`} - diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx index dd5d90a..e189a48 100644 --- a/frontend/src/components/ui/textarea.tsx +++ b/frontend/src/components/ui/textarea.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; -export interface TextareaProps extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes; const Textarea = React.forwardRef( ({ className, ...props }, ref) => {