I Know Kung Fu! - The Rust Beginner Series


“I Know Kung Fu!” - The Rust Beginner Series

If you have been following along this Rust beginner series so far, we started out by enumerating why the Rust programming language is fun and enjoyable. Even when sometimes the compiler makes it appear painful, it pays it looking forward. So, congratulations! You now know “Kung Fu”. After three exotic episodes, we have explored Rust data types, instruction sequence control and function structures. Gradually, you should have realised that you now know Kung Fu, and if you aren’t still sure yet, hopefully you will by the end of today’s episode. This is Episode 4.

From Episode 1, the Financial jargon called Sharpe Ratio, was the vehicle used to demonstrate how flexible and powerful Rust is. We then expanded the theme in Episode 2 and 3 using the same Sharpe ratios to compare three different Financial strategies.

Episode 3 was all about restructuring our Sharpe ratio program using functions. All the ingredients presented from Episodes 1 to 3, now, it is time to cook. Episode 3 ended with a question worth sitting with: picking a single best strategy out of three is fine if you can only hold one at a time, but that’s rarely how real investing works. A blend of momentum, mean reversion, and buy and hold, held simultaneously in the right proportions, can genuinely outperform any single one of them on a risk-adjusted basis. Answering “what proportions” properly is a real optimisation problem, and this episode is where we start solving it. We use a real world problem to to put our Rust “Kung Fu” skills to the test.

Here are the links to the previous articles in this series.

The Financial portfolio optimiser discussed in today’s post will be implemented in two stages, spanning two episodes. Today, it is solved the honest, slow way: by trying a large number of candidate portfolios and keeping the best one, a technique known as brute-force search, or grid search. It’s slower than it needs to be and it doesn’t scale past a handful of assets, but it has one enormous advantage for a beginner series: every single step is something you can see happening, with nothing hidden behind a library. Next episode, once you understand exactly what problem we’re solving and why, we’ll swap this brute-force search for a real optimisation solver crate and get the same answer, properly, in a fraction of the time.

What This Episode Covers

  • What an optimisation problem actually asks you to do so with a simple two-variable example solved by exhaustive search
  • Expected return, variance, and covariance: the three numbers a portfolio optimiser needs to know about its assets
  • The budget and weight constraints that keep a portfolio’s allocation honest
  • Four common objective functions a portfolio optimiser can pursue, and what each one optimises for.
  • Defining your own enum, and dispatching on it with match to choose which objective to pursue.
  • Closures and iterator adapters: what .iter(), .zip(), and .map() are actually doing
  • The repeat literal, [value; N] and vec![value; N], for building an array or vector of identical values
  • A brute-force grid search across our three real strategies, in idiomatic Rust
  • Reading the output: optimal weights, expected return, risk, Sharpe ratio, and drawdown
  • A primer on error handling: Option and Result, unwrap, expect, and the ? operator
  • A main that returns a Result instead of crashing, and a final coding challenge that reads its return data as text and handles every parsing failure as a value
  • Where brute force breaks down, and why a real solver earns its place next episode

What an optimisation problem actually asks

Before touching anything related to strategies or Sharpe ratios, it’s worth seeing the shape of an optimisation problem somewhere simpler. Every optimisation problem has three parts: an objective, some quantity you want to make as large or as small as possible; decision variables, the numbers you’re actually allowed to choose; and constraints, the rules those numbers have to obey.

Here’s a classic, tiny example. Suppose a small workshop makes two products, and each unit of the first product earns £3 profit while each unit of the second earns £5. Producing them consumes two shared resources: labour and raw material, and the workshop only has so much of each available today. The question is simple to state and, done by hand, surprisingly fiddly to answer: how many units of each product should the workshop make to maximise total profit, without running out of either resource?

fn profit(x: f64, y: f64) -> f64 {
    3.0 * x + 5.0 * y
}

fn feasible(x: f64, y: f64) -> bool {
    x >= 0.0 && y >= 0.0 && x + 2.0 * y <= 10.0 && 3.0 * x + y <= 15.0
}

fn main() {
    let step = 0.1;
    let mut best = (0.0, 0.0, f64::MIN);

    let mut x = 0.0;
    while x <= 5.0 {
        let mut y = 0.0;
        while y <= 5.0 {
            if feasible(x, y) {
                let p = profit(x, y);
                if p > best.2 {
                    best = (x, y, p);
                }
            }
            y += step;
        }
        x += step;
    }

    println!("Best x = {:.2}, y = {:.2}, profit = £{:.2}", best.0, best.1, best.2);
}

profit is the objective: the quantity we’re trying to maximise. feasible is every constraint bundled into one check: neither product can be made in a negative quantity, and both resource limits, x + 2.0 * y <= 10.0 for labour and 3.0 * x + y <= 15.0 for material, must hold at once. The two nested while loops are the brute-force part: rather than solving the problem algebraically, we simply try every combination of x and y on a fine grid, in steps of 0.1, check whether each one is feasible, and keep whichever feasible combination produces the highest profit seen so far. This is exhaustive search in its plainest form: no cleverness, just checking everything and remembering the best.

It works, and for a problem this small it’s genuinely fast. But notice what happens as the problem grows: two decision variables at fifty steps each is 2,500 combinations to check. Three variables becomes 125,000. Ten variables, which is a perfectly ordinary number of assets in a real portfolio, becomes many billions. That’s the ceiling brute force runs into, and it’s exactly why real optimisation software exists. We’ll feel that ceiling directly by the end of this episode, and it’s what motivates reaching for a solver crate in the next one.

From a toy example to a real portfolio

Now let’s translate that same shape, objective, decision variables, constraints, onto the actual problem: choosing how much of our investment budget to put into each of the three strategies from Episode 2 and Episode 3.

An investor has a total investment budget, and it needs to be spread across several assets, in our case, the three strategies. For each asset i, we care about a handful of numbers: p_i, its current price; μ_i, its expected return; σ_i, its variance, or risk; σ_ij, its covariance with every other asset, a measure of how two assets’ returns move together; and x_i, how much of the budget goes into it.

For this episode, we’re going to work in weights rather than raw prices and share counts. Instead of asking “how many shares of each strategy do I buy,” which needs a price per share we haven’t defined for a trading strategy, we ask “what proportion of my total budget goes into each strategy,” a value w_i between 0 and 1, with every strategy’s weight adding up to exactly 1 across the whole portfolio. This is a standard simplification in portfolio theory, and it doesn’t lose anything important for what we’re building today: it’s simply working directly with the budget constraint in its normalised, percentage form, Σw_i = 1, rather than its raw-currency form, Σp_ix_i = B.

With weights in hand, the two central quantities of the whole problem are:

Expected portfolio return, the weighted average of each strategy’s own expected return:

E[Rp] = Σ μᵢwᵢ

Portfolio variance, the total risk of the blended portfolio, which is not simply the weighted average of each strategy’s individual risk:

Var(Rp) = ΣᵢΣⱼ wᵢwⱼσᵢⱼ

That double sum is the single most important idea in this entire episode, so it’s worth sitting with. σᵢⱼ is the covariance between strategy i and strategy j: a positive number if they tend to rise and fall together, a negative number if one tends to rise when the other falls, and close to zero if they move independently of each other. When i and j are the same strategy, σᵢᵢ is just that strategy’s own variance. The reason Var(Rp) isn’t a simple weighted average of individual risks is that diversification is a real, mathematically genuine effect: blending two strategies that move in opposite directions can produce a combined portfolio that’s less risky than either strategy held alone, and the covariance terms in that double sum are exactly what capture that effect. This is the entire reason portfolio optimisation is worth doing at all, rather than just picking whichever single strategy has the best Sharpe ratio, which is precisely what Episode 2 stopped at.

Objective functions: what are we actually optimising for?

Depending on what an investor actually wants, “optimise the portfolio” can mean several genuinely different things:

(a) Maximise expected return, subject to the budget constraint and no short selling: max E[Rp] = Σμᵢxᵢ, subject to Σpᵢxᵢ ≤ B and xᵢ ≥ 0. This chases the highest possible return with no regard for how bumpy the ride is.

(b) Minimise risk, subject to achieving at least some minimum acceptable return: hold Var(Rp) as low as possible while still requiring Σμᵢxᵢ ≥ Rmin and xᵢ ≥ 0. This is the cautious investor’s version of the problem: don’t chase extra return, just get to an acceptable return as safely as possible.

(c) Maximise the Sharpe ratio, the return-per-unit-of-risk measure this entire series has been building toward since Episode 1. This is the objective we’ll actually implement today, since it’s the one running thread that ties every episode together, and because maximising the Sharpe ratio naturally balances return and risk in one number, rather than requiring you to pick one and constrain the other.

(d) Minimise drawdown or tail risk, which cares less about the smooth statistical spread of returns and more about protecting against the worst realistic outcomes, the kind of stability-first thinking that matters enormously to an investor who genuinely cannot afford a catastrophic month.

All four of these are really special cases of one more general objective, a unified trade-off between reward and risk:

max J(x) = E[Rp] − λVar(Rp)

where λ > 0 is a risk aversion coefficient the investor chooses. A small λ barely penalises risk at all, so the optimiser behaves close to objective (a), chasing pure return. A large λ penalises risk heavily, pulling the optimiser toward objective (b), caution first. This trade-off, plotted across every value of λ, traces out what’s known as the Markowitz efficient frontier: the set of portfolios that deliver the maximum possible return for every given level of risk. We won’t plot the full frontier today, that’s a natural next step once we have a real solver in hand, but it’s worth knowing the phrase and the shape of the idea, because it’s exactly what today’s brute-force search is doing one point of, for one particular choice of objective.

Naming that choice in code: defining an enum

Those four objectives are a closed set. An investor pursuing one of them is not pursuing some fifth unnamed thing, and no portfolio is optimised against two of them at the same time. Four bullet points of prose is a perfectly good way to say that to a human reader, but if the program is going to let you choose between them, that choice has to exist as a value the code can hold, pass around, and act on. Rust’s tool for exactly this job is the enum, and this is the first episode where we define one of our own rather than simply using somebody else’s:

enum Objective {
    MaximiseReturn,
    MinimiseRisk,
    MaximiseSharpe,
    MinimiseDrawdown,
}

