NATHAN GOMES

Investment research / Python / SQL / Statistical learning

Quantitative Investment Analytics ETL Pipeline

From historical prices to portfolio decisions: a reproducible workflow for comparing Canadian equity allocations, measuring risk after trading costs, and evaluating volatility forecasts.

Independent research project by Nathan Gomes. Frozen market data through 31 August 2026. All values in CAD.

19,575Validated price observations
4 + XICPortfolio allocations and benchmark
377Held-out forecasts per portfolio
15Automated tests passing

01 / Research findings

Higher returns came with different risks.

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.

  • Growth: 27.80% annualized return, 48.72% maximum drawdown.

    A 65% technology target produced the highest historical return and volatility. The large peak-to-trough loss matters as much as the ending value.

  • Balanced: the highest sample Sharpe ratio, 0.97.

    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.

  • Low volatility: 15.29% realized annualized volatility.

    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.

  • Forecasting: improvements were mixed.

    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.

Net results, 3 January 2019 to 31 August 2026. CAD 100,000 starting value; 252-session annualization.
PortfolioCumulative returnAnnualized returnVolatilitySharpeMax drawdownTrading costs
Balanced279.82%19.12%16.29%0.97-28.73%$776.61
XIC benchmark214.75%16.22%16.37%0.82-37.21%$0.00
Growth549.20%27.80%28.60%0.90-48.72%$1,856.88
Income212.94%16.13%16.06%0.83-33.06%$395.51
Low volatility243.59%17.57%15.29%0.94-29.71%$573.68
Portfolio value and drawdown histories for four Canadian equity portfolios and XIC
Actual saved research output. Cumulative performance includes modeled rebalancing costs. Drawdown measures loss from the previous portfolio peak.

Download the complete performance metrics

02 / Portfolio design

Eight securities, four allocation policies.

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.

Rebalancing targets. Low-volatility percentages are rounded; full-precision targets sum to 100%.
SecuritySectorGrowthIncomeBalancedLow volatility
RY.TO · Royal Bank of CanadaFinancials15.00%25.00%15.00%18.75%
TD.TO · Toronto-Dominion BankFinancials0.00%20.00%10.00%19.72%
SHOP.TO · ShopifyTechnology35.00%0.00%10.00%4.04%
CSU.TO · Constellation SoftwareTechnology30.00%0.00%10.00%7.85%
ENB.TO · EnbridgeEnergy0.00%20.00%15.00%8.82%
FTS.TO · FortisUtilities0.00%20.00%15.00%16.14%
EMA.TO · EmeraUtilities0.00%15.00%10.00%13.66%
CNR.TO · Canadian National RailwayIndustrials20.00%0.00%15.00%11.03%

Why XIC?

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.

Download exact target weights

03 / Financial methodology

Make the accounting conventions explicit.

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.

Daily asset return

rt = Pt / Pt−1 − 1

Today's adjusted close divided by yesterday's, minus one. This makes differently priced stocks comparable.

Portfolio return

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.

Annualized compound return

CAGR = (Vend / Vstart)252/N − 1

Converts growth over N observed daily returns into an annualized compound rate. V is net portfolio value.

Volatility and Sharpe

σ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.

Maximum drawdown

Dt = Vt / max(V0, …, Vt) − 1

Maximum drawdown is the most negative D. Including starting capital ensures an immediate loss is counted.

Trading costs

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

A traceable path from inputs to outputs.

  1. Extract

    Read prices, target holdings and the security master. Download adjusted prices through yfinance or reuse a checksum-verified frozen CSV.

  2. Clean and validate

    Parse dates and numbers, count exact duplicates, reject conflicting records and missing prices, and verify the security universe and target weights.

  3. Calculate

    Pandas and NumPy calculate asset returns and rolling statistics, then simulate portfolio value, drifting allocations and transaction costs.

  4. Evaluate models

    Construct forward volatility labels, purge overlapping training labels, tune models chronologically and retain held-out predictions.

  5. Publish the data mart

    Replace the SQLite snapshot atomically after integrity checks. Preserve successful run history and reconcile positions to portfolio NAV.

  6. Review

    Export CSVs, interactive charts and a notebook with saved figures. Configuration, input hashes and package versions identify the research run.

SQL data model

TableGrainResearch purpose
securitiesSecuritySector and currency dimension
security_daily_analyticsSecurity × dateAdjusted price, return and rolling risk
portfolio_positionsPortfolio × security × datePosition value, weight and sector
portfolio_daily_summaryPortfolio × dateNAV, return, costs and drawdown
model_predictionsPortfolio × model × forecast dateActual and predicted volatility
pipeline_runsSuccessful runConfiguration, provenance and hashes
Inspect the implemented SQL queries

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

Can past volatility predict the next month?

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.

Candidate models

  • Linear regression: an unpenalized reference.
  • Ridge: an L2 penalty shrinks coefficients.
  • Lasso: an L1 penalty can set coefficients to zero.
  • ElasticNet: combines L1 and L2 regularization.

A practical baseline

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.

Chronology is part of the model.

Develop and tuneFour expanding training foldsLast training feature: 2 Jan 2025
Purge 20 sessionsLast training label ends 30 Jan 2025
Evaluate once31 Jan 2025 to 31 Jul 2026377 test predictions per portfolio

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.

CV selected Ridge with α = 100 for each portfolio. RMSE is shown in percentage points of annualized volatility; positive improvement means lower error than persistence.
PortfolioRidge RMSEBaseline RMSERMSE improvementTest R²
Balanced4.18 pp4.98 pp+16.1%-0.020
Growth9.93 pp13.14 pp+24.4%-0.045
Income3.51 pp3.12 pp-12.7%-0.332
Low volatility3.60 pp3.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.

Model selection decisions worth examining

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.

Inspect the actual feature construction and validation code
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)

All model scores · Cross-validation boundaries

06 / Engineering assurance

Tests target errors that would change the conclusion.

Calculation and chronology

  • A 10% loss followed by a 10% gain compounds to a 1% loss.
  • Initial losses count toward drawdown.
  • Portfolio returns use previous-close weights.
  • Trading costs reduce the final portfolio value.
  • Changing future prices leaves initial low-volatility weights unchanged.
  • Forward labels contain exactly the intended next 20 returns.

Data and publication

  • Reject negative, missing and infinite price fields.
  • Reject conflicting duplicates and unknown holdings.
  • Count and remove exact duplicate records.
  • Reconcile position weights and values to NAV.
  • Reject incomplete ticker-date coverage.
  • Preserve the previous database when publication fails.

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.

Review the automated tests
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
Research configuration
{
  "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
}

Inspect run provenance and checksums

07 / Critical assessment

What the evidence establishes, and what remains open.

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.

Current limitations

  • Today's selected stock universe introduces survivorship and selection bias.
  • Adjusted history can be revised; it is not institutional point-in-time data.
  • Sector exposures differ materially from the benchmark.
  • No FX, taxes, delisted securities or explicit dividend cash ledger.
  • A constant risk-free rate and flat transaction-cost assumption.
  • Shared missing dates need an exchange calendar to detect.
  • Database publication is atomic; CSV and HTML exports are not a single transaction.

Priorities for the next research iteration

  1. Add a trading calendar and stronger data-coverage auditing.
  2. Evaluate alternative lookbacks, cost levels and sector-matched benchmarks.
  3. Align CV and test post-processing, then validate wider penalty grids on fresh data.
  4. Add a training-mean baseline and methods that account for overlapping forecast errors.
  5. Introduce point-in-time universes, uncertainty estimates and versioned artifact publication.

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

Read the results alongside the implementation.

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.