Skip to content

Commit ba0b986

Browse files
working version
1 parent 89caf37 commit ba0b986

31 files changed

+1378
-1051
lines changed

analytics/__init__.py

Whitespace-only changes.

analytics/performance.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pandas as pd
2+
from typing import Dict
3+
4+
5+
def evaluate_portfolio_performance(trade_log: pd.DataFrame, benchmark_data: pd.DataFrame) -> Dict[str, float]:
6+
"""
7+
Evaluate portfolio performance using trade logs and benchmark returns.
8+
9+
:param trade_log: DataFrame of trade history with 'date' and 'cash_remaining'
10+
:param benchmark_data: DataFrame with 'Close' prices indexed by date
11+
:return: Dictionary with performance metrics like sharpe and alpha
12+
"""
13+
if trade_log.empty or benchmark_data.empty:
14+
return {"sharpe": float('nan'), "alpha": float('nan')}
15+
16+
equity_curve = trade_log.groupby('date')['cash_remaining'].last().fillna(method='ffill')
17+
returns = equity_curve.pct_change().dropna()
18+
19+
benchmark_returns = benchmark_data['Close'].pct_change().dropna()
20+
aligned = pd.concat([returns, benchmark_returns], axis=1).dropna()
21+
aligned.columns = ['portfolio', 'benchmark']
22+
23+
excess_returns = aligned['portfolio'] - aligned['benchmark']
24+
alpha = excess_returns.mean() * 252
25+
sharpe = aligned['portfolio'].mean() / aligned['portfolio'].std() * (252 ** 0.5)
26+
27+
return {
28+
"sharpe": round(sharpe, 4),
29+
"alpha": round(alpha, 4)
30+
}

contracts/__init__.py

Whitespace-only changes.

contracts/asset.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class Asset:
2+
"""
3+
Represents a tradable asset with a ticker and quantity held.
4+
"""
5+
def __init__(self, ticker: str, shares: int = 0):
6+
self.ticker = ticker
7+
self.shares = shares
8+
self.trade_history = []
9+
10+
def buy(self, quantity: int):
11+
self.shares += quantity
12+
13+
def sell(self, quantity: int):
14+
if quantity > self.shares:
15+
raise ValueError("Cannot sell more than held quantity")
16+
self.shares -= quantity
17+
18+
def is_empty(self):
19+
return self.shares == 0
20+
21+
22+
class CashAsset(Asset):
23+
"""
24+
Represents the cash reserve in a portfolio.
25+
"""
26+
def __init__(self, initial_cash: float = 0.0):
27+
super().__init__(ticker='CASH', shares=initial_cash)
28+
29+
@property
30+
def balance(self):
31+
return self.shares
32+
33+
def deposit_cash(self, amount: float):
34+
if amount < 0:
35+
raise ValueError("Cannot add negative cash")
36+
self.shares += amount
37+
38+
def withdraw_cash(self, amount: float):
39+
if amount > self.shares:
40+
raise ValueError("Cannot withdraw more than available cash")
41+
self.shares -= amount

