Tactical Portfolio Rebalancing with Linear Programming in F#
After recently returning to update the Operations-Research library, I wanted a compelling example to showcase the DSL's capabilities for practical optimization problems. Tactical rebalancing turned out to be an ideal candidate—it combines market data, simple heuristics, and optimization to systematically harvest gains and manage positions.
Consider this scenario: You have a portfolio with various positions bought at different prices. Some stocks have appreciated significantly above your cost basis, while others may have declined. You want to determine which positions to sell (locking in gains) and which to buy more of (averaging down), all while maximizing your cash position.
This is Linear Programming with tactical constraints: only sell positions showing sufficient gains, only buy positions at meaningful discounts, stay within budget and position limits. Here's how to model and solve it using the Operations-Research library in F#.
The Model
Tactical rebalancing uses two simple rules:
1. Sell winners: Only sell if current_price > cost_basis × (1 + sellThreshold)
2. Buy dips: Only buy if current_price < cost_basis × (1 - buyThreshold)
For each eligible stock, we define decision variables:
- sell_symbol: shares to sell (for positions above threshold)
- buy_symbol: shares to buy (for positions below threshold)
- sell_ind_symbol, buy_ind_symbol: binary indicators (1 if we trade, 0 otherwise)
Our objective maximizes net cash increase: Σ(sell_i × (current_price_i - cost_basis_i)) - Σ(buy_i × current_price_i) - fees
Selling above cost basis generates realized gains. Buying costs cash but averages down losing positions. The optimizer finds the best combination.
Understanding the Constraints
The budget constraint ensures purchase costs don't exceed available cash plus sales proceeds. In canonical form: Σ(buy_i × price_i) - Σ(sell_i × price_i) ≤ cash. This means total purchases minus sales revenue must not exceed available cash. Sales generate positive cash flow (hence the negative coefficient), while purchases consume cash.
The inventory constraints prevent selling more shares than owned: sell_i ≤ shares_i for each sellable stock. If you own 500 shares of AAPL, you can sell at most 500.
The position limit constraints prevent over-concentration: buy_i × price_i ≤ max_investment_per_stock. This ensures we don't put all proceeds into a single position, maintaining diversification.
The diversification constraint requires trading at least 3 different stocks: Σ(sell_ind_i) + Σ(buy_ind_i) ≥ 3. This prevents overly concentrated rebalancing and ensures we spread activity across multiple positions.
The linking constraints connect trading amounts to binary indicators. For selling: shares_i × sell_ind_i - sell_i ≥ 0, which reads as "allowed amount minus actual amount must be non-negative." For buying: (cash/price_i) × buy_ind_i - buy_i ≥ 0, limiting purchases to affordable amounts when the indicator is active.
Complete Implementation
Save this as portfolio.fsx:
fsharp
#r "nuget: Google.OrTools, 9.14.6206"
#r "./Operations.Research.dll"
open System
open Operations.Research.Types
open Operations.Research.Models
open Operations.Research.Solvers.Google.Linear
// Mock price data - REPLACE WITH YOUR OWN API CLIENT
// For real data, use Yahoo Finance, Alpha Vantage, IEX Cloud, etc.
let fetchPrices (symbols: string list) =
Map.ofList [
"AAPL", 234.50
"GOOGL", 175.30
"MSFT", 425.75
"NVDA", 145.20
]
let optimizePortfolio (priceMap: Map) (portfolio: Map) =
let cash = 100000.0
let totalFees = 50.0
let maxInvestmentPerStock = 30000.0
let sellThreshold = 0.05 // Only sell if 5%+ gain
let buyThreshold = 0.05 // Only buy if 5%+ below cost basis
// Filter stocks that meet tactical criteria
let sellableStocks =
portfolio
|> Map.filter (fun symbol (shares, costBasis) ->
priceMap.[symbol] > costBasis * (1.0 + sellThreshold))
let buyableStocks =
portfolio
|> Map.filter (fun symbol (shares, costBasis) ->
priceMap.[symbol] < costBasis * (1.0 - buyThreshold))
printfn "\nTactical Analysis:"
printfn "Sellable (>5%% gain): %A" (sellableStocks |> Map.keys |> List.ofSeq)
printfn "Buyable (<5%% below): %A" (buyableStocks |> Map.keys |> List.ofSeq)
if Map.isEmpty sellableStocks && Map.isEmpty buyableStocks then
printfn "\nNo positions meet tactical thresholds. No trades recommended."
else
// Create decision variables for selling
let sellVars =
sellableStocks
|> Map.map (fun symbol _ ->
Variable.real $"sell_{symbol}" 0.0 Double.PositiveInfinity |> toExpression)
let sellIndicators =
sellableStocks
|> Map.map (fun symbol _ ->
Variable.boolean $"sell_ind_{symbol}" |> toExpression)
// Create decision variables for buying
let buyVars =
buyableStocks
|> Map.map (fun symbol _ ->
Variable.real $"buy_{symbol}" 0.0 Double.PositiveInfinity |> toExpression)
let buyIndicators =
buyableStocks
|> Map.map (fun symbol _ ->
Variable.boolean $"buy_ind_{symbol}" |> toExpression)
// Build realized gains terms: sell_i * (price_i - cost_i)
let gainTerms =
[for KeyValue(symbol, (shares, costBasis)) in sellableStocks ->
sellVars.[symbol] * (priceMap.[symbol] - costBasis)]
let realizedGains = List.reduce (+) gainTerms
// Build purchase cost terms: buy_i * price_i
let costTerms =
[for KeyValue(symbol, _) in buyableStocks ->
(-1.0) buyVars.[symbol] priceMap.[symbol]]
let purchaseCost = List.reduce (+) costTerms
// Build sales revenue terms: sell_i * price_i
let revenueTerms =
[for KeyValue(symbol, _) in sellableStocks ->
sellVars.[symbol] * priceMap.[symbol]]
let salesRevenue = List.reduce (+) revenueTerms
// Objective: Maximize net cash increase
// add all terms (purchaseCost already negative)
let objective = realizedGains + purchaseCost + (-totalFees)
// Budget constraint: Σ(buy_i price_i) - Σ(sell_i price_i) <= cash
let budget =
[for KeyValue(symbol, _) in buyableStocks ->
buyVars.[symbol] * priceMap.[symbol]] // POSITIVE buys
@
[for KeyValue(symbol, _) in sellableStocks ->
(-1.0) sellVars.[symbol] priceMap.[symbol]] // NEGATIVE sells
let budgetConstraint = List.reduce (+) budget <== cash
// Inventory constraints: can't sell more than we own
let inventoryConstraints =
[for KeyValue(symbol, (shares, _)) in sellableStocks ->
sellVars.[symbol] <== shares]
// Position limit constraints: don't invest too much in any single stock
let positionLimitConstraints =
[for KeyValue(symbol, _) in buyableStocks ->
buyVars.[symbol] * priceMap.[symbol] <== maxInvestmentPerStock]
// Diversification: must trade at least 3 stocks
let minTrades = min 3 (Map.count sellableStocks + Map.count buyableStocks)
let indicatorTerms =
(sellIndicators |> Map.values |> List.ofSeq) @
(buyIndicators |> Map.values |> List.ofSeq)
let totalTrades = List.reduce (+) indicatorTerms
let diversificationConstraint = totalTrades >== float minTrades
// Sell linking constraints: shares_i * sell_ind_i - sell_i >= 0
let sellLinkConstraints =
[for KeyValue(symbol, (shares, _)) in sellableStocks ->
shares sellIndicators.[symbol] + (-1.0) sellVars.[symbol] >== 0.0]
// Buy linking constraints: maxBuyable_i * buy_ind_i - buy_i >= 0
let buyLinkConstraints =
[for KeyValue(symbol, _) in buyableStocks ->
let price = priceMap.[symbol]
let maxBuyable = cash / price
maxBuyable buyIndicators.[symbol] + (-1.0) buyVars.[symbol] >== 0.0]
// Collect all variables
let allVars =
(sellVars |> Map.values |> List.ofSeq) @
(buyVars |> Map.values |> List.ofSeq) @
(sellIndicators |> Map.values |> List.ofSeq) @
(buyIndicators |> Map.values |> List.ofSeq)
// Collect all constraints
let allConstraints =
[budgetConstraint; diversificationConstraint] @
inventoryConstraints @
positionLimitConstraints @
sellLinkConstraints @
buyLinkConstraints
let mdl =
Model.Default
|> DecisionVars allVars
|> Goal Maximize
|> Objective objective
|> Constraints allConstraints
match SolveWithCustomOptions mdl SolverOptions.Default with
| Solution sol ->
printfn "\n=== TACTICAL REBALANCING RESULTS ==="
printfn "\nCurrent Portfolio:"
portfolio |> Map.iter (fun symbol (shares, costBasis) ->
let currentPrice = priceMap.[symbol]
let gain = ((currentPrice - costBasis) / costBasis) * 100.0
printfn " %s: %.0f shares @ $%.2f cost basis (current: $%.2f, %+.1f%%)"
symbol shares costBasis currentPrice gain)
printfn "\nRecommended Trades:"
let sellTrades =
[for KeyValue(symbol, (shares, costBasis)) in sellableStocks do
let sellAmount = sol.Variables.[$"sell_{symbol}"]
if sellAmount > 0.5 then
let currentPrice = priceMap.[symbol]
let gain = sellAmount * (currentPrice - costBasis)
yield sprintf " SELL %.0f shares of %s @ $%.2f (gain: $%.2f)"
sellAmount symbol currentPrice gain]
let buyTrades =
[for KeyValue(symbol, _) in buyableStocks do
let buyAmount = sol.Variables.[$"buy_{symbol}"]
if buyAmount > 0.5 then
let currentPrice = priceMap.[symbol]
let cost = buyAmount * currentPrice
yield sprintf " BUY %.0f shares of %s @ $%.2f (cost: $%.2f)"
buyAmount symbol currentPrice cost]
let allTrades = sellTrades @ buyTrades
if List.isEmpty allTrades then
printfn " No trades recommended (thresholds too restrictive or insufficient capital)"
else
allTrades |> List.iter (printfn "%s")
printfn "\nOptimization Result (realized gains - costs - fees): $%.2f" sol.Objective.toFloat
// Calculate final cash position
let mutable totalSalesRevenue = 0.0
let mutable totalPurchaseCost = 0.0
for KeyValue(symbol, _) in sellableStocks do
let sellAmount = sol.Variables.[$"sell_{symbol}"]
if sellAmount > 0.5 then
totalSalesRevenue <- totalSalesRevenue + (sellAmount * priceMap.[symbol])
for KeyValue(symbol, _) in buyableStocks do
let buyAmount = sol.Variables.[$"buy_{symbol}"]
if buyAmount > 0.5 then
totalPurchaseCost <- totalPurchaseCost + (buyAmount * priceMap.[symbol])
let finalCash = cash + totalSalesRevenue - totalPurchaseCost - totalFees
printfn "Final Cash Position: $%.2f" finalCash
| Error e ->
printfn "Solver failed: %A" e
// Run optimization with realistic portfolio data
let portfolio = Map.ofList [
"AAPL", (500.0, 223.00) // Cost basis $223, current $234.50 (~5.2% gain)
"GOOGL", (200.0, 185.00) // Cost basis $185, current $175.30 (~5.2% loss)
"MSFT", (300.0, 405.00) // Cost basis $405, current $425.75 (~5.1% gain)
"NVDA", (100.0, 153.00) // Cost basis $153, current $145.20 (~5.1% loss)
]
let symbols = portfolio |> Map.keys |> List.ofSeq
let priceMap = fetchPrices symbols
printfn "Current Market Prices:"
priceMap |> Map.iter (fun k v -> printfn " %s: $%.2f" k v)
optimizePortfolio priceMap portfolio
Running the Example
Requirements: .NET 8 SDK or higher
Download the latest release from the repository. Place Operations.Research.dll in the same folder as portfolio.fsx, then run:
bash
dotnet fsi portfolio.fsx
Current Market Prices:
AAPL: $234.50
GOOGL: $175.30
MSFT: $425.75
NVDA: $145.20
Tactical Analysis:
Sellable (>5% gain): ["AAPL"; "MSFT"]
Buyable (<5% below): ["GOOGL"; "NVDA"]
=== TACTICAL REBALANCING RESULTS ===
Current Portfolio:
AAPL: 500 shares @ $223.00 cost basis (current: $234.50, +5.2%)
GOOGL: 200 shares @ $185.00 cost basis (current: $175.30, -5.2%)
MSFT: 300 shares @ $405.00 cost basis (current: $425.75, +5.1%)
NVDA: 100 shares @ $153.00 cost basis (current: $145.20, -5.1%)
Recommended Trades:
SELL 171 shares of AAPL @ $234.50 (gain: $1961.62)
BUY 171 shares of GOOGL @ $175.30 (cost: $30000.00)
BUY 207 shares of NVDA @ $145.20 (cost: $30000.00)
Optimization Result (realized gains - costs - fees): $61911.62
Final Cash Position: $79950.00
Understanding the Results
The optimizer identified the optimal strategy given our constraints:
1. Harvest gains from AAPL by selling 171 shares at a 5.2% profit
2. Deploy capital into undervalued positions - buying GOOGL and NVDA both trading 5% below cost basis
3. Maximize asset exposure - ending with $79,950 cash (down from $100,000) but $60,000 more invested
This demonstrates the model working as designed: it maximizes the objective function (realized gains minus costs) while respecting all constraints. The strategy deploys most available capital into positions with potential upside, a logical outcome when maximizing gains.
To maintain higher liquidity, you could modify:
- Reduce
maxInvestmentPerStockfrom $30,000 to $10,000 to limit individual position sizes - Add a minimum cash constraint:
cash_final >= 50000.0 - Modify the objective to include a liquidity preference term
The DSL handles the tactical logic cleanly: pre-filtering eligible stocks, calculating realized gains versus purchase costs, and enforcing budget constraints that account for sales proceeds. The model naturally balances harvesting gains against averaging down positions, with configurable thresholds (sellThreshold, buyThreshold) that users can adjust based on their strategy.
Portfolio data is structured as Map, making it type-safe and clear that each position has both a quantity and a reference price. The optimization finds the combination of sells and buys that maximizes immediate cash increase while respecting position limits and diversification requirements. The example uses mock price data for simplicity—replace fetchPrices with your own API client for real market data.
Note: This is a simplified educational model designed to demonstrate the DSL's capabilities for tactical rebalancing. Professional models would include additional factors: tax implications (short-term vs. long-term gains), market impact costs, sector constraints, risk metrics, multi-period planning, and compliance rules. The beauty of the DSL is that these complexities can be added incrementally while maintaining readability.
Try it out: https://github.com/acco32/Operations-Research