Dynamic Portfolio Optimization Example
Portfolio optimization selects assets to balance return and risk. This dynamic variant extends classical Markowitz theory across multiple time periods and incorporates transaction costs when rebalancing portfolios.
In this formulation, asset selections are optimized jointly over all time steps. The model assumes that expected returns and risk estimates are known for each period, resulting in a globally optimal sequence of portfolio decisions.
This corresponds to an offline optimization setting (also referred to as perfect foresight), where future information is available during optimization. As such, the model provides a theoretical benchmark for multi-period portfolio allocation rather than a directly implementable trading strategy.
import getpass
import os
from datetime import datetime, timedelta
import pytz
import yfinance as yf
from dotenv import load_dotenv
from luna_quantum.algorithms import SCIP
from luna_usecases.dynamic_portfolio_optimization import (
DynamicPoCollection,
DynamicPoData,
DynamicPoFormulation,
DynamicPoInstance,
)
load_dotenv()
if "LUNA_API_KEY" not in os.environ:
os.environ["LUNA_API_KEY"] = getpass.getpass("Enter your Luna API key: ")
Download Historical Market Data
We start by selecting a set of assets and downloading historical price data.
From these prices, we compute daily returns, which will serve as the basis for estimating: - expected returns - risk (covariance)
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "JPM", "JNJ", "V"]
end_date = datetime.now(tz=pytz.timezone("Europe/London"))
start_date = end_date - timedelta(days=180)
stock_data = yf.download(tickers, start=start_date, end=end_date, progress=False)
close = stock_data["Close"]
daily_returns = close.pct_change().dropna()
returns = daily_returns.values # shape: (T, N)
From Static Returns to Time-Dependent Estimates
In a static portfolio model, we would compute a single: - expected return vector μ - covariance matrix Σ
However, in a dynamic setting, these quantities change over time.
To model this, we estimate:
- μ[t]: expected returns at time step t
- Σ[t]: covariance matrix at time step t
using a rolling window over past observations.
Rolling Window Estimation
We use a rolling window of fixed size to estimate statistics from recent data.
For each time step t:
- we look at the last window observations
- compute the average return (μ[t])
- compute the covariance matrix (Σ[t])
This means that each estimate is based only on past observations relative to t.
However, note that in the subsequent optimization step, all time steps are considered jointly. As a result, while the statistical estimates themselves do not use future data, the overall optimization still operates in an offline setting with access to all time steps simultaneously.
window = 10 # rolling window
data = DynamicPoData.from_returns(
tickers=tickers,
returns=returns,
window=window,
pick_n=3,
risk_aversion=1.5,
transaction_cost=0.01,
)
data = DynamicPoData(
tickers=["AAPL", "GOOGL", "MSFT"],
expected_returns=[[0.05, 0.08, 0.03], [0.06, 0.04, 0.07]],
covariance_matrices=[
np.array([[0.04, 0.01, 0.005], [0.01, 0.09, 0.02], [0.005, 0.02, 0.03]]),
np.array([[0.05, 0.015, 0.01], [0.015, 0.07, 0.025], [0.01, 0.025, 0.04]]),
],
n_assets=3,
n_time_steps=2,
pick_n=2,
risk_aversion=1.0,
transaction_cost=0.01,
)
print(data.to_string())
Plot Data
Visualize expected returns and risk across time periods.
<Axes: title={'center': 'Dynamic Portfolio Data — Cumulative Returns'}, xlabel='Time Step', ylabel='Growth of 1 unit invested'>
Create Formulation
Optimize multi-period asset allocation balancing returns, risk, and transaction costs.
Dynamic Portfolio Optimization Formulation:
Assets: 8
Time steps: 112
Pick per period: 3
Risk aversion: 1.5
Transaction cost: 0.01
Decision Variables:
x[t,i] in {0,1} for t = 0, ..., 111, i = 0, ..., 7
x[t,i] = 1 if asset i is selected at time step t
z[t,i] in {0,1} for t = 1, ..., 111, i = 0, ..., 7
z[t,i] = 1 if asset i changes between t-1 and t
Total: 1784 binary variables
Objective:
maximize sum_t sum_i mu[t][i]*x[t,i]
- risk_aversion * sum_t sum_{{i,j}} sigma[t][i][j]*x[t,i]*x[t,j]
- transaction_cost * sum_{{t>0}} sum_i z[t,i]
Constraints:
1. Pick exactly 3 assets per period (112 constraints):
sum_i x[t,i] == 3 for all t = 0, ..., 111
2. Transaction cost linearization (1776 constraints):
z[t,i] >= x[t,i] - x[t-1,i] for all t = 1, ..., 111, i = 0, ..., 7
z[t,i] >= x[t-1,i] - x[t,i] for all t = 1, ..., 111, i = 0, ..., 7
Create Instance
Combine data and formulation into a solvable instance.
Data:Dynamic Portfolio Optimization Data:
Tickers: AAPL, MSFT, GOOGL, AMZN, TSLA, JPM, JNJ, V
Number of assets: 8
Time steps: 112
Assets to select per period: 3
Risk aversion: 1.5
Transaction cost: 0.01
Formulation:Dynamic Portfolio Optimization Formulation:
Assets: 8
Time steps: 112
Pick per period: 3
Risk aversion: 1.5
Transaction cost: 0.01
Decision Variables:
x[t,i] in {0,1} for t = 0, ..., 111, i = 0, ..., 7
x[t,i] = 1 if asset i is selected at time step t
z[t,i] in {0,1} for t = 1, ..., 111, i = 0, ..., 7
z[t,i] = 1 if asset i changes between t-1 and t
Total: 1784 binary variables
Objective:
maximize sum_t sum_i mu[t][i]*x[t,i]
- risk_aversion * sum_t sum_{{i,j}} sigma[t][i][j]*x[t,i]*x[t,j]
- transaction_cost * sum_{{t>0}} sum_i z[t,i]
Constraints:
1. Pick exactly 3 assets per period (112 constraints):
sum_i x[t,i] == 3 for all t = 0, ..., 111
2. Transaction cost linearization (1776 constraints):
z[t,i] >= x[t,i] - x[t-1,i] for all t = 1, ..., 111, i = 0, ..., 7
z[t,i] >= x[t-1,i] - x[t,i] for all t = 1, ..., 111, i = 0, ..., 7
Formulate Model
Translate the instance into a mathematical optimization model.
Solve and Interpret
Solve the model with SCIP and interpret the raw result into a use-case-specific solution.
scip = SCIP()
job = scip.run(model)
sol = job.result()
uc_solution = instance.interpret(sol)
print(uc_solution.to_string())
/Users/maximilianjanetschek/PycharmProjects/luna-usecases/.venv/lib/python3.13/site-packages/rich/live.py:260:
UserWarning: install "ipywidgets" for Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
2026-05-29 11:33:37 INFO Sleeping for 5.0 seconds. Waiting and checking a function in a loop.
2026-05-29 11:33:43 INFO Sleeping for 10.0 seconds. Waiting and checking a function in a loop.
2026-05-29 11:33:54 INFO Sleeping for 15.0 seconds. Waiting and checking a function in a loop.
Dynamic Portfolio Optimization Solution:
Status: VALID
Period 0: TSLA, JNJ, V
Period 1: TSLA, JNJ, V
Period 2: TSLA, JNJ, V
Period 3: TSLA, JNJ, V
Period 4: TSLA, JNJ, V
Period 5: TSLA, JNJ, V
Period 6: TSLA, JNJ, V
Period 7: TSLA, JNJ, V
Period 8: TSLA, JNJ, V
Period 9: MSFT, TSLA, V
Period 10: MSFT, TSLA, V
Period 11: MSFT, GOOGL, TSLA
Period 12: MSFT, GOOGL, TSLA
Period 13: MSFT, GOOGL, TSLA
Period 14: MSFT, GOOGL, TSLA
Period 15: MSFT, GOOGL, TSLA
Period 16: MSFT, GOOGL, TSLA
Period 17: MSFT, GOOGL, TSLA
Period 18: MSFT, GOOGL, AMZN
Period 19: MSFT, GOOGL, AMZN
Period 20: MSFT, GOOGL, AMZN
Period 21: MSFT, GOOGL, AMZN
Period 22: MSFT, GOOGL, AMZN
Period 23: MSFT, GOOGL, AMZN
Period 24: MSFT, GOOGL, AMZN
Period 25: MSFT, GOOGL, AMZN
Period 26: MSFT, GOOGL, AMZN
Period 27: MSFT, GOOGL, AMZN
Period 28: MSFT, GOOGL, AMZN
Period 29: MSFT, GOOGL, AMZN
Period 30: MSFT, GOOGL, AMZN
Period 31: MSFT, GOOGL, AMZN
Period 32: AAPL, GOOGL, AMZN
Period 33: AAPL, GOOGL, AMZN
Period 34: AAPL, AMZN, TSLA
Period 35: AAPL, AMZN, TSLA
Period 36: AAPL, AMZN, TSLA
Period 37: AAPL, AMZN, TSLA
Period 38: AAPL, AMZN, TSLA
Period 39: AAPL, AMZN, TSLA
Period 40: AAPL, AMZN, TSLA
Period 41: AAPL, AMZN, TSLA
Period 42: AAPL, AMZN, TSLA
Period 43: AAPL, AMZN, TSLA
Period 44: AAPL, AMZN, TSLA
Period 45: AAPL, AMZN, TSLA
Period 46: AAPL, MSFT, AMZN
Period 47: AAPL, MSFT, AMZN
Period 48: AAPL, MSFT, AMZN
Period 49: AAPL, MSFT, AMZN
Period 50: AAPL, MSFT, AMZN
Period 51: AAPL, MSFT, AMZN
Period 52: MSFT, AMZN, JPM
Period 53: MSFT, AMZN, JPM
Period 54: MSFT, AMZN, JPM
Period 55: MSFT, AMZN, JPM
Period 56: MSFT, AMZN, JPM
Period 57: MSFT, AMZN, JPM
Period 58: MSFT, AMZN, JPM
Period 59: MSFT, AMZN, JPM
Period 60: MSFT, AMZN, JPM
Period 61: MSFT, AMZN, JPM
Period 62: MSFT, AMZN, JPM
Period 63: MSFT, AMZN, JPM
Period 64: MSFT, AMZN, TSLA
Period 65: MSFT, AMZN, TSLA
Period 66: MSFT, AMZN, TSLA
Period 67: MSFT, AMZN, TSLA
Period 68: MSFT, AMZN, TSLA
Period 69: MSFT, AMZN, TSLA
Period 70: MSFT, AMZN, TSLA
Period 71: MSFT, AMZN, TSLA
Period 72: MSFT, AMZN, TSLA
Period 73: MSFT, AMZN, TSLA
Period 74: MSFT, AMZN, TSLA
Period 75: MSFT, AMZN, TSLA
Period 76: MSFT, AMZN, TSLA
Period 77: MSFT, GOOGL, TSLA
Period 78: MSFT, GOOGL, TSLA
Period 79: MSFT, GOOGL, TSLA
Period 80: MSFT, GOOGL, TSLA
Period 81: MSFT, GOOGL, TSLA
Period 82: MSFT, GOOGL, JPM
Period 83: MSFT, GOOGL, JPM
Period 84: MSFT, GOOGL, JPM
Period 85: MSFT, GOOGL, JPM
Period 86: MSFT, GOOGL, JPM
Period 87: MSFT, GOOGL, JPM
Period 88: MSFT, GOOGL, JPM
Period 89: MSFT, GOOGL, JPM
Period 90: MSFT, GOOGL, JPM
Period 91: MSFT, GOOGL, JPM
Period 92: MSFT, GOOGL, JPM
Period 93: AAPL, MSFT, GOOGL
Period 94: AAPL, MSFT, GOOGL
Period 95: AAPL, MSFT, GOOGL
Period 96: AAPL, MSFT, GOOGL
Period 97: AAPL, MSFT, GOOGL
Period 98: AAPL, GOOGL, JNJ
Period 99: AAPL, GOOGL, JNJ
Period 100: AAPL, GOOGL, JNJ
Period 101: AAPL, GOOGL, JNJ
Period 102: AAPL, GOOGL, JNJ
Period 103: AAPL, GOOGL, JNJ
Period 104: AAPL, GOOGL, JNJ
Period 105: AAPL, GOOGL, JNJ
Period 106: AAPL, AMZN, JNJ
Period 107: AAPL, AMZN, JNJ
Period 108: AAPL, AMZN, JNJ
Period 109: AAPL, AMZN, JNJ
Period 110: AAPL, AMZN, JNJ
Period 111: AAPL, AMZN, JNJ
Portfolio Return: 1.605505
Portfolio Risk: 0.132801
Transaction Costs: 0.260000
Plot Solution
Visualize the optimal solution.
<Axes: title={'center': 'Dynamic Portfolio — Time-dependent Selection'}, xlabel='Time Step', ylabel='Growth of 1 unit invested'>
Collections
Generate a benchmark collection of random instances for batch processing.
collection = DynamicPoCollection.from_random(
min_assets=3, max_assets=6, n_time_steps=2, pick_n=2, num_instances=1, seed=42
)
model = collection.instances[0].formulate()
print(model)
Model: dynamic_portfolio_optimization<dynamic_portfolio_optimization>
Maximize
-0.00031845908234652613 * x_0_0 * x_0_1 - 0.005378824097872742 * x_0_0 * x_0_2
- 0.003801733038977486 * x_0_1 * x_0_2 + 0.006563805484193941 * x_1_0 * x_1_1
- 0.006438512615851617 * x_1_0 * x_1_2 - 0.006590377259410821 * x_1_1 * x_1_2
+ 0.03303333549964048 * x_0_0 + 0.013952958333736984 * x_0_1
+ 0.052749880094328724 * x_0_2 + 0.03631039904708676 * x_1_0
+ 0.021702789925954542 * x_1_1 + 0.04562590629288803 * x_1_2 - 0.01 * z_1_0
- 0.01 * z_1_1 - 0.01 * z_1_2
Subject To
pick_n_period_0: x_0_0 + x_0_1 + x_0_2 == 2
pick_n_period_1: x_1_0 + x_1_1 + x_1_2 == 2
tc_pos_1_0: x_0_0 - x_1_0 + z_1_0 >= 0
tc_neg_1_0: -x_0_0 + x_1_0 + z_1_0 >= 0
tc_pos_1_1: x_0_1 - x_1_1 + z_1_1 >= 0
tc_neg_1_1: -x_0_1 + x_1_1 + z_1_1 >= 0
tc_pos_1_2: x_0_2 - x_1_2 + z_1_2 >= 0
tc_neg_1_2: -x_0_2 + x_1_2 + z_1_2 >= 0
Binary
x_0_0 x_0_1 x_0_2 x_1_0 x_1_1 x_1_2 z_1_0 z_1_1 z_1_2