TradingAgents/tradingagents/portfolio/state.py

200 lines
6.4 KiB
Python

"""Portfolio state models for swing trading."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
@dataclass
class Position:
"""An open position in the portfolio."""
ticker: str
market: str # "KRX" or "US"
entry_date: str
entry_price: float
quantity: int
stop_loss: float
take_profit: float
max_hold_days: int = 20
current_price: float = 0.0
screening_reason: str = ""
@property
def days_held(self) -> int:
entry = datetime.strptime(self.entry_date, "%Y-%m-%d")
return (datetime.now() - entry).days
@property
def unrealized_pnl(self) -> float:
return (self.current_price - self.entry_price) * self.quantity
@property
def unrealized_pnl_pct(self) -> float:
if self.entry_price == 0:
return 0.0
return (self.current_price - self.entry_price) / self.entry_price * 100
@property
def cost_basis(self) -> float:
return self.entry_price * self.quantity
def should_check_exit(self, current_date: str) -> bool:
"""Check if position needs exit evaluation (stop-loss, take-profit, or max hold)."""
if self.current_price <= self.stop_loss:
return True
if self.current_price >= self.take_profit:
return True
if self.days_held >= self.max_hold_days:
return True
return False
@dataclass
class ClosedTrade:
"""A completed trade with realized P&L."""
ticker: str
market: str
entry_date: str
exit_date: str
entry_price: float
exit_price: float
quantity: int
exit_reason: str # "stop_loss", "take_profit", "max_hold", "agent_decision"
@property
def pnl(self) -> float:
return (self.exit_price - self.entry_price) * self.quantity
@property
def pnl_pct(self) -> float:
if self.entry_price == 0:
return 0.0
return (self.exit_price - self.entry_price) / self.entry_price * 100
@dataclass
class Order:
"""A trading order generated by the system."""
action: str # "BUY", "SELL"
ticker: str
market: str
price: float
stop_loss: float
take_profit: float
quantity: int
position_size_pct: float # % of total capital
max_hold_days: int = 20
rationale: str = ""
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
@dataclass
class PortfolioState:
"""Complete portfolio state for swing trading."""
portfolio_id: str = "default"
total_capital: float = 100_000_000 # 1억원 default
available_capital: float = 100_000_000
max_positions: int = 5
max_position_pct: float = 0.20 # 20% of total capital per position
positions: dict[str, Position] = field(default_factory=dict)
closed_trades: list[ClosedTrade] = field(default_factory=list)
orders_history: list[Order] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
@property
def invested_capital(self) -> float:
return sum(p.cost_basis for p in self.positions.values())
@property
def total_unrealized_pnl(self) -> float:
return sum(p.unrealized_pnl for p in self.positions.values())
@property
def total_realized_pnl(self) -> float:
return sum(t.pnl for t in self.closed_trades)
@property
def position_count(self) -> int:
return len(self.positions)
def can_add_position(self) -> bool:
return self.position_count < self.max_positions
def available_slots(self) -> int:
return self.max_positions - self.position_count
def max_position_capital(self) -> float:
return self.total_capital * self.max_position_pct
def has_position(self, ticker: str) -> bool:
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
self.orders_history.append(order)
self.updated_at = datetime.now().isoformat()
def close_position(self, ticker: str, exit_price: float, exit_reason: str) -> Optional[ClosedTrade]:
"""Close an existing position and record the trade."""
if ticker not in self.positions:
return None
pos = self.positions.pop(ticker)
trade = ClosedTrade(
ticker=pos.ticker,
market=pos.market,
entry_date=pos.entry_date,
exit_date=datetime.now().strftime("%Y-%m-%d"),
entry_price=pos.entry_price,
exit_price=exit_price,
quantity=pos.quantity,
exit_reason=exit_reason,
)
self.closed_trades.append(trade)
self.available_capital += exit_price * pos.quantity
self.updated_at = datetime.now().isoformat()
return trade
def summary(self) -> str:
"""Generate a text summary of the portfolio for agent context."""
lines = [
f"=== 포트폴리오 현황 ===",
f"총 자본: {self.total_capital:,.0f}",
f"가용 자본: {self.available_capital:,.0f}",
f"투자 중: {self.invested_capital:,.0f}",
f"포지션: {self.position_count}/{self.max_positions}",
f"미실현 손익: {self.total_unrealized_pnl:,.0f}",
f"실현 손익: {self.total_realized_pnl:,.0f}",
]
if self.positions:
lines.append("\n--- 보유 종목 ---")
for ticker, pos in self.positions.items():
lines.append(
f" {ticker}: 진입가 {pos.entry_price:,.0f} / "
f"현재가 {pos.current_price:,.0f} / "
f"수익률 {pos.unrealized_pnl_pct:+.1f}% / "
f"보유일 {pos.days_held}일 / "
f"손절 {pos.stop_loss:,.0f} / 익절 {pos.take_profit:,.0f}"
)
return "\n".join(lines)