From 22b88ff05380b54f10ffad98e1a80e9fbcd13821 Mon Sep 17 00:00:00 2001 From: hyejwon Date: Thu, 19 Mar 2026 19:43:33 +0900 Subject: [PATCH] Separate core strategy and add DCA averaging logic --- cli/main.py | 250 +++++++++++++++++++- tradingagents/agents/utils/korean_prompt.py | 5 +- tradingagents/broker/executor.py | 45 +++- tradingagents/default_config.py | 9 + tradingagents/portfolio/state.py | 59 +++-- 5 files changed, 327 insertions(+), 41 deletions(-) diff --git a/cli/main.py b/cli/main.py index d88e2398..c0fec505 100644 --- a/cli/main.py +++ b/cli/main.py @@ -40,6 +40,15 @@ app = typer.Typer( ) +def _normalize_ticker_list(values) -> list[str]: + """Normalize ticker list from config/user input.""" + if not values: + return [] + if isinstance(values, str): + values = [v.strip() for v in values.split(",")] + return [str(v).strip().upper() for v in values if str(v).strip()] + + class MessageBuffer: """Tracks agent status and reports during swing trading analysis.""" @@ -824,6 +833,12 @@ def swing(): run_swing_pipeline() +@app.command() +def core(): + """Core long-term strategy: separated buy-and-hold track for large caps.""" + run_core_strategy() + + def _get_swing_config(): """Get config selections for swing pipeline (no ticker needed).""" try: @@ -903,6 +918,195 @@ def _get_swing_config(): } +def run_core_strategy(): + """Run separated long-term core strategy (independent from swing pipeline).""" + config = DEFAULT_CONFIG.copy() + core_tickers = _normalize_ticker_list(config.get("core_long_term_tickers", [])) + if not core_tickers: + console.print( + "[yellow]core_long_term_tickers가 비어 있습니다. " + "default_config.py에서 코어 종목을 설정하세요.[/yellow]" + ) + return + + from tradingagents.portfolio import load_portfolio, save_portfolio + + core_portfolio = load_portfolio( + portfolio_id=config.get("core_portfolio_id", "core_long_term"), + results_dir=config.get("results_dir", "./results"), + defaults={ + "total_capital": config.get("total_capital", 100_000_000), + "max_positions": max(len(core_tickers), 1), + "max_position_pct": config.get("core_max_position_pct", 0.40), + }, + ) + + budget_per_ticker = float(config.get("core_budget_per_ticker", 1_000_000)) + total_capital = float(core_portfolio.total_capital or 0) + cap_pct = float(config.get("core_max_position_pct", 0.40)) + base_target_pct = min( + budget_per_ticker / total_capital if total_capital > 0 else 0.0, + cap_pct, + ) + + plans = [] + for ticker in core_tickers: + pos = core_portfolio.positions.get(ticker) + invested = pos.cost_basis if pos else 0.0 + invested_pct = (invested / total_capital) if total_capital > 0 else 0.0 + remaining_pct = max(0.0, cap_pct - invested_pct) + run_target_pct = min(base_target_pct, remaining_pct) + + if run_target_pct <= 0: + status = "SKIP (cap reached)" + action_type = "SKIP" + else: + status = "DCA BUY" if pos else "INIT BUY" + action_type = "BUY" + + plans.append( + { + "ticker": ticker, + "status": status, + "action_type": action_type, + "invested_pct": invested_pct, + "run_target_pct": run_target_pct, + } + ) + + actionable_plans = [p for p in plans if p["action_type"] == "BUY"] + + console.print() + console.print(Rule("Core Long-Term Strategy (장기 코어 분리 전략)", style="bold cyan")) + console.print( + f"[dim]Core Portfolio ID: {core_portfolio.portfolio_id} | " + f"Swing Portfolio ID: {config.get('swing_portfolio_id', 'swing_default')}[/dim]" + ) + console.print(f"[dim]Core Tickers: {', '.join(core_tickers)}[/dim]") + console.print(f"[dim]Budget/Ticker: {budget_per_ticker:,.0f}[/dim]") + console.print(f"[dim]Base Target Size: {base_target_pct * 100:.2f}%[/dim]\n") + + table = Table( + title="Core Allocation Plan", + show_header=True, + header_style="bold", + box=box.ROUNDED, + ) + table.add_column("Ticker", justify="center", width=10) + table.add_column("Status", justify="center", width=14) + table.add_column("Current %", justify="right", width=10) + table.add_column("Budget", justify="right", width=14) + table.add_column("This Run %", justify="right", width=10) + + for plan in plans: + ticker = plan["ticker"] + status = plan["status"] + color = "dim" if plan["action_type"] == "SKIP" else "green" + table.add_row( + ticker, + f"[{color}]{status}[/{color}]", + f"{plan['invested_pct'] * 100:.2f}%", + f"{budget_per_ticker:,.0f}", + f"{plan['run_target_pct'] * 100:.2f}%", + ) + + console.print(table) + + if not actionable_plans: + console.print( + "\n[dim]모든 코어 종목이 목표 비중에 도달했습니다. 이번 회차 추가매수 없음.[/dim]" + ) + return + + if base_target_pct <= 0: + console.print( + "[red]target position size가 0입니다. total_capital/core_budget_per_ticker를 확인하세요.[/red]" + ) + return + + if not config.get("broker_enabled"): + console.print( + "\n[dim]현재는 계획만 출력했습니다. 실제 주문은 " + "broker_enabled=True 설정 후 core 명령을 다시 실행하세요.[/dim]" + ) + return + + 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 "실제 주문" + + execute = typer.prompt( + f"\n코어 전략 {len(actionable_plans)}건 매수를 실행하시겠습니까? ({mode_label}/{run_label})", + default="N", + ).strip().upper() + if execute not in ("Y", "YES"): + return + + from tradingagents.broker.kiwoom_client import KiwoomClient + from tradingagents.broker.executor import BrokerExecutor + + client = KiwoomClient( + app_key=config["kiwoom_app_key"], + app_secret=config["kiwoom_app_secret"], + account_no=config["kiwoom_account_no"], + is_paper=is_paper, + ) + executor = BrokerExecutor(client=client, portfolio=core_portfolio, dry_run=dry_run) + + sl_pct = float(config.get("core_stop_loss_pct", 0.30)) + tp_pct = float(config.get("core_take_profit_pct", 1.00)) + max_hold_days = int(config.get("core_max_hold_days", 3650)) + + for plan in actionable_plans: + ticker = plan["ticker"] + run_target_pct = plan["run_target_pct"] + + with console.status(f"[bold cyan]Executing core BUY {ticker}...[/bold cyan]"): + current_price = client.get_current_price_value(ticker) + if not current_price or current_price <= 0: + console.print(f"[red]{ticker}: 현재가 조회 실패로 스킵[/red]") + continue + + signal = { + "action": "BUY", + "entry_price": float(current_price), + "stop_loss": float(current_price) * (1 - sl_pct), + "take_profit": float(current_price) * (1 + tp_pct), + "position_size_pct": run_target_pct, + "max_hold_days": max_hold_days, + "rationale": "코어 장기 보유 분리 전략", + } + result = executor.execute_signal( + ticker=ticker, + swing_signal=signal, + market=config.get("market", "KRX"), + allow_add_to_existing=True, + ) + if not result: + console.print(f"[dim]{ticker}: no-op[/dim]") + continue + + status = result.get("status", "unknown") + color = {"filled": "green", "dry_run": "yellow", "rejected": "red", "failed": "red"}.get(status, "dim") + buy_mode = "추가매수" if result.get("is_add_on") else "신규매수" + msg = f"[{color}]{ticker}: {status} ({buy_mode})[/{color}]" + if result.get("quantity"): + msg += f" (x{result['quantity']} @ {result.get('price', 0):,.0f})" + if result.get("reason"): + msg += f" - {result['reason']}" + if result.get("broker_msg"): + msg += f" - {result['broker_msg']}" + console.print(msg) + + save_path = save_portfolio( + core_portfolio, + results_dir=config.get("results_dir", "./results"), + ) + console.print(f"\n[green]Core portfolio saved:[/green] {save_path}") + console.print(f"\n{core_portfolio.summary()}") + + def _display_swing_signal(ticker: str, name: str, swing_signal: dict): """Display a single swing signal result.""" action = swing_signal.get("action", "PASS") @@ -942,6 +1146,7 @@ def run_swing_pipeline(): config["llm_provider"] = selections["llm_provider"] config["google_thinking_level"] = selections.get("google_thinking_level") config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort") + config["portfolio_id"] = config.get("swing_portfolio_id", "swing_default") selected_set = {a.value for a in selections["analysts"]} selected_analyst_keys = [a for a in ANALYST_ORDER if a in selected_set] @@ -956,13 +1161,37 @@ def run_swing_pipeline(): trade_date = selections["analysis_date"] + from tradingagents.portfolio import load_portfolio, save_portfolio + + swing_portfolio = load_portfolio( + portfolio_id=config.get("portfolio_id", "swing_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), + }, + ) + swing_positions = list(swing_portfolio.positions.keys()) + core_tickers = _normalize_ticker_list(config.get("core_long_term_tickers", [])) + excluded_tickers = sorted(set(swing_positions + core_tickers)) + portfolio_context = swing_portfolio.summary() + # ─── Phase 1: Screening ─── console.print() console.print(Rule("Phase 1: Stock Screening (종목 발굴)", style="bold cyan")) console.print(f"[dim]Market: {selections['market']} / Date: {trade_date}[/dim]\n") + if core_tickers: + console.print( + f"[dim]코어 장기보유 종목 제외: {', '.join(core_tickers)}[/dim]" + ) with console.status("[bold cyan]Scanning market universe...[/bold cyan]"): - screening_result = graph.screen(trade_date=trade_date) + screening_result = graph.screen( + trade_date=trade_date, + existing_positions=excluded_tickers, + portfolio_context=portfolio_context, + ) # Display screening report console.print(Panel( @@ -1017,6 +1246,7 @@ def run_swing_pipeline(): company_name=ticker, trade_date=trade_date, screening_context=screening_context, + portfolio_context=portfolio_context, ) _display_swing_signal(ticker, name, swing_signal) @@ -1157,7 +1387,6 @@ def run_swing_pipeline(): 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"], @@ -1165,16 +1394,11 @@ def run_swing_pipeline(): 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=swing_portfolio, + dry_run=dry_run, ) - executor = BrokerExecutor(client=client, portfolio=portfolio, dry_run=dry_run) exec_results = [] for r in buy_signals: @@ -1198,11 +1422,11 @@ def run_swing_pipeline(): console.print(msg) save_path = save_portfolio( - portfolio, + swing_portfolio, results_dir=config.get("results_dir", "./results"), ) console.print(f"\n[green]Portfolio saved:[/green] {save_path}") - console.print(f"\n{portfolio.summary()}") + console.print(f"\n{swing_portfolio.summary()}") elif buy_signals and not config.get("broker_enabled"): console.print( diff --git a/tradingagents/agents/utils/korean_prompt.py b/tradingagents/agents/utils/korean_prompt.py index ad16d967..b09b46f5 100644 --- a/tradingagents/agents/utils/korean_prompt.py +++ b/tradingagents/agents/utils/korean_prompt.py @@ -1,7 +1,4 @@ -"""Shared Korean localization prompt fragments for all trading agents. - -Includes swing trading context prompts for multi-agent swing strategy system. -""" +"""Shared Korean localization prompt fragments for all trading agents.""" KOREAN_INVESTOR_GUIDE = """ [한국형 운영 가이드] diff --git a/tradingagents/broker/executor.py b/tradingagents/broker/executor.py index 289329b1..c0b40e5e 100644 --- a/tradingagents/broker/executor.py +++ b/tradingagents/broker/executor.py @@ -38,6 +38,7 @@ class BrokerExecutor: ticker: str, swing_signal: dict, market: str = "KRX", + allow_add_to_existing: bool = False, ) -> Optional[dict]: """Execute a swing trading signal. @@ -46,6 +47,7 @@ class BrokerExecutor: swing_signal: Dict from signal_processing with action, entry_price, stop_loss, take_profit, position_size_pct, etc. market: 'KRX' or 'US' + allow_add_to_existing: If True, BUY can add to existing position (DCA). Returns: Execution result dict or None if skipped. @@ -53,34 +55,58 @@ class BrokerExecutor: action = swing_signal.get("action", "PASS").upper() if action == "BUY": - return self._execute_buy(ticker, swing_signal, market) + return self._execute_buy( + ticker, + swing_signal, + market, + allow_add_to_existing=allow_add_to_existing, + ) 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: + def _execute_buy( + self, + ticker: str, + signal: dict, + market: str, + allow_add_to_existing: bool = False, + ) -> dict: """Execute a BUY order.""" - # Validate portfolio capacity - if not self.portfolio.can_add_position(): + has_existing = self.portfolio.has_position(ticker) + + # Validate portfolio capacity for NEW positions only. + if not has_existing and 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): + if has_existing and not allow_add_to_existing: 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 + target_capital = self.portfolio.total_capital * position_size_pct available = self.portfolio.available_capital - capital_to_use = min(max_capital, available) + existing_capital = ( + self.portfolio.positions[ticker].cost_basis if has_existing else 0.0 + ) + ticker_cap = self.portfolio.total_capital * self.portfolio.max_position_pct + remaining_ticker_cap = max(0.0, ticker_cap - existing_capital) + + capital_to_use = min(target_capital, available, remaining_ticker_cap) if capital_to_use <= 0: - logger.warning(f"Cannot buy {ticker}: insufficient capital") - return {"status": "rejected", "reason": "insufficient_capital", "ticker": ticker} + reason = ( + "max_ticker_allocation_reached" + if remaining_ticker_cap <= 0 + else "insufficient_capital" + ) + logger.warning(f"Cannot buy {ticker}: {reason}") + return {"status": "rejected", "reason": reason, "ticker": ticker} # Get current price from broker current_price = self.client.get_current_price_value(ticker) @@ -114,6 +140,7 @@ class BrokerExecutor: "status": "pending", "ticker": ticker, "action": "BUY", + "is_add_on": has_existing, "quantity": quantity, "price": entry_price, "total_cost": quantity * entry_price, diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 4ea5b3cc..3f594743 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -23,11 +23,20 @@ DEFAULT_CONFIG = { "swing_hold_days_max": 20, # Portfolio settings "portfolio_id": "default", + "swing_portfolio_id": "swing_default", + "core_portfolio_id": "core_long_term", "total_capital": 100_000_000, # 1억원 (KRX) or $100,000 (US) "max_positions": 5, "max_position_pct": 0.20, # 20% of total capital per position "default_stop_loss_pct": 0.05, # 5% "default_take_profit_pct": 0.15, # 15% + # Core long-term strategy settings (separated from swing) + "core_long_term_tickers": ["005930"], # e.g., Samsung Electronics + "core_budget_per_ticker": 1_000_000, # 1백만원 per run + "core_max_position_pct": 0.40, # 40% cap per core ticker + "core_stop_loss_pct": 0.30, # wide stop for long-term holding + "core_take_profit_pct": 1.00, # optional broad target + "core_max_hold_days": 3650, # ~10 years # Screening settings "screening_min_market_cap": 500_000_000_000, # 5000억원 "screening_min_volume": 100_000, diff --git a/tradingagents/portfolio/state.py b/tradingagents/portfolio/state.py index e069eddc..cb598b6a 100644 --- a/tradingagents/portfolio/state.py +++ b/tradingagents/portfolio/state.py @@ -135,20 +135,48 @@ class PortfolioState: return ticker in self.positions def add_position(self, order: Order) -> None: - """Add a new position from a BUY order.""" - self.positions[order.ticker] = Position( - ticker=order.ticker, - market=order.market, - entry_date=datetime.now().strftime("%Y-%m-%d"), - entry_price=order.price, - quantity=order.quantity, - stop_loss=order.stop_loss, - take_profit=order.take_profit, - max_hold_days=order.max_hold_days, - current_price=order.price, - screening_reason=order.rationale, - ) - self.available_capital -= order.price * order.quantity + """Add a BUY order into portfolio, supporting DCA averaging for existing positions.""" + added_cost = order.price * order.quantity + + if order.ticker in self.positions: + # DCA: update weighted-average entry/levels for existing position. + pos = self.positions[order.ticker] + old_qty = pos.quantity + new_qty = old_qty + order.quantity + + if new_qty > 0: + pos.entry_price = ( + (pos.entry_price * old_qty) + (order.price * order.quantity) + ) / new_qty + pos.stop_loss = ( + (pos.stop_loss * old_qty) + (order.stop_loss * order.quantity) + ) / new_qty + pos.take_profit = ( + (pos.take_profit * old_qty) + (order.take_profit * order.quantity) + ) / new_qty + pos.quantity = new_qty + pos.max_hold_days = max(pos.max_hold_days, order.max_hold_days) + pos.current_price = order.price + if order.rationale: + if pos.screening_reason: + pos.screening_reason = f"{pos.screening_reason} | {order.rationale}" + else: + pos.screening_reason = order.rationale + else: + self.positions[order.ticker] = Position( + ticker=order.ticker, + market=order.market, + entry_date=datetime.now().strftime("%Y-%m-%d"), + entry_price=order.price, + quantity=order.quantity, + stop_loss=order.stop_loss, + take_profit=order.take_profit, + max_hold_days=order.max_hold_days, + current_price=order.price, + screening_reason=order.rationale, + ) + + self.available_capital -= added_cost self.orders_history.append(order) self.updated_at = datetime.now().isoformat() @@ -189,7 +217,8 @@ class PortfolioState: lines.append("\n--- 보유 종목 ---") for ticker, pos in self.positions.items(): lines.append( - f" {ticker}: 진입가 {pos.entry_price:,.0f} / " + f" {ticker}: 수량 {pos.quantity:,} / " + f"평단 {pos.entry_price:,.0f} / " f"현재가 {pos.current_price:,.0f} / " f"수익률 {pos.unrealized_pnl_pct:+.1f}% / " f"보유일 {pos.days_held}일 / "