The syntax is the enum keyword, a name for the new type, then a comma-separated list in curly braces of the values that type is allowed to take, each one called a variant. That is the whole definition. Objective is now a genuine type, every bit as real as f64 or bool, and it has precisely four possible values: Objective::MaximiseReturn, Objective::MinimiseRisk, Objective::MaximiseSharpe, and Objective::MinimiseDrawdown, each reached with the same :: you already use for module paths. Nothing else is an Objective, and there is no way to construct a fifth one. That is the entire point: the closed set of choices from the prose above is now enforced by the compiler rather than merely remembered by the programmer [5].

Notice that each variant here is just a name, with no data attached to it. That is the simplest form an enum can take, and it is worth pausing on, because you have been matching against enums for two episodes without ever having seen one defined. std::cmp::Ordering, from Episode 2, is exactly this shape: a type with three bare variants, Less, Equal, and Greater, and nothing more. Option<T> is the slightly richer case, with two variants, Some and None, where None is a bare name like ours but Some carries a value along with it, which is precisely why you write Some(Ordering::Greater) with parentheses and None without. Variants that carry data are a genuinely useful next step, and we will reach for them when the series needs them; today, four bare names is all the Objective type has to say.

Because an Objective value is only ever one of four things, match can dispatch on it, the same match you used on Ordering and Option in Episode 2, and turn a chosen objective into the single number the search should try to make as large as possible:

fn score(
    objective: &Objective,
    weights: &[f64; 3],
    means: &[f64; 3],
    cov: &[[f64; 3]; 3],
    risk_free_daily: f64,
    blended: &[f64],
) -> f64 {
    match objective {
        Objective::MaximiseReturn   => portfolio_return(weights, means),
        Objective::MinimiseRisk     => -portfolio_variance(weights, cov),
        Objective::MaximiseSharpe   => portfolio_sharpe(weights, means, cov, risk_free_daily),
        Objective::MinimiseDrawdown => -max_drawdown(blended),
    }
}

The four functions this calls are the ones we build in the rest of this episode, so read it as a sketch of where we are heading rather than something to compile just yet. The important part is the match: it has one arm per variant, and it must have one arm per variant. match in Rust is exhaustive, meaning the compiler checks that every possible value of the matched type is handled and refuses to build the program if any is missed. That is not simply a tidiness rule, it is the property that makes defining an enum worth the effort. Add a fifth variant to Objective next month, say MinimiseTurnover, and every match in the codebase that dispatches on an Objective immediately stops compiling until you decide what it ought to do about the new case. The compiler hands you the list of places needing your attention, rather than leaving you to find them by running the program and noticing a wrong answer.

Two smaller details in that signature are worth calling out. The two minimising arms return their quantity negated, so that score always produces something the search wants to be as large as possible; flipping the sign is the standard trick for expressing a minimisation as a maximisation, and it means the search loop needs to know nothing whatsoever about which objective it happens to be serving. And objective: &Objective borrows the objective with the & from Episode 3 rather than taking it by value, so a search loop calling score thousands of times still owns its one Objective value on every iteration instead of handing it away on the first. Matching through a reference works exactly as it reads: the arms are still written Objective::MaximiseReturn and friends, with no extra ceremony required.

Today we implement objective (c) directly, with the Sharpe ratio written straight into the search rather than selected through an Objective, because that keeps the search loop as small and readable as possible for a first look at it. But the enum above is how you would make it a runtime choice, and it is worth having met the idea now, because a great deal of real Rust is built out of types like this one: a closed set of named possibilities, with a match somewhere deciding what each of them means.

Constraints: keeping the search honest

Alongside the objective, a realistic portfolio optimisation problem carries several constraints. The ones that matter for today’s brute-force version are:

  1. Budget, in weight form: Σwᵢ = 1. Every pound of the budget is allocated somewhere; nothing sits idle, and nothing is allocated twice.
  2. No short selling: xᵢ ≥ 0 for every asset. We are not allowed to bet against a strategy, only to hold a non-negative share of it.
  3. Weight bounds: lᵢ ≤ wᵢ ≤ uᵢ, an optional lower and upper limit on any individual strategy’s share, useful for enforcing “never put more than half the portfolio in one place,” for instance.

Two further constraint types, a minimum expected return floor and a limit on how much the portfolio’s weights are allowed to change from one rebalancing period to the next, called a turnover constraint, matter enormously in a live trading system but need more than a single six-day snapshot of history to mean anything real. We’ll set those aside for today.

First, we need covariance between two strategies’ return series, since Var(Rp) depends on it directly:

fn mean(returns: &[f64]) -> f64 {
    returns.iter().sum::<f64>() / returns.len() as f64
}

fn covariance(a: &[f64], b: &[f64]) -> f64 {
    let mean_a = mean(a);
    let mean_b = mean(b);

    a.iter()
        .zip(b.iter())
        .map(|(x, y)| (x - mean_a) * (y - mean_b))
        .sum::<f64>()
        / a.len() as f64
}

covariance pairs up each day’s return from strategy a with the same day’s return from strategy b, using .zip(...) to walk both slices together, multiplies each pair’s deviation from its own mean, and averages the result. When a and b are the same slice, this collapses to the ordinary variance formula from Episode 1 and Episode 2.

Closures and iterator adapters: reading that chain line by line

That paragraph describes what covariance computes, but it glosses over how, and the how is a piece of Rust worth slowing right down for. Every episode so far has quietly used this style of code, .iter() followed by .map(...) followed by .sum(), without ever naming the two mechanisms that make it work: closures, and iterator adapters. Both of them appear in the four lines of covariance, so let us take that function’s body apart one line at a time.

The chain starts with a.iter(). An iterator is any value that can hand out the items of a collection one at a time, on request, and .iter() is how you ask a slice for one: a.iter() produces an iterator over &f64 references to each of a’s six daily returns, in order. On its own it has done no work at all. It has merely promised to produce those six values when something eventually asks for them.

.zip(b.iter()) is the first adapter. An iterator adapter is a method you call on an iterator that returns another iterator, one which transforms or combines the values flowing through it [7]. .zip(...) takes a second iterator and walks both of them in lockstep, producing a tuple of one item from each at every step [2]: first day one of a paired with day one of b, then day two of a paired with day two of b, and so on down the series. If the two iterators are of different lengths it stops the moment either one runs out, which is exactly the pairing behaviour covariance needs, since a day’s return from a is only meaningful against the same day’s return from b.

.map(|(x, y)| (x - mean_a) * (y - mean_b)) is the second adapter, and it is where the closure lives. .map(...) applies a function to every item its input iterator produces and yields the results [8], and the thing between those parentheses is that function, written inline at the point of use. That is a closure: an anonymous function, with no fn and no name, whose parameters go between a pair of vertical bars and whose body follows immediately afterwards [6]. Here the parameter list is |(x, y)|, which quietly does two jobs at once: it takes the single tuple that .zip produced and destructures it into x, the day’s return from a, and y, the same day’s return from b. The body, (x - mean_a) * (y - mean_b), is the product of the two deviations from their respective means, which is one term of the covariance sum.

The genuinely important word in “closure” is the closing-over part. Look carefully at where mean_a and mean_b come from. They are not parameters of the closure, and they are not parameters of covariance either. They are local let bindings in the enclosing function body, three lines further up, and the closure reaches out and uses them anyway. That is the thing a closure can do and a plain fn cannot: it captures variables from the scope it was written in, and carries access to them along wherever it goes. A nested fn inside covariance, of the kind Episode 3 showed you, would have to take mean_a and mean_b as explicit parameters, because a fn sees nothing at all of the scope surrounding it. The closure simply uses them, which is precisely why this style of code stays so short.

.sum::<f64>() is not an adapter, it is the consumer, and it is the line that makes everything above it actually run. Adapters are lazy: .zip and .map build up a description of work to be done and produce no values whatsoever until something demands them. .sum() demands them, pulling one item at a time through the entire chain and adding it to a running total. That laziness is the reason the chain costs nothing extra: at no point does a collection of six zipped tuples exist in memory, and at no point does a collection of six products exist either. Each day’s pair is created, multiplied, added to the total, and discarded before the next day’s pair is even asked for. The ::<f64> on the end is a type annotation for the sum, telling .sum() which type to accumulate into, since it is happy to add up several different numeric types and cannot infer this one from the surrounding context on its own.

So the whole four-line chain reads: take each day of a, pair it with the same day of b, turn each pair into the product of its two deviations, add all of those up, and divide by the number of days. Written out as a loop instead, with a mut accumulator of the kind portfolio_variance uses just below, the same function would be:

fn covariance_by_loop(a: &[f64], b: &[f64]) -> f64 {
    let mean_a = mean(a);
    let mean_b = mean(b);

    let mut total = 0.0;
    for i in 0..a.len() {
        total += (a[i] - mean_a) * (b[i] - mean_b);
    }

    total / a.len() as f64
}

Both versions compute an identical number, and neither one is cheating: the adapter version compiles down to essentially the same machine code as the loop, so choosing between them is a question of which states your intent more clearly, not which runs faster. The loop is explicit about its indices and needs you to check that i is used consistently across both slices; the adapter chain says “pair them up, transform each pair, add the results” and cannot get its indexing wrong because it never indexes anything in the first place. Once you can read the second form as fluently as the first, a great deal of idiomatic Rust stops looking dense. You will see the very same three-part shape, .iter(), then adapters, then a consumer, in portfolio_return immediately below, where weights.iter().zip(means.iter()).map(|(w, m)| w * m).sum() pairs each weight with its strategy’s expected return, multiplies each pair, and sums the lot, which is the formula E[Rp] = Σ μᵢwᵢ written as a single line of Rust.

Next, the portfolio-level quantities, built directly from the definitions above:

fn portfolio_return(weights: &[f64; 3], means: &[f64; 3]) -> f64 {
    weights.iter().zip(means.iter()).map(|(w, m)| w * m).sum()
}

fn portfolio_variance(weights: &[f64; 3], cov: &[[f64; 3]; 3]) -> f64 {
    let mut total = 0.0;
    for i in 0..3 {
        for j in 0..3 {
            total += weights[i] * weights[j] * cov[i][j];
        }
    }
    total
}

