C++
Filtering and Sorting a Trade Pipeline with C++ Lambdas
Introduction
Every production trading system processes streams of trade records: filter by instrument, partition by risk tier, sort by notional value, transform into normalized forms for downstream consumers. The standard library's algorithm suite — std::sort, std::find_if, std::partition, std::transform — was built precisely for this kind of pipeline work. But using it requires passing callable objects, and the traditional approach to those callables is a serious liability.
Named functor structs and standalone function pointers are the pre-C++11 answer. They work, but they scatter logic across the file: the comparison lives in a struct defined fifty lines above the call site that uses it, the context it needs is threaded through constructor parameters, and multiplying filters means multiplying structs. In a codebase processing dozens of instrument types and risk categories, this becomes a maintenance problem — changing a filter threshold requires finding the right struct, updating a field, and hoping nothing else depends on it.
Lambda expressions, introduced in C++11 and extended significantly in C++14, solve this directly. A lambda is an anonymous callable defined exactly where it is used, capturing the surrounding variables it needs without ceremony. This tutorial builds a trade filtering and sorting pipeline from scratch, replacing verbose named functors one at a time and covering every practical aspect of lambda usage: capture modes, STL algorithm integration, mutable captures, generic lambdas, and the choice between std::function and auto for storage.
Background
A lambda expression creates a closure — a function object that bundles code with the variables it references from its surrounding scope. The syntax has four parts:
- Capture list
[...]: which variables from the enclosing scope the lambda can see, and whether it sees them by value or by reference - Parameter list
(...): the function's parameters, same syntax as any function - Return type
-> T: optional; the compiler deduces it in most cases - Body
{ ... }: the function body
The compiler transforms each lambda into an anonymous struct with operator() defined. Every distinct lambda has a unique type — even two lambdas with identical text are different types. This matters when choosing how to store them.
Key terms used throughout:
- Capture by value
[=]or[x]: the lambda gets its own copy of the variable at the moment of creation - Capture by reference
[&]or[&y]: the lambda holds a reference to the original variable; changes in the lambda affect the caller and vice versa - Mutable lambda: a lambda marked
mutablethat is allowed to modify its by-value captures - Generic lambda: a lambda with
autoparameters, equivalent to a templatedoperator()
Practical Scenario
A proprietary trading desk runs a real-time trade processing pipeline. Each trade record carries an instrument ticker, a side (buy or sell), a quantity, and a price per unit. The pipeline must do several things in sequence: filter out trades below a minimum notional threshold (quantity times price), partition the remaining trades into high-value and standard tiers, sort each tier by notional value descending, and produce a normalized report where each record includes the computed notional alongside the original fields.
The pipeline configuration changes frequently. Threshold values are read from a configuration object at startup. The sorting criteria can be switched between ascending and descending order at runtime. Different instrument families use different tier cutoffs. Each of these requirements means the callable passed to an algorithm must have access to runtime configuration — something that function pointers cannot do at all, and named functors do only through constructor injection.
The pipeline must also compose: the output of one algorithm step feeds directly into the next. Clean, readable code at each step is not a cosmetic preference — it is a maintenance requirement for a codebase that three engineers touch every day and that changes in response to new trading regulations.
The Problem
The initial implementation uses standalone function pointers and named functor structs — the approach that predates lambdas.
Create a new file:
touch trades.cpp
Compile and run with:
g++ -std=c++17 -o trades trades.cpp && ./trades
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
struct Trade {
std::string ticker;
std::string side;
int quantity;
double price;
};
// Named functor: must be defined far from the call site
struct NotionalAboveThreshold {
double threshold;
explicit NotionalAboveThreshold(double t) : threshold(t) {}
bool operator()(const Trade& t) const {
return (t.quantity * t.price) >= threshold;
}
};
struct SortByNotionalDesc {
bool operator()(const Trade& a, const Trade& b) const {
return (a.quantity * a.price) > (b.quantity * b.price);
}
};
void print_trades(const std::vector<Trade>& trades, const std::string& label) {
std::cout << label << ":\n";
for (const auto& t : trades) {
std::cout << " " << t.ticker << " " << t.side
<< " qty=" << t.quantity
<< " px=" << t.price
<< " notional=" << (t.quantity * t.price) << "\n";
}
}
int main() {
std::vector<Trade> trades = {
{"AAPL", "BUY", 500, 182.30},
{"MSFT", "SELL", 200, 415.10},
{"TSLA", "BUY", 50, 172.80},
{"NVDA", "SELL", 300, 875.50},
{"AMZN", "BUY", 100, 185.20},
{"GOOG", "SELL", 20, 2750.00},
};
double min_notional = 50000.0;
// Filter: keep only trades above threshold
std::vector<Trade> filtered;
std::copy_if(trades.begin(), trades.end(),
std::back_inserter(filtered),
NotionalAboveThreshold(min_notional));
// Sort by notional descending
std::sort(filtered.begin(), filtered.end(), SortByNotionalDesc());
print_trades(filtered, "Trades above $50,000 (sorted by notional desc)");
}
Trades above $50,000 (sorted by notional desc):
NVDA SELL qty=300 px=875.5 notional=262650
MSFT SELL qty=200 px=415.1 notional=83020
AAPL BUY qty=500 px=182.3 notional=91150
The output is correct, but NotionalAboveThreshold and SortByNotionalDesc are defined at the top of the file, completely disconnected from the main function where they are actually used. Adding a new filter — say, a minimum quantity check, or an instrument allowlist — means defining yet another struct. The threshold value is threaded through the constructor, which means the struct is essentially a workaround for the fact that function pointers cannot close over local variables. Changing the threshold at runtime requires constructing a new functor object every time.
Basic Lambda Syntax: Replacing the Named Functor
A lambda replaces NotionalAboveThreshold with an expression written directly at the call site. The capture list handles what the constructor previously did.
Replace the entire main function with the following:
int main() {
std::vector<Trade> trades = {
{"AAPL", "BUY", 500, 182.30},
{"MSFT", "SELL", 200, 415.10},
{"TSLA", "BUY", 50, 172.80},
{"NVDA", "SELL", 300, 875.50},
{"AMZN", "BUY", 100, 185.20},
{"GOOG", "SELL", 20, 2750.00},
};
double min_notional = 50000.0;
std::vector<Trade> filtered;
std::copy_if(trades.begin(), trades.end(),
std::back_inserter(filtered),
[min_notional](const Trade& t) {
return (t.quantity * t.price) >= min_notional;
});
std::sort(filtered.begin(), filtered.end(),
[](const Trade& a, const Trade& b) {
return (a.quantity * a.price) > (b.quantity * b.price);
});
print_trades(filtered, "Trades above $50,000 (sorted by notional desc)");
}
You can also delete NotionalAboveThreshold and SortByNotionalDesc — they are no longer needed.
Trades above $50,000 (sorted by notional desc):
NVDA SELL qty=300 px=875.5 notional=262650
AAPL BUY qty=500 px=182.3 notional=91150
MSFT SELL qty=200 px=415.1 notional=83020
The filter logic and the threshold it depends on are now in the same place. A reader looking at the std::copy_if call immediately sees what the filter does and what value it uses — without scrolling to a struct definition elsewhere. The sort comparator is similarly self-contained. The [min_notional] capture replaces the constructor parameter, and it does so without any boilerplate.
Capture Modes: Value, Reference, and Mixed
The capture list controls both what the lambda can see and whether mutations inside the lambda affect the original. The distinction matters the moment a lambda needs to update something in its surrounding scope.
Replace the main function with the following extended version that demonstrates each capture mode:
int main() {
std::vector<Trade> trades = {
{"AAPL", "BUY", 500, 182.30},
{"MSFT", "SELL", 200, 415.10},
{"TSLA", "BUY", 50, 172.80},
{"NVDA", "SELL", 300, 875.50},
{"AMZN", "BUY", 100, 185.20},
{"GOOG", "SELL", 20, 2750.00},
};
double min_notional = 50000.0;
std::string target_side = "SELL";
int matched_count = 0;
// [=] captures everything by value — safe, but no writes back to caller
std::vector<Trade> sells_only;
std::copy_if(trades.begin(), trades.end(),
std::back_inserter(sells_only),
[=](const Trade& t) {
return t.side == target_side
&& (t.quantity * t.price) >= min_notional;
});
// [&] captures everything by reference — writes to matched_count are visible
std::for_each(sells_only.begin(), sells_only.end(),
[&](const Trade& t) {
matched_count++;
(void)t;
});
// Mixed: [min_notional, &matched_count] — value for one, reference for another
std::vector<Trade> buys_above;
std::copy_if(trades.begin(), trades.end(),
std::back_inserter(buys_above),
[min_notional, &matched_count](const Trade& t) {
bool ok = t.side == "BUY" && (t.quantity * t.price) >= min_notional;
if (ok) matched_count++;
return ok;
});
print_trades(sells_only, "SELL trades above threshold");
print_trades(buys_above, "BUY trades above threshold");
std::cout << "Total matched: " << matched_count << "\n";
}
SELL trades above threshold:
MSFT SELL qty=200 px=415.1 notional=83020
NVDA SELL qty=300 px=875.5 notional=262650
BUY trades above threshold:
AAPL BUY qty=500 px=182.3 notional=91150
Total matched: 3
[=] is the safe default when the lambda only reads: it takes snapshots of the values it needs and cannot cause surprise mutations in the caller. [&] is for lambdas that accumulate into an output — like matched_count — where the whole point is to write back. Mixed captures name exactly which variables are captured how, making the lambda's dependencies explicit at a glance.
Note: Capturing by reference with [&] is dangerous if the lambda outlives the scope of the captured variables. If the lambda is stored and called after the local variables go out of scope, it holds a dangling reference. For lambdas passed directly to STL algorithms that complete synchronously, this is not a concern — but for lambdas stored in callbacks or deferred tasks, always prefer by-value captures.
std::partition and Tiered Classification
std::partition splits a range into two groups in place: elements satisfying the predicate come first, elements not satisfying it come second. It returns an iterator pointing to the first element of the second group — which is exactly what is needed to print two tiers separately.
Replace main with the following:
int main() {
std::vector<Trade> trades = {
{"AAPL", "BUY", 500, 182.30},
{"MSFT", "SELL", 200, 415.10},
{"TSLA", "BUY", 50, 172.80},
{"NVDA", "SELL", 300, 875.50},
{"AMZN", "BUY", 100, 185.20},
{"GOOG", "SELL", 20, 2750.00},
};
double min_notional = 50000.0;
double high_tier_cutoff = 100000.0;
// Step 1: keep only trades above minimum notional
std::vector<Trade> filtered;
std::copy_if(trades.begin(), trades.end(),
std::back_inserter(filtered),
[min_notional](const Trade& t) {
return (t.quantity * t.price) >= min_notional;
});
// Step 2: partition into high-value and standard tiers
auto tier_boundary = std::partition(
filtered.begin(), filtered.end(),
[high_tier_cutoff](const Trade& t) {
return (t.quantity * t.price) >= high_tier_cutoff;
});
// Step 3: sort each tier by notional descending
auto sort_desc = [](const Trade& a, const Trade& b) {
return (a.quantity * a.price) > (b.quantity * b.price);
};
std::sort(filtered.begin(), tier_boundary, sort_desc);
std::sort(tier_boundary, filtered.end(), sort_desc);
std::cout << "=== HIGH-VALUE TIER (>= $100,000) ===\n";
for (auto it = filtered.begin(); it != tier_boundary; ++it) {
const auto& t = *it;
std::cout << " " << t.ticker << " " << t.side
<< " notional=" << (t.quantity * t.price) << "\n";
}
std::cout << "=== STANDARD TIER ===\n";
for (auto it = tier_boundary; it != filtered.end(); ++it) {
const auto& t = *it;
std::cout << " " << t.ticker << " " << t.side
<< " notional=" << (t.quantity * t.price) << "\n";
}
}
=== HIGH-VALUE TIER (>= $100,000) ===
NVDA SELL notional=262650
GOOG SELL notional=55000
=== STANDARD TIER ===
AAPL BUY notional=91150
MSFT SELL notional=83020
sort_desc is stored in a local auto variable and reused for both sorts — eliminating the duplication of writing the comparator twice. The partition boundary iterator gives direct access to both groups without building a second vector. The entire pipeline — filter, partition, sort — reads as a sequence of intent-describing steps, each expressed in a single algorithm call with an inline lambda.
std::transform: Normalizing Records
std::transform applies a lambda to each element and writes the result into an output range. For the trade pipeline, this is the step that enriches raw records with computed fields — here, the notional value — before they flow to downstream consumers.
Add the following struct and update main:
struct TradeReport {
std::string ticker;
std::string side;
int quantity;
double price;
double notional;
};
Replace the final print block in main with the following transform step:
// Step 4: transform into report records with precomputed notional
std::vector<TradeReport> report;
report.resize(filtered.size());
std::transform(filtered.begin(), filtered.end(),
report.begin(),
[](const Trade& t) -> TradeReport {
return {t.ticker, t.side, t.quantity, t.price,
static_cast<double>(t.quantity) * t.price};
});
std::cout << "\n=== TRADE REPORT ===\n";
for (const auto& r : report) {
std::cout << " " << r.ticker << " " << r.side
<< " qty=" << r.quantity
<< " notional=$" << r.notional << "\n";
}
=== TRADE REPORT ===
NVDA SELL qty=300 notional=$262650
GOOG SELL qty=20 notional=$55000
AAPL BUY qty=500 notional=$91150
MSFT SELL qty=200 notional=$83020
The explicit -> TradeReport return type annotation on the lambda documents what it produces without forcing the reader to infer it from the struct initializer. When a lambda produces a different type than its input — as here, converting Trade to TradeReport — annotating the return type is a clarity win. For lambdas that return the same type they receive, deduction is usually fine.
Mutable Lambdas: Modifying By-Value Captures
By default, a lambda's by-value captures are const inside the body — the lambda holds a copy but cannot modify it. The mutable keyword removes that restriction. This is useful when the lambda needs internal state, like a counter that increments with each invocation.
Add the following function and call it from main:
void demonstrate_mutable() {
std::vector<Trade> trades = {
{"AAPL", "BUY", 500, 182.30},
{"MSFT", "SELL", 200, 415.10},
{"TSLA", "BUY", 50, 172.80},
{"NVDA", "SELL", 300, 875.50},
};
int sequence = 1000;
// mutable: sequence is captured by value but the lambda can modify its copy
auto assign_sequence = [sequence](const Trade& t) mutable -> std::string {
return t.ticker + "-" + std::to_string(sequence++);
};
std::cout << "\n=== SEQUENCE IDs ===\n";
for (const auto& t : trades) {
std::cout << " " << assign_sequence(t) << "\n";
}
// The original 'sequence' variable is unchanged — the lambda modified its own copy
std::cout << "Original sequence after calls: " << sequence << "\n";
}
=== SEQUENCE IDs ===
AAPL-1000
MSFT-1001
TSLA-1002
NVDA-1003
Original sequence after calls: 1000
mutable gives a lambda private state that does not leak back to the caller. The sequence counter increments inside the lambda's own copy, so sequence in the surrounding scope is untouched. This is the correct tool when a lambda needs to carry stateful bookkeeping without coupling it to an external variable that other code might read.
Note: If the lambda is called multiple times through an auto variable (as assign_sequence is above), the mutable state persists across calls on that same closure instance. If the lambda is passed directly to a standard algorithm that copies it internally, each call may operate on a fresh copy — the accumulated state is not preserved. In that case, capture by reference instead.
Generic Lambdas with auto Parameters
A generic lambda uses auto for one or more parameter types. The compiler instantiates a separate version for each distinct type it is called with, exactly like a function template. This lets a single lambda handle multiple record types in a pipeline that processes different instrument categories.
Add the following function and call it from main:
void demonstrate_generic() {
struct FxTrade {
std::string pair;
double rate;
int units;
};
std::vector<Trade> equity_trades = {
{"AAPL", "BUY", 500, 182.30},
{"NVDA", "SELL", 300, 875.50},
};
std::vector<FxTrade> fx_trades = {
{"EUR/USD", 1.0875, 1000000},
{"GBP/USD", 1.2650, 500000},
};
// Generic lambda: works for any type with a notional-like computation
auto print_notional = [](const auto& t, double notional) {
std::cout << " notional = $" << notional << "\n";
};
auto equity_notional = [](const Trade& t) {
return static_cast<double>(t.quantity) * t.price;
};
auto fx_notional = [](const FxTrade& t) {
return static_cast<double>(t.units) * t.rate;
};
std::cout << "\n=== EQUITY NOTIONALS ===\n";
for (const auto& t : equity_trades)
print_notional(t, equity_notional(t));
std::cout << "=== FX NOTIONALS ===\n";
for (const auto& t : fx_trades)
print_notional(t, fx_notional(t));
}
=== EQUITY NOTIONALS ===
notional = $91150
notional = $262650
=== FX NOTIONALS ===
notional = $1087500
notional = $632500
print_notional accepts any type as its first argument because auto turns the lambda into a templated callable. Writing two separate print lambdas or two overloaded functions would duplicate the formatting logic. Generic lambdas are particularly useful for utility operations — printing, logging, hashing — that should work uniformly across a family of types without requiring a common base class.
Storing Lambdas: auto vs std::function
When a lambda must be stored beyond its immediate use — in a configuration map, a dispatch table, or a member variable — the choice of storage type matters. auto is the correct choice when the type is known at compile time and performance matters. std::function<> is the correct choice when the type must be erased and different callables (lambdas, function pointers, bound member functions) must be stored in the same container.
Add the following function and call it from main:
#include <functional>
#include <map>
void demonstrate_storage() {
std::vector<Trade> trades = {
{"AAPL", "BUY", 500, 182.30},
{"MSFT", "SELL", 200, 415.10},
{"TSLA", "BUY", 50, 172.80},
{"NVDA", "SELL", 300, 875.50},
{"AMZN", "BUY", 100, 185.20},
};
// auto: zero overhead, but the type is anonymous — cannot store two different lambdas
auto is_high_value = [](const Trade& t) {
return (t.quantity * t.price) >= 100000.0;
};
// std::function: type-erased, can mix lambdas with different captures in one container
std::map<std::string, std::function<bool(const Trade&)>> filters;
double buy_threshold = 80000.0;
double sell_threshold = 50000.0;
filters["large_buy"] = [buy_threshold](const Trade& t) {
return t.side == "BUY" && (t.quantity * t.price) >= buy_threshold;
};
filters["large_sell"] = [sell_threshold](const Trade& t) {
return t.side == "SELL" && (t.quantity * t.price) >= sell_threshold;
};
std::cout << "\n=== FILTER RESULTS ===\n";
for (const auto& [name, filter] : filters) {
std::cout << name << ":\n";
for (const auto& t : trades) {
if (filter(t))
std::cout << " " << t.ticker
<< " notional=" << (t.quantity * t.price) << "\n";
}
}
// auto for the sort comparator: resolved at compile time, no overhead
std::vector<Trade> high_value;
std::copy_if(trades.begin(), trades.end(),
std::back_inserter(high_value), is_high_value);
std::cout << "High-value count (auto predicate): " << high_value.size() << "\n";
}
=== FILTER RESULTS ===
large_buy:
AAPL notional=91150
large_sell:
MSFT notional=83020
NVDA notional=262650
High-value count (auto predicate): 1
auto gives the compiler full visibility into the closure type, enabling inlining and zero overhead. std::function wraps any callable with a matching signature into a type-erased object, which is what makes the std::map<std::string, std::function<...>> dispatch table possible — you cannot put two different lambda types in the same container without it. The cost is a virtual dispatch and a potential heap allocation per call. Use auto by default; reach for std::function when you need to store, copy, or return callables whose types differ.
Note: Passing a std::function to an STL algorithm instead of a lambda or function pointer prevents the compiler from inlining the call. In hot paths — tight inner loops processing thousands of trades per millisecond — this matters. Profile before committing to std::function in performance-critical code.
Summary
Lambda expressions eliminate the boilerplate of named functors and function pointers by putting callable logic exactly where it is used. This tutorial built a multi-step trade pipeline that demonstrates the full range of lambda capabilities:
- The capture list
[x]by value and[&x]by reference replaces constructor injection that named functors required;[=]captures everything by value safely,[&]captures everything by reference for accumulation, and mixed captures like[threshold, &counter]make dependencies explicit std::sort,std::copy_if,std::partition, andstd::transformall accept lambdas directly as callable arguments; the lambda is defined inline at the call site and captures whatever runtime configuration it needs- By-value captures are
constinside the lambda body by default; themutablekeyword allows the lambda to modify its own copy without affecting the caller, which is correct for stateful lambdas that accumulate internal bookkeeping - Generic lambdas with
autoparameters are templated callables that the compiler instantiates for each distinct argument type; they eliminate duplication for utility operations that must work across multiple record types autois the correct storage type for a lambda when its type is known at compile time;std::function<Signature>is the correct type when different callables must be stored in the same container or returned from a function, at the cost of type erasure overhead- Capturing by reference with
[&]is safe only when the lambda does not outlive the scope of the captured variables; for lambdas stored in callbacks or deferred tasks, always capture by value