228 lines
7.9 KiB
Python
228 lines
7.9 KiB
Python
"""Trade execution module for the Portfolio Manager.
|
|
|
|
Executes PM agent decisions by calling PortfolioRepository methods.
|
|
SELLs are always executed before BUYs to free up cash first.
|
|
All constraint checks happen before each BUY.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from tradingagents.portfolio.exceptions import (
|
|
InsufficientCashError,
|
|
InsufficientSharesError,
|
|
PortfolioError,
|
|
)
|
|
from tradingagents.portfolio.risk_evaluator import check_constraints
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TradeExecutor:
|
|
"""Executes PM decisions against a PortfolioRepository.
|
|
|
|
Args:
|
|
repo: PortfolioRepository instance. If None, a new instance is
|
|
created on first use (requires a live DB connection).
|
|
config: Portfolio config dict. If None, defaults are used.
|
|
"""
|
|
|
|
def __init__(self, repo=None, config: dict[str, Any] | None = None) -> None:
|
|
self._repo = repo
|
|
self._config = config or {}
|
|
|
|
@property
|
|
def repo(self):
|
|
"""Lazy-load repo if not provided at construction."""
|
|
if self._repo is None:
|
|
from tradingagents.portfolio.repository import PortfolioRepository
|
|
|
|
self._repo = PortfolioRepository()
|
|
return self._repo
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def execute_decisions(
|
|
self,
|
|
portfolio_id: str,
|
|
decisions: dict[str, Any],
|
|
prices: dict[str, float],
|
|
date: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Execute a PM decision dict against the portfolio.
|
|
|
|
SELLs are processed first (to free cash), then BUYs. Each trade
|
|
undergoes constraint pre-flight for BUYs. Failures are caught
|
|
gracefully and added to ``failed_trades``.
|
|
|
|
Args:
|
|
portfolio_id: The portfolio to trade against.
|
|
decisions: Dict with ``sells`` and ``buys`` lists as produced by
|
|
the PM decision agent.
|
|
prices: Current EOD prices (ticker → price).
|
|
date: Trade date string (ISO). Defaults to now (UTC).
|
|
|
|
Returns:
|
|
Dict with keys: executed_trades, failed_trades, snapshot, summary.
|
|
"""
|
|
trade_date = date or datetime.now(timezone.utc).isoformat()
|
|
executed_trades: list[dict[str, Any]] = []
|
|
failed_trades: list[dict[str, Any]] = []
|
|
|
|
sells = decisions.get("sells") or []
|
|
buys = decisions.get("buys") or []
|
|
|
|
# --- SELLs first (frees cash before BUYs; no constraint pre-flight for sells) ---
|
|
for sell in sells:
|
|
ticker = (sell.get("ticker") or "").upper()
|
|
shares = float(sell.get("shares") or 0)
|
|
rationale = sell.get("rationale") or ""
|
|
|
|
if not ticker or shares <= 0:
|
|
failed_trades.append({
|
|
"action": "SELL",
|
|
"ticker": ticker,
|
|
"reason": "Invalid ticker or shares",
|
|
"detail": str(sell),
|
|
})
|
|
continue
|
|
|
|
price = prices.get(ticker)
|
|
if price is None:
|
|
failed_trades.append({
|
|
"action": "SELL",
|
|
"ticker": ticker,
|
|
"reason": f"No price found for {ticker}",
|
|
})
|
|
logger.warning("execute_decisions: no price for %s — skipping SELL", ticker)
|
|
continue
|
|
|
|
try:
|
|
self.repo.remove_holding(portfolio_id, ticker, shares, price)
|
|
executed_trades.append({
|
|
"action": "SELL",
|
|
"ticker": ticker,
|
|
"shares": shares,
|
|
"price": price,
|
|
"rationale": rationale,
|
|
"trade_date": trade_date,
|
|
})
|
|
logger.info("SELL %s x %.2f @ %.2f", ticker, shares, price)
|
|
except (InsufficientSharesError, PortfolioError) as exc:
|
|
failed_trades.append({
|
|
"action": "SELL",
|
|
"ticker": ticker,
|
|
"reason": str(exc),
|
|
})
|
|
logger.warning("SELL failed for %s: %s", ticker, exc)
|
|
|
|
# --- BUYs second ---
|
|
for buy in buys:
|
|
ticker = (buy.get("ticker") or "").upper()
|
|
shares = float(buy.get("shares") or 0)
|
|
sector = buy.get("sector")
|
|
rationale = buy.get("rationale") or ""
|
|
|
|
if not ticker or shares <= 0:
|
|
failed_trades.append({
|
|
"action": "BUY",
|
|
"ticker": ticker,
|
|
"reason": "Invalid ticker or shares",
|
|
"detail": str(buy),
|
|
})
|
|
continue
|
|
|
|
price = prices.get(ticker)
|
|
if price is None:
|
|
failed_trades.append({
|
|
"action": "BUY",
|
|
"ticker": ticker,
|
|
"reason": f"No price found for {ticker}",
|
|
})
|
|
logger.warning("execute_decisions: no price for %s — skipping BUY", ticker)
|
|
continue
|
|
|
|
# Pre-flight constraint check
|
|
try:
|
|
portfolio, holdings = self.repo.get_portfolio_with_holdings(
|
|
portfolio_id, prices
|
|
)
|
|
except PortfolioError as exc:
|
|
failed_trades.append({
|
|
"action": "BUY",
|
|
"ticker": ticker,
|
|
"reason": f"Could not load portfolio: {exc}",
|
|
})
|
|
continue
|
|
|
|
violations = check_constraints(
|
|
portfolio,
|
|
holdings,
|
|
self._config,
|
|
new_ticker=ticker,
|
|
new_shares=shares,
|
|
new_price=price,
|
|
new_sector=sector,
|
|
)
|
|
if violations:
|
|
failed_trades.append({
|
|
"action": "BUY",
|
|
"ticker": ticker,
|
|
"reason": "Constraint violation",
|
|
"violations": violations,
|
|
})
|
|
logger.warning("BUY %s rejected — constraints: %s", ticker, violations)
|
|
continue
|
|
|
|
try:
|
|
self.repo.add_holding(
|
|
portfolio_id,
|
|
ticker,
|
|
shares,
|
|
price,
|
|
sector=sector,
|
|
)
|
|
executed_trades.append({
|
|
"action": "BUY",
|
|
"ticker": ticker,
|
|
"shares": shares,
|
|
"price": price,
|
|
"sector": sector,
|
|
"rationale": rationale,
|
|
"trade_date": trade_date,
|
|
})
|
|
logger.info("BUY %s x %.2f @ %.2f", ticker, shares, price)
|
|
except (InsufficientCashError, PortfolioError) as exc:
|
|
failed_trades.append({
|
|
"action": "BUY",
|
|
"ticker": ticker,
|
|
"reason": str(exc),
|
|
})
|
|
logger.warning("BUY failed for %s: %s", ticker, exc)
|
|
|
|
# --- EOD snapshot ---
|
|
try:
|
|
snapshot = self.repo.take_snapshot(portfolio_id, prices)
|
|
snapshot_dict = snapshot.to_dict()
|
|
except PortfolioError as exc:
|
|
snapshot_dict = {"error": str(exc)}
|
|
logger.error("Snapshot failed: %s", exc)
|
|
|
|
summary = {
|
|
"executed": len(executed_trades),
|
|
"failed": len(failed_trades),
|
|
"total_attempted": len(sells) + len(buys),
|
|
}
|
|
|
|
return {
|
|
"executed_trades": executed_trades,
|
|
"failed_trades": failed_trades,
|
|
"snapshot": snapshot_dict,
|
|
"summary": summary,
|
|
}
|