contracts/portfolio.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from typing import Dict, List
2+
3+
import pandas as pd
4+
5+
from analytics.performance import evaluate_portfolio_performance
6+
from contracts.asset import Asset, CashAsset
7+
from strategies.stock.base import StrategyBase
8+
9+
10+
class Portfolio:
11+
def __init__(self,
12+
name: str,
13+
tickers: List[str],
14+
starting_cash: float,
15+
strategy: StrategyBase,
16+
benchmark: str = "^SPY",
17+
rebalance_freq: str = "monthly",
18+
metadata: Dict = None):
19+
"""
20+
Initialize a Portfolio object with strategy, tickers, and starting cash.
21+
22+
:param name: Name of the portfolio
23+
:param tickers: List of tickers in the portfolio
24+
:param starting_cash: Initial cash in the portfolio
25+
:param strategy: Strategy object associated with this portfolio
26+
:param benchmark: Benchmark ticker used to compare portfolio performance (e.g., ^SPY)
27+
:param rebalance_freq: Frequency of rebalancing (e.g., monthly, quarterly)
28+
:param metadata: Additional metadata or user-defined attributes
29+
"""
30+
self.name = name
31+
self.tickers = tickers
32+
self.strategy = strategy
33+
self.benchmark = benchmark
34+
self.rebalance_freq = rebalance_freq
35+
self.metadata = metadata or {}
36+
37+
self.positions: Dict[str, Asset | CashAsset] = {
38+
ticker: Asset(ticker)
39+
for ticker in tickers
40+
}
41+
self.positions['CASH'] = CashAsset(starting_cash)
42+
self.trade_log = []
43+
self.position_history: Dict[str, List[int]] = {}
44+
45+
def execute_trade(self, date, ticker, action, shares, price, note='Strategy Signal'):
46+
"""
47+
Executes a trade and adjusts portfolio cash and position accordingly.
48+
49+
:param date: Trade date
50+
:param ticker: Ticker symbol
51+
:param action: 'BUY' or 'SELL'
52+
:param shares: Number of shares
53+
:param price: Trade price per share
54+
:param note: Optional note (e.g., 'Strategy Signal')
55+
"""
56+
trade_value = shares * price
57+
cash_asset = self.positions['CASH']
58+
59+
if action == 'BUY':
60+
if cash_asset.balance < trade_value:
61+
raise ValueError(f"Insufficient cash to buy {shares} shares of {ticker}")
62+
cash_asset.withdraw_cash(trade_value)
63+
self.update_position(ticker, shares)
64+
65+
elif action == 'SELL':
66+
held = self.get_position(ticker)
67+
if held < shares:
68+
raise ValueError(f"Trying to sell more shares than held for {ticker}")
69+
self.update_position(ticker, -shares)
70+
cash_asset.deposit_cash(trade_value)
71+
72+
else:
73+
raise ValueError("Action must be either 'BUY' or 'SELL'")
74+
75+
self.add_trade(date, ticker, action, shares, price, cash_asset.balance, note)
76+
77+
def get_cash(self) -> float:
78+
return self.positions['CASH'].balance
79+
80+
def add_trade(self, date, ticker, action, shares, price, cash_remaining, note=''):
81+
entry = {
82+
'date': date,
83+
'ticker': ticker,
84+
'action': action,
85+
'shares': shares,
86+
'price': price,
87+
'cash_remaining': cash_remaining,
88+
'note': note
89+
}
90+
if action == 'BUY':
91+
entry['cost'] = shares * price
92+
elif action == 'SELL':
93+
entry['revenue'] = shares * price
94+
self.trade_log.append(entry)
95+
96+
def update_position(self, ticker, shares_delta):
97+
if ticker not in self.positions:
98+
self.positions[ticker] = Asset(ticker)
99+
if shares_delta > 0:
100+
self.positions[ticker].buy(shares_delta)
101+
elif shares_delta < 0:
102+
self.positions[ticker].sell(abs(shares_delta))
103+
if self.positions[ticker].is_empty():
104+
del self.positions[ticker]
105+
106+
def get_trade_log(self) -> pd.DataFrame:
107+
return pd.DataFrame(self.trade_log)
108+
109+
def get_position(self, ticker) -> int:
110+
if ticker not in self.positions:
111+
return 0
112+
return self.positions[ticker].shares
113+
114+
def evaluate_performance(self, benchmark_data: pd.DataFrame) -> Dict[str, float]:
115+
"""
116+
Evaluate portfolio performance using trade logs and benchmark returns.
117+
118+
:param benchmark_data: DataFrame with 'Close' prices indexed by date
119+
:return: Dictionary with performance metrics like Sharpe ratio and Alpha
120+
"""
121+
return evaluate_portfolio_performance(self.get_trade_log(), benchmark_data)

core/backtester.py

Lines changed: 31 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,56 @@
1-
from core.executor import PortfolioExecutor
2-
from typing import Dict, List
1+
from typing import List
32
import pandas as pd
43

