feat: improve KJB screening workflow
All checks were successful
Deploy to Production / deploy (push) Successful in 3m5s

This commit is contained in:
머니페니 2026-05-14 07:52:28 +09:00
parent c97f73ab0c
commit 0b66eae847
6 changed files with 707 additions and 193 deletions

View File

@ -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])

View File

@ -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 = ""

View File

@ -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()

View File

@ -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

View File

@ -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<string, string> = {
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
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{s.sector ?? '-'}</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${(s.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{s.daily_return !== null ? `${fmt(s.daily_return)}%` : '-'}
{fmtPct(s.daily_return)}
</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(s.trigger_low)}</td>
<td className="px-4 py-3 text-sm text-right">
@ -180,7 +219,7 @@ function HistoryTab({ signals, loading }: { signals: ScreeningSignal[]; loading:
<p className="text-xs font-mono text-muted-foreground">{s.ticker}</p>
</td>
<td className={`px-4 py-3 text-sm text-right ${(s.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{s.daily_return !== null ? `${fmt(s.daily_return)}%` : '-'}
{fmtPct(s.daily_return)}
</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(s.entry_price)}</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(s.exit_price)}</td>
@ -227,7 +266,7 @@ function WatchlistTab({ items, loading }: { items: WatchlistItem[]; loading: boo
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{item.screen_date}</td>
<td className={`px-4 py-3 text-sm text-right ${(item.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{item.daily_return !== null ? `${fmt(item.daily_return)}%` : '-'}
{fmtPct(item.daily_return)}
</td>
<td className="px-4 py-3 text-sm text-right">
{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 <Skeleton className="h-48 w-full" />;
if (positions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<Briefcase className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm">KIS .</p>
<p className="text-xs">KIS .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{positions.map((p) => (
<tr key={p.ticker} className="hover:bg-muted/50">
<td className="px-4 py-3">
<p className="font-medium text-sm">{p.name || p.ticker}</p>
<p className="text-xs font-mono text-muted-foreground">{p.ticker}</p>
</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(p.qty)}</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(p.avg_price)}</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(p.current_price)}</td>
<td className={`px-4 py-3 text-sm text-right ${(p.pnl_amount ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{fmtKRW(p.pnl_amount)}
</td>
<td className={`px-4 py-3 text-sm text-right ${(p.pnl_rate ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{fmtPct((p.pnl_rate ?? 0) / 100)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ──────────────────────────────────────────────────────────
// Main page
// ──────────────────────────────────────────────────────────
@ -300,37 +387,48 @@ export default function ScreeningPage() {
const [history, setHistory] = useState<ScreeningSignal[]>([]);
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
const [autoOrders, setAutoOrders] = useState<AutoOrder[]>([]);
const [positions, setPositions] = useState<TradingPosition[]>([]);
const [balance, setBalance] = useState<TradingBalance | null>(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<ExecuteResult | null>(null);
const loadData = useCallback(async () => {
setTodayLoading(true);
setHistoryLoading(true);
setWatchlistLoading(true);
setOrdersLoading(true);
setPositionsLoading(true);
const results = await Promise.allSettled([
api.get<ScreeningSignal[]>('/api/screening/today'),
api.get<ScreeningSignal[]>('/api/screening/history'),
api.get<WatchlistItem[]>('/api/screening/watchlist'),
api.get<AutoOrder[]>('/api/screening/auto-orders'),
api.get<AutoOrder[]>('/api/trading/orders'),
api.get<{ positions: TradingPosition[] }>('/api/trading/positions'),
api.get<TradingBalance>('/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<ExecuteResult>(`/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 (
<DashboardLayout>
@ -383,15 +488,23 @@ export default function ScreeningPage() {
</p>
</div>
<Button
variant="outline"
onClick={() => setExecuteConfirmOpen(true)}
disabled={watchlist.length === 0}
className="shrink-0"
>
<Settings2 className="h-4 w-4 mr-2" />
</Button>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
onClick={() => openExecuteConfirm(false)}
disabled={watchlist.length === 0}
>
<Settings2 className="h-4 w-4 mr-2" />
</Button>
<Button
variant="destructive"
onClick={() => openExecuteConfirm(true)}
disabled={watchlist.length === 0}
>
</Button>
</div>
</div>
{/* KPI row */}
@ -406,6 +519,78 @@ export default function ScreeningPage() {
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
<Wallet className="h-3 w-3" />
</p>
<p className="text-xl font-bold">{balance ? fmtKRW(balance.total_amount) : '-'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1"> </p>
<p className="text-xl font-bold">{balance ? fmtKRW(balance.available_amount) : '-'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1"> </p>
<p className="text-xl font-bold">{balance ? fmtKRW(balance.stock_amount) : '-'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className={`text-xl font-bold ${(balance?.pnl_amount ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{balance ? fmtKRW(balance.pnl_amount) : '-'}
</p>
</CardContent>
</Card>
</div>
{executeResult && (
<Card className="mb-6 border-primary/30">
<CardHeader>
<CardTitle className="text-base">
{executeResult.execute_orders ? '실주문 실행 결과' : '진입 조건 확인 결과'}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">{executeResult.message}</p>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{executeResult.orders.map((o, idx) => (
<tr key={`${o.ticker}-${idx}`}>
<td className="px-3 py-2 text-sm font-mono">{o.ticker}</td>
<td className="px-3 py-2 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge(o.status)}`}>{o.status}</span>
</td>
<td className="px-3 py-2 text-sm text-right">{fmtKRW(o.entry_price)}</td>
<td className="px-3 py-2 text-sm text-right">{fmtKRW(o.stop_price)}</td>
<td className="px-3 py-2 text-sm text-right">{o.qty ?? '-'}</td>
<td className="px-3 py-2 text-sm text-muted-foreground">{o.message ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Tabs */}
<Card>
<Tabs defaultValue="today">
@ -427,6 +612,10 @@ export default function ScreeningPage() {
<Settings2 className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="positions">
<Briefcase className="h-4 w-4 mr-1" />
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent className="pt-4 p-0">
@ -442,6 +631,9 @@ export default function ScreeningPage() {
<TabsContent value="auto-orders">
<AutoOrdersTab orders={autoOrders} loading={ordersLoading} />
</TabsContent>
<TabsContent value="positions">
<PositionsTab positions={positions} loading={positionsLoading} />
</TabsContent>
</CardContent>
</Tabs>
</Card>
@ -452,19 +644,20 @@ export default function ScreeningPage() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{executeOrders ? '실주문 실행 확인' : '진입 조건 확인'}
</DialogTitle>
<DialogDescription>
({watchlist.length}) KIS API를 .
. ?
{executeOrders
? `워치리스트(${watchlist.length}종목)에 대해 KIS API 실제 매수 주문을 실행합니다. 실수로 실행하면 실제 거래가 발생합니다.`
: `워치리스트(${watchlist.length}종목)의 진입 조건과 포지션 사이징만 계산합니다. 주문은 전송되지 않습니다.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setExecuteConfirmOpen(false)} disabled={executing}>
</Button>
<Button variant="destructive" onClick={handleExecute} disabled={executing}>
{executing ? '주문 중...' : '실행'}
<Button variant={executeOrders ? 'destructive' : 'default'} onClick={handleExecute} disabled={executing}>
{executing ? '처리 중...' : executeOrders ? '실주문 실행' : '조건 확인'}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {