From 8cd0a4248cab9fed667f016d06e133c6cb678a44 Mon Sep 17 00:00:00 2001 From: hyejwon Date: Thu, 12 Mar 2026 17:07:59 +0900 Subject: [PATCH] update auto trading client --- cli/main.py | 99 ++++++++++ tradingagents/broker/__init__.py | 4 + tradingagents/broker/executor.py | 260 ++++++++++++++++++++++++++ tradingagents/broker/kiwoom_client.py | 244 ++++++++++++++++++++++++ tradingagents/default_config.py | 7 + 5 files changed, 614 insertions(+) create mode 100644 tradingagents/broker/__init__.py create mode 100644 tradingagents/broker/executor.py create mode 100644 tradingagents/broker/kiwoom_client.py diff --git a/cli/main.py b/cli/main.py index e18672eb..d88e2398 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1112,5 +1112,104 @@ def run_swing_pipeline(): console.print(f"\n[green]\u2713 Reports saved:[/green] {base_path.resolve()}") + # ─── Phase 4: Order Execution (선택적 주문 실행) ─── + if buy_signals and config.get("broker_enabled"): + console.print() + console.print(Rule("Phase 4: Order Execution (주문 실행)", style="bold red")) + + is_paper = config.get("kiwoom_is_paper", True) + dry_run = config.get("broker_dry_run", True) + + mode_label = "모의투자" if is_paper else "실전투자" + run_label = "DRY RUN (검증만)" if dry_run else "실제 주문" + console.print(f"[dim]Mode: {mode_label} / {run_label}[/dim]\n") + + order_table = Table( + title="Pending Orders", + show_header=True, + header_style="bold", + box=box.ROUNDED, + ) + order_table.add_column("Ticker", justify="center", width=10) + order_table.add_column("Name", width=20) + order_table.add_column("Action", justify="center", width=8) + order_table.add_column("Entry Price", justify="right", width=12) + order_table.add_column("Stop Loss", justify="right", width=12) + order_table.add_column("Take Profit", justify="right", width=12) + + for r in buy_signals: + sig = r["swing_signal"] + entry_str = f"{int(sig['entry_price']):,}" if sig.get("entry_price") else "-" + sl_str = f"{int(sig['stop_loss']):,}" if sig.get("stop_loss") else "-" + tp_str = f"{int(sig['take_profit']):,}" if sig.get("take_profit") else "-" + order_table.add_row( + r["ticker"], r["name"], "[green]BUY[/green]", + entry_str, sl_str, tp_str, + ) + + console.print(order_table) + + execute = typer.prompt( + f"\n{len(buy_signals)}건 주문을 실행하시겠습니까? ({mode_label}/{run_label})", + default="N", + ).strip().upper() + + if execute in ("Y", "YES"): + from tradingagents.broker.kiwoom_client import KiwoomClient + from tradingagents.broker.executor import BrokerExecutor + from tradingagents.portfolio import load_portfolio, save_portfolio + + client = KiwoomClient( + app_key=config["kiwoom_app_key"], + app_secret=config["kiwoom_app_secret"], + account_no=config["kiwoom_account_no"], + is_paper=is_paper, + ) + portfolio = load_portfolio( + portfolio_id=config.get("portfolio_id", "default"), + results_dir=config.get("results_dir", "./results"), + defaults={ + "total_capital": config.get("total_capital", 100_000_000), + "max_positions": config.get("max_positions", 5), + "max_position_pct": config.get("max_position_pct", 0.20), + }, + ) + executor = BrokerExecutor(client=client, portfolio=portfolio, dry_run=dry_run) + + exec_results = [] + for r in buy_signals: + with console.status(f"[bold cyan]Executing {r['ticker']}...[/bold cyan]"): + result = executor.execute_signal( + ticker=r["ticker"], + swing_signal=r["swing_signal"], + market=selections["market"], + ) + if result: + exec_results.append(result) + status = result.get("status", "unknown") + color = {"filled": "green", "dry_run": "yellow", "rejected": "red", "failed": "red"}.get(status, "dim") + msg = f" [{color}]{r['ticker']}: {status}[/{color}]" + if result.get("quantity"): + msg += f" (x{result['quantity']} @ {result.get('price', 0):,.0f})" + if result.get("broker_msg"): + msg += f" - {result['broker_msg']}" + if result.get("reason"): + msg += f" - {result['reason']}" + console.print(msg) + + save_path = save_portfolio( + portfolio, + results_dir=config.get("results_dir", "./results"), + ) + console.print(f"\n[green]Portfolio saved:[/green] {save_path}") + console.print(f"\n{portfolio.summary()}") + + elif buy_signals and not config.get("broker_enabled"): + console.print( + "\n[dim]Tip: 주문 실행을 원하시면 config에서 broker_enabled=True 설정 후 " + "KIWOOM_APP_KEY, KIWOOM_APP_SECRET, KIWOOM_ACCOUNT_NO 환경변수를 설정하세요.[/dim]" + ) + + if __name__ == "__main__": app() diff --git a/tradingagents/broker/__init__.py b/tradingagents/broker/__init__.py new file mode 100644 index 00000000..dd0bc554 --- /dev/null +++ b/tradingagents/broker/__init__.py @@ -0,0 +1,4 @@ +from tradingagents.broker.kiwoom_client import KiwoomClient +from tradingagents.broker.executor import BrokerExecutor + +__all__ = ["KiwoomClient", "BrokerExecutor"] diff --git a/tradingagents/broker/executor.py b/tradingagents/broker/executor.py new file mode 100644 index 00000000..289329b1 --- /dev/null +++ b/tradingagents/broker/executor.py @@ -0,0 +1,260 @@ +"""Broker executor: bridges trading agent decisions to actual orders. + +Converts swing_signal dicts from the trading pipeline into +real orders via the Kiwoom REST API. +""" + +import logging +import math +from typing import Optional + +from tradingagents.broker.kiwoom_client import KiwoomClient +from tradingagents.portfolio.state import Order, PortfolioState + +logger = logging.getLogger(__name__) + + +class BrokerExecutor: + """Executes trading decisions through Kiwoom Securities API.""" + + def __init__( + self, + client: KiwoomClient, + portfolio: PortfolioState, + dry_run: bool = False, + ): + """ + Args: + client: Authenticated KiwoomClient instance + portfolio: Current portfolio state + dry_run: If True, validate but don't send orders to broker + """ + self.client = client + self.portfolio = portfolio + self.dry_run = dry_run + + def execute_signal( + self, + ticker: str, + swing_signal: dict, + market: str = "KRX", + ) -> Optional[dict]: + """Execute a swing trading signal. + + Args: + ticker: Stock code (e.g. '005930') + swing_signal: Dict from signal_processing with action, entry_price, + stop_loss, take_profit, position_size_pct, etc. + market: 'KRX' or 'US' + + Returns: + Execution result dict or None if skipped. + """ + action = swing_signal.get("action", "PASS").upper() + + if action == "BUY": + return self._execute_buy(ticker, swing_signal, market) + elif action == "SELL": + return self._execute_sell(ticker, swing_signal, market) + else: + logger.info(f"PASS for {ticker}: {swing_signal.get('rationale', '')}") + return None + + def _execute_buy(self, ticker: str, signal: dict, market: str) -> dict: + """Execute a BUY order.""" + # Validate portfolio capacity + if not self.portfolio.can_add_position(): + logger.warning(f"Cannot buy {ticker}: max positions reached ({self.portfolio.max_positions})") + return {"status": "rejected", "reason": "max_positions_reached", "ticker": ticker} + + if self.portfolio.has_position(ticker): + logger.warning(f"Cannot buy {ticker}: already holding position") + return {"status": "rejected", "reason": "already_holding", "ticker": ticker} + + # Calculate position size + position_size_pct = signal.get("position_size_pct") or 0.10 + position_size_pct = min(position_size_pct, self.portfolio.max_position_pct) + max_capital = self.portfolio.total_capital * position_size_pct + available = self.portfolio.available_capital + + capital_to_use = min(max_capital, available) + if capital_to_use <= 0: + logger.warning(f"Cannot buy {ticker}: insufficient capital") + return {"status": "rejected", "reason": "insufficient_capital", "ticker": ticker} + + # Get current price from broker + current_price = self.client.get_current_price_value(ticker) + entry_price = signal.get("entry_price") or current_price + + if not entry_price or entry_price <= 0: + logger.warning(f"Cannot buy {ticker}: no valid price") + return {"status": "rejected", "reason": "no_price", "ticker": ticker} + + # Calculate quantity + quantity = math.floor(capital_to_use / entry_price) + if quantity <= 0: + logger.warning(f"Cannot buy {ticker}: price {entry_price} exceeds available capital") + return {"status": "rejected", "reason": "price_too_high", "ticker": ticker} + + # Build order + order = Order( + action="BUY", + ticker=ticker, + market=market, + price=entry_price, + stop_loss=signal.get("stop_loss") or entry_price * 0.95, + take_profit=signal.get("take_profit") or entry_price * 1.15, + quantity=quantity, + position_size_pct=position_size_pct, + max_hold_days=signal.get("max_hold_days") or 20, + rationale=signal.get("rationale", ""), + ) + + result = { + "status": "pending", + "ticker": ticker, + "action": "BUY", + "quantity": quantity, + "price": entry_price, + "total_cost": quantity * entry_price, + "position_size_pct": position_size_pct, + "stop_loss": order.stop_loss, + "take_profit": order.take_profit, + } + + if self.dry_run: + result["status"] = "dry_run" + logger.info( + f"[DRY RUN] BUY {ticker} x{quantity} @ {entry_price:,.0f} " + f"(SL: {order.stop_loss:,.0f} / TP: {order.take_profit:,.0f})" + ) + # Still update portfolio state for tracking + self.portfolio.add_position(order) + return result + + # Execute via Kiwoom API + try: + broker_result = self.client.buy( + stock_code=ticker, + quantity=quantity, + price=0, # Market order + order_type="market", + ) + + return_code = int(broker_result.get("return_code", -1)) + + if return_code == 0: + result["status"] = "filled" + result["order_no"] = broker_result.get("ord_no") + result["broker_msg"] = broker_result.get("return_msg") + self.portfolio.add_position(order) + logger.info(f"BUY filled: {ticker} x{quantity} @ market (order #{result['order_no']})") + else: + result["status"] = "failed" + result["broker_msg"] = broker_result.get("return_msg", "Unknown error") + logger.error(f"BUY failed for {ticker}: {result['broker_msg']}") + + except Exception as e: + result["status"] = "error" + result["error"] = str(e) + logger.error(f"BUY error for {ticker}: {e}") + + return result + + def _execute_sell(self, ticker: str, signal: dict, market: str) -> dict: + """Execute a SELL order.""" + if not self.portfolio.has_position(ticker): + logger.warning(f"Cannot sell {ticker}: no position held") + return {"status": "rejected", "reason": "no_position", "ticker": ticker} + + position = self.portfolio.positions[ticker] + quantity = position.quantity + + # Get current price + current_price = self.client.get_current_price_value(ticker) + exit_price = current_price or signal.get("entry_price") or position.current_price + + result = { + "status": "pending", + "ticker": ticker, + "action": "SELL", + "quantity": quantity, + "price": exit_price, + "entry_price": position.entry_price, + "pnl": (exit_price - position.entry_price) * quantity, + "pnl_pct": (exit_price / position.entry_price - 1) * 100 if position.entry_price else 0, + } + + if self.dry_run: + result["status"] = "dry_run" + logger.info( + f"[DRY RUN] SELL {ticker} x{quantity} @ {exit_price:,.0f} " + f"(PnL: {result['pnl']:+,.0f} / {result['pnl_pct']:+.1f}%)" + ) + self.portfolio.close_position(ticker, exit_price, "agent_decision") + return result + + # Execute via Kiwoom API + try: + broker_result = self.client.sell( + stock_code=ticker, + quantity=quantity, + price=0, + order_type="market", + ) + + return_code = int(broker_result.get("return_code", -1)) + + if return_code == 0: + result["status"] = "filled" + result["order_no"] = broker_result.get("ord_no") + result["broker_msg"] = broker_result.get("return_msg") + self.portfolio.close_position(ticker, exit_price, "agent_decision") + logger.info(f"SELL filled: {ticker} x{quantity} @ market (order #{result['order_no']})") + else: + result["status"] = "failed" + result["broker_msg"] = broker_result.get("return_msg", "Unknown error") + logger.error(f"SELL failed for {ticker}: {result['broker_msg']}") + + except Exception as e: + result["status"] = "error" + result["error"] = str(e) + logger.error(f"SELL error for {ticker}: {e}") + + return result + + def check_exit_conditions(self, trade_date: str) -> list[dict]: + """Check all positions for exit conditions (SL/TP/max hold). + + Returns list of execution results for positions that were closed. + """ + results = [] + + for ticker, position in list(self.portfolio.positions.items()): + current_price = self.client.get_current_price_value(ticker) + if not current_price: + continue + + position.current_price = current_price + exit_reason = None + + if current_price <= position.stop_loss: + exit_reason = "stop_loss" + elif current_price >= position.take_profit: + exit_reason = "take_profit" + elif position.days_held >= position.max_hold_days: + exit_reason = "max_hold" + + if exit_reason: + logger.info( + f"Exit trigger [{exit_reason}] for {ticker}: " + f"price={current_price:,.0f} SL={position.stop_loss:,.0f} " + f"TP={position.take_profit:,.0f} days={position.days_held}" + ) + signal = {"action": "SELL", "entry_price": current_price} + result = self._execute_sell(ticker, signal, position.market) + if result: + result["exit_reason"] = exit_reason + results.append(result) + + return results diff --git a/tradingagents/broker/kiwoom_client.py b/tradingagents/broker/kiwoom_client.py new file mode 100644 index 00000000..e1a945b1 --- /dev/null +++ b/tradingagents/broker/kiwoom_client.py @@ -0,0 +1,244 @@ +"""Kiwoom Securities REST API client. + +Supports both 실전투자 (real) and 모의투자 (paper trading). +API docs: https://openapi.kiwoom.com +""" + +import logging +import time +from datetime import datetime +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +# Base URLs +REAL_BASE_URL = "https://api.kiwoom.com" +PAPER_BASE_URL = "https://mockapi.kiwoom.com" + + +class KiwoomClient: + """Kiwoom Securities REST API client.""" + + def __init__( + self, + app_key: str, + app_secret: str, + account_no: str, + is_paper: bool = True, + ): + self.app_key = app_key + self.app_secret = app_secret + self.account_no = account_no + self.is_paper = is_paper + self.base_url = PAPER_BASE_URL if is_paper else REAL_BASE_URL + + self._token: Optional[str] = None + self._token_expires: Optional[datetime] = None + + # ── Authentication ────────────────────────────────────────── + + def _ensure_token(self) -> str: + """Get valid access token, refreshing if needed.""" + if self._token and self._token_expires and datetime.now() < self._token_expires: + return self._token + + resp = requests.post( + f"{self.base_url}/oauth2/token", + json={ + "grant_type": "client_credentials", + "appkey": self.app_key, + "secretkey": self.app_secret, + }, + headers={"Content-Type": "application/json;charset=UTF-8"}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + + self._token = data["token"] + self._token_expires = datetime.strptime(data["expires_dt"], "%Y-%m-%d %H:%M:%S") + logger.info(f"Kiwoom token acquired, expires: {data['expires_dt']}") + return self._token + + def _headers(self, api_id: str, cont_yn: str = "N", next_key: str = "") -> dict: + """Build request headers.""" + token = self._ensure_token() + headers = { + "Content-Type": "application/json;charset=UTF-8", + "authorization": f"Bearer {token}", + "api-id": api_id, + } + if cont_yn: + headers["cont-yn"] = cont_yn + if next_key: + headers["next-key"] = next_key + return headers + + def _post(self, path: str, api_id: str, body: dict, **kwargs) -> dict: + """Make authenticated POST request.""" + headers = self._headers(api_id, **kwargs) + resp = requests.post( + f"{self.base_url}{path}", + json=body, + headers=headers, + timeout=15, + ) + resp.raise_for_status() + result = resp.json() + + if result.get("return_code") and int(result["return_code"]) != 0: + logger.error(f"Kiwoom API error [{api_id}]: {result.get('return_msg')}") + + return result + + # ── Orders ────────────────────────────────────────────────── + + def buy( + self, + stock_code: str, + quantity: int, + price: int = 0, + order_type: str = "market", + ) -> dict: + """Place a buy order. + + Args: + stock_code: 6-digit stock code (e.g. '005930') + quantity: Number of shares + price: Limit price (0 for market order) + order_type: 'market' or 'limit' + + Returns: + Order response with ord_no, return_code, return_msg + """ + body = { + "dmst_stex_tp": "SOR", + "stk_cd": stock_code.zfill(6), + "ord_qty": str(quantity), + "ord_uv": str(price), + "trde_tp": "3" if order_type == "market" else "0", + } + result = self._post("/api/dostk/ordr", "kt10000", body) + logger.info( + f"BUY {stock_code} x{quantity} @ {'시장가' if order_type == 'market' else price}: " + f"{result.get('return_msg', '')}" + ) + return result + + def sell( + self, + stock_code: str, + quantity: int, + price: int = 0, + order_type: str = "market", + ) -> dict: + """Place a sell order. + + Args: + stock_code: 6-digit stock code + quantity: Number of shares + price: Limit price (0 for market order) + order_type: 'market' or 'limit' + + Returns: + Order response with ord_no, return_code, return_msg + """ + body = { + "dmst_stex_tp": "SOR", + "stk_cd": stock_code.zfill(6), + "ord_qty": str(quantity), + "ord_uv": str(price), + "trde_tp": "3" if order_type == "market" else "0", + } + result = self._post("/api/dostk/ordr", "kt10001", body) + logger.info( + f"SELL {stock_code} x{quantity} @ {'시장가' if order_type == 'market' else price}: " + f"{result.get('return_msg', '')}" + ) + return result + + def cancel_order(self, original_order_no: str, stock_code: str, quantity: int) -> dict: + """Cancel an existing order.""" + body = { + "dmst_stex_tp": "SOR", + "stk_cd": stock_code.zfill(6), + "ord_qty": str(quantity), + "orgn_ord_no": original_order_no, + } + return self._post("/api/dostk/ordr", "kt10003", body) + + # ── Account / Balance ─────────────────────────────────────── + + def get_account_no(self) -> str: + """Retrieve account number from API.""" + result = self._post("/api/dostk/acnt", "ka00001", {}) + acct = result.get("acctNo", self.account_no) + logger.info(f"Account number: {acct}") + return acct + + def get_deposit(self) -> dict: + """Get deposit/cash balance details (예수금상세현황). + + Returns: + Dict with deposit info including available cash. + """ + return self._post("/api/dostk/acnt", "kt00001", {}) + + def get_balance(self) -> dict: + """Get account valuation and holdings (계좌평가현황). + + Returns: + Dict with total valuation, P&L, and individual holdings. + """ + return self._post("/api/dostk/acnt", "kt00004", {}) + + def get_holdings(self) -> dict: + """Get filled/settled positions (체결잔고). + + Returns: + Dict with list of current stock holdings. + """ + return self._post("/api/dostk/acnt", "kt00005", {}) + + # ── Market Data ───────────────────────────────────────────── + + def get_current_price(self, stock_code: str) -> dict: + """Get current price and basic stock info (주식기본정보). + + Args: + stock_code: 6-digit stock code + + Returns: + Dict with current price, volume, bid/ask, etc. + """ + body = {"stk_cd": stock_code.zfill(6)} + return self._post("/api/dostk/mrkcond", "ka10001", body) + + def get_orderbook(self, stock_code: str) -> dict: + """Get order book / bid-ask data (호가). + + Args: + stock_code: 6-digit stock code + + Returns: + Dict with bid/ask prices and volumes. + """ + body = {"stk_cd": stock_code.zfill(6)} + return self._post("/api/dostk/mrkcond", "ka10004", body) + + # ── Helpers ────────────────────────────────────────────────── + + def get_current_price_value(self, stock_code: str) -> Optional[int]: + """Get just the current price as an integer.""" + try: + result = self.get_current_price(stock_code) + # Price field varies; try common field names + for field in ("cur_prc", "stk_prpr", "현재가"): + if field in result: + return abs(int(str(result[field]).replace(",", ""))) + return None + except Exception as e: + logger.warning(f"Failed to get price for {stock_code}: {e}") + return None diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index f7c5fd18..4ea5b3cc 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -34,6 +34,13 @@ DEFAULT_CONFIG = { "screening_max_candidates": 5, "us_universe": "sp500", # "sp500", "nasdaq100", or "custom" "custom_watchlist": [], # custom ticker list for manual universe + # Broker settings (Kiwoom Securities REST API) + "broker_enabled": False, # True to enable order execution + "broker_dry_run": True, # True = validate only, False = real orders + "kiwoom_app_key": os.getenv("KIWOOM_APP_KEY", ""), + "kiwoom_app_secret": os.getenv("KIWOOM_APP_SECRET", ""), + "kiwoom_account_no": os.getenv("KIWOOM_ACCOUNT_NO", ""), + "kiwoom_is_paper": True, # True = 모의투자, False = 실전투자 # Data vendor configuration "data_vendors": { "core_stock_apis": "yfinance",