4+
from core.executor import PortfolioExecutor
55
from core.guardrails.base import Guardrail
6+
from core.market_data import MarketData
7+
from contracts.portfolio import Portfolio
68

79

810
class Backtester:
9-
def __init__(self, strategy, data_loader, starting_cash=100000.0, guardrails: List[Guardrail] = None):
11+
def __init__(self, strategy, market_data: MarketData, starting_cash=100000.0, guardrails: List[Guardrail] = None):
1012
self.strategy = strategy
11-
self.data_loader = data_loader
12-
self.executor = PortfolioExecutor(starting_cash=starting_cash, guardrails=guardrails)
13-
self.signals = {}
14-
self.market_data = {}
15-
self.symbols = []
13+
self.market_data = market_data
14+
15+
# Initialize portfolio and executor separately
16+
self.portfolio = Portfolio(
17+
name="BacktestPortfolio",
18+
tickers=market_data.get_available_symbols(),
19+
starting_cash=starting_cash,
20+
strategy=strategy,
21+
metadata={"source": "Backtester"}
22+
)
23+
self.executor = PortfolioExecutor(portfolio=self.portfolio, market_data=market_data, guardrails=guardrails)
24+
25+
self.tickers = []
1626
self.start_date = None
1727
self.end_date = None
28+
self.signals = {}
1829

19-
def run(self, symbols: List[str], start_date: str, end_date: str):
20-
self.symbols = symbols
30+
def run(self, tickers: List[str], start_date: str, end_date: str):
31+
self.tickers = tickers
2132
self.start_date = start_date
2233
self.end_date = end_date
2334

24-
# Load data once
25-
self.market_data = {
26-
symbol: self.data_loader(symbol, start_date, end_date)
27-
for symbol in symbols
28-
}
29-
30-
# Align index
31-
common_index = self.market_data[symbols[0]].index
32-
for sym in self.market_data:
33-
self.market_data[sym] = self.market_data[sym].reindex(common_index).ffill()
34-
35-
# Precompute signals
36-
self.signals = self.strategy.generate_signals(self.market_data)
35+
# Determine common index (trading calendar)
36+
price_frames = [self.market_data.get_series(tic) for tic in tickers]
37+
common_index = price_frames[0].index
38+
for df in price_frames[1:]:
39+
common_index = common_index.intersection(df.index)
40+
common_index = common_index.sort_values()
3741

3842
for current_date in common_index:
39-
# Extract closing prices for current date
40-
prices = {
41-
symbol: df.loc[current_date, 'Close']
42-
for symbol, df in self.market_data.items()
43-
if current_date in df.index
44-
}
45-
46-
# Allocate shares
47-
allocations = self.strategy.generate_allocations(
48-
signals={s: df.loc[[current_date]] for s, df in self.signals.items()},
49-
portfolio_cash=self.executor.cash,
50-
market_data={s: df.loc[[current_date]] for s, df in self.market_data.items()}
51-
)
52-
53-
self.executor.execute_trades(current_date, allocations, prices)
54-
self.executor.update_equity(current_date, prices)
43+
self.executor.execute_trades(current_date)
44+
self.executor.update_equity(current_date)
5545

5646
def get_trade_log(self) -> pd.DataFrame:
57-
return self.executor.get_trade_log()
47+
return self.portfolio.get_trade_log()
5848

5949
def get_equity_curve(self) -> pd.DataFrame:
6050
return self.executor.get_equity_curve()
6151

6252
def get_final_net_worth(self) -> float:
6353
return self.get_equity_curve()['net_worth'].iloc[-1]
6454

65-
def get_market_data(self) -> Dict[str, pd.DataFrame]:
55+
def get_market_data(self) -> MarketData:
6656
return self.market_data

core/data_loader.py

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,49 @@
11
import os
22
import hashlib
33
import pandas as pd
4+
from typing import Dict, List
45

56

