feat: implement cash management sweep and auto-liquidation (#129)
- Added `CashSweep` node to `PortfolioGraphSetup` to automatically sweep idle cash above 5% threshold into SGOV ETF - Updated `TradeExecutor` to support auto-liquidating SGOV holdings to fund new equity purchases when cash is insufficient - Added `cash_sweep` tracking field to `PortfolioManagerState` Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
This commit is contained in:
parent
635ec430b1
commit
9ccb22d073
|
|
@ -192,6 +192,7 @@ class PortfolioGraph:
|
||||||
"macro_memory_context": "",
|
"macro_memory_context": "",
|
||||||
"micro_memory_context": "",
|
"micro_memory_context": "",
|
||||||
"pm_decision": "",
|
"pm_decision": "",
|
||||||
|
"cash_sweep": "",
|
||||||
"execution_result": "",
|
"execution_result": "",
|
||||||
"sender": "",
|
"sender": "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,73 @@ class PortfolioGraphSetup:
|
||||||
|
|
||||||
return prioritize_candidates_node
|
return prioritize_candidates_node
|
||||||
|
|
||||||
|
def _make_cash_sweep_node(self):
|
||||||
|
"""Node to automatically sweep excess cash into a cash-equivalent ETF."""
|
||||||
|
def cash_sweep_node(state):
|
||||||
|
portfolio_data_str = state.get("portfolio_data") or "{}"
|
||||||
|
pm_decision_str = state.get("pm_decision") or "{}"
|
||||||
|
prices = state.get("prices") or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
portfolio_data = json.loads(portfolio_data_str)
|
||||||
|
from tradingagents.portfolio.models import Holding, Portfolio
|
||||||
|
portfolio = Portfolio.from_dict(portfolio_data.get("portfolio") or _EMPTY_PORTFOLIO_DICT)
|
||||||
|
holdings = [Holding.from_dict(h) for h in (portfolio_data.get("holdings") or [])]
|
||||||
|
|
||||||
|
if prices and portfolio.total_value is None:
|
||||||
|
equity = sum(prices.get(h.ticker, 0.0) * h.shares for h in holdings)
|
||||||
|
total_value = portfolio.cash + equity
|
||||||
|
for h in holdings:
|
||||||
|
if h.ticker in prices:
|
||||||
|
h.enrich(prices[h.ticker], total_value)
|
||||||
|
portfolio.enrich(holdings)
|
||||||
|
|
||||||
|
total_value = portfolio.total_value or portfolio.cash
|
||||||
|
|
||||||
|
# Default target cash threshold
|
||||||
|
target_cash_pct = 0.05
|
||||||
|
sweep_etf = "SGOV"
|
||||||
|
sweep_etf_price = prices.get(sweep_etf, 100.0) # Assume 100.0 if not in prices
|
||||||
|
|
||||||
|
try:
|
||||||
|
decisions = json.loads(pm_decision_str)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
decisions = {"sells": [], "buys": []}
|
||||||
|
|
||||||
|
if "buys" not in decisions:
|
||||||
|
decisions["buys"] = []
|
||||||
|
|
||||||
|
sweep_details = "No sweep needed"
|
||||||
|
if total_value > 0:
|
||||||
|
current_cash_pct = portfolio.cash / total_value
|
||||||
|
if current_cash_pct > target_cash_pct:
|
||||||
|
excess_cash = portfolio.cash - (total_value * target_cash_pct)
|
||||||
|
shares_to_buy = int(excess_cash / sweep_etf_price)
|
||||||
|
|
||||||
|
if shares_to_buy > 0:
|
||||||
|
# Add SGOV buy to decisions
|
||||||
|
sweep_buy = {
|
||||||
|
"ticker": sweep_etf,
|
||||||
|
"shares": float(shares_to_buy),
|
||||||
|
"sector": "Cash Equivalent",
|
||||||
|
"rationale": f"Automatic cash sweep of excess cash (${excess_cash:.2f}) to maintain {target_cash_pct*100:.1f}% target."
|
||||||
|
}
|
||||||
|
decisions["buys"].append(sweep_buy)
|
||||||
|
pm_decision_str = json.dumps(decisions)
|
||||||
|
sweep_details = f"Swept {shares_to_buy} shares of {sweep_etf}"
|
||||||
|
logger.info("CashSweep: %s", sweep_details)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("cash_sweep_node: %s", exc)
|
||||||
|
sweep_details = f"Error: {exc}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pm_decision": pm_decision_str,
|
||||||
|
"cash_sweep": sweep_details,
|
||||||
|
"sender": "cash_sweep",
|
||||||
|
}
|
||||||
|
|
||||||
|
return cash_sweep_node
|
||||||
|
|
||||||
def _make_execute_trades_node(self):
|
def _make_execute_trades_node(self):
|
||||||
repo = self._repo
|
repo = self._repo
|
||||||
config = self._config
|
config = self._config
|
||||||
|
|
@ -266,8 +333,11 @@ class PortfolioGraphSetup:
|
||||||
workflow.add_edge("macro_summary", "make_pm_decision")
|
workflow.add_edge("macro_summary", "make_pm_decision")
|
||||||
workflow.add_edge("micro_summary", "make_pm_decision")
|
workflow.add_edge("micro_summary", "make_pm_decision")
|
||||||
|
|
||||||
|
workflow.add_node("cash_sweep", self._make_cash_sweep_node())
|
||||||
|
|
||||||
# Tail
|
# Tail
|
||||||
workflow.add_edge("make_pm_decision", "execute_trades")
|
workflow.add_edge("make_pm_decision", "cash_sweep")
|
||||||
|
workflow.add_edge("cash_sweep", "execute_trades")
|
||||||
workflow.add_edge("execute_trades", END)
|
workflow.add_edge("execute_trades", END)
|
||||||
|
|
||||||
return workflow.compile()
|
return workflow.compile()
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class PortfolioManagerState(MessagesState):
|
||||||
micro_memory_context: Annotated[str, _last_value]
|
micro_memory_context: Annotated[str, _last_value]
|
||||||
|
|
||||||
pm_decision: Annotated[str, _last_value]
|
pm_decision: Annotated[str, _last_value]
|
||||||
|
cash_sweep: Annotated[str, _last_value]
|
||||||
execution_result: Annotated[str, _last_value]
|
execution_result: Annotated[str, _last_value]
|
||||||
|
|
||||||
sender: Annotated[str, _last_value]
|
sender: Annotated[str, _last_value]
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,44 @@ class TradeExecutor:
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Auto-liquidate cash-sweep ETF (SGOV) if cash is insufficient
|
||||||
|
cost = shares * price
|
||||||
|
if portfolio.cash < cost and ticker != "SGOV":
|
||||||
|
sgov_holding = next((h for h in holdings if h.ticker == "SGOV"), None)
|
||||||
|
if sgov_holding:
|
||||||
|
shortfall = cost - portfolio.cash
|
||||||
|
sgov_price = prices.get("SGOV")
|
||||||
|
if sgov_price and sgov_price > 0:
|
||||||
|
# Add a tiny buffer (1.01) to ensure we have enough to avoid precision issues
|
||||||
|
sgov_shares_to_sell = int((shortfall * 1.01) / sgov_price) + 1
|
||||||
|
|
||||||
|
# Don't sell more than we own
|
||||||
|
sgov_shares_to_sell = min(sgov_shares_to_sell, int(sgov_holding.shares))
|
||||||
|
|
||||||
|
if sgov_shares_to_sell > 0:
|
||||||
|
logger.info(
|
||||||
|
"TradeExecutor: Auto-liquidating %d shares of SGOV to cover shortfall for %s",
|
||||||
|
sgov_shares_to_sell, ticker
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
executed, failed = self.repo.batch_remove_holdings(
|
||||||
|
portfolio_id,
|
||||||
|
[{
|
||||||
|
"ticker": "SGOV",
|
||||||
|
"shares": sgov_shares_to_sell,
|
||||||
|
"price": sgov_price,
|
||||||
|
"rationale": f"Auto-liquidated to fund {ticker} purchase"
|
||||||
|
}],
|
||||||
|
trade_date
|
||||||
|
)
|
||||||
|
executed_trades.extend(executed)
|
||||||
|
# Reload portfolio to reflect new cash balance
|
||||||
|
portfolio, holdings = self.repo.get_portfolio_with_holdings(
|
||||||
|
portfolio_id, prices
|
||||||
|
)
|
||||||
|
except PortfolioError as exc:
|
||||||
|
logger.error("TradeExecutor auto-liquidation failed: %s", exc)
|
||||||
|
|
||||||
violations = check_constraints(
|
violations = check_constraints(
|
||||||
portfolio,
|
portfolio,
|
||||||
holdings,
|
holdings,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue