Spaces:
Sleeping
Sleeping
| 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) | |