zephyrdark c1ee879cb4 feat: add backtest services (portfolio, engine, worker)
- VirtualPortfolio for portfolio simulation
- BacktestEngine for strategy backtesting
- Worker for async background execution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:34:48 +09:00

238 lines
7.2 KiB
Python

"""
Virtual portfolio simulation for backtesting.
"""
from decimal import Decimal
from typing import Dict, List, Optional
from dataclasses import dataclass, field
@dataclass
class Transaction:
"""A single buy/sell transaction."""
ticker: str
action: str # 'buy' or 'sell'
shares: int
price: Decimal
commission: Decimal
@dataclass
class HoldingInfo:
"""Information about a single holding."""
ticker: str
name: str
shares: int
price: Decimal
weight: Decimal
class VirtualPortfolio:
"""
Simulates a portfolio for backtesting.
Handles cash management, buying/selling with transaction costs.
"""
def __init__(self, initial_capital: Decimal):
self.cash: Decimal = initial_capital
self.holdings: Dict[str, int] = {} # ticker -> shares
self.initial_capital = initial_capital
def get_value(self, prices: Dict[str, Decimal]) -> Decimal:
"""Calculate total portfolio value."""
holdings_value = sum(
Decimal(str(shares)) * prices.get(ticker, Decimal("0"))
for ticker, shares in self.holdings.items()
)
return self.cash + holdings_value
def get_holdings_with_weights(
self,
prices: Dict[str, Decimal],
names: Dict[str, str],
) -> List[HoldingInfo]:
"""Get current holdings with weights."""
total_value = self.get_value(prices)
if total_value == 0:
return []
result = []
for ticker, shares in self.holdings.items():
if shares <= 0:
continue
price = prices.get(ticker, Decimal("0"))
value = Decimal(str(shares)) * price
weight = value / total_value * 100
result.append(HoldingInfo(
ticker=ticker,
name=names.get(ticker, ticker),
shares=shares,
price=price,
weight=weight,
))
# Sort by weight descending
result.sort(key=lambda x: x.weight, reverse=True)
return result
def rebalance(
self,
target_tickers: List[str],
prices: Dict[str, Decimal],
names: Dict[str, str],
commission_rate: Decimal,
slippage_rate: Decimal,
) -> List[Transaction]:
"""
Rebalance portfolio to equal-weight target tickers.
Returns list of transactions executed.
"""
transactions: List[Transaction] = []
target_set = set(target_tickers)
# Step 1: Sell holdings not in target
for ticker in list(self.holdings.keys()):
if ticker not in target_set and self.holdings[ticker] > 0:
txn = self._sell_all(ticker, prices, commission_rate, slippage_rate)
if txn:
transactions.append(txn)
# Step 2: Calculate target value per stock (equal weight)
total_value = self.get_value(prices)
if len(target_tickers) == 0:
return transactions
target_value_per_stock = total_value / Decimal(str(len(target_tickers)))
# Step 3: Sell excess from current holdings in target
for ticker in target_tickers:
if ticker in self.holdings:
current_shares = self.holdings[ticker]
price = prices.get(ticker, Decimal("0"))
if price <= 0:
continue
current_value = Decimal(str(current_shares)) * price
if current_value > target_value_per_stock * Decimal("1.05"):
# Sell excess
excess_value = current_value - target_value_per_stock
sell_price = price * (1 - slippage_rate)
shares_to_sell = int(excess_value / sell_price)
if shares_to_sell > 0:
txn = self._sell(ticker, shares_to_sell, sell_price, commission_rate)
if txn:
transactions.append(txn)
# Step 4: Buy to reach target weight
for ticker in target_tickers:
price = prices.get(ticker, Decimal("0"))
if price <= 0:
continue
current_shares = self.holdings.get(ticker, 0)
current_value = Decimal(str(current_shares)) * price
if current_value < target_value_per_stock * Decimal("0.95"):
# Buy more
buy_value = target_value_per_stock - current_value
buy_price = price * (1 + slippage_rate)
# Account for commission in available cash
max_buy_value = self.cash / (1 + commission_rate)
actual_buy_value = min(buy_value, max_buy_value)
shares_to_buy = int(actual_buy_value / buy_price)
if shares_to_buy > 0:
txn = self._buy(ticker, shares_to_buy, buy_price, commission_rate)
if txn:
transactions.append(txn)
return transactions
def _buy(
self,
ticker: str,
shares: int,
price: Decimal,
commission_rate: Decimal,
) -> Optional[Transaction]:
"""Execute a buy order."""
cost = Decimal(str(shares)) * price
commission = cost * commission_rate
total_cost = cost + commission
if total_cost > self.cash:
# Reduce shares to fit budget
available = self.cash / (1 + commission_rate)
shares = int(available / price)
if shares <= 0:
return None
cost = Decimal(str(shares)) * price
commission = cost * commission_rate
total_cost = cost + commission
self.cash -= total_cost
self.holdings[ticker] = self.holdings.get(ticker, 0) + shares
return Transaction(
ticker=ticker,
action='buy',
shares=shares,
price=price,
commission=commission,
)
def _sell(
self,
ticker: str,
shares: int,
price: Decimal,
commission_rate: Decimal,
) -> Optional[Transaction]:
"""Execute a sell order."""
current_shares = self.holdings.get(ticker, 0)
shares = min(shares, current_shares)
if shares <= 0:
return None
proceeds = Decimal(str(shares)) * price
commission = proceeds * commission_rate
net_proceeds = proceeds - commission
self.cash += net_proceeds
self.holdings[ticker] -= shares
if self.holdings[ticker] <= 0:
del self.holdings[ticker]
return Transaction(
ticker=ticker,
action='sell',
shares=shares,
price=price,
commission=commission,
)
def _sell_all(
self,
ticker: str,
prices: Dict[str, Decimal],
commission_rate: Decimal,
slippage_rate: Decimal,
) -> Optional[Transaction]:
"""Sell all shares of a ticker."""
shares = self.holdings.get(ticker, 0)
if shares <= 0:
return None
price = prices.get(ticker, Decimal("0"))
if price <= 0:
return None
sell_price = price * (1 - slippage_rate)
return self._sell(ticker, shares, sell_price, commission_rate)