Quantitative Investment Analytics ETL Pipeline¶
Four Canadian equity portfolios, auditable SQL analytics, and purged volatility prediction. This notebook reads the last completed pipeline run. Charts are embedded as PNG outputs for notebook viewers. See the source modules for the executable implementation.
from pathlib import Path
import json, sqlite3
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display
%matplotlib inline
ROOT = Path.cwd()
if not (ROOT / 'output').exists():
ROOT = ROOT.parent
manifest = json.loads((ROOT / 'output/run_manifest.json').read_text())
print(manifest['source']['source'])
print(manifest['config'])
conn = sqlite3.connect(ROOT / 'output/investment_analytics.db')
summary = pd.read_sql('SELECT * FROM portfolio_summary', conn)
display(summary)
Yahoo Finance via yfinance
{'start': '2018-01-01', 'end': '2026-09-01', 'benchmark': 'XIC.TO', 'initial_capital': 100000, 'risk_free_rate': 0.03, 'transaction_cost_bps': 10, 'warmup_days': 252, 'forecast_days': 20, 'test_fraction': 0.2, 'simulation_years': 5, 'simulation_paths': 5000, 'bootstrap_block_days': 20, 'trading_days': 252, 'seed': 42}
| cumulative_return | annualized_return | volatility | sharpe | max_drawdown | portfolio_id | total_cost | turnover | excess_annualized_return | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 2.798174 | 0.191215 | 0.162911 | 0.974572 | -0.287301 | Balanced | 776.605214 | 3.625783 | 0.028992 |
| 1 | 2.147465 | 0.162223 | 0.163749 | 0.820370 | -0.372117 | Benchmark | 0.000000 | 0.000000 | 0.000000 |
| 2 | 5.492029 | 0.277950 | 0.286007 | 0.897374 | -0.487163 | Growth | 1856.877502 | 5.196306 | 0.115728 |
| 3 | 2.129369 | 0.161344 | 0.160645 | 0.827860 | -0.330561 | Income | 395.514165 | 2.350359 | -0.000878 |
| 4 | 2.435912 | 0.175662 | 0.152889 | 0.942033 | -0.297091 | Low volatility | 573.676954 | 2.992934 | 0.013439 |
Portfolio performance and drawdown¶
Previous-close weights earn daily returns. Month-start rebalancing deducts costs. These are selected equity allocations, not an optimized investment recommendation.
daily = pd.read_sql('SELECT * FROM portfolio_daily_summary ORDER BY date', conn, parse_dates=['date'])
fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
for name, group in daily.groupby('portfolio_id'):
axes[0].plot(group.date, group.nav, label=name)
axes[1].plot(group.date, group.drawdown * 100, label=name)
axes[0].set(title='Portfolio value after transaction costs', ylabel='CAD')
axes[1].set(title='Drawdown', ylabel='% below peak')
axes[0].legend(ncol=3)
for ax in axes: ax.grid(alpha=.2)
plt.tight_layout()
plt.show()
Equal-capital five-year strategy comparison¶
All portfolios and XIC begin with CAD 100,000 on the same date and share sampled 20-session blocks. The main chart, uncertainty panels and SQL summary use equal capital. The final, secondary chart continues historical wealth using each portfolio's prior ending balance. Scenario medians depend on the historical sample; strong past growth is carried forward, not independently forecast. Ranges omit parameter uncertainty and unseen regimes. The horizon is exactly five calendar years with 252 modeled steps per year, not exchange calendar dates.
from IPython.display import Image
for filename in ['forward_indexed.png', 'scenario_downside.png', 'scenario_drawdowns.png', 'forward_ranges.png']:
display(Image(filename=str(ROOT / 'output' / filename)))
for table in ['forward_projection_summary', 'scenario_downside', 'scenario_paired', 'scenario_sensitivity']:
display(pd.read_sql(f'SELECT * FROM {table}', conn))
display(Image(filename=str(ROOT / 'output' / 'forward_scenarios.png')))
| portfolio_id | as_of_date | start_value | terminal_p05 | terminal_median | terminal_p95 | probability_terminal_loss | median_max_drawdown | scenario_paths | horizon_years | bootstrap_block_days | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Balanced | 2026-08-31 00:00:00 | 100000 | 131664.553083 | 235937.755808 | 394953.451525 | 0.0072 | -0.202865 | 5000 | 5 | 20 |
| 1 | Benchmark | 2026-08-31 00:00:00 | 100000 | 106181.845788 | 213678.413038 | 360350.434855 | 0.0392 | -0.242438 | 5000 | 5 | 20 |
| 2 | Growth | 2026-08-31 00:00:00 | 100000 | 114707.843378 | 329002.427170 | 872361.536794 | 0.0308 | -0.335489 | 5000 | 5 | 20 |
| 3 | Income | 2026-08-31 00:00:00 | 100000 | 119698.008807 | 211348.942785 | 341758.067547 | 0.0156 | -0.221340 | 5000 | 5 | 20 |
| 4 | Low volatility | 2026-08-31 00:00:00 | 100000 | 129046.399493 | 222659.609900 | 355249.560558 | 0.0084 | -0.194353 | 5000 | 5 | 20 |
| portfolio_id | start_value | terminal_p01 | terminal_p05 | worst5_mean | probability_terminal_loss | terminal_loss_count | paths | median_max_drawdown | severe_max_drawdown_p05 | probability_ever_below_80pct | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Balanced | 100000 | 102851.338325 | 131664.553083 | 114216.685355 | 0.0072 | 36 | 5000 | -0.202865 | -0.365675 | 0.0880 |
| 1 | Benchmark | 100000 | 77752.653230 | 106181.845788 | 88260.764821 | 0.0392 | 196 | 5000 | -0.242438 | -0.464498 | 0.1612 |
| 2 | Growth | 100000 | 74185.151236 | 114707.843378 | 90946.659836 | 0.0308 | 154 | 5000 | -0.335489 | -0.539968 | 0.2424 |
| 3 | Income | 100000 | 92131.143848 | 119698.008807 | 103300.682987 | 0.0156 | 78 | 5000 | -0.221340 | -0.399387 | 0.1216 |
| 4 | Low volatility | 100000 | 103036.835022 | 129046.399493 | 112975.309453 | 0.0084 | 42 | 5000 | -0.194353 | -0.361439 | 0.0922 |
| experiment | left_portfolio | right_portfolio | probability_underperformance | difference_p05 | difference_median | difference_p95 | |
|---|---|---|---|---|---|---|---|
| 0 | Published model | Growth | Balanced | 0.1932 | -50892.988104 | 91045.424557 | 504010.588330 |
| 1 | 126-session moving blocks | Growth | Balanced | 0.3036 | -75397.029446 | 61840.229658 | 425376.198234 |
| 2 | 252-session moving blocks | Growth | Balanced | 0.2998 | -78635.832431 | 66626.113830 | 417996.993913 |
| 3 | 126-session circular blocks | Growth | Balanced | 0.2254 | -68713.743504 | 113837.574874 | 659185.457107 |
| 4 | 252-session circular blocks | Growth | Balanced | 0.2330 | -75701.618038 | 109043.281899 | 625772.788790 |
| experiment | portfolio_id | block_days | circular | paths | seed | terminal_p05 | terminal_median | probability_terminal_loss | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Published model | Balanced | 20 | 0 | 5000 | 42 | 131664.553083 | 235937.755808 | 0.0072 |
| 1 | Published model | Benchmark | 20 | 0 | 5000 | 42 | 106181.845788 | 213678.413038 | 0.0392 |
| 2 | Published model | Growth | 20 | 0 | 5000 | 42 | 114707.843378 | 329002.427170 | 0.0308 |
| 3 | Published model | Income | 20 | 0 | 5000 | 42 | 119698.008807 | 211348.942785 | 0.0156 |
| 4 | Published model | Low volatility | 20 | 0 | 5000 | 42 | 129046.399493 | 222659.609900 | 0.0084 |
| 5 | 126-session moving blocks | Balanced | 126 | 0 | 5000 | 42 | 140103.983849 | 217691.414266 | 0.0030 |
| 6 | 126-session moving blocks | Benchmark | 126 | 0 | 5000 | 42 | 120790.919976 | 198758.348233 | 0.0152 |
| 7 | 126-session moving blocks | Growth | 126 | 0 | 5000 | 42 | 90881.567022 | 283667.478885 | 0.0622 |
| 8 | 126-session moving blocks | Income | 126 | 0 | 5000 | 42 | 123989.457408 | 202547.629599 | 0.0112 |
| 9 | 126-session moving blocks | Low volatility | 126 | 0 | 5000 | 42 | 136793.259810 | 207476.585442 | 0.0036 |
| 10 | 252-session moving blocks | Balanced | 252 | 0 | 5000 | 42 | 139162.941366 | 211997.119396 | 0.0018 |
| 11 | 252-session moving blocks | Benchmark | 252 | 0 | 5000 | 42 | 119393.145134 | 194349.319004 | 0.0122 |
| 12 | 252-session moving blocks | Growth | 252 | 0 | 5000 | 42 | 91528.034093 | 283125.770378 | 0.0644 |
| 13 | 252-session moving blocks | Income | 252 | 0 | 5000 | 42 | 115143.274086 | 193218.299098 | 0.0172 |
| 14 | 252-session moving blocks | Low volatility | 252 | 0 | 5000 | 42 | 132587.505888 | 198950.733553 | 0.0020 |
| 15 | 126-session circular blocks | Balanced | 126 | 1 | 5000 | 42 | 152113.516845 | 244540.412218 | 0.0018 |
| 16 | 126-session circular blocks | Benchmark | 126 | 1 | 5000 | 42 | 130674.095889 | 214140.974572 | 0.0084 |
| 17 | 126-session circular blocks | Growth | 126 | 1 | 5000 | 42 | 111726.651722 | 360857.040713 | 0.0360 |
| 18 | 126-session circular blocks | Income | 126 | 1 | 5000 | 42 | 130684.787705 | 212058.211441 | 0.0078 |
| 19 | 126-session circular blocks | Low volatility | 126 | 1 | 5000 | 42 | 147647.932906 | 227208.541744 | 0.0018 |
| 20 | 252-session circular blocks | Balanced | 252 | 1 | 5000 | 42 | 152583.746830 | 241208.782847 | 0.0004 |
| 21 | 252-session circular blocks | Benchmark | 252 | 1 | 5000 | 42 | 130039.046865 | 212149.398589 | 0.0066 |
| 22 | 252-session circular blocks | Growth | 252 | 1 | 5000 | 42 | 106308.342729 | 358140.576266 | 0.0408 |
| 23 | 252-session circular blocks | Income | 252 | 1 | 5000 | 42 | 124425.979562 | 210147.251196 | 0.0098 |
| 24 | 252-session circular blocks | Low volatility | 252 | 1 | 5000 | 42 | 144277.528711 | 224122.009343 | 0.0008 |
Downside interpretation¶
The 5th percentile is a cutoff, not the mean of adverse outcomes. The worst-5% mean averages terminal wealth below that cutoff. Paired comparisons subtract Balanced from Growth inside each identical scenario; differences of separate percentiles do not give percentiles of that difference. Maximum drawdown records losses from prior peaks even when terminal wealth recovers. Longer moving blocks change dependence and underweight sample endpoints; circular blocks reduce endpoint weighting but create artificial end-to-start joins. These sensitivity runs are not independently validated forecasts and are not selected to produce a preferred result.
Backtesting and look-ahead controls¶
Returns use previous-close weights; low-volatility targets use only the initial calibration window. Later prices cannot affect earlier weights. Regression labels are purged at chronological split boundaries and scaling stays within training folds. These timing controls do not remove retrospectively chosen securities, manual allocations or survivorship bias. Five-year scenarios use completed history at the final as-of date, never feed into earlier decisions, and have not been validated as five-year forecasts.
SQL data mart¶
The view joins position facts to the security dimension. Monthly returns use a CTE and LAG over month-end NAV.
print((ROOT / 'sql/analysis_queries.sql').read_text())
exposure = pd.read_sql('SELECT * FROM latest_sector_exposure', conn)
display(exposure)
exposure[exposure.portfolio_id != 'Benchmark'].pivot(index='portfolio_id', columns='sector', values='weight').plot.bar(stacked=True, figsize=(10, 5))
plt.title('Latest sector exposure')
plt.ylabel('Portfolio weight')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()
-- Latest allocations, with sector names obtained from the security dimension.
CREATE VIEW latest_sector_exposure AS
WITH latest AS (SELECT MAX(date) AS date FROM portfolio_positions)
SELECT p.portfolio_id, s.sector, SUM(p.market_value) AS market_value,
SUM(p.weight) AS weight
FROM portfolio_positions p
JOIN securities s ON s.ticker = p.ticker
JOIN latest l ON l.date = p.date
GROUP BY p.portfolio_id, s.sector;
-- Compound monthly returns from month-end NAV using a window function.
CREATE VIEW monthly_portfolio_returns AS
WITH ranked AS (
SELECT *, SUBSTR(date, 1, 7) AS month,
ROW_NUMBER() OVER (PARTITION BY portfolio_id, SUBSTR(date, 1, 7) ORDER BY date DESC) AS rn
FROM portfolio_daily_summary
), endpoints AS (
SELECT portfolio_id, month, nav FROM ranked WHERE rn = 1
)
SELECT portfolio_id, month, nav,
nav / LAG(nav) OVER (PARTITION BY portfolio_id ORDER BY month) - 1 AS monthly_return
FROM endpoints;
-- Daily cross-sectional ranking by trailing annualized volatility.
CREATE VIEW volatility_ranking AS
SELECT date, ticker, rolling_volatility_20,
DENSE_RANK() OVER (PARTITION BY date ORDER BY rolling_volatility_20) AS volatility_rank
FROM security_daily_analytics WHERE rolling_volatility_20 IS NOT NULL;
CREATE VIEW rolling_portfolio_returns AS
SELECT date, portfolio_id,
nav / LAG(nav, 20) OVER (PARTITION BY portfolio_id ORDER BY date) - 1 AS return_20_sessions
FROM portfolio_daily_summary;
| portfolio_id | sector | market_value | weight | |
|---|---|---|---|---|
| 0 | Balanced | Energy | 54729.056875 | 0.144093 |
| 1 | Balanced | Financials | 94183.667480 | 0.247971 |
| 2 | Balanced | Industrials | 55795.837249 | 0.146902 |
| 3 | Balanced | Technology | 82793.440689 | 0.217982 |
| 4 | Balanced | Utilities | 92315.391825 | 0.243052 |
| 5 | Benchmark | Benchmark | 314746.547784 | 1.000000 |
| 6 | Growth | Financials | 90212.881038 | 0.138959 |
| 7 | Growth | Industrials | 120383.907598 | 0.185433 |
| 8 | Growth | Technology | 438606.100043 | 0.675607 |
| 9 | Income | Energy | 61403.248725 | 0.196216 |
| 10 | Income | Financials | 142866.983701 | 0.456536 |
| 11 | Income | Utilities | 108666.677989 | 0.347248 |
| 12 | Low volatility | Energy | 29400.047297 | 0.085567 |
| 13 | Low volatility | Financials | 132849.082620 | 0.386649 |
| 14 | Low volatility | Industrials | 37455.714306 | 0.109012 |
| 15 | Low volatility | Technology | 43578.712575 | 0.126833 |
| 16 | Low volatility | Utilities | 100307.672655 | 0.291939 |
Machine learning validation¶
Predict next-20-session volatility from past returns, volatility and market volume. All scaling is inside cross-validation. Training labels end before validation begins. Compare held-out RMSE with persistence; ML may lose.
scores = pd.read_sql('SELECT * FROM model_scores', conn)
display(scores[['portfolio_id', 'model', 'rmse', 'r2', 'selected_by_cv']])
scores.pivot(index='portfolio_id', columns='model', values='rmse').plot.bar(figsize=(12, 5))
plt.title('Held-out volatility prediction RMSE: lower is better')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()
display(pd.read_sql('SELECT * FROM validation_splits', conn))
| portfolio_id | model | rmse | r2 | selected_by_cv | |
|---|---|---|---|---|---|
| 0 | Balanced | Linear regression | 0.043156 | -0.087686 | 0 |
| 1 | Balanced | Ridge | 0.041783 | -0.019568 | 1 |
| 2 | Balanced | Lasso | 0.041799 | -0.020322 | 0 |
| 3 | Balanced | ElasticNet | 0.042053 | -0.032806 | 0 |
| 4 | Balanced | Persistence baseline | 0.049813 | -0.449087 | 0 |
| 5 | Growth | Linear regression | 0.099861 | -0.057570 | 0 |
| 6 | Growth | Ridge | 0.099264 | -0.044973 | 1 |
| 7 | Growth | Lasso | 0.099706 | -0.054298 | 0 |
| 8 | Growth | ElasticNet | 0.099741 | -0.055036 | 0 |
| 9 | Growth | Persistence baseline | 0.131365 | -0.830095 | 0 |
| 10 | Income | Linear regression | 0.035779 | -0.382895 | 0 |
| 11 | Income | Ridge | 0.035118 | -0.332244 | 1 |
| 12 | Income | Lasso | 0.035205 | -0.338864 | 0 |
| 13 | Income | ElasticNet | 0.035291 | -0.345389 | 0 |
| 14 | Income | Persistence baseline | 0.031161 | -0.048943 | 0 |
| 15 | Low volatility | Linear regression | 0.037045 | -0.210236 | 0 |
| 16 | Low volatility | Ridge | 0.036026 | -0.144576 | 1 |
| 17 | Low volatility | Lasso | 0.036005 | -0.143252 | 0 |
| 18 | Low volatility | ElasticNet | 0.036156 | -0.152897 | 0 |
| 19 | Low volatility | Persistence baseline | 0.039793 | -0.396456 | 0 |
| portfolio_id | fold | train_start | last_train_label | validation_start | validation_end | |
|---|---|---|---|---|---|---|
| 0 | Balanced | 0 | 2019-01-30 00:00:00 | 2020-04-07 00:00:00 | 2020-04-08 00:00:00 | 2021-06-14 00:00:00 |
| 1 | Balanced | 1 | 2019-01-30 00:00:00 | 2021-06-14 00:00:00 | 2021-06-15 00:00:00 | 2022-08-19 00:00:00 |
| 2 | Balanced | 2 | 2019-01-30 00:00:00 | 2022-08-19 00:00:00 | 2022-08-22 00:00:00 | 2023-10-26 00:00:00 |
| 3 | Balanced | 3 | 2019-01-30 00:00:00 | 2023-10-26 00:00:00 | 2023-10-27 00:00:00 | 2025-01-02 00:00:00 |
| 4 | Growth | 0 | 2019-01-30 00:00:00 | 2020-04-07 00:00:00 | 2020-04-08 00:00:00 | 2021-06-14 00:00:00 |
| 5 | Growth | 1 | 2019-01-30 00:00:00 | 2021-06-14 00:00:00 | 2021-06-15 00:00:00 | 2022-08-19 00:00:00 |
| 6 | Growth | 2 | 2019-01-30 00:00:00 | 2022-08-19 00:00:00 | 2022-08-22 00:00:00 | 2023-10-26 00:00:00 |
| 7 | Growth | 3 | 2019-01-30 00:00:00 | 2023-10-26 00:00:00 | 2023-10-27 00:00:00 | 2025-01-02 00:00:00 |
| 8 | Income | 0 | 2019-01-30 00:00:00 | 2020-04-07 00:00:00 | 2020-04-08 00:00:00 | 2021-06-14 00:00:00 |
| 9 | Income | 1 | 2019-01-30 00:00:00 | 2021-06-14 00:00:00 | 2021-06-15 00:00:00 | 2022-08-19 00:00:00 |
| 10 | Income | 2 | 2019-01-30 00:00:00 | 2022-08-19 00:00:00 | 2022-08-22 00:00:00 | 2023-10-26 00:00:00 |
| 11 | Income | 3 | 2019-01-30 00:00:00 | 2023-10-26 00:00:00 | 2023-10-27 00:00:00 | 2025-01-02 00:00:00 |
| 12 | Low volatility | 0 | 2019-01-30 00:00:00 | 2020-04-07 00:00:00 | 2020-04-08 00:00:00 | 2021-06-14 00:00:00 |
| 13 | Low volatility | 1 | 2019-01-30 00:00:00 | 2021-06-14 00:00:00 | 2021-06-15 00:00:00 | 2022-08-19 00:00:00 |
| 14 | Low volatility | 2 | 2019-01-30 00:00:00 | 2022-08-19 00:00:00 | 2022-08-22 00:00:00 | 2023-10-26 00:00:00 |
| 15 | Low volatility | 3 | 2019-01-30 00:00:00 | 2023-10-26 00:00:00 | 2023-10-27 00:00:00 | 2025-01-02 00:00:00 |
Implementation¶
The following cells display the actual pipeline source used to generate these results.
extract.py¶
import hashlib
import json
from datetime import datetime, timezone
import numpy as np
import pandas as pd
def extract(root, config, source):
securities = pd.read_csv(root / "data/securities.csv")
holdings = pd.read_csv(root / "data/holdings.csv")
path = root / "data" / ("synthetic_prices.csv" if source == "synthetic" else "cached_prices.csv")
metadata_path = path.with_suffix(".json")
if source == "download":
import yfinance as yf
frames = []
for ticker in securities.ticker:
data = yf.download(ticker, start=config["start"], end=config["end"],
auto_adjust=False, progress=False, threads=False)
if data.empty:
raise ValueError(f"No market data returned for {ticker}; use --source synthetic explicitly for a demo.")
if isinstance(data.columns, pd.MultiIndex):
data.columns = data.columns.get_level_values(0)
frames.append(pd.DataFrame({"date": data.index.strftime("%Y-%m-%d"),
"ticker": ticker, "adjusted_price": data["Adj Close"].to_numpy(),
"volume": data.Volume.to_numpy()}))
prices = pd.concat(frames, ignore_index=True)
elif source == "synthetic":
rng = np.random.default_rng(config["seed"])
dates = pd.bdate_range(config["start"], pd.Timestamp(config["end"]) - pd.Timedelta(days=1))
market = rng.normal(0.00025, 0.01, len(dates))
frames = []
for row in securities.itertuples():
scale = {"Technology": 0.018, "Utilities": 0.006}.get(row.sector, 0.009)
r = market * (0.65 if row.sector == "Utilities" else 1) + rng.normal(0, scale, len(dates))
frames.append(pd.DataFrame({"date": dates, "ticker": row.ticker,
"adjusted_price": 100 * np.cumprod(1 + r),
"volume": rng.integers(100000, 3000000, len(dates))}))
prices = pd.concat(frames, ignore_index=True)
else:
prices = pd.read_csv(path)
metadata = json.loads(metadata_path.read_text())
if metadata["sha256"] != hashlib.sha256(path.read_bytes()).hexdigest():
raise ValueError("Cached price checksum does not match provenance metadata")
return prices, holdings, securities, metadata
prices.to_csv(path, index=False)
metadata = {"source": "Yahoo Finance via yfinance" if source == "download" else "SYNTHETIC DEMONSTRATION",
"retrieved_at": datetime.now(timezone.utc).isoformat(),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"start": config["start"], "end_exclusive": config["end"],
"price_basis": "Dividend/split adjusted close; CAD"}
metadata_path.write_text(json.dumps(metadata, indent=2))
return prices, holdings, securities, metadata
validation.py¶
import numpy as np
import pandas as pd
def clean_inputs(prices, holdings, securities):
prices = prices.copy()
prices["date"] = pd.to_datetime(prices.date, errors="raise")
for col in ("adjusted_price", "volume"):
prices[col] = pd.to_numeric(prices[col], errors="raise")
if prices.isna().any().any() or not np.isfinite(prices[["adjusted_price", "volume"]]).all().all():
raise ValueError("Missing or non-finite price fields")
if not prices.adjusted_price.gt(0).all() or not prices.volume.ge(0).all():
raise ValueError("Prices must be positive and volume nonnegative")
before = len(prices)
prices = prices.drop_duplicates()
if prices.duplicated(["date", "ticker"]).any():
raise ValueError("Conflicting duplicate ticker-date records")
if securities.ticker.duplicated().any() or securities.isna().any().any():
raise ValueError("Invalid security master")
if set(prices.ticker) != set(securities.ticker):
raise ValueError("Price universe does not match security master")
if holdings.isna().any().any() or holdings.duplicated(["portfolio_id", "ticker"]).any():
raise ValueError("Invalid or duplicate holdings")
if not set(holdings.ticker) <= set(securities.ticker):
raise ValueError("Unknown security in holdings")
if not np.isfinite(holdings.target_weight).all() or not holdings.target_weight.ge(0).all():
raise ValueError("Holdings must have finite nonnegative weights")
if not np.allclose(holdings.groupby("portfolio_id").target_weight.sum(), 1):
raise ValueError("Portfolio weights must sum to one")
if set(securities.currency) != {"CAD"}:
raise ValueError("This pipeline requires a single CAD currency basis")
prices = prices.sort_values(["ticker", "date"]).reset_index(drop=True)
wide = prices.pivot(index="date", columns="ticker", values="adjusted_price")
if wide.isna().any().any():
raise ValueError("Missing ticker-date prices; repair the source instead of silently filling")
return prices, {"exact_duplicates_removed": before - len(prices), "missing_prices": 0,
"price_records": len(prices), "securities": len(securities), "dates": len(wide)}
def validate_outputs(daily, positions):
if not np.isfinite(daily[["net_return", "nav"]]).all().all() or not daily.nav.gt(0).all():
raise ValueError("Invalid portfolio results")
if not np.allclose(positions.groupby(["date", "portfolio_id"]).weight.sum(), 1):
raise ValueError("Position weights do not reconcile")
totals = positions.groupby(["date", "portfolio_id"]).market_value.sum()
expected = daily.set_index(["date", "portfolio_id"]).nav.reindex(totals.index)
if not np.allclose(totals, expected):
raise ValueError("Position values do not reconcile to NAV")
analytics.py¶
import numpy as np
import pandas as pd
def metrics(returns, risk_free_rate=0.03):
returns = pd.Series(returns).dropna()
wealth = (1 + returns).cumprod()
drawdown = wealth / wealth.cummax().clip(lower=1) - 1
std = returns.std(ddof=1)
rf_daily = (1 + risk_free_rate) ** (1 / 252) - 1
return {"cumulative_return": wealth.iloc[-1] - 1,
"annualized_return": wealth.iloc[-1] ** (252 / len(returns)) - 1,
"volatility": std * np.sqrt(252),
"sharpe": (returns.mean() - rf_daily) / std * np.sqrt(252) if std > 0 else np.nan,
"max_drawdown": drawdown.min()}
def security_analytics(prices):
groups = []
for _, group in prices.groupby("ticker", sort=True):
group = group.sort_values("date").copy()
group["daily_return"] = group.adjusted_price.pct_change(fill_method=None)
group["cumulative_return"] = group.adjusted_price / group.adjusted_price.iloc[0] - 1
group["moving_average_20"] = group.adjusted_price.rolling(20).mean()
group["rolling_volatility_20"] = group.daily_return.rolling(20).std() * np.sqrt(252)
group["drawdown"] = group.adjusted_price / group.adjusted_price.cummax() - 1
group["max_drawdown_to_date"] = group.drawdown.cummin()
group["average_volume_20"] = group.volume.rolling(20).mean()
groups.append(group)
return pd.concat(groups, ignore_index=True)
def portfolio_analytics(prices, holdings, securities, config):
wide = prices.pivot(index="date", columns="ticker", values="adjusted_price").sort_index()
returns = wide.pct_change(fill_method=None)
warmup = config["warmup_days"]
if len(wide) < warmup + 300:
raise ValueError("At least 300 evaluation days plus the warmup period are required")
assets = sorted(set(wide.columns) - {config["benchmark"]})
targets = {name: group.set_index("ticker").target_weight.reindex(wide.columns, fill_value=0)
for name, group in holdings.groupby("portfolio_id")}
# Freeze inverse-volatility weights using only pre-investment observations.
inv = 1 / returns.iloc[1:warmup + 1][assets].std()
if not np.isfinite(inv).all():
raise ValueError("Low-volatility calibration requires nonzero finite volatility")
targets["Low volatility"] = (inv / inv.sum()).reindex(wide.columns, fill_value=0)
targets["Benchmark"] = pd.Series({config["benchmark"]: 1.0}).reindex(wide.columns, fill_value=0)
rows, position_rows, trades = [], [], []
for name, target in targets.items():
nav, weights = config["initial_capital"], target.copy()
initial_date = wide.index[warmup]
# Initial positions are assumed funded at the calibration close; entry cost excluded for all strategies.
rows.append({"date": initial_date, "portfolio_id": name, "nav": nav,
"gross_return": 0., "net_return": 0., "turnover": 0., "cost": 0.})
for date in wide.index[warmup + 1:]:
r = returns.loc[date]
gross = float(weights @ r)
before_cost = nav * (1 + gross)
drifted = weights * (1 + r) / (1 + gross)
index = wide.index.get_loc(date)
rebalance = date.month != wide.index[index - 1].month and name != "Benchmark"
turnover = float((target - drifted).abs().sum()) if rebalance else 0.
cost = before_cost * turnover * config["transaction_cost_bps"] / 10000
new_nav = before_cost - cost
rows.append({"date": date, "portfolio_id": name, "nav": new_nav,
"gross_return": gross, "net_return": new_nav / nav - 1,
"turnover": turnover, "cost": cost})
if rebalance:
trades.append({"date": date, "portfolio_id": name, "turnover": turnover, "cost": cost})
weights = target.copy() if rebalance else drifted
nav = new_nav
for ticker in wide.columns:
if weights[ticker] > 0:
position_rows.append({"date": date, "portfolio_id": name, "ticker": ticker,
"adjusted_units": nav * weights[ticker] / wide.loc[date, ticker],
"market_value": nav * weights[ticker], "weight": weights[ticker]})
daily = pd.DataFrame(rows)
daily["cumulative_return"] = daily.nav / config["initial_capital"] - 1
daily["drawdown"] = daily.nav / daily.groupby("portfolio_id").nav.cummax() - 1
positions = pd.DataFrame(position_rows).merge(securities, on="ticker", validate="many_to_one")
sectors = positions.groupby(["date", "portfolio_id", "sector"], as_index=False).agg(
market_value=("market_value", "sum"), weight=("weight", "sum"))
summary = []
for name, group in daily.groupby("portfolio_id"):
row = metrics(group.net_return.iloc[1:], config["risk_free_rate"])
row.update(portfolio_id=name, total_cost=group.cost.sum(), turnover=group.turnover.sum())
summary.append(row)
summary = pd.DataFrame(summary)
benchmark_return = summary.loc[summary.portfolio_id == "Benchmark", "annualized_return"].iloc[0]
summary["excess_annualized_return"] = summary.annualized_return - benchmark_return
target_table = pd.concat([v.rename("weight").rename_axis("ticker").reset_index().assign(portfolio_id=k)
for k, v in targets.items()], ignore_index=True)
return daily, positions, sectors, summary, pd.DataFrame(trades), target_table
forward.py¶
"""Forward-looking portfolio scenarios built from completed historical returns."""
import numpy as np
import pandas as pd
def scenario_paths(daily, config, circular=False):
"""Create deterministic block-bootstrap projections without using future data.
The simulation starts immediately after the final backtest date. Consecutive
blocks of realized net returns preserve short-run market behaviour while the
percentile bands communicate uncertainty rather than a point prediction.
"""
paths = int(config["simulation_paths"])
days = int(config["simulation_years"] * config["trading_days"])
block = int(config["bootstrap_block_days"])
if paths < 100 or days < 1 or block < 1:
raise ValueError("Invalid forward-scenario configuration")
actual = daily.sort_values(["date", "portfolio_id"]).copy()
returns = actual.pivot(index="date", columns="portfolio_id", values="net_return").iloc[1:]
initial_capital = float(config["initial_capital"])
if not np.isfinite(initial_capital) or initial_capital <= 0:
raise ValueError("Initial scenario capital must be positive and finite")
starts = pd.Series(initial_capital, index=returns.columns)
if len(returns) < block:
raise ValueError("Not enough completed return history for the selected bootstrap block")
# Map 252 modeled sessions per year to an exact calendar horizon, not an exchange calendar.
dates = pd.date_range(returns.index.max(), returns.index.max() + pd.DateOffset(years=int(config["simulation_years"])), periods=days + 1)
rng = np.random.default_rng(config["seed"])
blocks_needed = int(np.ceil(days / block))
block_starts = rng.integers(0, len(returns) if circular else len(returns) - block + 1, size=(paths, blocks_needed))
sampled_index = (block_starts[:, :, None] + np.arange(block)).reshape(paths, -1)[:, :days]
if circular:
sampled_index %= len(returns)
for portfolio_id in returns.columns:
series = returns[portfolio_id].to_numpy(dtype=float)
sampled_returns = series[sampled_index]
values = starts[portfolio_id] * np.cumprod(1 + sampled_returns, axis=1)
values = np.column_stack([np.full(paths, starts[portfolio_id]), values])
yield portfolio_id, dates, values
def forward_scenarios(daily, config):
quantiles = [0.05, 0.25, 0.50, 0.75, 0.95]
bands, summaries = [], []
for portfolio_id, dates, values in scenario_paths(daily, config):
percentile_values = np.quantile(values, quantiles, axis=0)
bands.append(pd.DataFrame({
"date": dates,
"portfolio_id": portfolio_id,
"p05": percentile_values[0],
"p25": percentile_values[1],
"median": percentile_values[2],
"p75": percentile_values[3],
"p95": percentile_values[4],
}))
drawdowns = values / np.maximum.accumulate(values, axis=1) - 1
terminal = values[:, -1]
summaries.append({
"portfolio_id": portfolio_id,
"as_of_date": dates[0],
"start_value": config['initial_capital'],
"terminal_p05": np.quantile(terminal, 0.05),
"terminal_median": np.quantile(terminal, 0.50),
"terminal_p95": np.quantile(terminal, 0.95),
"probability_terminal_loss": np.mean(terminal < config['initial_capital']),
"median_max_drawdown": np.quantile(drawdowns.min(axis=1), 0.50),
"scenario_paths": config['simulation_paths'],
"horizon_years": config["simulation_years"],
"bootstrap_block_days": config['bootstrap_block_days'],
})
return pd.concat(bands, ignore_index=True), pd.DataFrame(summaries)
scenario_risk.py¶
"""Downside and paired comparisons under declared sampling assumptions."""
import numpy as np
import pandas as pd
from .forward import scenario_paths
def paired_outcomes(left, right, left_name='Growth', right_name='Balanced'):
difference = np.asarray(left) - np.asarray(right)
return dict(left_portfolio=left_name, right_portfolio=right_name,
probability_underperformance=float(np.mean(difference < 0)),
difference_p05=float(np.quantile(difference, .05)),
difference_median=float(np.median(difference)),
difference_p95=float(np.quantile(difference, .95)))
def risk_review(daily, config):
experiments = [('Published model', config['bootstrap_block_days'], False),
('126-session moving blocks', 126, False),
('252-session moving blocks', 252, False),
('126-session circular blocks', 126, True),
('252-session circular blocks', 252, True)]
downside, sensitivity, paired = [], [], []
capital = config['initial_capital']
history_days = daily.date.nunique() - 1
for label, block, circular in experiments:
if block > history_days:
continue
terminals = {}
for name, dates, values in scenario_paths(daily, dict(config, bootstrap_block_days=block), circular):
final = values[:, -1]
terminals[name] = final
p05 = np.quantile(final, .05)
sensitivity.append(dict(experiment=label, portfolio_id=name, block_days=block,
circular=circular, paths=len(final), seed=config['seed'],
terminal_p05=p05, terminal_median=np.median(final),
probability_terminal_loss=np.mean(final < capital)))
if label == 'Published model':
worst = (values / np.maximum.accumulate(values, axis=1) - 1).min(axis=1)
downside.append(dict(portfolio_id=name, start_value=capital,
terminal_p01=np.quantile(final, .01), terminal_p05=p05,
worst5_mean=np.mean(final[final <= p05]),
probability_terminal_loss=np.mean(final < capital),
terminal_loss_count=np.sum(final < capital), paths=len(final),
median_max_drawdown=np.median(worst), severe_max_drawdown_p05=np.quantile(worst,.05),
probability_ever_below_80pct=np.mean(values.min(axis=1) < .8 * capital)))
if 'Growth' in terminals and 'Balanced' in terminals:
paired.append(dict(experiment=label, **paired_outcomes(terminals['Growth'], terminals['Balanced'])))
return pd.DataFrame(downside), pd.DataFrame(sensitivity), pd.DataFrame(paired)
risk_presentation.py¶
"""Shared risk charts and source-backed explanation for the report and website."""
import html
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
import numpy as np
from .scenario_charts import PALETTE
def risk_charts(output, downside):
data = downside.set_index('portfolio_id').reindex(PALETTE).dropna()
y = np.arange(len(data))
fig, ax = plt.subplots(figsize=(11, 4.8), dpi=180)
for field, label, marker, color, offset in [
('terminal_p01', '1st percentile', 'v', '#b43a43', -.18),
('worst5_mean', 'Average of worst 5%', 'D', '#7b529d', 0),
('terminal_p05', '5th-percentile cutoff', 'o', '#256f94', .18)]:
ax.scatter(data[field], y+offset, marker=marker, color=color, label=label, s=45, zorder=3)
for i, value in enumerate(data[field]):
ax.annotate(f'${value:,.0f}', (value, i+offset), xytext=(7,0), textcoords='offset points', va='center', fontsize=9)
capital = data.start_value.iloc[0]
ax.axvline(capital, color='#252d32', ls='--', lw=1, label=f'Initial ${capital:,.0f}')
ax.set_xlim(0, max(capital, data.terminal_p05.max()) * 1.25)
ax.set_yticks(y, data.index)
ax.invert_yaxis()
ax.xaxis.set_major_formatter(FuncFormatter(lambda v,_: f'${v:,.0f}'))
ax.set_xlabel('Ending portfolio value (CAD)')
ax.set_title('Adverse five-year outcomes from equal investments', loc='left', pad=45)
ax.legend(loc='lower left', bbox_to_anchor=(0,1.01), ncol=2, frameon=False, fontsize=9)
ax.grid(axis='x', alpha=.18)
fig.tight_layout()
fig.savefig(output/'scenario_downside.png', facecolor='white')
plt.close(fig)
fig, ax = plt.subplots(figsize=(11,4.8), dpi=180)
ax.barh(y-.16, -data.median_max_drawdown*100, height=.3, color='#347f80', label='Median maximum drawdown')
ax.barh(y+.16, -data.severe_max_drawdown_p05*100, height=.3, color='#b43a43', label='Severe drawdown threshold (5% of paths are worse)')
for i,row in enumerate(data.itertuples()):
for offset,value in [(-.16,-row.median_max_drawdown*100),(.16,-row.severe_max_drawdown_p05*100)]:
ax.text(value+1,i+offset,f'{value:.1f}%',va='center',fontsize=10)
ax.set_yticks(y,data.index)
ax.invert_yaxis()
ax.set_xlim(0,min(110,max(-data.severe_max_drawdown_p05*100)*1.2))
ax.set_xlabel('Size of peak-to-trough loss during the five years (%)')
ax.set_title('Losses along the way, even when ending wealth recovers',loc='left',pad=42)
ax.legend(loc='lower left',bbox_to_anchor=(0,1.01),frameon=False,fontsize=9)
ax.grid(axis='x',alpha=.18)
fig.tight_layout()
fig.savefig(output/'scenario_drawdowns.png',facecolor='white')
plt.close(fig)
def risk_section(tables, config, prefix=''):
d = tables['scenario_downside'].copy()
for key in ['terminal_p01','terminal_p05','worst5_mean']:
d[key] = d[key].map(lambda v:f'${v:,.0f}')
for key in ['probability_terminal_loss','probability_ever_below_80pct']:
d[key] = d[key].map(lambda v:f'{v:.2%}')
d = d[['portfolio_id','terminal_p01','terminal_p05','worst5_mean','probability_terminal_loss','probability_ever_below_80pct']]
d.columns=['Portfolio','1st percentile','5th percentile','Worst-5% average','Finish below initial capital','Ever lose over 20% of initial capital']
paired = tables['scenario_paired']
paired_text = ''
base = paired[paired.experiment == 'Published model'] if not paired.empty else paired
if not base.empty:
r = base.iloc[0]
paired_text = f'''<h3>Growth versus Balanced in the same market scenarios</h3>
<p>Subtract Balanced's ending value from Growth's in each shared simulated market sequence. Growth finishes below Balanced in <strong>{r.probability_underperformance:.2%}</strong> of paths. The median difference is <strong>${r.difference_median:+,.0f}</strong>; the 5th-percentile difference is <strong>${r.difference_p05:+,.0f}</strong>. These are conditional simulation results.</p>
<p>Subtracting the portfolios' separate 5th percentiles does not give the 5th percentile of their difference. Their individual adverse outcomes need not occur in the same path. The paired shortfall is not a maximum loss.</p>'''
s = tables['scenario_sensitivity'].copy()
s = s[s.portfolio_id.isin(['Growth','Balanced'])][['experiment','portfolio_id','terminal_p05','terminal_median','probability_terminal_loss']]
for key in ['terminal_p05','terminal_median']:
s[key] = s[key].map(lambda v:f'${v:,.0f}')
s.probability_terminal_loss = s.probability_terminal_loss.map(lambda v:f'{v:.2%}')
s.columns=['Sampling assumption','Portfolio','5th percentile','Median final value','Finish below initial capital']
return f'''<section id="scenario-risk"><h3>How much could the investment lose?</h3>
<p>The lower fan boundary is a 5th-percentile cutoff: 5% of simulated ending balances lie below it. The average of that worst 5% describes the tail beyond the cutoff. Values below the initial CAD {config['initial_capital']:,.0f} represent losses of principal.</p>
<figure><a href="{prefix}scenario_downside.png" title="Open full-size downside chart"><img src="{prefix}scenario_downside.png" alt="Adverse ending balances compared with the initial investment" style="width:100%;height:auto"></a><figcaption>Same dollar scale across all portfolios. This view focuses on downside without compression from the large upside range.</figcaption></figure>
<div class="table-wrap table" style="overflow-x:auto" tabindex="0" role="region" aria-label="Scenario downside metrics">{d.to_html(index=False,border=0)}</div>
{paired_text}
<h3>What can happen before year five?</h3>
<figure><a href="{prefix}scenario_drawdowns.png" title="Open full-size drawdown chart"><img src="{prefix}scenario_drawdowns.png" alt="Median and severe simulated maximum drawdown for each portfolio" style="width:100%;height:auto"></a><figcaption>Each path's largest fall from a prior peak, including the initial balance. A peak can be above starting capital. Larger bars mean deeper losses.</figcaption></figure>
<h3>Does the conclusion survive different sampling assumptions?</h3>
<p>All comparisons use {config['simulation_paths']:,} paths, seed {config['seed']}, equal capital and the same completed history. Longer moving blocks retain longer sequences but underweight dates near the sample boundaries. Circular blocks wrap the end to the beginning, reducing that weighting effect while introducing artificial joins. The choice affects both dependence and sampled returns. No variant is selected because it produces a preferred ranking.</p>
<div class="table-wrap table" style="overflow-x:auto" tabindex="0" role="region" aria-label="Sampling sensitivity">{s.to_html(index=False,border=0)}</div>
<p><strong>Research finding:</strong> Growth's historical return advantage makes its median attractive under these assumptions, while several measures show greater downside than Balanced. A positive 5th percentile does not rule out principal losses. Higher volatility alone does not require every lower percentile to rank worst. The future return distribution remains unvalidated, and changing sampling assumptions can change the conclusion.</p>
<p>The bootstrap can generate worse compounded outcomes than any observed historical path by repeating adverse blocks. It cannot establish probabilities for risks absent from the sample. The separate volatility-model holdout does not validate these five-year return scenarios.</p>
<p><a href="{prefix}scenario_downside.csv">Download downside metrics</a> · <a href="{prefix}scenario_paired.csv">Download paired comparisons</a> · <a href="{prefix}scenario_sensitivity.csv">Download sampling sensitivity</a></p></section>'''
models.py¶
import numpy as np
import pandas as pd
from sklearn.linear_model import ElasticNet, Lasso, LinearRegression, Ridge
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import GridSearchCV, TimeSeriesSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
FEATURES = ["return_1", "return_5", "return_20", "volatility_20", "market_return_20", "volume_ratio"]
def feature_frame(group, benchmark, volume, horizon):
r = group.set_index("date").net_return.sort_index()
frame = pd.DataFrame({"return_1": r, "return_5": (1 + r).rolling(5).apply(np.prod, raw=True) - 1,
"return_20": (1 + r).rolling(20).apply(np.prod, raw=True) - 1,
"volatility_20": r.rolling(20).std() * np.sqrt(252),
"market_return_20": (1 + benchmark).rolling(20).apply(np.prod, raw=True) - 1,
"volume_ratio": volume / volume.rolling(20).mean()})
# The label at t contains returns t+1 through t+h; purge h rows at every split.
frame["target"] = r.rolling(horizon).std().shift(-horizon) * np.sqrt(252)
frame["target_end"] = pd.Series(r.index, index=r.index).shift(-horizon)
return frame.replace([np.inf, -np.inf], np.nan).dropna()
def fit_models(daily, prices, config):
benchmark = daily[daily.portfolio_id == "Benchmark"].set_index("date").net_return
volume = prices[prices.ticker == config["benchmark"]].set_index("date").volume
horizon = config["forecast_days"]
scores, predictions, coefficients, audits = [], [], [], []
candidates = {"Linear regression": (LinearRegression(), {}),
"Ridge": (Ridge(), {"model__alpha": [0.1, 1., 10., 100.]}),
"Lasso": (Lasso(max_iter=20000), {"model__alpha": [0.00001, 0.0001, 0.001]}),
"ElasticNet": (ElasticNet(max_iter=20000),
{"model__alpha": [0.0001, 0.001], "model__l1_ratio": [0.25, 0.75]})}
for name, group in daily.groupby("portfolio_id"):
if name == "Benchmark":
continue
frame = feature_frame(group, benchmark, volume, horizon)
split = int(len(frame) * (1 - config["test_fraction"]))
train, test = frame.iloc[:split - horizon], frame.iloc[split:]
if len(train) < 150 or len(test) < 30:
raise ValueError("Insufficient history for purged chronological validation")
if train.target_end.max() >= test.index.min():
raise ValueError("Training labels overlap the test period")
cv = TimeSeriesSplit(n_splits=4, gap=horizon)
for fold, (tr, va) in enumerate(cv.split(train)):
if train.iloc[tr].target_end.max() >= train.iloc[va].index.min():
raise ValueError("Cross-validation label leakage")
audits.append({"portfolio_id": name, "fold": fold, "train_start": train.iloc[tr].index.min(),
"last_train_label": train.iloc[tr].target_end.max(),
"validation_start": train.iloc[va].index.min(), "validation_end": train.iloc[va].index.max()})
models = {}
for model_name, (model, grid) in candidates.items():
search = GridSearchCV(Pipeline([("scale", StandardScaler()), ("model", model)]), grid,
cv=cv, scoring="neg_mean_squared_error", n_jobs=1)
search.fit(train[FEATURES], train.target)
models[model_name] = (np.maximum(search.predict(test[FEATURES]), 0), -search.best_score_, str(search.best_params_))
for feature, value in zip(FEATURES, search.best_estimator_.named_steps["model"].coef_):
coefficients.append({"portfolio_id": name, "model": model_name,
"feature": feature, "coefficient": value})
selected = min(models, key=lambda key: models[key][1])
models["Persistence baseline"] = (test.volatility_20.to_numpy(), np.nan, "Trailing 20-day volatility")
for model_name, (prediction, cv_mse, params) in models.items():
scores.append({"portfolio_id": name, "model": model_name,
"rmse": np.sqrt(mean_squared_error(test.target, prediction)),
"r2": r2_score(test.target, prediction), "cv_mse": cv_mse,
"selected_by_cv": model_name == selected, "parameters": params,
"train_end": train.index[-1], "last_train_label": train.target_end.max(),
"test_start": test.index[0], "test_end": test.index[-1], "test_rows": len(test)})
predictions.extend({"date": date, "portfolio_id": name, "model": model_name,
"actual": actual, "prediction": value}
for date, actual, value in zip(test.index, test.target, prediction))
return pd.DataFrame(scores), pd.DataFrame(predictions), pd.DataFrame(coefficients), pd.DataFrame(audits)
load.py¶
import os
import sqlite3
def load_mart(output, tables, queries):
path = output / "investment_analytics.db"
temporary = output / "investment_analytics.tmp.db"
try:
with sqlite3.connect(temporary) as conn:
if path.exists():
with sqlite3.connect(path) as old:
old.backup(conn)
for view in ("latest_sector_exposure", "monthly_portfolio_returns", "volatility_ranking", "rolling_portfolio_returns"):
conn.execute(f'DROP VIEW IF EXISTS "{view}"')
for name, frame in tables.items():
frame.to_sql(name, conn, if_exists="append" if name == "pipeline_runs" else "replace", index=False)
conn.executescript(queries)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS security_date ON security_daily_analytics(ticker, date)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS portfolio_date ON portfolio_daily_summary(portfolio_id, date)")
if conn.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("SQLite integrity check failed")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
pipeline.py¶
import argparse
import hashlib
import json
import logging
import time
import uuid
from importlib.metadata import version
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from .analytics import portfolio_analytics, security_analytics
from .extract import extract
from .forward import forward_scenarios
from .scenario_risk import risk_review
from .load import load_mart
from .models import fit_models
from .report import render_report
from .validation import clean_inputs, validate_outputs
def run(root, source="cached", config_path=None):
started = time.perf_counter()
output = root / "output"
output.mkdir(exist_ok=True)
config = json.loads((config_path or root / "config.json").read_text())
if not (0 < config["test_fraction"] < 0.5 and config["forecast_days"] >= 2
and config["initial_capital"] > 0 and config["transaction_cost_bps"] >= 0
and config["risk_free_rate"] > -1 and config["warmup_days"] >= 20
and config["simulation_years"] >= 1 and config["simulation_paths"] >= 100
and config["bootstrap_block_days"] >= 1 and config["trading_days"] >= 1):
raise ValueError("Invalid research configuration")
run_id = str(uuid.uuid4())
logging.info("[1/9] Extracting prices, holdings and security metadata")
prices, holdings, securities, provenance = extract(root, config, source)
prices = prices[(pd.to_datetime(prices.date) >= config["start"]) & (pd.to_datetime(prices.date) < config["end"])]
logging.info("[2/9] Cleaning and validating inputs")
prices, quality = clean_inputs(prices, holdings, securities)
logging.info("[3/9] Calculating security analytics")
security = security_analytics(prices)
logging.info("[4/9] Simulating portfolios and transaction costs")
daily, positions, sectors, summary, trades, targets = portfolio_analytics(prices, holdings, securities, config)
logging.info("[5/9] Validating NAV and allocation reconciliation")
validate_outputs(daily, positions)
logging.info("[6/9] Training models with purged time-series validation")
scores, predictions, coefficients, audits = fit_models(daily, prices, config)
logging.info("[7/9] Simulating five-year portfolio scenarios from completed history")
projection_bands, projection_summary = forward_scenarios(daily, config)
downside, sensitivity, paired = risk_review(daily, config)
tables = {"securities": securities, "holdings": holdings, "security_daily_analytics": security,
"portfolio_positions": positions, "portfolio_daily_summary": daily,
"sector_exposures": sectors, "portfolio_summary": summary, "rebalancing_history": trades,
"target_weights": targets, "model_scores": scores, "model_predictions": predictions,
"model_coefficients": coefficients, "validation_splits": audits,
"forward_projection_bands": projection_bands, "forward_projection_summary": projection_summary,
"scenario_downside": downside, "scenario_sensitivity": sensitivity, "scenario_paired": paired}
logging.info("[8/9] Generating offline report and CSV exports")
charts = render_report(output, tables, provenance, config, quality)
for name, frame in tables.items():
frame.to_csv(output / f"{name}.csv", index=False)
manifest = {"run_id": run_id, "timestamp": datetime.now(timezone.utc).isoformat(),
"source": provenance, "config": config, "validation": quality,
"package_versions": {package: version(package) for package in ("pandas", "numpy", "scipy", "scikit-learn", "plotly", "yfinance")},
"source_hashes": {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in (root / "src").glob("*.py")},
"input_hashes": {name: hashlib.sha256((root / "data" / name).read_bytes()).hexdigest()
for name in ("holdings.csv", "securities.csv")}}
(output / "run_manifest.json").write_text(json.dumps(manifest, indent=2))
tables["pipeline_runs"] = pd.DataFrame([{"run_id": run_id, "timestamp": manifest["timestamp"],
"status": "success", "price_rows": len(prices),
"source": provenance["source"], "manifest": json.dumps(manifest)}])
logging.info("[9/9] Loading SQL data mart")
load_mart(output, tables, (root / "sql/analysis_queries.sql").read_text())
logging.info("Pipeline complete: %s prices; %.1f seconds; report: %s", len(prices), time.perf_counter() - started, output / "report.html")
return tables, charts
def main():
parser = argparse.ArgumentParser(description="Investment analytics ETL and volatility forecasting")
parser.add_argument("--source", choices=["cached", "download", "synthetic"], default="cached")
parser.add_argument("--config", type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
(root / "output").mkdir(exist_ok=True)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s",
handlers=[logging.StreamHandler(), logging.FileHandler(root / "output/pipeline.log")])
try:
run(root, args.source, args.config)
except Exception:
logging.exception("Pipeline failed")
raise
if __name__ == "__main__":
main()
conn.close()