update auto trading client
This commit is contained in:
parent
692a55dfd5
commit
8cd0a4248c
99
cli/main.py
99
cli/main.py
|
|
@ -1112,5 +1112,104 @@ def run_swing_pipeline():
|
||||||
console.print(f"\n[green]\u2713 Reports saved:[/green] {base_path.resolve()}")
|
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__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from tradingagents.broker.kiwoom_client import KiwoomClient
|
||||||
|
from tradingagents.broker.executor import BrokerExecutor
|
||||||
|
|
||||||
|
__all__ = ["KiwoomClient", "BrokerExecutor"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -34,6 +34,13 @@ DEFAULT_CONFIG = {
|
||||||
"screening_max_candidates": 5,
|
"screening_max_candidates": 5,
|
||||||
"us_universe": "sp500", # "sp500", "nasdaq100", or "custom"
|
"us_universe": "sp500", # "sp500", "nasdaq100", or "custom"
|
||||||
"custom_watchlist": [], # custom ticker list for manual universe
|
"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 vendor configuration
|
||||||
"data_vendors": {
|
"data_vendors": {
|
||||||
"core_stock_apis": "yfinance",
|
"core_stock_apis": "yfinance",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue