Daily asset return
rt = Pt / Pt−1 − 1
Today's adjusted close divided by yesterday's, minus one. This makes differently priced stocks comparable.
Investment research / Python / SQL / Statistical learning
From historical prices to portfolio decisions: a reproducible workflow for comparing Canadian equity allocations, measuring risk after trading costs, and evaluating volatility forecasts.
01 / Research findings
The experiment asks two questions: how does portfolio construction affect realized risk and return, and does regularized regression improve a simple forecast of future volatility? Portfolio performance and forecast accuracy are evaluated separately.
A 65% technology target produced the highest historical return and volatility. The large peak-to-trough loss matters as much as the ending value.
Its 19.12% annualized return exceeded XIC's 16.22%, with similar volatility and a smaller maximum drawdown. This is an observed comparison, not evidence of persistent alpha.
The lowest among the four constructed portfolios, compared with 16.37% for XIC. Inverse-volatility weighting is a heuristic; it does not solve for minimum portfolio variance.
The CV-selected Ridge model beat trailing-volatility persistence on test RMSE for three portfolios. All four selected models had negative test R², so predictive usefulness remains unproven.
| Portfolio | Cumulative return | Annualized return | Volatility | Sharpe | Max drawdown | Trading costs |
|---|---|---|---|---|---|---|
| Balanced | 279.82% | 19.12% | 16.29% | 0.97 | -28.73% | $776.61 |
| XIC benchmark | 214.75% | 16.22% | 16.37% | 0.82 | -37.21% | $0.00 |
| Growth | 549.20% | 27.80% | 28.60% | 0.90 | -48.72% | $1,856.88 |
| Income | 212.94% | 16.13% | 16.06% | 0.83 | -33.06% | $395.51 |
| Low volatility | 243.59% | 17.57% | 15.29% | 0.94 | -29.71% | $573.68 |

