feat: improve KJB screening workflow
All checks were successful
Deploy to Production / deploy (push) Successful in 3m5s
All checks were successful
Deploy to Production / deploy (push) Successful in 3m5s
This commit is contained in:
parent
c97f73ab0c
commit
0b66eae847
@ -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])
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user