6-
def _make_cache_key(symbol: str, start_date: str, end_date: str, source: str) -> str:
7-
key = f"{symbol}_{start_date}_{end_date}_{source}"
7+
def _make_cache_key(ticker: str, start_date: str, end_date: str, source: str) -> str:
8+
key = f"{ticker}_{start_date}_{end_date}_{source}"
89
return hashlib.md5(key.encode()).hexdigest()
910

1011

11-
def load_price_data(symbol: str, start_date: str, end_date: str,
12+
def _fetch_data(ticker: str, start_date: str, end_date: str, source: str) -> pd.DataFrame:
13+
if source == "yahoo":
14+
from data_ingestion.yahoo_fetcher import fetch_yahoo_data
15+
return fetch_yahoo_data(ticker, start_date, end_date)
16+
elif source == "polygon":
17+
from data_ingestion.polygon_fetcher import fetch_polygon_data
18+
return fetch_polygon_data(ticker, start_date, end_date)
19+
else:
20+
raise ValueError(f"Unsupported data source: {source}")
21+
22+
23+
def load_price_data(ticker: str, start_date: str, end_date: str,
1224
use_cache: bool = True,
1325
force_refresh: bool = False,
1426
source: str = "yahoo") -> pd.DataFrame:
1527
"""
16-
Load historical OHLCV data for a given symbol, with cache and source switching.
28+
Load historical OHLCV data for a single ticker.
1729
"""
1830
os.makedirs("./data_cache", exist_ok=True)
19-
cache_key = _make_cache_key(symbol, start_date, end_date, source)
31+
cache_key = _make_cache_key(ticker, start_date, end_date, source)
2032
cache_path = os.path.join("./data_cache", f"{cache_key}.parquet")
2133

22-
# Load from cache
2334
if use_cache and os.path.exists(cache_path) and not force_refresh:
2435
try:
2536
return pd.read_parquet(cache_path)
2637
except Exception:
2738
print(f"⚠️ Cache corrupted at {cache_path}, refetching...")
2839

29-
# Fetch data from appropriate source
30-
if source == "yahoo":
31-
from data_ingestion.yahoo_fetcher import fetch_yahoo_data
32-
df = fetch_yahoo_data(symbol, start_date, end_date)
33-
elif source == "polygon":
34-
from data_ingestion.polygon_fetcher import fetch_polygon_data
35-
df = fetch_polygon_data(symbol, start_date, end_date)
36-
else:
37-
raise ValueError(f"Unsupported data source: {source}")
40+
df = _fetch_data(ticker, start_date, end_date, source)
3841

3942
if df.empty or "Close" not in df.columns:
40-
raise ValueError(f"No data returned for {symbol} from {start_date} to {end_date}")
43+
raise ValueError(f"No data returned for {ticker} from {start_date} to {end_date}")
4144

4245
df = df[['Open', 'High', 'Low', 'Close', 'Volume']].dropna()
46+
df.index = pd.to_datetime(df.index)
4347
df.to_parquet(cache_path)
4448

4549
return df
@@ -51,13 +55,20 @@ def __init__(self, use_cache=True, force_refresh=False, source="yahoo"):
5155
self.force_refresh = force_refresh
5256
self.source = source
5357

54-
def get_data(self, symbol: str, start_date: str, end_date: str):
55-
return load_price_data(
56-
symbol=symbol,
57-
start_date=start_date,
58-
end_date=end_date,
59-
use_cache=self.use_cache,
60-
force_refresh=self.force_refresh,
61-
source=self.source
62-
)
58+
def get_data(self, tickers: List[str], start_date: str, end_date: str) -> Dict[str, pd.DataFrame]:
59+
"""
60+
Return a dictionary of {ticker: DataFrame} for all requested tickers.
61+
"""
62+
result = {}
63+
for ticker in tickers:
64+
df = load_price_data(
65+
ticker=ticker,
66+
start_date=start_date,
67+
end_date=end_date,
68+
use_cache=self.use_cache,
69+
force_refresh=self.force_refresh,
70+
source=self.source
71+
)
72+
result[ticker] = df
73+
return result
6374

0 commit comments

Comments
 (0)