Control & Structures in Rust


From One Strategy to Many

Welcome to Episode 2 of the Rust Beginner Series based on my book - Rust the good parts. In the pilot episode,Falling in love - Making a case for the Rust Programming Language, the case for why Rust’s guarantees was established, how the language saves you time by catching those bug hardest to trace. In the following episode, Introducing Rust Datatypes, we used six numbers to compute a Sharpe ratio by hand inside the main function using Rust, demonstrating Rust’s data types along the way. In today’s post, we extend one strategy to three, each with years of daily returns behind it. To make that jump, we reach for a tool from the software engineering toolbox called domain modelling. You already learned a little about domain modelling in Episode 1, back when we scratched the surface by discussing data types. In Rust, primitive data is modelled using scalar types: a single number, a single boolean, a single character. Complex domain objects, however, need a special Rust type called a struct. We will talk a great deal about structs and their memory ownership in this article, because that is exactly how Rust models domain objects: a struct is a way to bundle scalar types, and possibly other structs, together under one name. In our focus domain, financial strategies, a strategy’s name and its results are bundled together into one struct. We will also meet a vector of structs: a way to hold several strategies side by side so they can be compared.

Every Rust program eventually has to answer a question once data is shared instead of owned by one block of code: who owns this, and who’s just borrowing it? But that raises an earlier question first: why share data at all, instead of keeping every calculation walled off inside its own six lines the way Episode 1 did? The answer is that comparison is the whole point of this episode. You cannot decide which strategy is best by looking at one strategy at a time in isolation; you need all three sitting side by side, in one place, so a decision can be made about them together. This episode answers that question of ownership and sharing, and along the way picks up the second tool every real program needs: control flow. Deciding which branch to take and repeating work until it’s done are not separate topics from ownership; they are what you need the moment you have more than one strategy to judge.

What This Episode Covers

  • if, else, and match, including patterns, guards, and Option
  • loop, while, and for, plus break and continue
  • Structs, and building them with an associated function instead of a constructor
  • Why moving a value into a Vec is safe by default, with nothing extra to write
  • Opting in to duplication with Clone and Copy, rather than opting out of it
  • Drop, Rust’s automatic cleanup, and why a destructor must never panic
  • A closing challenge: comparing three real trading strategies, choosing the best one, entirely in idiomatic Rust

What is program control?

Before any code, it helps to name what “control flow” actually means in plain terms, because the phrase sounds more abstract than the idea is. Every program a computer runs is really just a long list of instructions carried out one after another, and control flow is simply the set of tools that let you change that order: to skip some instructions when a condition isn’t met, to repeat a group of instructions several times instead of writing them out again and again, and to choose between two or more different paths depending on what the data in front of you looks like. In our domain, control flow is the difference between a program that can only ever describe one strategy and a program that can look at several strategies and actually decide something about them: which one is best, which ones are too risky to keep, which ones deserve a second look. Without control flow, a program is a fixed script. With it, a program becomes something closer to a decision-maker.

if as an expression

The first and most familiar tool is if and else. If you’ve written code in any other language before, the shape will already feel comfortable: a condition is checked, and one block of code or another runs depending on whether that condition is true or false. Rust has one detail worth calling out specifically, because it is a little different from what many beginners expect coming from other languages: if in Rust is an expression, meaning it produces a value of its own, rather than simply being a control statement that decides which side effects happen.

let sharpe = 1.24;

let verdict = if sharpe >= 1.0 { "keep it" } else { "reconsider" };
println!("{verdict}");

Here, sharpe represents a Sharpe ratio we’ve already calculated for some strategy, and the if expression itself becomes the value bound to verdict, rather than verdict being set inside each branch separately. Both branches must produce the same type, in this case the text &str, because the compiler needs to know at compile time exactly what type verdict will hold no matter which branch actually runs. Notice too that the semicolon after the closing brace belongs to the let statement as a whole, not to the expression tucked inside it. In our domain, this small idiom is doing real work: it lets you turn a Sharpe ratio directly into a plain-English recommendation in a single, readable line, which is exactly the kind of translation a program built for financial decision-making needs to make constantly.

match: Rust’s pattern switch

if and else work well when there are only two outcomes to consider, but a Sharpe ratio doesn’t really have two outcomes: it has a whole spectrum of possible qualities, from disastrous to exceptional. For that kind of many-branched decision, Rust gives you match, a tool that compares a value against a list of patterns and runs whichever one fits first. match has a property that makes it especially trustworthy for financial code: it is exhaustive. The Rust compiler will not let your program build at all unless you have accounted for every possible value the thing you’re matching on could take. That is a strong guarantee to have in code that decides where money goes.

let category = match sharpe {
    s if s < 0.0  => "poor",
    s if s < 1.0  => "fair",
    s if s < 2.0  => "good",
    _             => "excellent",
};

println!("Sharpe {sharpe:.2} is {category}");

Each line here is called an arm: a pattern on the left of the =>, and the code to run when that pattern fits on the right. The s if ... form you see on the first three arms is called a match guard: it’s an extra condition attached to a pattern, and Rust needs it here specifically because the language does not allow you to write a plain numeric range pattern, like 0.0..=1.0, directly against a floating-point value such as an f64. The arms are checked strictly from top to bottom, so a Sharpe ratio of 0.6 only reaches the "fair" arm because it already failed the "poor" arm’s test of being less than zero. The final _ is a wildcard pattern, catching anything not already matched by an earlier arm, and without it the compiler would refuse to build the program at all, because it has no way to prove that every possible f64 value has been accounted for. In our domain, this single match block is doing the job a human analyst does by eye when skimming a performance report: translating a raw, hard-to-interpret number into a plain-language verdict a decision-maker can act on immediately.

match can also reach inside a value and pull pieces out of it, a technique called destructuring:

let point = (3, -1);

match point {
    (0, 0) => println!("origin"),
    (x, 0) => println!("on the x-axis at {x}"),
    (_, y) => println!("y is {y}"),
}

Each arm’s pattern is itself shaped like a tuple, and Rust tries each pattern against point in order, binding new variable names, like x and y here, as it finds a match. We won’t need this particular trick for our Sharpe ratio work today, but it’s worth seeing early, because it’s the same underlying mechanism we’ll lean on constantly once our strategies carry more structure than a single number.

Option and if let

Not every question has a guaranteed answer. Sometimes a value might simply not exist yet: a “best strategy so far” before you’ve looked at any strategies, for instance. Rust represents that possibility with a type called Option<T>, which means “either a value of type T, or nothing at all.” This is one of Rust’s genuinely distinctive ideas: rather than allowing a variable to silently hold a null or missing reference the way many other languages do, and risking a crash the moment your code assumes a value is there when it isn’t, Rust forces you to handle the “nothing” case explicitly, every single time.

let best: Option<&str> = Some("Momentum");

match best {
    Some(name) => println!("Best strategy: {name}"),
    None       => println!("No strategy qualified"),
}

Both arms here are required. Leaving out the None case is not a runtime risk you hope never happens; it is a compile error the moment you try to build the program, caught long before any money or decisions are ever on the line. When you only genuinely care about one of the two possibilities, and are happy to do nothing at all in the other case, if let gives you a shorter way to write the same idea:

if let Some(name) = best {
    println!("Best strategy: {name}");
}

Loops: loop, while, and for

Deciding between branches is only half of control flow. The other half is repetition: doing the same kind of work again and again without writing it out by hand each time, which becomes essential the instant you have more than a handful of strategies or more than a handful of days of returns to look at. Rust gives you three different loop constructs, each suited to a slightly different situation.

loop repeats a block of code forever, with no built-in stopping condition of its own; you decide when to stop by using break explicitly:

let mut count = 0;
loop {
    count += 1;
    if count == 3 {
        break;
    }
}

while repeats a block of code for as long as some condition remains true, checking that condition again before every single pass:

let mut remaining = 3;
while remaining > 0 {
    remaining -= 1;
}

for is the loop you will reach for most often in everyday Rust code: it runs its body once for every item in something iterable, most commonly a range of numbers:

for day in 1..=6 {
    println!("Day {day}");
}

1..=6 is called an inclusive range, and the = matters: it means the range includes both its start and its end, so this loop runs for days 1 through 6. Writing 1..6 instead would stop one short, at day 5, which is one of the easiest off-by-one mistakes to make in any language, and Rust’s explicit .. versus ..= distinction exists precisely to make you spell out which one you mean rather than guess. Alongside break, which exits a loop entirely, Rust also gives you continue, which skips the remainder of the current pass through the loop and jumps straight to the next one. That’s especially useful when you want to filter out certain cases without wrapping the rest of a loop’s body in yet another layer of if.

Arrays and vectors: two ways to hold many values

Everything we’ve worked with so far, in this episode and in Episode 1, has been one value at a time: one Sharpe ratio, one boolean, one strategy name. But a real trading strategy isn’t one return, it’s a whole history of them, and before we can bundle that history into a struct, we need a way to hold several numbers together in the first place. Rust gives you two different tools for this, and the choice between them is not just a style preference; it reflects a real decision about whether you know, in advance, how many values you’re going to have.

The first tool is the array, written [T; N], where T is the type of thing you’re storing and N is exactly how many of them there are:

let returns: [f64; 6] = [0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052];

println!("First day: {}", returns[0]);
println!("Number of days: {}", returns.len());

The size, 6 here, is part of the array’s type, not something decided while the program is running. That means [f64; 6] and [f64; 5] are genuinely different types as far as the compiler is concerned, the same way i32 and u32 are different types, and it means the compiler knows exactly how much space this array needs before your program ever runs. That’s exactly why an array is a good fit for our six-day return history in this episode’s challenge: we decided, up front, that we’re always comparing exactly six days across all three strategies, so the size is a known, fixed fact about the domain, not something that changes from one run of the program to the next. Indexing into an array, returns[0], gives you the value at that position, counting from zero, and .len() gives you back the size, which for an array the compiler already knew anyway. You can loop over every value in an array with a for loop, the same tool from earlier in this episode:

for r in &returns {
    println!("{r:.4}");
}

Writing &returns here borrows the array rather than taking ownership of it, so returns is still available to use again after the loop finishes, exactly the same borrowing idea you’ve already seen with references.

The second tool is the vector, Vec<T>, and it exists for exactly the situation an array can’t handle: when you don’t know how many values you’re going to have until the program is actually running. Five years of daily returns for a real strategy is around 1,260 numbers, and that count depends on which strategy you’re looking at, how long it’s been trading, and how much history you happen to have loaded, none of which the compiler can know in advance. A Vec solves this by owning a buffer of memory that it can grow, on demand, while your program runs:

let mut history: Vec<f64> = Vec::new();
history.push(0.0041);
history.push(-0.0018);
history.push(0.0026);

println!("Days recorded so far: {}", history.len());

Vec::new() starts with an empty, zero-length vector, and each call to .push(...) adds one more value onto the end, growing the vector’s underlying buffer as needed behind the scenes, entirely automatically. There’s also a shorthand for building a vector with values already in it, the vec! macro, which mirrors how you’d write an array literal:

let history = vec![0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052];

A Vec<f64> and an [f64; 6] support many of the same operations, indexing with history[0], asking for .len(), looping with for r in &history, but they differ in one fundamental way that matters for ownership. An array’s size is fixed and known at compile time, so an array is typically stored directly wherever it’s declared, and moving it means moving that fixed block of memory as a whole. A Vec, by contrast, owns a separate buffer of memory out on the heap, sized however large it currently needs to be, and the Vec value you actually hold is really just a small handle, a pointer, a length, and a capacity, pointing at that buffer. When a Vec moves, as one did earlier in this episode when we built strategies, only that small handle moves; the underlying buffer of returns doesn’t need to be copied at all. That’s the same move-by-default behaviour you’ve already seen with String, and for exactly the same reason: a Vec, like a String, owns something that lives separately from the variable name you use to refer to it.

For this episode’s challenge, we’ll stick with the fixed-size array, [f64; 6], inside our Strategy struct, because comparing exactly six days across all three strategies is a decision we’ve deliberately made about the shape of today’s problem. But it’s worth holding onto Vec as the tool you’d reach for the moment that assumption stops being true, the moment “six days” becomes “however many days this particular strategy happens to have,” which is the far more common shape a real trading strategy’s history actually takes.

With that in place, the domain’s central question becomes concrete: a Strategy needs a name and a set of returns held together as one thing, and now you have both tools, arrays and vectors, needed to hold that set of returns before wrapping the whole bundle in a struct.

Structs: bundling data that belongs together

With control flow in hand, we can turn to the second half of this episode’s promise: how Rust represents a whole trading strategy, not just a single return or a single Sharpe ratio. A struct groups several related values together under one name. A trading strategy, in our domain, naturally has a name and a set of daily returns; those two pieces of information belong together as a single idea, so in Rust they become the fields of one type:

struct Strategy {
    name: String,
    returns: [f64; 6],
}

name is a String: an owned, growable piece of text, capable of holding something like "Momentum" or "Mean Reversion". returns is a fixed-size array of exactly six f64 values, the same six daily returns you worked with by hand in Episode 1’s challenge. Every field of a struct carries its own explicit type, exactly the way every let binding does; a struct is really nothing more mysterious than several typed values gathered under one shared name, which happens to be precisely what a trading strategy is at heart: a name, attached to a track record.

Building a struct: associated functions instead of constructors

Unlike some languages you may have encountered before, Rust does not automatically generate a constructor for you the moment you define a struct. There is no default, built-in way to produce a Strategy value until you write one yourself, and the conventional way Rust programmers write it is an associated function named new:

impl Strategy {
    fn new(name: &str, returns: [f64; 6]) -> Self {
        Self {
            name: name.to_string(),
            returns,
        }
    }
}

impl Strategy opens what’s called an implementation block: a place where you attach functions and behaviour directly to the Strategy type. Inside that block, Self is simply shorthand for “the type this impl block belongs to,” so writing Self { ... } here means exactly the same thing as writing Strategy { ... } would. The call to name.to_string() converts the borrowed &str parameter, a temporary view into some text, into an owned String that the struct can genuinely hold onto for as long as it exists. The name new itself is only a convention, not a special keyword the compiler recognises; you are free to call this function anything you like, but you will find that essentially every Rust library you ever read uses new for exactly this purpose, so this episode follows that same convention.

let momentum = Strategy::new("Momentum", [0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052]);

Notice that Strategy::new(...) is called on the type itself, using ::, rather than on an already-existing value using a dot. That distinction is the visible signal that this function doesn’t need an existing Strategy in hand to do its job; its entire purpose is to bring one into being from scratch.

Ownership is the move constructor, for free

Now let’s put several strategies together in one place, since comparison, remember, is the whole point of this episode:

let strategies = vec![
    Strategy::new("Momentum", [0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052]),
    Strategy::new("Mean Reversion", [0.0012, 0.0009, -0.0004, 0.0015, 0.0002, -0.0003]),
    Strategy::new("Buy and Hold", [0.0006, 0.0007, 0.0005, 0.0006, 0.0006, 0.0007]),
];

Each call to Strategy::new produces a fresh value, and that value moves directly into the Vec that holds all three strategies together. Nothing is copied along the way. There is no hidden step where the compiler quietly duplicates the newly built struct before storing it; ownership of the value simply transfers, in one motion, from the return value of new straight into its slot inside strategies. This is the default behaviour for every struct in Rust, and remarkably, no code at all is required to make it happen, and no code is required to make it safe either. A String, like a strategy’s name, is only ever owned by one place in your program at any given moment, which means there is never a point where two different parts of your program could each believe they alone own the authoritative copy of, say, "Momentum".

