update auto trading client

This commit is contained in:
hyejwon 2026-03-12 17:07:59 +09:00
parent 692a55dfd5
commit 8cd0a4248c
5 changed files with 614 additions and 0 deletions

View File

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

View File

@ -0,0 +1,4 @@
from tradingagents.broker.kiwoom_client import KiwoomClient
from tradingagents.broker.executor import BrokerExecutor
__all__ = ["KiwoomClient", "BrokerExecutor"]

View File

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

View File

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

View File

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