202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
"""Lot- and cash-constrained portfolio rebalancer (Task 4).
|
|
|
|
Task 3's ``PortfolioLedger`` executes and accounts single orders. This module
|
|
turns a frozen recommendation (the 50/20/30 target) into *executable* orders
|
|
subject to:
|
|
|
|
* every trade is a multiple of the 100-share lot;
|
|
* sells execute first and credit cash (realized P&L + proceeds) before buys;
|
|
* buys never exceed available cash after fees;
|
|
* an unaffordable target lot is skipped and the cash retained (never an odd
|
|
lot, never negative cash);
|
|
* if the target equals the current holdings, no trade occurs at all.
|
|
|
|
It reuses the canonical ``allocate_capital`` for the target shape (the same
|
|
50/20/30 logic the live dashboard uses) so the backtest and the live board stay
|
|
consistent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Iterable, Optional
|
|
|
|
from .portfolio_ledger import PortfolioLedger
|
|
from .simulation import allocate_capital, Candidate
|
|
|
|
|
|
@dataclass
|
|
class RebalanceResult:
|
|
date: str
|
|
signal_date: str
|
|
trades: list = field(default_factory=list)
|
|
target_changed: bool = False
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
def trade_count(self) -> int:
|
|
return len(self.trades)
|
|
|
|
|
|
class RebalanceError(ValueError):
|
|
pass
|
|
|
|
|
|
def _qty_affordable(cash: float, price: float, lot: int, fee_rate: float) -> int:
|
|
"""Max 100-lot qty affordable with cash, including the 0.3% buy fee."""
|
|
if price <= 0 or cash <= 0:
|
|
return 0
|
|
# find largest q (multiple of lot) with q*price*(1+fee) <= cash
|
|
best = 0
|
|
q = lot
|
|
while True:
|
|
cost = q * price * (1 + fee_rate)
|
|
if cost > cash + 1e-6:
|
|
break
|
|
best = q
|
|
q += lot
|
|
return best
|
|
|
|
|
|
def _latest_close(series: dict, sym: str, date: dt.date) -> Optional[float]:
|
|
bars = (series.get(sym) or {}).get("bars", [])
|
|
chosen = None
|
|
for b in bars:
|
|
d = str(b.get("date") or "")[:10]
|
|
try:
|
|
bd = dt.date.fromisoformat(d)
|
|
except ValueError:
|
|
continue
|
|
if bd <= date:
|
|
chosen = b.get("adjusted_close")
|
|
return float(chosen) if chosen is not None else None
|
|
|
|
|
|
def _current_qty(ledger: PortfolioLedger, symbol: str) -> int:
|
|
pos = ledger.position(symbol)
|
|
return pos.qty if pos else 0
|
|
|
|
|
|
class PortfolioRebalancer:
|
|
"""Convert a frozen target into executable 100-lot orders via ledger."""
|
|
|
|
def __init__(
|
|
self,
|
|
ledger: PortfolioLedger,
|
|
price_series: dict[str, Any],
|
|
candidates: list[Candidate],
|
|
*,
|
|
fee_rate: float = 0.003,
|
|
lot_size: int = 100,
|
|
) -> None:
|
|
self.ledger = ledger
|
|
self.price_series = price_series
|
|
self.candidates = candidates
|
|
self.fee_rate = fee_rate
|
|
self.lot_size = lot_size
|
|
|
|
def _target(self, date: dt.date) -> dict[str, int]:
|
|
"""Compute the 50/20/30 target over current equity + price as-of date.
|
|
|
|
Equity is grossed up by cumulative fees so transaction costs do not
|
|
silently drift the target (and cause churn) across otherwise-unchanged
|
|
signals. Without this, every fee paid would shrink the bucket amounts
|
|
and force a spurious 1-lot trade on the next rebalance.
|
|
"""
|
|
prices: dict[str, float] = {}
|
|
for c in self.candidates:
|
|
px = _latest_close(self.price_series, c.symbol, date)
|
|
if px is not None:
|
|
prices[c.symbol] = px
|
|
equity = self.ledger.equity(prices) + self.ledger.state.fees
|
|
alloc = allocate_capital(equity, self.candidates)
|
|
return {o.symbol: o.qty for o in alloc.orders}
|
|
|
|
def rebalance(self, *, date: dt.date, signal_date: dt.date) -> RebalanceResult:
|
|
result = RebalanceResult(
|
|
date=date.isoformat(), signal_date=signal_date.isoformat()
|
|
)
|
|
target = self._target(date)
|
|
current = {
|
|
sym: _current_qty(self.ledger, sym)
|
|
for sym in list(self.ledger.state.positions.keys())
|
|
}
|
|
|
|
# unchanged target -> no trade (avoid churn)
|
|
if all(current.get(sym, 0) == tgt for sym, tgt in target.items()) and all(
|
|
target.get(sym, 0) == qty for sym, qty in current.items()
|
|
):
|
|
result.target_changed = False
|
|
return result
|
|
result.target_changed = True
|
|
|
|
date_str = date.isoformat()
|
|
sig_str = signal_date.isoformat()
|
|
|
|
# --- sells first: exit / trim names not in (or over) target ---
|
|
for sym in list(self.ledger.state.positions.keys()):
|
|
cur = _current_qty(self.ledger, sym)
|
|
tgt = target.get(sym, 0)
|
|
if cur > tgt:
|
|
sell_qty = cur - tgt
|
|
# reduce to a 100-lot multiple
|
|
sell_qty = (sell_qty // self.lot_size) * self.lot_size
|
|
if sell_qty <= 0:
|
|
continue
|
|
px = _latest_close(self.price_series, sym, date)
|
|
if px is None or px <= 0:
|
|
continue
|
|
trade = self.ledger.sell(
|
|
sym, sell_qty, px, date=date_str, signal_date=sig_str,
|
|
reason="exit/trim to target",
|
|
)
|
|
result.trades.append(trade)
|
|
|
|
# --- buys: deficit up to available cash after sells ---
|
|
for sym, tgt in target.items():
|
|
cur = _current_qty(self.ledger, sym)
|
|
deficit = tgt - cur
|
|
if deficit <= 0:
|
|
continue
|
|
px = _latest_close(self.price_series, sym, date)
|
|
if px is None or px <= 0:
|
|
continue
|
|
# buy in lots, limited by that symbol's target deficit AND cash
|
|
max_by_target = (deficit // self.lot_size) * self.lot_size
|
|
affordable = _qty_affordable(
|
|
self.ledger.state.cash, px, self.lot_size, self.fee_rate
|
|
)
|
|
buy_qty = min(max_by_target, affordable)
|
|
if buy_qty < self.lot_size:
|
|
continue # can't afford even one lot; keep cash
|
|
try:
|
|
trade = self.ledger.buy(
|
|
sym, buy_qty, px, date=date_str, signal_date=sig_str,
|
|
reason="enter/upsize to target",
|
|
)
|
|
except Exception as exc:
|
|
result.notes.append(f"{sym}: {exc}")
|
|
continue
|
|
result.trades.append(trade)
|
|
|
|
return result
|
|
|
|
|
|
def build_candidates(
|
|
score_by_symbol: dict[str, Any], price_series: dict, date: dt.date
|
|
) -> list[Candidate]:
|
|
"""Build Candidate list from a frozen score map + prices as-of date."""
|
|
out: list[Candidate] = []
|
|
for sym, meta in score_by_symbol.items():
|
|
px = _latest_close(price_series, sym, date)
|
|
if px is None or px <= 0:
|
|
continue
|
|
out.append(Candidate(
|
|
symbol=sym,
|
|
price=px,
|
|
combined_score=float(meta.get("combined", 0.0)),
|
|
is_dividend=bool(meta.get("is_dividend", False)),
|
|
dividend_yield=float(meta.get("dividend_yield") or 0.0),
|
|
))
|
|
return out
|