Opting in to duplication: Clone and Copy

There are, of course, situations where you genuinely do want a real duplicate of something rather than a single owner. Rust handles this by making duplication something you explicitly opt into, rather than something that happens automatically behind your back:

#[derive(Clone)]
struct Strategy {
    name: String,
    returns: [f64; 6],
}

let backup = momentum.clone();

The line #[derive(Clone)] instructs the compiler to generate a .clone() method that duplicates every single field of the struct. Without that line present, calling momentum.clone() simply fails to compile at all: there is no such method available to call. This is worth pausing on, because it runs the opposite direction from what many beginners expect: the safe default in Rust is that a value cannot be duplicated at all, and you must opt in to duplication, type by type, by explicitly naming that intention in the type’s own definition. Think about what that means for something like a trading ledger that records every executed trade: if that type never derives Clone, it simply cannot be accidentally duplicated anywhere in your program, and two different parts of the system can never quietly disagree about which copy of the trade history is the real one, because there is only ever exactly one.

Copy goes one step further still, reserved for small values, like an individual i32 or f64, where duplicating the value is so cheap that it happens implicitly on assignment rather than requiring an explicit method call at all. Copy can only ever be derived for a type built entirely out of other Copy types, so a struct that holds a String, like our Strategy, can never be Copy: the compiler will not let a type claim to be freely and silently duplicable when one of its own parts genuinely is not.

Drop: automatic cleanup, and why it must never panic

When a value goes out of scope in Rust, meaning the block of code it was created in finishes running, Rust automatically runs that value’s memory clean up for you, with no manual bookkeeping required on your part. You can customise exactly what that automatic cleanup does by implementing a trait called Drop:

struct TradeLedger {
    entries: Vec<String>,
}

impl Drop for TradeLedger {
    fn drop(&mut self) {
        println!("Ledger closed with {} entries", self.entries.len());
    }
}

The drop method takes &mut self and returns nothing at all. That empty return type is a real, meaningful constraint, not an oversight: a destructor in Rust is simply not permitted to report failure back to anyone. If closing something down could genuinely fail in real life, flushing a trading ledger to disk before shutdown, for instance, the idiomatic Rust answer is to give that type its own explicit method that is allowed to fail, and to let Drop act only as a fallback safety net for callers who forgot to close things down properly themselves:

impl TradeLedger {
    fn close(self) -> Result<(), String> {
        // flushing logic that might fail
        Ok(())
    }
}

Notice that close takes self outright, not &self: it takes full ownership of the ledger, rather than merely borrowing it. Once close has been called on a given ledger, that value is gone for good; there is no remaining self sitting around for a second, accidental call to consume. The entire class of bug where a resource gets closed, freed, or finalised twice, a genuinely common and often serious bug in other languages, is not merely discouraged by convention here: it is structurally impossible to even write, because the compiler will refuse to let you use a value again after it has already been moved into a function that took ownership of it.

Why “half-built” objects cannot call methods on themselves

There is a closely related trap that Rust simply does not allow to happen at all: calling a method on a value before that value is fully and completely built. Inside Self { name: ..., returns: ... }, every single field of the struct must be supplied before that expression is able to produce anything whatsoever. There is no such thing, in Rust’s eyes, as a half-constructed Strategy sitting around somewhere with only some of its fields set, waiting for a stray method call to accidentally reach it. A value simply does not exist yet, as far as the type system is concerned, until every one of its fields does.

Internalize: in Rust, construction is atomic. You cannot observe, borrow, or call a method on a struct until it is completely built, because the struct literal itself is the only way to bring one into existence.

Builder-style methods that return Self

Chaining several setup steps together does not require any special syntax of its own in Rust; it simply falls naturally out of ordinary methods that take self by value and return Self right back:

struct StrategyBuilder {
    name: String,
    returns: Vec<f64>,
}

impl StrategyBuilder {
    fn new(name: &str) -> Self {
        Self { name: name.to_string(), returns: Vec::new() }
    }

    fn with_return(mut self, r: f64) -> Self {
        self.returns.push(r);
        self
    }
}

let built = StrategyBuilder::new("Momentum")
    .with_return(0.0041)
    .with_return(-0.0018);

with_return takes mut self, which means it takes ownership of the builder for the duration of the call and is permitted to mutate its own local copy of it, pushes one new return value onto returns, and then hands self right back as its return value. Each call in the chain consumes the builder handed to it by the previous call and produces a fresh one to pass along to the next .with_return(...). No aliasing is ever possible partway through this chain, because only one method at a time ever actually holds the value.

One small, pleasant consequence falls out of this design for free: assigning a value to itself, written let x = x;, is either a Copy, a cheap and always-safe bitwise duplicate, or simply a move of the value onto itself, which amounts to doing nothing at all. There is no version of assignment in Rust that first discards an old value and only afterward reads from where it used to be, so there is no lurking bug where that discarding happens a moment too soon.

Deriving traits copies every field, by construction

#[derive(Clone, Debug, PartialEq)]
struct Strategy {
    name: String,
    returns: [f64; 6],
}

#[derive(...)] generates implementations of Clone, Debug, and PartialEq that automatically touch every field of the struct. Add a brand new field to Strategy next month, perhaps a benchmark: String to compare against, and every one of these derived implementations updates itself automatically the very next time the project compiles. There is no separate, hand-written clone method living elsewhere in your codebase that could quietly forget about the new field and silently produce an incomplete copy. If you choose to write an implementation by hand instead of deriving it, the compiler has no way to protect you from forgetting a field; deriving, wherever the automatic behaviour is genuinely what you want, which is most of the time, removes that entire category of mistake before it can ever happen.

The challenge: comparing three real trading strategies

Disclaimer: The numbers used here are completely fictitious and this article is by no means any form of financial advice. You have been warned!

Now let’s bring control flow, structs, and ownership together on a genuinely realistic problem: three real trading strategies, each with a different personality, compared side by side to find the best one.

Before looking at the code, it’s worth understanding what these three strategies actually represent, because the numbers in the challenge aren’t arbitrary; they’re shaped to reflect how each strategy really tends to behave in practice.

Momentum is a strategy built on a simple, intuitive idea: assets that have been rising tend to keep rising for a while, so you buy into strength and ride the trend. Momentum strategies often produce their biggest gains when a trend is running hot, but they are also the first to suffer when that trend suddenly reverses, because the strategy has no way of knowing a reversal is coming until the losses have already started. That personality shows up directly in our numbers: [0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052] swing noticeably from day to day, with strong up days mixed against sharper down days, exactly the jagged, higher-variance pattern you’d expect from a strategy that is always chasing whatever is currently moving.

Mean Reversion takes the opposite bet: it assumes that when a price has moved too far, too fast, in either direction, it tends to snap back toward its average over time, so the strategy buys what has fallen and sells what has risen. Because it’s essentially betting on stability returning rather than a trend continuing, mean reversion strategies tend to produce smaller, steadier gains punctuated by occasional mild losses when the expected reversion simply doesn’t happen in time. Our numbers reflect that: [0.0012, 0.0009, -0.0004, 0.0015, 0.0002, -0.0003] are consistently modest, with small dips rather than sharp ones, the fingerprint of a strategy designed around consistency rather than chasing outsized wins.

Buy and Hold is, by design, the simplest strategy of the three: buy once, and then do essentially nothing at all, riding whatever broad growth the market delivers over the long run without ever trying to time an entry or an exit. Because it isn’t reacting to short-term price movements in any way, its daily returns tend to be the steadiest and least dramatic of the group. Our numbers show exactly that: [0.0006, 0.0007, 0.0005, 0.0006, 0.0006, 0.0007] barely move at all from one day to the next, the quiet, low-variance signature of a strategy that has simply chosen not to react.

