Separate core strategy and add DCA averaging logic

This commit is contained in:
hyejwon 2026-03-19 19:43:33 +09:00
parent 8cd0a4248c
commit 22b88ff053
5 changed files with 327 additions and 41 deletions

View File

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

View File

@ -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 = """
[한국형 운영 가이드]

View File

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

View File

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

View File

@ -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}일 / "