02 / Portfolio design
Growth, Income and Balanced use manually specified target weights. Low volatility uses inverse sample volatility estimated from the first 252 return observations, then freezes those targets. All portfolios are long-only equities; Balanced has no bond allocation, and Income does not separately model dividend cash payments.
| Security | Sector | Growth | Income | Balanced | Low volatility |
|---|---|---|---|---|---|
| RY.TO · Royal Bank of Canada | Financials | 15.00% | 25.00% | 15.00% | 18.75% |
| TD.TO · Toronto-Dominion Bank | Financials | 0.00% | 20.00% | 10.00% | 19.72% |
| SHOP.TO · Shopify | Technology | 35.00% | 0.00% | 10.00% | 4.04% |
| CSU.TO · Constellation Software | Technology | 30.00% | 0.00% | 10.00% | 7.85% |
| ENB.TO · Enbridge | Energy | 0.00% | 20.00% | 15.00% | 8.82% |
| FTS.TO · Fortis | Utilities | 0.00% | 20.00% | 15.00% | 16.14% |
| EMA.TO · Emera | Utilities | 0.00% | 15.00% | 10.00% | 13.66% |
| CNR.TO · Canadian National Railway | Industrials | 20.00% | 0.00% | 15.00% | 11.03% |
XIC.TO, the iShares Core S&P/TSX Capped Composite ETF, supplies a broad Canadian equity reference in the same currency. It answers whether these selected allocations improved on holding the Canadian market. The portfolios' sector concentrations differ from XIC, so return differences combine market exposure and security selection; they are not isolated manager skill.
VFV could be a useful secondary opportunity-cost comparison with US equities. It is not included in this experiment. A sector-matched benchmark would further help distinguish allocation effects from security selection.
03 / Financial methodology
Historical adjusted closes approximate dividend-reinvested returns. Position quantities are adjusted-price accounting units, not actual broker share balances. Portfolios start at the calibration close; the first measured return is the following trading session.
rt = Pt / Pt−1 − 1
Today's adjusted close divided by yesterday's, minus one. This makes differently priced stocks comparable.
rp,t = ∑ wi,t−1 ri,t
Weight each asset's return by its previous-close portfolio weight. Using end-of-day weights would mix today's price movement into the allocation that supposedly earned it.
CAGR = (Vend / Vstart)252/N − 1
Converts growth over N observed daily returns into an annualized compound rate. V is net portfolio value.
σannual = s(r) √252
Sharpe = mean(r − rf,daily) / s(r) × √252
s(r) is sample daily standard deviation. The fixed 3% annual risk-free assumption is converted to a daily compound rate. Zero volatility gives an undefined Sharpe ratio.
Dt = Vt / max(V0, …, Vt) − 1
Maximum drawdown is the most negative D. Including starting capital ensures an immediate loss is counted.
Cost = Vbefore cost × ∑ |wtarget − wdrifted| × 0.001
Ten basis points on total absolute traded notional, counting both purchases and sales. Costs reduce NAV before the next return is calculated.
Rebalance timing: weights drift daily and reset at the close of the first observed session of each month. XIC is held without rebalancing. Initial entry costs, taxes and additional market impact are excluded. The recorded costs are cumulative cash charges, not the full compounded performance drag.
04 / Data engineering
Read prices, target holdings and the security master. Download adjusted prices through yfinance or reuse a checksum-verified frozen CSV.
Parse dates and numbers, count exact duplicates, reject conflicting records and missing prices, and verify the security universe and target weights.
Pandas and NumPy calculate asset returns and rolling statistics, then simulate portfolio value, drifting allocations and transaction costs.
Construct forward volatility labels, purge overlapping training labels, tune models chronologically and retain held-out predictions.
Replace the SQLite snapshot atomically after integrity checks. Preserve successful run history and reconcile positions to portfolio NAV.
Export CSVs, interactive charts and a notebook with saved figures. Configuration, input hashes and package versions identify the research run.
| Table | Grain | Research purpose |
|---|---|---|
| securities | Security | Sector and currency dimension |
| security_daily_analytics | Security × date | Adjusted price, return and rolling risk |
| portfolio_positions | Portfolio × security × date | Position value, weight and sector |
| portfolio_daily_summary | Portfolio × date | NAV, return, costs and drawdown |
| model_predictions | Portfolio × model × forecast date | Actual and predicted volatility |
| pipeline_runs | Successful run | Configuration, provenance and hashes |
CTEs and ROW_NUMBER select month-end NAV; LAG computes compounded monthly and 20-session returns; DENSE_RANK ranks assets by trailing volatility. Sector reporting joins positions to the security dimension. The first monthly return is undefined without a preceding month-end.
-- 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;
05 / Statistical learning
Each portfolio has its own regression model. At close t, the target is the sample standard deviation of returns from t+1 through t+20, multiplied by √252. Predictors use information available through t: 1-, 5- and 20-session portfolio returns, trailing 20-session volatility, the benchmark's 20-session return, and benchmark volume relative to its trailing average.
Persistence assumes the next 20 sessions will have the same volatility as the trailing 20 sessions. Every candidate is evaluated on the same held-out dates. Lower RMSE means smaller errors in annualized volatility.
The final 20% is reserved as holdout, with 20 rows removed at its training boundary. The same gap is applied inside TimeSeriesSplit. StandardScaler is fitted inside each training fold through a Pipeline. GridSearchCV selects parameters by validation MSE; the selected model is refitted on the development data and evaluated on holdout. It is not retrained daily during that test period.
The last test forecast date precedes the final market-data date because each forecast needs 20 future returns to score it. Predictions are clipped at zero. Forecasts remain separate from allocations and are not used to claim a trading return.
| Portfolio | Ridge RMSE | Baseline RMSE | RMSE improvement | Test R² |
|---|---|---|---|---|
| Balanced | 4.18 pp | 4.98 pp | +16.1% | -0.020 |
| Growth | 9.93 pp | 13.14 pp | +24.4% | -0.045 |
| Income | 3.51 pp | 3.12 pp | -12.7% | -0.332 |
| Low volatility | 3.60 pp | 3.98 pp | +9.5% | -0.145 |
Interpretation: beating persistence and having negative R² can both be true. R² compares errors against a constant equal to the observed test-period mean, which would not be known in advance. These results support further investigation, not a claim of dependable forecasting. Overlapping 20-day targets also make the errors dependent.
The selected Ridge penalty is the largest value in the tested grid. A broader penalty range is a useful follow-up, using a new validation plan before inspecting additional test results. Lasso has slightly lower held-out error than Ridge for Low volatility, but the report retains Ridge because selection was based on development CV, not test performance.
Grid search scores raw predictions; final test predictions are clipped to nonnegative volatility. This is a documented evaluation mismatch to align in a future revision. There is no uncertainty interval or significance test in the current experiment.
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)
06 / Engineering assurance
The automated suite has 15 passing cases. A GitHub Actions workflow is defined for tests, a synthetic pipeline run and notebook generation. Local execution is verified; remote CI execution has not been verified. The application is a local research pipeline with a published static report.
import sqlite3
import numpy as np
import pandas as pd
import pytest
from src.analytics import metrics, portfolio_analytics
from src.load import load_mart
from src.models import feature_frame, FEATURES
from src.validation import clean_inputs, validate_outputs
@pytest.fixture
def inputs():
dates = pd.bdate_range("2020-01-01", periods=360)
frames = []
for i, ticker in enumerate(["A", "B", "M"]):
r = 0.0002 + (i + 1) * 0.002 * np.sin(np.arange(len(dates)) / 7)
frames.append(pd.DataFrame({"date": dates, "ticker": ticker,
"adjusted_price": 100 * np.cumprod(1 + r), "volume": 1000}))
prices = pd.concat(frames, ignore_index=True)
holdings = pd.DataFrame({"portfolio_id": ["Balanced"] * 2, "ticker": ["A", "B"], "target_weight": [0.6, 0.4]})
securities = pd.DataFrame({"ticker": ["A", "B", "M"], "sector": ["Tech", "Banks", "Benchmark"], "currency": ["CAD"] * 3})
config = {"warmup_days": 30, "benchmark": "M", "initial_capital": 100000,
"transaction_cost_bps": 10, "risk_free_rate": 0.03}
return prices, holdings, securities, config
def test_compounding_and_initial_loss_drawdown():
result = metrics([-0.1, 0.1], 0)
assert result["cumulative_return"] == pytest.approx(-0.01)
assert result["max_drawdown"] == pytest.approx(-0.1)
assert result["annualized_return"] == pytest.approx(0.99 ** 126 - 1)
def test_zero_volatility_sharpe_is_undefined():
assert np.isnan(metrics([0., 0., 0.])["sharpe"])
@pytest.mark.parametrize("fault", ["negative", "missing", "infinite", "conflict", "gap", "weights", "unknown"])
def test_reject_bad_inputs(inputs, fault):
prices, holdings, securities, _ = inputs
if fault == "negative":
prices.loc[0, "adjusted_price"] = -1
elif fault == "missing":
prices.loc[0, "volume"] = np.nan
elif fault == "infinite":
prices.loc[0, "adjusted_price"] = np.inf
elif fault == "conflict":
prices = pd.concat([prices, prices.iloc[:1].assign(adjusted_price=999)])
elif fault == "gap":
prices = prices.iloc[1:]
elif fault == "weights":
holdings.loc[0, "target_weight"] = 0.8
elif fault == "unknown":
holdings.loc[0, "ticker"] = "UNKNOWN"
with pytest.raises(ValueError):
clean_inputs(prices, holdings, securities)
def test_exact_duplicates_are_audited(inputs):
p, h, s, _ = inputs
cleaned, audit = clean_inputs(pd.concat([p, p.iloc[:1]]), h, s)
assert len(cleaned) == len(p)
assert audit["exact_duplicates_removed"] == 1
def test_previous_weights_and_reconciliation(inputs):
p, h, s, c = inputs
daily, positions, *_ = portfolio_analytics(p, h, s, c)
validate_outputs(daily, positions)
wide = p.pivot(index="date", columns="ticker", values="adjusted_price")
r = wide.pct_change().iloc[31]
observed = daily[daily.portfolio_id == "Balanced"].iloc[1]
assert observed.gross_return == pytest.approx(0.6 * r.A + 0.4 * r.B)
assert daily[daily.portfolio_id == "Benchmark"].cost.sum() == 0
def test_costs_reduce_nav(inputs):
p, h, s, c = inputs
cost = portfolio_analytics(p, h, s, c)[0]
free = portfolio_analytics(p, h, s, dict(c, transaction_cost_bps=0))[0]
a = cost[cost.portfolio_id == "Balanced"]
b = free[free.portfolio_id == "Balanced"]
assert a.cost.sum() > 0
assert a.nav.iloc[-1] < b.nav.iloc[-1]
def test_low_vol_weights_ignore_future_prices(inputs):
p, h, s, c = inputs
first = portfolio_analytics(p, h, s, c)[-1]
altered = p.copy()
mask = altered.date > sorted(p.date.unique())[c["warmup_days"]]
altered.loc[mask & (altered.ticker == "A"), "adjusted_price"] *= 2
second = portfolio_analytics(altered, h, s, c)[-1]
pd.testing.assert_frame_equal(first, second)
def test_forecast_label_and_features_use_correct_dates():
dates = pd.bdate_range("2020-01-01", periods=120)
r = pd.Series(np.sin(np.arange(120)) * 0.01, index=dates)
group = pd.DataFrame({"date": dates, "net_return": r.values})
frame = feature_frame(group, r, pd.Series(1000, index=dates), 20)
t = frame.index[10]
i = dates.get_loc(t)
assert frame.loc[t, "target"] == pytest.approx(r.iloc[i + 1:i + 21].std() * np.sqrt(252))
altered = group.copy()
altered.loc[altered.date > t, "net_return"] = 0.2
after = feature_frame(altered, r, pd.Series(1000, index=dates), 20)
np.testing.assert_allclose(frame.loc[t, FEATURES].astype(float), after.loc[t, FEATURES].astype(float))
def test_atomic_load_preserves_previous_database(tmp_path):
with sqlite3.connect(tmp_path / "investment_analytics.db") as conn:
conn.execute("CREATE TABLE original (value INTEGER)")
conn.execute("INSERT INTO original VALUES (7)")
with pytest.raises(sqlite3.Error):
load_mart(tmp_path, {"new_table": pd.DataFrame({"x": [1]})}, "INVALID SQL;")
with sqlite3.connect(tmp_path / "investment_analytics.db") as conn:
assert conn.execute("SELECT value FROM original").fetchone()[0] == 7
{
"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,
"seed": 42
}07 / Critical assessment
The system demonstrates portfolio accounting, reproducible ETL, relational reporting and chronological model evaluation. The reported return and risk differences apply to this selected historical sample.
Python, pandas, NumPy, SQLite, scikit-learn, Plotly, matplotlib and Jupyter are implemented here. MATLAB and portfolio optimization belong to the separate ETF portfolio research project.
08 / Evidence
The repository contains the frozen inputs, pipeline modules, SQL views, tests, configuration, workflow, notebook, and generated report. CSVs expose the underlying portfolio metrics, model scores and validation boundaries for independent review.