These three very different personalities, aggressive and trend-chasing, steady and contrarian, and patient and passive, are precisely why comparing them by eye is so hard, and precisely why a Sharpe ratio, and a program that can compute and rank one for each strategy automatically, earns its keep.

use std::cmp::Ordering;

const TRADING_DAYS: usize = 252;
const RISK_FREE_ANNUAL: f64 = 0.05;

struct Strategy {
    name: String,
    returns: [f64; 6],
}

impl Strategy {
    fn new(name: &str, returns: [f64; 6]) -> Self {
        Self { name: name.to_string(), returns }
    }

    fn sharpe_ratio(&self) -> f64 {
        let risk_free_daily = RISK_FREE_ANNUAL / TRADING_DAYS as f64;

        let mean_return = self.returns.iter().sum::<f64>() / self.returns.len() as f64;
        let mean_return = mean_return - risk_free_daily;

        let variance = self
            .returns
            .iter()
            .map(|r| (r - mean_return).powi(2))
            .sum::<f64>()
            / self.returns.len() as f64;

        let std_dev = variance.sqrt();
        let scale = (TRADING_DAYS as f64).sqrt();

        (mean_return / std_dev) * scale
    }

    fn category(&self) -> &'static str {
        match self.sharpe_ratio() {
            s if s < 0.0 => "poor",
            s if s < 1.0 => "fair",
            s if s < 2.0 => "good",
            _            => "excellent",
        }
    }
}

fn main() {
    let strategies = vec![
        Strategy::new("Momentum", [0.0041, -0.0018, 0.0026, 0.0003, -0.0007, 0.0052]),
        Strategy::new("Mean Reversion", [0.0012, 0.0009, -0.0004, 0.0015, 0.0002, -0.0003]),
        Strategy::new("Buy and Hold", [0.0006, 0.0007, 0.0005, 0.0006, 0.0006, 0.0007]),
    ];

    let mut best_index = 0;
    let mut best_sharpe = f64::MIN;

    for (i, strategy) in strategies.iter().enumerate() {
        let sharpe = strategy.sharpe_ratio();
        println!("{:<15} {:>7.3}  ({})", strategy.name, sharpe, strategy.category());

        match sharpe.partial_cmp(&best_sharpe) {
            Some(Ordering::Greater) => {
                best_sharpe = sharpe;
                best_index = i;
            }
            _ => continue,
        }
    }

    println!("\nBest strategy: {}", strategies[best_index].name);
}

Here is how this program works, from top to bottom, and what each piece means for the domain we’re modelling.

strategies is a Vec<Strategy>, built by moving three freshly constructed Strategy values into it, exactly as we practised earlier in this episode. This single line is where domain modelling and ownership meet directly: three genuinely different trading philosophies now live side by side, as equal citizens of the same collection, ready to be judged by the same yardstick. strategies.iter() produces an iterator of &Strategy references rather than handing over ownership of the strategies themselves, and for (i, strategy) in strategies.iter().enumerate() pairs each of those references with its position in the vector. Because this loop only borrows strategies rather than consuming it, strategies remains fully usable once the loop finishes, which matters here because the very last line of the program indexes back into it to announce the winner.

strategy.sharpe_ratio() is called through a shared reference, since sharpe_ratio takes &self, meaning it can read every field of a strategy, its name and its full return history, without ever needing to take ownership of it away from the vector. Inside that method, self.returns.iter().sum::<f64>() replaces the six individually named variables from Episode 1 with a single line that adds up every return in the array at once, whether that strategy happens to be the jagged momentum series or the placid buy-and-hold one. .map(|r| (r - mean_return).powi(2)) then produces the squared deviation for every individual return before .sum::<f64>() adds those together too, exactly the same variance calculation from Episode 1, now written once and reused automatically for every strategy in the vector rather than copied and pasted three separate times. Nothing in this method is ever mutable; every intermediate value is computed once and used once, which means there is no possibility of a stray leftover value from momentum’s calculation accidentally bleeding into mean reversion’s.