fn portfolio_sharpe(weights: &[f64; 3], means: &[f64; 3], cov: &[[f64; 3]; 3], risk_free_daily: f64) -> f64 {
    let excess_return = portfolio_return(weights, means) - risk_free_daily;
    let risk = portfolio_variance(weights, cov).sqrt();
    excess_return / risk
}

portfolio_variance is a direct, literal translation of the double sum ΣᵢΣⱼ wᵢwⱼσᵢⱼ: two nested loops over every pair of assets, including a strategy paired with itself, each contributing wᵢ × wⱼ × σᵢⱼ to the running total. There is nothing clever happening here on purpose; the code reads exactly like the mathematics because that’s the clearest way to make sure the two agree.

Building an array of identical values: [value; N] and vec![value; N]

portfolio_variance takes its covariance matrix as &[[f64; 3]; 3], an array of three arrays of three f64s, and the main function below builds that matrix by writing out all nine covariance(...) calls in full. That is the clearest possible way to show every one of the nine pairings at once, which is why it stays exactly as it is, but it is not the only way to build such a thing, and the alternative introduces a piece of syntax this series has not shown you yet.

Episode 2 built arrays by listing their elements, [0.0041, -0.0018, 0.0026], and Episode 3 did the same for vectors with vec![0.0041, -0.0018, 0.0026]. There is a second form, for the very common case where every element starts out the same:

let mut cov = [[0.0; 3]; 3];

[0.0; 3] is the repeat form: a value, a semicolon, and a count, meaning “an array of three copies of 0.0” [9]. Its type is [f64; 3], exactly as if you had typed [0.0, 0.0, 0.0] by hand. Wrapping that in another repeat, [[0.0; 3]; 3], gives three copies of that row, so cov has type [[f64; 3]; 3], which is precisely what portfolio_variance expects a covariance matrix to be. Read the semicolon as the divider between “what goes in it” and “how many of them,” and note that the count has to be known at compile time, because an array’s length is part of its type.

An all-zero matrix is of no use by itself, of course. The point of starting from one is that you can then fill it in with a loop rather than by hand:

let series = [momentum, mean_reversion, buy_and_hold];
let mut cov = [[0.0; 3]; 3];

for i in 0..3 {
    for j in 0..3 {
        cov[i][j] = covariance(&series[i], &series[j]);
    }
}

This produces exactly the same nine numbers as the spelled-out version in main, out of two lines of setup and a pair of nested loops whose i and j mirror the subscripts of σᵢⱼ directly. The mut on cov is required because we write into it after creating it, and the zeros here are genuine placeholders: every one of the nine slots is overwritten before anything reads it. Which version you prefer is a real judgement call rather than a matter of correctness. Three strategies is small enough that the explicit literal is arguably clearer, since you can see every pairing at a glance without mentally running a loop; at ten strategies, a hundred hand-written covariance(...) calls would be indefensible, and the loop is the only sane option left.

Vec<T> has the same shorthand, spelled with the vec! macro:

let equal_weights = vec![1.0 / 3.0; 3];   // an equal-weighted starting portfolio

vec![value; N] builds a growable Vec of N copies of value, where vec![a, b, c] builds one from the elements you list out [10]. The distinction between the two containers is the same one as ever: [0.0; 3] is a fixed-size array whose length of three is baked into its type and settled at compile time, while vec![0.0; 3] is a heap-allocated vector that merely happens to start with three elements and can grow or shrink afterwards. That difference is what makes the vector form indispensable the moment the number of assets stops being a constant you can type out: vec![0.0; n], where n is read from a file or worked out at runtime, is perfectly legal, whereas [0.0; n] is not. There is one further wrinkle worth knowing about, though it never bites when filling matrices with numbers: the array form needs its value to be freely copyable, which every numeric type is, while the vec! form is content with a value that can be cloned instead [10].

Errors are values: a primer on Option, Result, and ?

Every function in this episode has so far trusted its inputs completely. mean assumes its slice is non-empty, portfolio_sharpe assumes the risk is never zero, and the return series arrive as array literals you typed by hand, so they cannot be malformed. That is the happy path, and the happy path is where tutorials live. Real programs spend a surprising amount of their time on the other path, on data that is missing, malformed, or out of range. Before we assemble the final coding challenge, there is one ingredient left to meet: the way Rust answers “what do we do when this fails?” The answer is interesting, because it is the same answer as “what do we do when this succeeds?”: a value.

Rust does not throw exceptions the way many other languages do. Instead, a fallible operation returns a value that carries the outcome with it, and the code that called the operation decides what failure means. There are two such types, and which one you reach for depends on whether the failure comes with an explanation worth keeping.

Option<T> is the type for “there might not be a value here.” A value of type Option<T> is either Some(value) or None. You met it in Episode 2, where you matched on Option and saw Some(Ordering::Greater) and None, but you have never had to construct one, because the standard library hands them to you ready-made. Asking a slice for an element that may not exist returns an Option: series.get(1) is Some(&-0.0018) on a six-element series, while series.get(9) is None, and the type is telling you that “no element at that index” is an ordinary, expected outcome rather than a disaster [11]. Option is the right tool when the absence of a value is a normal state of affairs.

Result<T, E> is the type for “the operation either produced a value of type T, or it failed with an error of type E.” A Result is either Ok(value) or Err(error). This is the one to reach for when the failure deserves an explanation, what went wrong, or perhaps how far it got. The canonical example is parsing, turning text into a number, which is exactly the situation the final challenge is about to be in. "0.0041".parse::<f64>() succeeds and gives Ok(0.0041); "banana".parse::<f64>() fails, and the value you get back is Err(ParseFloatError { kind: Invalid }), an error type from the standard library that exists precisely so the failure can say something about itself. parse is the FromStr trait’s method, and its return type, Result<f64, ParseFloatError>, is spelled out on the documentation page for str::parse [12][13].

let good = "0.0041".parse::<f64>();
let bad = "banana".parse::<f64>();

println!("{:?}", good);  // Ok(0.0041)
println!("{:?}", bad);   // Err(ParseFloatError { kind: Invalid })

The interesting part is what happens next, because a Result does nothing on its own. It sits there containing either a value or an explanation, and you have to do something with it. The most explicit something is a match, one arm per case, which you already know how to write from Episode 2:

match "0.0041".parse::<f64>() {
    Ok(rate) => println!("Parsed a daily return of {rate:.4}"),
    Err(err) => println!("Parsing failed: {err}"),
}

That is the honest, explicit way, and it is also a lot of ceremony for a parse you expect to succeed. Two shortcuts exist, and they are the difference between code that talks about its failures and code that hands the failure to someone else.

unwrap() is the shortcut that says “I am sure this cannot fail; if it does, crash.” Calling .unwrap() on an Ok hands you the value inside; calling it on an Err panics, printing the error and aborting the program [14]. expect("message") is the same panic behaviour with a message you choose, which is the one worth using if you insist on panicking, because a panic that says why it panicked is worth far more than a bare one when you are debugging later. Both are genuinely useful in small example programs and in tests, and both are genuinely dangerous in real code: a piece of input you assumed was valid turns out not to be, and the whole program dies on a Tuesday for no reason you can see.

The ? operator is the mature version. A function that calls a fallible operation and cannot itself handle the failure simply writes ?, which means: if this is Ok, unwrap the value and keep going; if this is Err, return that error from the enclosing function right now, before anything worse happens [15]. The key detail is that ? only works inside a function that returns a Result (or Option), because that is the only way to express “I’m bailing out of here with this failure.” The classic beneficiary is main itself, which is allowed to return a Result instead of (). When a ? bails out of main, Rust prints the error and exits with a failure status, a polite, informative stop rather than a silent one:

fn main() -> Result<(), String> {
    let rate = parse_daily_return("0.0041")?;
    println!("Rate: {rate:.4}");
    Ok(())
}

fn parse_daily_return(text: &str) -> Result<f64, String> {
    text.parse::<f64>().map_err(|err| format!("bad return: {err}"))
}

Two details in that snippet are worth pausing on. map_err rewrites the standard library’s ParseFloatError into a String with a message of your own wording, because the enclosing function has chosen String as its error type and the two must agree. And the trailing Ok(()) is not decoration: a Result-returning main must produce an Ok on the happy path, and () is the empty value that says “we got here, and there is nothing further to report.”

There is one more pattern worth meeting before it appears in the challenge, because it shows up in nearly every Rust program that reads data: an iterator whose items are Results. When you collect such an iterator, you can collect straight into a Result holding a collection, and the whole collection succeeds only if every single item succeeded:

let tokens = ["0.0041", "0.0012", "0.0006"];
let parsed: Result<Vec<f64>, _> = tokens.iter().map(|t| t.parse::<f64>()).collect();
// Ok([0.0041, 0.0012, 0.0006])

let tokens = ["0.0041", "not-a-number", "0.0006"];
let parsed: Result<Vec<f64>, _> = tokens.iter().map(|t| t.parse::<f64>()).collect();
// Err(ParseFloatError { kind: Invalid }) - one bad token fails the whole batch

One bad token and the entire collection short-circuits to Err, which is exactly the semantics a reader of real data wants: either every return parses or the whole line is rejected, with no half-parsed vectors floating around afterwards. The final pairing trick for the challenge is try_into(), which converts a Vec<T> into a fixed-size array of the right length and returns a Result, because the length might not match. vec![0.0041, -0.0018, 0.0026].try_into() into [f64; 3] succeeds; a four-element vector fails, and the failed conversion loses nothing, because the original Vec comes back inside the error [16]. Three mechanisms, ?, collecting Results, and try_into, and between them they cover reading a line of text into exactly the [f64; 6] arrays the portfolio code expects. That is precisely the job of the next function.

Now the brute-force search itself, the final coding challenge of this episode. The search logic is unchanged from the promise made above, respecting the budget and no-short-selling constraints by only ever generating weights that are non-negative and sum to one. What is new is how the data gets in: through parse_series, a function that can fail, and a main that is honest about it:

fn parse_series(line: &str) -> Result<[f64; 6], String> {
    let values: Vec<f64> = line
        .split(',')
        .map(|token| token.trim().parse::<f64>())
        .collect::<Result<_, _>>()
        .map_err(|err| format!("could not parse a return: {err}"))?;

    let count = values.len();
    values
        .try_into()
        .map_err(|_| format!("expected exactly six returns, got {count}"))
}

fn main() -> Result<(), String> {
    let momentum = parse_series("0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052")?;
    let mean_reversion = parse_series("0.0012, 0.0009, -0.0004, 0.0015, 0.0002, -0.0003")?;
    let buy_and_hold = parse_series("0.0006, 0.0007, 0.0005, 0.0006, 0.0006, 0.0007")?;

    let means = [mean(&momentum), mean(&mean_reversion), mean(&buy_and_hold)];

    let cov = [
        [covariance(&momentum, &momentum), covariance(&momentum, &mean_reversion), covariance(&momentum, &buy_and_hold)],
        [covariance(&mean_reversion, &momentum), covariance(&mean_reversion, &mean_reversion), covariance(&mean_reversion, &buy_and_hold)],
        [covariance(&buy_and_hold, &momentum), covariance(&buy_and_hold, &mean_reversion), covariance(&buy_and_hold, &buy_and_hold)],
    ];

    let risk_free_daily = 0.05 / 252.0;
    let step = 0.02;

    let mut best_weights = [0.0, 0.0, 0.0];
    let mut best_sharpe = f64::MIN;

    let mut w1 = 0.0;
    while w1 <= 1.0 {
        let mut w2 = 0.0;
        while w2 <= 1.0 - w1 {
            let w3 = 1.0 - w1 - w2;   // budget constraint: weights always sum to 1
            let weights = [w1, w2, w3];

            let sharpe = portfolio_sharpe(&weights, &means, &cov, risk_free_daily);
            if sharpe > best_sharpe {
                best_sharpe = sharpe;
                best_weights = weights;
            }

            w2 += step;
        }
        w1 += step;
    }

    let expected_return = portfolio_return(&best_weights, &means);
    let variance = portfolio_variance(&best_weights, &cov);
    let sigma_p = variance.sqrt();

    println!("Optimal weights:");
    println!("  Momentum:       {:.1}%", best_weights[0] * 100.0);
    println!("  Mean Reversion: {:.1}%", best_weights[1] * 100.0);
    println!("  Buy and Hold:   {:.1}%", best_weights[2] * 100.0);
    println!("Expected return E[Rp]: {:.4}%", expected_return * 100.0);
    println!("Portfolio risk σp:      {:.4}%", sigma_p * 100.0);
    println!("Sharpe ratio:           {best_sharpe:.3}");
    Ok(())
}

Here’s how it works. w1 and w2 are searched independently, in steps of 0.02, but w3 is never searched at all; it’s always computed as 1.0 - w1 - w2, which is exactly how the budget constraint, Σwᵢ = 1, is enforced. Bounding w2’s loop at 1.0 - w1 rather than a fixed 1.0 prevents w3 from ever going negative, which is how the no-short-selling constraint, xᵢ ≥ 0, is enforced without needing a separate check inside the loop. For every feasible combination of weights, we compute the resulting portfolio’s Sharpe ratio and keep whichever combination produced the best one seen so far, the same “track the best, discard the rest” pattern from the toy workshop example earlier, just with a genuinely meaningful objective this time. With a step of 0.02, this grid checks roughly 2,600 combinations, small enough to run in well under a second, but you can already feel the ceiling: dropping the step to 0.005 for a finer answer multiplies the work by roughly sixteen, and adding a fourth strategy multiplies it again by the size of a whole new dimension.

Two things in that listing are new since every earlier version of this program, and both are the primer above in action. parse_series is the function that stands between raw text and trusted data. It splits the incoming line on commas, trims each token, and tries to parse each one into an f64; the collect::<Result<_, _>>() line is the primer’s “one bad token fails the whole batch” pattern, so a single "banana" anywhere in a line turns the entire line into an Err. The try_into() call is the length check: a line that parses cleanly into four numbers is not a parse error, but it is still not a valid six-day return series, so it becomes its own Err with its own message. And the ? on each of the three parse_series(...) calls in main is the same operator as the primer: the moment anything fails, the error message is returned out of main, Rust prints it, and the program stops before any optimisation is attempted. That is why main now returns Result<(), String>, because ? is only legal inside a function that can return a failure, and it is why the listing ends with Ok(()), the happy-path value that says everything succeeded.

You can feel the failure path with one edit. Change the first number in the momentum line to banana and run the program again: instead of a cryptic crash you get Error: "could not parse a return: invalid float literal", and the search never even starts. Read that as a feature. A portfolio optimiser that silently rounded a bad number into a plausible-looking answer would be far more dangerous than one that refuses to run at all.

One number is conspicuously missing from that output: maximum drawdown. It’s worth computing, because it answers a genuinely different question than variance does, not “how much do the returns wobble on average” but “how bad did the worst losing streak actually get”:

fn max_drawdown(returns: &[f64]) -> f64 {
    let mut peak = 1.0;
    let mut value = 1.0;
    let mut worst = 0.0;

    for r in returns {
        value *= 1.0 + r;
        if value > peak {
            peak = value;
        }
        let drawdown = (peak - value) / peak;
        if drawdown > worst {
            worst = drawdown;
        }
    }

    worst
}

