portfolio-engine / tests /test_analytics.py
engineportf's picture
Initial Deployment from Local Engine
208fbf8 verified
Raw
History Blame Contribute Delete
9.48 kB
import sys
import os
import copy
# Bulletproof pathing: Force Python to look in both the current folder AND the parent folder
_this_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _this_dir)
sys.path.insert(0, os.path.dirname(_this_dir))
import pandas as pd
import numpy as np
# Import the functions we want to test from your engine
from utils.metrics import (
portfolio_gross_metrics,
israelsen_sharpe,
liquidity_score,
)
from backtest import expanding_window_backtest
from core_types import LotManager
from config import DEFAULT_CONFIG
# ─────────────────────────────────────────────
# 1. GROSS EXPOSURE TESTS
# ─────────────────────────────────────────────
def test_portfolio_gross_metrics():
"""Tests if the engine correctly calculates long, short, and gross leverage."""
fake_weights = pd.Series({'AAPL': 0.60, 'TSLA': -0.20, 'CASH': 0.10})
pgm = portfolio_gross_metrics(fake_weights)
gross_lev, long_e, short_e = pgm["gross_lev"], pgm["long_e"], pgm["short_e"]
# portfolio_gross_metrics intentionally drops CASH before computing leverage:
# - gross leverage = |risky longs| + |risky shorts| = |0.60| + |-0.20| = 0.80
# - long exposure = risky long only = 0.60
# - short exposure = risky short only = -0.20
# (CASH does not contribute to gross leverage or directional exposure)
assert np.isclose(gross_lev, 0.80)
assert np.isclose(long_e, 0.60)
assert np.isclose(short_e, -0.20)
# ─────────────────────────────────────────────
# 2. SHARPE RATIO TESTS
# ─────────────────────────────────────────────
def test_israelsen_sharpe_positive():
"""Tests the modified Sharpe ratio with positive returns."""
score = israelsen_sharpe(0.10, 0.20)
assert np.isclose(score, 0.5)
def test_israelsen_sharpe_negative():
"""
Tests Israelsen's specific fix: multiplying by volatility when returns are negative.
Classic Sharpe divides, which wrongly makes highly volatile losers look better.
"""
score = israelsen_sharpe(-0.10, 0.20)
# -0.10 / (0.20 ^ -1) = -0.10 * 0.20 = -0.02
assert np.isclose(score, -0.02)
def test_israelsen_sharpe_zero_vol():
"""Ensures the math doesn't crash (Divide by Zero) on cash-equivalent assets."""
score = israelsen_sharpe(0.05, 0.0)
assert score == 0.0
# ─────────────────────────────────────────────
# 3. LIQUIDITY TESTS
# ─────────────────────────────────────────────
def test_liquidity_score():
"""
Tests if the liquidity score correctly calculates the
weighted-average half-spread (normalized by gross weight).
"""
fake_weights = pd.Series({'AAPL': 0.60, 'TSLA': -0.20})
spread_map = {'AAPL': 0.0002, 'TSLA': 0.0005}
score = liquidity_score(fake_weights, spread_map)
# Gross weight = 0.80
# AAPL contribution = 0.60 * 0.0002 = 0.00012
# TSLA contribution = |-0.20| * 0.0005 = 0.00010
# Total = 0.00022. Normalized by gross (0.80) = 0.000275
assert np.isclose(score, 0.000275)
# ─────────────────────────────────────────────
# 4. TAX LIQUIDATION & BACKTEST TESTS
# ─────────────────────────────────────────────
def test_lot_manager_tax_liquidation():
"""
Tests the LotManager's HIFO tax liquidation logic, explicitly verifying
that it correctly splits Long-Term and Short-Term capital gains based on lot age.
"""
lm = LotManager()
# Current date for our simulated sale
sell_date = pd.Timestamp("2024-01-01")
# Lot 1: Bought 2 years ago (Long-Term). Price: $100. Shares: 10
lm.add_lot("AAPL", "2022-01-01", 100.0, 10.0)
# Lot 2: Bought 6 months ago (Short-Term). Price: $150. Shares: 10
lm.add_lot("AAPL", "2023-07-01", 150.0, 10.0)
# Lot 3: Bought 2 months ago (Short-Term). Price: $180. Shares: 10
lm.add_lot("AAPL", "2023-11-01", 180.0, 10.0)
# Current Price is $200. We want to sell 15 shares.
# Under HIFO (Highest In, First Out), we should sell:
# 1. 10 shares of Lot 3 (Cost $180) -> Short-Term
# 2. 5 shares of Lot 2 (Cost $150) -> Short-Term
total_gain, lt_gain, st_gain = lm.sell_shares_with_tax(
"AAPL", shares_to_sell=15.0, current_price=200.0,
current_date=sell_date, lt_days=366, method='hifo'
)
# Calculations:
# Lot 3 Gain: 10 shares * ($200 - $180) = $200 (Short-Term)
# Lot 2 Gain: 5 shares * ($200 - $150) = $250 (Short-Term)
# Total Gain = $450. Long-Term Gain = $0.
assert np.isclose(total_gain, 450.0)
assert np.isclose(st_gain, 450.0)
assert np.isclose(lt_gain, 0.0)
# Now sell remaining 15 shares.
# Remaining: 5 shares of Lot 2 (ST), 10 shares of Lot 1 (LT)
# HIFO will sell Lot 2 first ($150 cost), then Lot 1 ($100 cost).
total_gain_2, lt_gain_2, st_gain_2 = lm.sell_shares_with_tax(
"AAPL", shares_to_sell=15.0, current_price=200.0,
current_date=sell_date, lt_days=366, method='hifo'
)
# Lot 2 Gain: 5 shares * ($200 - $150) = $250 (Short-Term)
# Lot 1 Gain: 10 shares * ($200 - $100) = $1000 (Long-Term)
assert np.isclose(total_gain_2, 1250.0)
assert np.isclose(st_gain_2, 250.0)
assert np.isclose(lt_gain_2, 1000.0)
def test_expanding_window_tax_liquidation():
"""
Tests if the expanding window backtest correctly utilizes the LotManager
to liquidate lots and apply tax drag when rebalancing highly appreciated assets.
"""
rng = np.random.default_rng(42)
dates = pd.date_range("2020-01-01", periods=500, freq="B")
# Create a synthetic massive bull market (assets go up constantly)
# This guarantees massive embedded gains across rebalance boundaries.
rets_a = rng.normal(0.002, 0.01, size=500)
rets_b = rng.normal(0.001, 0.01, size=500)
returns_df = pd.DataFrame({'A': rets_a, 'B': rets_b}, index=dates)
spy_rets = pd.Series(rng.normal(0.001, 0.01, size=500), index=dates)
spread_map = {'A': 0.0, 'B': 0.0}
# Note: Use deepcopy to prevent nested dictionaries (sector_map, benchmarks)
# from permanently mutating the global DEFAULT_CONFIG during the test run.
base_cfg = copy.deepcopy(DEFAULT_CONFIG)
base_cfg.update({
"risk_free_rate": 0.0,
"transaction_cost": 0.0, # Zero friction to perfectly isolate the tax effect
"single_asset_min": 0.0,
"single_asset_max": 1.0,
"sector_limit": 1.0,
"sector_map": {"A": "Tech", "B": "Tech"}, # Note: Valid sector mapping string instead of integers
"cvar_enabled": False,
"garch_enabled": False,
"max_turnover": 2.0
})
# Run 1: Tax Disabled
cfg_no_tax = copy.deepcopy(base_cfg)
cfg_no_tax['tax_enabled'] = False
eq_no_tax, _ = expanding_window_backtest(
returns_df, spy_rets, capital=100000.0, rfr=0.0, cfg=cfg_no_tax,
model=1, allocation_engine=1, spread_map=spread_map,
initial_train_days=100, rebalance_freq=50
)
# Run 2: Tax Enabled (Aggressive ST rates)
cfg_tax = copy.deepcopy(base_cfg)
cfg_tax['tax_enabled'] = True
cfg_tax['tax_rate_lt'] = 0.20
cfg_tax['tax_rate_st'] = 0.40
cfg_tax['lt_days'] = 366
eq_tax, _ = expanding_window_backtest(
returns_df, spy_rets, capital=100000.0, rfr=0.0, cfg=cfg_tax,
model=1, allocation_engine=1, spread_map=spread_map,
initial_train_days=100, rebalance_freq=50
)
# Since rebalancing in a bull market forces selling winners to maintain risk targets,
# the tax-enabled portfolio must mathematically suffer tax drag and end up lower.
if eq_tax is not None and eq_no_tax is not None:
assert eq_tax.iloc[-1] < eq_no_tax.iloc[-1]
def test_lot_manager_thread_safety():
"""Verifies that LotManager safely handles concurrent add and sell operations."""
import threading
lm = LotManager()
def worker(worker_id):
for i in range(100):
lm.add_lot("AAPL", "2024-01-01", 100.0, 1.0)
if i % 2 == 0:
lm.sell_shares_with_tax("AAPL", 0.5, 150.0, method="fifo")
threads = []
for i in range(10):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join()
# Total shares added: 10 * 100 * 1.0 = 1000
# Total shares sold: 10 * 50 * 0.5 = 250
# Remaining: 750
remaining_shares = sum(lot.shares for lot in lm.lots["AAPL"])
assert np.isclose(remaining_shares, 750.0)