category reuses the exact match-with-guards pattern introduced earlier in this episode, simply called through self instead of against a standalone variable, translating each strategy’s raw Sharpe ratio into the same plain-English verdict a human analyst would reach for.

The comparison loop itself tracks the best strategy seen so far using two mut bindings, best_index and best_sharpe, updated as the loop makes its way through momentum, then mean reversion, then buy and hold. sharpe.partial_cmp(&best_sharpe) returns an Option<Ordering> rather than a plain Ordering, because a comparison between two floating-point numbers can genuinely fail to produce any order at all if either side happens to be NaN, not a number, which can occur in real financial data when a calculation divides by zero volatility. Matching on Some(Ordering::Greater) only updates the running best strategy when the new Sharpe ratio is genuinely and unambiguously larger than anything seen so far; every other outcome, including a comparison that failed to resolve at all, simply falls through to continue and the loop moves on to the next strategy without disturbing the current leader. By the time the loop finishes, best_index names the winning strategy’s position inside strategies, and the final println! reads its name straight back out of the vector, with no cloning required anywhere in the entire program.

Run it, and the three very different personalities we described earlier resolve into a single, defensible verdict: not the strategy with the flashiest headline return, but the one that earned its return most efficiently, per unit of risk actually taken.

Coming up next

Picking a single winner out of three strategies is a reasonable place to stop if you can only hold one at a time, but that’s rarely how real portfolios work. A more realistic question is not “which strategy is best” but “how much of my capital should I put into each strategy,” since a mix of momentum, mean reversion, and buy and hold might deliver a better risk-adjusted return than any single one of them alone. Answering that properly means writing genuine, reusable functions for the first time, rather than the methods and inline calculations we’ve leaned on so far, and then handing those functions to a technique from operations research called linear programming: a way of finding the exact mix of weights across several strategies that maximises return, or minimises risk, subject to constraints like “the weights must add up to one” or “no single strategy gets more than half the portfolio.” The next episode covers both: functions as a first-class tool in their own right, and portfolio optimisation as the first genuinely domain-driven problem this series has tackled that a match statement alone can’t solve.

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. ”Rust - The good parts!” guides you through these concepts step by step. You can learn about data and control structures in as little as 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] The Rust Programming Language, “Control Flow”, https://doc.rust-lang.org/book/ch03-05-control-flow.html

[2] The Rust Programming Language, “Common Collections”, https://doc.rust-lang.org/book/ch08-00-common-collections.html

[3] The Rust Reference, “Drop”, https://doc.rust-lang.org/reference/destructors.html

[4] The Rust Reference, “Derive”, https://doc.rust-lang.org/reference/attributes/derive.html

[5] The Rust Standard Library, std::cmp::Ordering, https://doc.rust-lang.org/std/cmp/enum.Ordering.html

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

Glossary

  • Struct: a named grouping of typed fields, defined with struct and given behaviour through one or more impl blocks.
  • Associated function: a function defined in an impl block and called on the type itself with ::, most commonly new, rather than on an existing value with ..
  • Move: the default way ownership transfers in Rust; the source binding becomes invalid, and nothing is duplicated.
  • Clone: a trait, usually derived with #[derive(Clone)], that provides an explicit .clone() method for producing a genuine duplicate of a value.
  • Copy: a trait for small, stack-only types where assignment duplicates the value implicitly instead of moving it.
  • Drop: a trait implementing automatic cleanup when a value goes out of scope; its drop method cannot return a failure.
  • Match guard: an if condition attached to a match arm, required for patterns, such as floating-point ranges, that cannot be written as a plain pattern.
  • Option<T>: a type representing either Some(value) or None, used wherever a value might be absent instead of a null reference.
  • Ordering: an enum with variants Less, Equal, and Greater, returned by comparison methods like .cmp() and .partial_cmp().