max_drawdown walks the return series as a running account balance, starting at 1.0, tracking the highest balance seen so far, peak, and how far the current balance has fallen from that peak at every step. The largest such fall across the whole series is the maximum drawdown. Two of the metrics from the original problem definition, conditional value at risk and turnover, genuinely deserve a proper treatment too, but both need a longer, more realistic return history than our six illustrative days to mean anything statistically honest; we’ll pick them up once the series moves from these small worked examples toward real historical data.

Coming up next

Run the search above with a step of 0.005 instead of 0.02 and you’ll notice the wait; add two more strategies to the mix and you’ll notice it a great deal more. Brute force got us a genuinely correct answer today, and seeing every step of it laid bare in code is worth the slowness once, for understanding. But it is not how anyone solves this problem at real scale. The next episode keeps the exact same objective and the exact same constraints, expected return, portfolio variance, the budget and no-short-selling rules, and hands them to a genuine optimisation solver crate, which finds the same optimum directly, without checking a single infeasible combination along the way.

Get the Book

Computer programming doesn’t have to be so hard, does it? It can be as easy as A, B, C. As 1, 2, 3. As “doh,” “ray,” “mi.” As data, control, and structures.

Don’t worry. My new book has you covered, and guides you through these concepts step by step. You can learn about data and control structures in one week, function structures by week two, and by the end of week four you will have built a complete working system, building a game from scratch.

References

[1] Markowitz, H., “Portfolio Selection,” The Journal of Finance, 1952.

[2] The Rust Standard Library, Iterator::zip, https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.zip

[3] Wikipedia, “Efficient frontier”, https://en.wikipedia.org/wiki/Efficient_frontier

[4] Wikipedia, “Brute-force search”, https://en.wikipedia.org/wiki/Brute-force_search

[5] The Rust Programming Language, “Defining an Enum”, https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html

[6] The Rust Programming Language, “Closures”, https://doc.rust-lang.org/book/ch13-01-closures.html

[7] The Rust Programming Language, “Processing a Series of Items with Iterators”, https://doc.rust-lang.org/book/ch13-02-iterators.html

[8] The Rust Standard Library, Iterator::map, https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.map

[9] The Rust Reference, “Array and index expressions”, https://doc.rust-lang.org/reference/expressions/array-expr.html

[10] The Rust Standard Library, std::vec!, https://doc.rust-lang.org/std/macro.vec.html

[11] The Rust Standard Library, std::option::Option, https://doc.rust-lang.org/std/option/enum.Option.html

[12] The Rust Standard Library, std::str::FromStr, https://doc.rust-lang.org/std/str/trait.FromStr.html

[13] The Rust Standard Library, str::parse, https://doc.rust-lang.org/std/primitive.str.html#method.parse

[14] The Rust Standard Library, Result::unwrap, https://doc.rust-lang.org/std/result/enum.Result.html#method.unwrap

[15] The Rust Programming Language, “Recoverable Errors with Result”, https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html

[16] The Rust Standard Library, TryFrom and TryInto, https://doc.rust-lang.org/std/convert/trait.TryFrom.html

Glossary

  • Objective function: the quantity an optimisation problem is trying to maximise or minimise.
  • Decision variable: a value the optimiser is free to choose, such as a portfolio weight.
  • Constraint: a rule a solution must satisfy, such as weights summing to one or none being negative.
  • Covariance (σᵢⱼ): a measure of how two assets’ returns move together; positive if they tend to rise and fall together, negative if they tend to move oppositely.
  • Grid search / brute-force search: an optimisation technique that evaluates every candidate solution on a discretised grid and keeps the best one found.
  • Risk aversion coefficient (λ): a parameter controlling how heavily an optimiser penalises risk relative to return, small values favouring return, large values favouring safety.
  • Efficient frontier: the set of portfolios that achieve the maximum possible expected return for each given level of risk.
  • Maximum drawdown (MDD): the largest peak-to-trough decline in a return series’ cumulative value.
  • Option<T>: a type whose value is either Some(value) or None, used for outcomes where there may simply be no value, such as a slice element that might not exist.
  • Result<T, E>: a type whose value is either Ok(value) or Err(error), used for operations that can fail with an explanation, such as parsing text into a number.
  • ? operator: a shorthand that unwraps an Ok and continues, or returns the Err from the enclosing Result-returning function immediately; the reason a program’s main can be declared to return a Result.
  • unwrap / expect: shortcuts that extract the value from a Result or Option and panic if it is an Err or None, with expect supplying a message of your choosing.
  • Closure: an anonymous, inline function written with its parameters between vertical bars, such as |(x, y)| (x - mean_a) * (y - mean_b), which can capture and use variables from the scope it was written in, something a named fn cannot do.
  • enum: a type definition listing a fixed, closed set of named alternatives called variants, such as this post’s Objective; a match on an enum must cover every variant, which is what makes the closed set enforceable by the compiler rather than merely a convention.
  • Iterator adapter: a method called on an iterator that returns another iterator, transforming or combining the values passing through it, such as .map() or .zip(); adapters are lazy and do no work at all until a consumer such as .sum() pulls values through the chain.
  • Repeat literal: the [value; N] and vec![value; N] forms, producing a fixed-size array or a growable vector of N copies of the same value, as against the explicit [a, b, c] and vec![a, b, c] forms that list every element.