Separate core strategy and add DCA averaging logic
This commit is contained in:
parent
8cd0a4248c
commit
22b88ff053
250
cli/main.py
250
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:
|
class MessageBuffer:
|
||||||
"""Tracks agent status and reports during swing trading analysis."""
|
"""Tracks agent status and reports during swing trading analysis."""
|
||||||
|
|
||||||
|
|
@ -824,6 +833,12 @@ def swing():
|
||||||
run_swing_pipeline()
|
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():
|
def _get_swing_config():
|
||||||
"""Get config selections for swing pipeline (no ticker needed)."""
|
"""Get config selections for swing pipeline (no ticker needed)."""
|
||||||
try:
|
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):
|
def _display_swing_signal(ticker: str, name: str, swing_signal: dict):
|
||||||
"""Display a single swing signal result."""
|
"""Display a single swing signal result."""
|
||||||
action = swing_signal.get("action", "PASS")
|
action = swing_signal.get("action", "PASS")
|
||||||
|
|
@ -942,6 +1146,7 @@ def run_swing_pipeline():
|
||||||
config["llm_provider"] = selections["llm_provider"]
|
config["llm_provider"] = selections["llm_provider"]
|
||||||
config["google_thinking_level"] = selections.get("google_thinking_level")
|
config["google_thinking_level"] = selections.get("google_thinking_level")
|
||||||
config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort")
|
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_set = {a.value for a in selections["analysts"]}
|
||||||
selected_analyst_keys = [a for a in ANALYST_ORDER if a in selected_set]
|
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"]
|
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 ───
|
# ─── Phase 1: Screening ───
|
||||||
console.print()
|
console.print()
|
||||||
console.print(Rule("Phase 1: Stock Screening (종목 발굴)", style="bold cyan"))
|
console.print(Rule("Phase 1: Stock Screening (종목 발굴)", style="bold cyan"))
|
||||||
console.print(f"[dim]Market: {selections['market']} / Date: {trade_date}[/dim]\n")
|
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]"):
|
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
|
# Display screening report
|
||||||
console.print(Panel(
|
console.print(Panel(
|
||||||
|
|
@ -1017,6 +1246,7 @@ def run_swing_pipeline():
|
||||||
company_name=ticker,
|
company_name=ticker,
|
||||||
trade_date=trade_date,
|
trade_date=trade_date,
|
||||||
screening_context=screening_context,
|
screening_context=screening_context,
|
||||||
|
portfolio_context=portfolio_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
_display_swing_signal(ticker, name, swing_signal)
|
_display_swing_signal(ticker, name, swing_signal)
|
||||||
|
|
@ -1157,7 +1387,6 @@ def run_swing_pipeline():
|
||||||
if execute in ("Y", "YES"):
|
if execute in ("Y", "YES"):
|
||||||
from tradingagents.broker.kiwoom_client import KiwoomClient
|
from tradingagents.broker.kiwoom_client import KiwoomClient
|
||||||
from tradingagents.broker.executor import BrokerExecutor
|
from tradingagents.broker.executor import BrokerExecutor
|
||||||
from tradingagents.portfolio import load_portfolio, save_portfolio
|
|
||||||
|
|
||||||
client = KiwoomClient(
|
client = KiwoomClient(
|
||||||
app_key=config["kiwoom_app_key"],
|
app_key=config["kiwoom_app_key"],
|
||||||
|
|
@ -1165,16 +1394,11 @@ def run_swing_pipeline():
|
||||||
account_no=config["kiwoom_account_no"],
|
account_no=config["kiwoom_account_no"],
|
||||||
is_paper=is_paper,
|
is_paper=is_paper,
|
||||||
)
|
)
|
||||||
portfolio = load_portfolio(
|
executor = BrokerExecutor(
|
||||||
portfolio_id=config.get("portfolio_id", "default"),
|
client=client,
|
||||||
results_dir=config.get("results_dir", "./results"),
|
portfolio=swing_portfolio,
|
||||||
defaults={
|
dry_run=dry_run,
|
||||||
"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 = []
|
exec_results = []
|
||||||
for r in buy_signals:
|
for r in buy_signals:
|
||||||
|
|
@ -1198,11 +1422,11 @@ def run_swing_pipeline():
|
||||||
console.print(msg)
|
console.print(msg)
|
||||||
|
|
||||||
save_path = save_portfolio(
|
save_path = save_portfolio(
|
||||||
portfolio,
|
swing_portfolio,
|
||||||
results_dir=config.get("results_dir", "./results"),
|
results_dir=config.get("results_dir", "./results"),
|
||||||
)
|
)
|
||||||
console.print(f"\n[green]Portfolio saved:[/green] {save_path}")
|
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"):
|
elif buy_signals and not config.get("broker_enabled"):
|
||||||
console.print(
|
console.print(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
"""Shared Korean localization prompt fragments for all trading agents.
|
"""Shared Korean localization prompt fragments for all trading agents."""
|
||||||
|
|
||||||
Includes swing trading context prompts for multi-agent swing strategy system.
|
|
||||||
"""
|
|
||||||
|
|
||||||
KOREAN_INVESTOR_GUIDE = """
|
KOREAN_INVESTOR_GUIDE = """
|
||||||
[한국형 운영 가이드]
|
[한국형 운영 가이드]
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class BrokerExecutor:
|
||||||
ticker: str,
|
ticker: str,
|
||||||
swing_signal: dict,
|
swing_signal: dict,
|
||||||
market: str = "KRX",
|
market: str = "KRX",
|
||||||
|
allow_add_to_existing: bool = False,
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Execute a swing trading signal.
|
"""Execute a swing trading signal.
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ class BrokerExecutor:
|
||||||
swing_signal: Dict from signal_processing with action, entry_price,
|
swing_signal: Dict from signal_processing with action, entry_price,
|
||||||
stop_loss, take_profit, position_size_pct, etc.
|
stop_loss, take_profit, position_size_pct, etc.
|
||||||
market: 'KRX' or 'US'
|
market: 'KRX' or 'US'
|
||||||
|
allow_add_to_existing: If True, BUY can add to existing position (DCA).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Execution result dict or None if skipped.
|
Execution result dict or None if skipped.
|
||||||
|
|
@ -53,34 +55,58 @@ class BrokerExecutor:
|
||||||
action = swing_signal.get("action", "PASS").upper()
|
action = swing_signal.get("action", "PASS").upper()
|
||||||
|
|
||||||
if action == "BUY":
|
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":
|
elif action == "SELL":
|
||||||
return self._execute_sell(ticker, swing_signal, market)
|
return self._execute_sell(ticker, swing_signal, market)
|
||||||
else:
|
else:
|
||||||
logger.info(f"PASS for {ticker}: {swing_signal.get('rationale', '')}")
|
logger.info(f"PASS for {ticker}: {swing_signal.get('rationale', '')}")
|
||||||
return None
|
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."""
|
"""Execute a BUY order."""
|
||||||
# Validate portfolio capacity
|
has_existing = self.portfolio.has_position(ticker)
|
||||||
if not self.portfolio.can_add_position():
|
|
||||||
|
# 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})")
|
logger.warning(f"Cannot buy {ticker}: max positions reached ({self.portfolio.max_positions})")
|
||||||
return {"status": "rejected", "reason": "max_positions_reached", "ticker": ticker}
|
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")
|
logger.warning(f"Cannot buy {ticker}: already holding position")
|
||||||
return {"status": "rejected", "reason": "already_holding", "ticker": ticker}
|
return {"status": "rejected", "reason": "already_holding", "ticker": ticker}
|
||||||
|
|
||||||
# Calculate position size
|
# Calculate position size
|
||||||
position_size_pct = signal.get("position_size_pct") or 0.10
|
position_size_pct = signal.get("position_size_pct") or 0.10
|
||||||
position_size_pct = min(position_size_pct, self.portfolio.max_position_pct)
|
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
|
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:
|
if capital_to_use <= 0:
|
||||||
logger.warning(f"Cannot buy {ticker}: insufficient capital")
|
reason = (
|
||||||
return {"status": "rejected", "reason": "insufficient_capital", "ticker": ticker}
|
"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
|
# Get current price from broker
|
||||||
current_price = self.client.get_current_price_value(ticker)
|
current_price = self.client.get_current_price_value(ticker)
|
||||||
|
|
@ -114,6 +140,7 @@ class BrokerExecutor:
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"action": "BUY",
|
"action": "BUY",
|
||||||
|
"is_add_on": has_existing,
|
||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"price": entry_price,
|
"price": entry_price,
|
||||||
"total_cost": quantity * entry_price,
|
"total_cost": quantity * entry_price,
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,20 @@ DEFAULT_CONFIG = {
|
||||||
"swing_hold_days_max": 20,
|
"swing_hold_days_max": 20,
|
||||||
# Portfolio settings
|
# Portfolio settings
|
||||||
"portfolio_id": "default",
|
"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)
|
"total_capital": 100_000_000, # 1억원 (KRX) or $100,000 (US)
|
||||||
"max_positions": 5,
|
"max_positions": 5,
|
||||||
"max_position_pct": 0.20, # 20% of total capital per position
|
"max_position_pct": 0.20, # 20% of total capital per position
|
||||||
"default_stop_loss_pct": 0.05, # 5%
|
"default_stop_loss_pct": 0.05, # 5%
|
||||||
"default_take_profit_pct": 0.15, # 15%
|
"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 settings
|
||||||
"screening_min_market_cap": 500_000_000_000, # 5000억원
|
"screening_min_market_cap": 500_000_000_000, # 5000억원
|
||||||
"screening_min_volume": 100_000,
|
"screening_min_volume": 100_000,
|
||||||
|
|
|
||||||
|
|
@ -135,20 +135,48 @@ class PortfolioState:
|
||||||
return ticker in self.positions
|
return ticker in self.positions
|
||||||
|
|
||||||
def add_position(self, order: Order) -> None:
|
def add_position(self, order: Order) -> None:
|
||||||
"""Add a new position from a BUY order."""
|
"""Add a BUY order into portfolio, supporting DCA averaging for existing positions."""
|
||||||
self.positions[order.ticker] = Position(
|
added_cost = order.price * order.quantity
|
||||||
ticker=order.ticker,
|
|
||||||
market=order.market,
|
if order.ticker in self.positions:
|
||||||
entry_date=datetime.now().strftime("%Y-%m-%d"),
|
# DCA: update weighted-average entry/levels for existing position.
|
||||||
entry_price=order.price,
|
pos = self.positions[order.ticker]
|
||||||
quantity=order.quantity,
|
old_qty = pos.quantity
|
||||||
stop_loss=order.stop_loss,
|
new_qty = old_qty + order.quantity
|
||||||
take_profit=order.take_profit,
|
|
||||||
max_hold_days=order.max_hold_days,
|
if new_qty > 0:
|
||||||
current_price=order.price,
|
pos.entry_price = (
|
||||||
screening_reason=order.rationale,
|
(pos.entry_price * old_qty) + (order.price * order.quantity)
|
||||||
)
|
) / new_qty
|
||||||
self.available_capital -= order.price * order.quantity
|
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.orders_history.append(order)
|
||||||
self.updated_at = datetime.now().isoformat()
|
self.updated_at = datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
@ -189,7 +217,8 @@ class PortfolioState:
|
||||||
lines.append("\n--- 보유 종목 ---")
|
lines.append("\n--- 보유 종목 ---")
|
||||||
for ticker, pos in self.positions.items():
|
for ticker, pos in self.positions.items():
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {ticker}: 진입가 {pos.entry_price:,.0f} / "
|
f" {ticker}: 수량 {pos.quantity:,} / "
|
||||||
|
f"평단 {pos.entry_price:,.0f} / "
|
||||||
f"현재가 {pos.current_price:,.0f} / "
|
f"현재가 {pos.current_price:,.0f} / "
|
||||||
f"수익률 {pos.unrealized_pnl_pct:+.1f}% / "
|
f"수익률 {pos.unrealized_pnl_pct:+.1f}% / "
|
||||||
f"보유일 {pos.days_held}일 / "
|
f"보유일 {pos.days_held}일 / "
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue