07. Generics & LINQ - C#

Quote

“All non-trivial abstractions, to some degree, are leaky.”

Joel Spolsky, The Law of Leaky Abstractions, blog post (2002)

Generics let you write type-safe code that works across multiple types without duplication — the compiler enforces correctness at compile time rather than deferring to runtime casts. LINQ (Language Integrated Query) extends this with a declarative pipeline model for filtering, transforming, grouping, and joining collections directly in C#, mirroring SQL semantics while preserving strong typing.

// Suppress CS1701/CS1702 assembly version warnings in .NET Interactive.
using System.Reflection;
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.CSharp;
 
var csharpKernel = (CSharpKernel)Kernel.Root.FindKernelByName("csharp");
var optionsField = typeof(CSharpKernel).GetField("_scriptOptions",
    BindingFlags.NonPublic | BindingFlags.Instance);
var scriptOptions = optionsField.GetValue(csharpKernel);
var withWarningLevel = scriptOptions.GetType().GetMethod("WithWarningLevel");
var newOptions = withWarningLevel.Invoke(scriptOptions, new object[] { 0 });
optionsField.SetValue(csharpKernel, newOptions);
#r "nuget: Microsoft.Data.SqlClient"
#r "nuget: Dapper"
#r "nuget: Polars.NET"
#r "nuget: Polars.NET.Native.win-x64"
 
using Microsoft.Data.SqlClient;
using Dapper;
using Polars.CSharp;
using static Polars.CSharp.Polars;
using Microsoft.DotNet.Interactive.Formatting;
// Register Polars DataFrame/Series HTML formatters (transparent for dark theme)
Formatter.Register<DataFrame>((df, writer) =>
{
    var html = df.ToHtml();
    html = System.Text.RegularExpressions.Regex.Replace(html, @"(>|>)&quot;(.+?)&quot;(<|<)", @"$1$2$3");
    html = System.Text.RegularExpressions.Regex.Replace(html, @">""(.+?)""<", @">$1<");
    var css = @"<style>.pl-dataframe,.pl-dataframe *{background:transparent!important;background-color:transparent!important;color:var(--vscode-editor-foreground,inherit)!important}.pl-dataframe{font-size:14px!important;border-collapse:collapse;width:auto}.pl-dataframe td,.pl-dataframe th{padding:6px 12px!important;text-align:left;border:1px solid var(--vscode-panel-border,#555)!important}.pl-dataframe th{font-weight:bold}.pl-dataframe .pl-dtype{font-size:11px;opacity:0.5}</style>";
    writer.Write(css + html);
}, "text/html");
Formatter.Register<Polars.CSharp.Series>((s, writer) =>
{
    var sdf = DataFrame.FromSeries(s);
    var shtml = sdf.ToHtml();
    shtml = System.Text.RegularExpressions.Regex.Replace(shtml, @"(>|>)&quot;(.+?)&quot;(<|<)", @"$1$2$3");
    shtml = System.Text.RegularExpressions.Regex.Replace(shtml, @">""(.+?)""<", @">$1<");
    var scss = @"<style>.pl-dataframe,.pl-dataframe *{background:transparent!important;color:var(--vscode-editor-foreground,inherit)!important}.pl-dataframe{font-size:14px!important;border-collapse:collapse}.pl-dataframe td,.pl-dataframe th{padding:6px 12px!important;text-align:left;border:1px solid var(--vscode-panel-border,#555)!important}.pl-dataframe th{font-weight:bold}.pl-dataframe .pl-dtype{font-size:11px;opacity:0.5}</style>";
    writer.Write(scss + shtml);
}, "text/html");

Generics

Generics enable writing reusable, type-safe code by parameterizing classes, methods, and interfaces with type placeholders (T, TKey, TValue). The compiler substitutes concrete types at compile time, eliminating boxing for value types and catching type mismatches before runtime. Constraints (where T : ...) restrict what types are valid, unlocking access to interface methods, constructors, and base class members within the generic body.

C# | Generics | type parameters and constraints

Generic type parameters are placeholders declared in angle brackets. The compiler infers T from arguments when possible, or you can specify it explicitly. Constraints narrow the allowed types, enabling the compiler to guarantee that operations like .CompareTo() or new T() are valid.

Generic method

Generic methods

  • T First<T>(T[] items) — declares a type parameter T the compiler infers from the argument
  • One method handles int[], string[], double[] — no overloads needed
  • Type safety preserved at compile time
  • Avoid object instead of generics (loses type safety, requires casting)
T First<T>(T[] items) => items[0];
 
Console.WriteLine(First(new[] { 1, 2, 3 }));          // int
Console.WriteLine(First(new[] { "a", "b", "c" }));    // string
Console.WriteLine(First(new[] { 1.1, 2.2, 3.3 }));    // double
 
Console.WriteLine(First<string>(new[] { "x", "y" }));  // explicit type argument
1
a
1.1
x

Generic constraints — where T : ...

Generic constraints restrict what types can be used as a type parameter. Without constraints, T could be anything — you can’t call methods on it because the compiler doesn’t know what T is. Adding where T : IComparable guarantees that T has a CompareTo method, enabling type-safe operations. Common constraints: class (reference type), struct (value type), new() (has parameterless constructor), notnull, and interface/base class requirements.

T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;
 
Console.WriteLine(Max(3, 7));                          // 7
Console.WriteLine(Max("apple", "banana"));             // banana
// Max(new object(), new object());  // Compile error — object doesn't implement IComparable
7
banana

Common generic constraints

ConstraintMeaning
where T : structT must be a value type (int, bool, custom struct)
where T : classT must be a reference type (string, class)
where T : new()T must have a parameterless constructor (must appear last)
where T : IComparable<T>T must implement the specified interface
where T : BaseClassT must inherit from a specific class
where T : notnullT cannot be null
where T : unmanagedT must be an unmanaged type (no reference-type fields)
where T : UT must be or derive from another type parameter U
where T : defaultResolves ambiguity when overriding unconstrained methods
where T : allows ref structT may be a ref struct (anti-constraint, C# 13+)

where T : class== tests reference identity, not value equality

When using the class constraint, the == and != operators on T compare reference identity, not value equality — even if the concrete type (e.g., string) overloads ==. Two distinct string instances with the same content will compare as false.

Use IEquatable<T> for value comparison

Add where T : IEquatable<T> and call a.Equals(b) instead of a == b when you need value equality semantics inside a generic method or class.

Generic class and multiple type parameters

A generic class is parameterized by one or more types, allowing the same data structure to work with any type while maintaining compile-time type safety. Result<TValue, TError> can represent a success value OR an error without boxing or casting. Multiple type parameters let you build type-safe pairs, key-value mappings, and response wrappers.

var ints = new List<int> { 1, 2, 3 };
var lookup = new Dictionary<string, int> { ["Alice"] = 85, ["Bob"] = 92 };
Console.WriteLine($"[{string.Join(", ", ints)}]");
Console.WriteLine(string.Join(", ", lookup.Select(kv => $"{kv.Key}: {kv.Value}")));
 
(TKey, TValue) MakePair<TKey, TValue>(TKey key, TValue value) => (key, value);
 
var pair1 = MakePair("name", 42);
var pair2 = MakePair(1, true);
Console.WriteLine(pair1);
Console.WriteLine(pair2);
[1, 2, 3]
92
(name, 42)
(1, True)

Advanced LINQ

LINQ extends C# with a declarative query model over any IEnumerable<T>. Method syntax chains — Where, Select, GroupBy, Join, SelectMany — compose into lazy pipelines that execute only when enumerated. This section demonstrates core LINQ operators using in-memory anonymous-type collections, covering grouping with aggregations, inner and left joins, lookups, zipping parallel sequences, and flattening nested collections.

C# | LINQ | core operators

The examples below use a shared dataset of employees and departments defined as anonymous types. Each operator is shown independently so the pipeline logic is clear.

Sample data

Shared in-memory collections used throughout the LINQ section — six employees across three departments and four department records (including one with no employees, to demonstrate left join behavior).

var employees = new[]
{
    new { Name = "Alice", Dept = "Engineering", Salary = 95000, Level = "senior" },
    new { Name = "Bob", Dept = "Sales", Salary = 65000, Level = "junior" },
    new { Name = "Charlie", Dept = "Engineering", Salary = 110000, Level = "lead" },
    new { Name = "Diana", Dept = "Sales", Salary = 78000, Level = "senior" },
    new { Name = "Eve", Dept = "Engineering", Salary = 88000, Level = "junior" },
    new { Name = "Frank", Dept = "Marketing", Salary = 72000, Level = "senior" },
};
 
var departments = new[]
{
    new { Dept = "Engineering", Budget = 500000, Head = "CTO" },
    new { Dept = "Sales", Budget = 300000, Head = "VP Sales" },
    new { Dept = "Marketing", Budget = 200000, Head = "CMO" },
    new { Dept = "HR", Budget = 150000, Head = "CHRO" },       // no employees
};

GroupBy and aggregations

GroupBy partitions a sequence into groups based on a key function, then lets you aggregate each group independently. It’s the LINQ equivalent of SQL’s GROUP BY — you specify what to group by (e.g., sector), then compute aggregates per group (count, sum, average). The result is an IGrouping<TKey, TElement> for each distinct key.

var byDept = employees.GroupBy(e => e.Dept);
 
foreach (var group in byDept)
{
    var names = string.Join(", ", group.Select(e => e.Name));
    var avgSalary = group.Average(e => e.Salary);
    Console.WriteLine($"  {group.Key,-15} ({group.Count()} people): [{names}] avg=${avgSalary:N0}");
}
 
foreach (var group in byDept)
{
    var top = group.MaxBy(e => e.Salary)!;
    Console.WriteLine($"  {group.Key,-15} top earner: {top.Name} ${top.Salary:N0}");
}
 
var deptStats = employees.GroupBy(e => e.Dept).Select(g => new
{
    Dept = g.Key,
    Count = g.Count(),
    AvgSalary = g.Average(e => e.Salary),
    MaxSalary = g.Max(e => e.Salary),
    MinSalary = g.Min(e => e.Salary),
    TotalSalary = g.Sum(e => e.Salary),
});
foreach (var s in deptStats)
    Console.WriteLine($"  {s.Dept,-15} count={s.Count} avg=${s.AvgSalary:N0} range=[${s.MinSalary:N0}-${s.MaxSalary:N0}] total=${s.TotalSalary:N0}");
Engineering     (3 people): [Alice, Charlie, Eve] avg=$97'667
Sales           (2 people): [Bob, Diana] avg=$71'500
Marketing       (1 people): [Frank] avg=$72'000
Engineering     top earner: Charlie $110'000
Sales           top earner: Diana $78'000
Marketing       top earner: Frank $72'000
Engineering     count=3 avg=$97'667 range=[$88'000-$110'000] total=$293'000
Sales           count=2 avg=$71'500 range=[$65'000-$78'000] total=$143'000
Marketing       count=1 avg=$72'000 range=[$72'000-$72'000] total=$72'000

Join and GroupJoin

Join combines two sequences by matching a key from each — the LINQ equivalent of SQL’s INNER JOIN. GroupJoin is a LEFT JOIN variant that groups all matching right-side elements under each left-side element, producing a hierarchical result. Both require you to specify the outer key, inner key, and result selector.

Join requires matching key types

The outer and inner key selectors must return the same type. If one returns int and the other returns string, the join silently produces zero results with no compile-time error. Always verify key types match.

Verify key types before joining

Confirm that both key selectors return the same type (e.g., both string). Use explicit casts or .ToString() if types differ, and add a unit test that asserts the join result count is greater than zero.

var innerJoin = employees.Join(
    departments,
    e => e.Dept,
    d => d.Dept,
    (e, d) => new { e.Name, e.Dept, d.Head, d.Budget }
);
foreach (var r in innerJoin.Take(3))
    Console.WriteLine($"  {r.Name,-10} {r.Dept,-15} head={r.Head,-10} budget=${r.Budget:N0}");
 
var leftJoin = departments.GroupJoin(
    employees,
    d => d.Dept,
    e => e.Dept,
    (d, emps) => new { d.Dept, d.Head, Count = emps.Count() }
);
foreach (var r in leftJoin)
    Console.WriteLine($"  {r.Dept,-15} head={r.Head,-10} employees={r.Count}");
Alice      Engineering     head=CTO        budget=$500'000
Bob        Sales           head=VP Sales   budget=$300'000
Charlie    Engineering     head=CTO        budget=$500'000
Engineering     head=CTO        employees=3
Sales           head=VP Sales   employees=2
Marketing       head=CMO        employees=1
HR              head=CHRO       employees=0

Chained pipeline, Lookup, and Zip

LINQ pipelines compose by chaining operators — WhereSelectOrderByDescendingTake reads left to right as filter, project, sort, limit. ToLookup creates a dictionary-like structure that allows multiple values per key (unlike ToDictionary which throws on duplicates). Zip pairs elements from two or three sequences positionally, stopping at the shortest.

var result = employees
    .Where(e => e.Salary > 75000)
    .Select(e => new { e.Name, e.Salary, Tax = e.Salary * 0.3 })
    .OrderByDescending(e => e.Salary)
    .Take(3);
foreach (var r in result)
    Console.WriteLine($"  {r.Name,-10} salary=${r.Salary:N0}  tax=${r.Tax:N0}");
 
var empLookup = employees.ToLookup(e => e.Dept);
Console.WriteLine($"Engineering: [{string.Join(", ", empLookup["Engineering"].Select(e => e.Name))}]");
Console.WriteLine($"Unknown:     [{string.Join(", ", empLookup["Unknown"].Select(e => e.Name))}]");
 
var names = employees.Select(e => e.Name);
var salaries = employees.Select(e => e.Salary);
var raises = employees.Select(e => e.Salary * 0.1);
 
foreach (var (name, salary, raise_amt) in names.Zip(salaries, raises))
    Console.WriteLine($"  {name,-10} ${salary,8:N0} + ${raise_amt,7:N0} raise");
Charlie    salary=$110'000  tax=$33'000
Alice      salary=$95'000  tax=$28'500
Eve        salary=$88'000  tax=$26'400
Engineering: [Alice, Charlie, Eve]
Unknown:     []
Alice      $  95'000 + $  9'500 raise
Bob        $  65'000 + $  6'500 raise
Charlie    $ 110'000 + $ 11'000 raise
Diana      $  78'000 + $  7'800 raise
Eve        $  88'000 + $  8'800 raise
Frank      $  72'000 + $  7'200 raise

SelectMany — flatten nested collections

SelectMany projects each element to a collection, then flattens all those collections into one sequence. It’s the LINQ equivalent of a nested loop or SQL’s CROSS APPLY. Common use: a list of orders where each order has multiple line items — SelectMany gives you a flat list of all line items across all orders.

var people = new[]
{
    new { Name = "Alice", Skills = new[] { "C#", "LINQ", "SQL" } },
    new { Name = "Bob",   Skills = new[] { "Python", "SQL" } },
    new { Name = "Charlie", Skills = new[] { "C#", "Go" } },
};
 
foreach (var arr in people.Select(p => p.Skills))
    Console.WriteLine($"  [{string.Join(", ", arr)}]");
 
var allSkills = people.SelectMany(p => p.Skills);
Console.WriteLine($"SelectMany (flat): [{string.Join(", ", allSkills)}]");
 
var pairs = people.SelectMany(
    p => p.Skills,
    (p, skill) => $"{p.Name}: {skill}"
);
foreach (var pair in pairs)
    Console.WriteLine($"  {pair}");
 
Console.WriteLine($"Distinct skills: [{string.Join(", ", people.SelectMany(p => p.Skills).Distinct().OrderBy(s => s))}]");
 
var matrix = new List<List<int>>
{
    new List<int> { 1, 2, 3 },
    new List<int> { 4, 5 },
    new List<int> { 6, 7, 8, 9 },
};
Console.WriteLine($"Flat matrix: [{string.Join(", ", matrix.SelectMany(row => row))}]");
Select (nested):
  [C#, LINQ, SQL]
  [Python, SQL]
  [C#, Go]
 
SelectMany (flat): [C#, LINQ, SQL, Python, SQL, C#, Go]
 
SelectMany with result selector:
  Alice: C#
  Alice: LINQ
  Alice: SQL
  Bob: Python
  Bob: SQL
  Charlie: C#
  Charlie: Go
 
Distinct skills: [C#, Go, LINQ, Python, SQL]
Flat matrix: [1, 2, 3, 4, 5, 6, 7, 8, 9]

LINQ Analytics on Live SQL Server Data

Advanced analytics queries written in LINQ against the local stoxx database — the C# equivalent of SQL window functions, running aggregates, and analytical patterns.

Tables used:

  • silver.eurostoxx50_ohlcv — 66K rows of daily OHLCV data for 50 European stocks
  • gold.scores_daily — composite scores with 36 metrics per stock
  • gold.index_performance — daily index-level returns and rolling metrics
  • silver.index_dim — company metadata (sector, country, exchange)
  • bronze.trading_calendar — 29K trading day flags per exchange

C# | LINQ | SQL Server data setup

Connection and DTO records for loading EUROSTOXX 50 OHLCV and composite score data from the local stoxx database using Dapper.

DTO records for SQL Server data mapping

Dapper maps SQL result columns to C# record properties by matching names. Records must be declared in a separate cell because C# requires type declarations before top-level statements in .NET Interactive.

record Ohlcv(string Symbol, DateTime Date, double Open, double High, double Low,
             double Close, double AdjClose, long Volume);
record ScoreRow(string Symbol, string Sector, string Country, double CompositeScore,
               short CompositeRank, double MomentumScore, double CurrentPrice, double YtdChangePct);
var connStr = "Server=localhost,1434;Database=stoxx;"
    + "User Id=sa;Password=EsgDev2026Pass1;"
    + "Encrypt=True;TrustServerCertificate=True;";
 
List<Ohlcv> ohlcv;
List<ScoreRow> scores;
using (var conn = new SqlConnection(connStr))
{
    conn.Open();
    ohlcv = conn.Query<Ohlcv>(
        "SELECT symbol AS Symbol, date AS Date, [open] AS [Open], high AS High, low AS Low, "
        + "[close] AS [Close], adj_close AS AdjClose, volume AS Volume "
        + "FROM silver.eurostoxx50_ohlcv").AsList();
    scores = conn.Query<ScoreRow>(
        "SELECT symbol AS Symbol, sector AS Sector, country AS Country, "
        + "composite_score AS CompositeScore, composite_rank AS CompositeRank, "
        + "momentum_score AS MomentumScore, current_price AS CurrentPrice, "
        + "ytd_change_pct AS YtdChangePct "
        + "FROM gold.scores_daily").AsList();
}
Console.WriteLine($"  OHLCV: {ohlcv.Count:N0} rows, {ohlcv.Select(r => r.Symbol).Distinct().Count()} symbols");
Console.WriteLine($"  Scores: {scores.Count:N0} rows");
Console.WriteLine($"  Date range: {ohlcv.Min(r => r.Date):yyyy-MM-dd} to {ohlcv.Max(r => r.Date):yyyy-MM-dd}");
66'355 rows, 50 symbols
466 rows
2021-01-04 to 2026-03-12

C# | LINQ | analytical queries on financial data

Each query below demonstrates a LINQ pattern equivalent to a common SQL analytical operation — aggregation, window functions, filtering after grouping, and joins — applied to real EUROSTOXX 50 market data.

LINQ — GroupBy with Aggregates

Splits a collection into groups by key and applies multiple aggregate functions (Average, Sum, Min, Max, Count) to each group.

var summary = ohlcv
    .GroupBy(r => r.Symbol)
    .Select(g => new
    {
        Symbol = g.Key,
        AvgClose = g.Average(r => r.Close),
        TotalVolume = g.Sum(r => r.Volume),
        MinLow = g.Min(r => r.Low),
        MaxHigh = g.Max(r => r.High),
        Days = g.Count(),
    })
    .OrderByDescending(s => s.AvgClose)
    .Take(10);
 
// Display as Polars DataFrame
var results = summary.ToList();
new DataFrame(
    Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
    Series.From("AvgClose", results.Select(s => Math.Round(s.AvgClose, 2)).ToArray()),
    Series.From("TotalVolume", results.Select(s => s.TotalVolume).ToArray()),
    Series.From("MinLow", results.Select(s => Math.Round(s.MinLow, 2)).ToArray()),
    Series.From("MaxHigh", results.Select(s => Math.Round(s.MaxHigh, 2)).ToArray()),
    Series.From("Days", results.Select(s => s.Days).ToArray()))
SymbolAvgCloseTotalVolumeMinLowMaxHighDays
RMS.PA1761.5681633862839.429571331
ADYEN.AS1545.98110400463602.828351331
ASML.AS671.35945070720375.751312.81331
MC.PA662.4557855567436.55904.61331
RHM.DE544.6630835974476.2820081324
ARGX.BR413.6994592244201.48101331
OR.PA377.54484115375290.1461.851331
MUV2.DE374.66398802950205.15615.81324
RACE.MI289.75476686026154.4492.81321
ALV.DE252.191101960308156.223961324

LINQ — Window Function ROW_NUMBER()

Assigns a sequential rank to each row within a partition, ordered by a column. Equivalent to SQL ROW_NUMBER() OVER (PARTITION BY … ORDER BY …).

var topVolumeDay = ohlcv
    .GroupBy(r => r.Symbol)
    .SelectMany(g => g
        .OrderByDescending(r => r.Volume)
        .Select((r, idx) => new { r.Symbol, r.Date, r.Close, r.Volume, Rank = idx + 1 })
        .Where(r => r.Rank == 1))
    .OrderByDescending(r => r.Volume)
    .Take(10);
 
var results = topVolumeDay.ToList();
new DataFrame(
    Series.From("Symbol", results.Select(r => r.Symbol).ToArray()),
    Series.From("Date", results.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", results.Select(r => Math.Round(r.Close, 2)).ToArray()),
    Series.From("Volume", results.Select(r => r.Volume).ToArray()))
SymbolDateCloseVolume
ISP.MI2023-08-082.34376391539
SAN.MC2021-10-203.36367211467
BBVA.MC2021-09-175.67228528294
NDA-FI.HE2022-09-169.14140675854
PRX.AS2021-08-1732.43114772834
ENEL.MI2021-10-156.92101413521
UCG.MI2021-12-0912.882881371
IBE.MC2022-10-219.5382592287
INGA.AS2024-02-0112.3455872649
ENI.MI2025-04-0712.0448554374

LINQ — Window Function LAG()

Accesses the value from the previous row in a sorted sequence. Implemented via Zip with a shifted copy of the list.

var withReturns = ohlcv
    .GroupBy(r => r.Symbol)
    .SelectMany(g =>
    {
        var sorted = g.OrderBy(r => r.Date).ToList();
        return sorted.Skip(1).Zip(sorted, (curr, prev) => new
        {
            curr.Symbol, curr.Date, curr.Close,
            PrevClose = prev.Close,
            DailyReturn = (curr.Close - prev.Close) / prev.Close * 100,
        });
    });
 
var topGains = withReturns.OrderByDescending(r => r.DailyReturn).Take(10);
 
var results = topGains.ToList();
new DataFrame(
    Series.From("Symbol", results.Select(r => r.Symbol).ToArray()),
    Series.From("Date", results.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", results.Select(r => Math.Round(r.Close, 2)).ToArray()),
    Series.From("PrevClose", results.Select(r => Math.Round(r.PrevClose, 2)).ToArray()),
    Series.From("Return%", results.Select(r => Math.Round(r.DailyReturn, 2)).ToArray()))
SymbolDateClosePrevCloseReturn%
ADYEN.AS2023-11-09958.8695.737.82
ARGX.BR2023-07-17437.633431.02
RHM.DE2022-02-28133.6107.0524.8
PRX.AS2022-03-1624.119.4523.88
ADS.DE2022-11-04114.0493.9521.38
ADYEN.AS2024-02-081436.21183.621.34
ENR.DE2024-11-1346.3338.9518.95
RHM.DE2022-03-01156.6133.617.22
VOW.DE2021-03-17308.8266.615.83
PRX.AS2022-06-2728.1724.3515.72

LINQ — Window Function Cumulative SUM()

Computes a running total where each row includes the sum of all preceding rows. Implemented via Aggregate with an accumulator.

var cumVol = ohlcv
    .Where(r => r.Symbol == "ASML.AS")
    .OrderBy(r => r.Date)
    .Aggregate(
        new List<(DateTime Date, long Vol, long CumVol)>(),
        (acc, r) => { acc.Add((r.Date, r.Volume, (acc.Count > 0 ? acc[^1].CumVol : 0) + r.Volume)); return acc; });
 
new DataFrame(
    Series.From("Date", cumVol.TakeLast(10).Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Volume", cumVol.TakeLast(10).Select(r => r.Vol).ToArray()),
    Series.From("CumVolume", cumVol.TakeLast(10).Select(r => r.CumVol).ToArray()))
DateVolumeCumVolume
2026-02-271010698938726541
2026-03-02871267939597808
2026-03-03941945940539753
2026-03-04714587941254340
2026-03-05778081942032421
2026-03-06857271942889692
2026-03-09689086943578778
2026-03-10800815944379593
2026-03-11562904944942497
2026-03-12128223945070720

LINQ — Window Function AVG() Moving Average

Computes the average of a sliding window of N rows. Implemented via Skip/Take on a sorted list for each position.

var W = 20;
var asml = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).ToList();
 
var sma = Enumerable.Range(W - 1, asml.Count - W + 1)
    .Select(i => new { asml[i].Date, asml[i].Close,
        SMA20 = asml.Skip(i - W + 1).Take(W).Average(r => r.Close) });
 
var results = sma.TakeLast(10).ToList();
new DataFrame(
    Series.From("Date", results.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", results.Select(r => Math.Round(r.Close, 2)).ToArray()),
    Series.From("SMA20", results.Select(r => Math.Round(r.SMA20, 2)).ToArray()))
DateCloseSMA20
2026-02-271233.41213.73
2026-03-021210.41213.01
2026-03-031161.81211.58
2026-03-041199.81214.54
2026-03-0511861216.36
2026-03-0611471214.02
2026-03-091147.61211.16
2026-03-1012001211.51
2026-03-111198.81211.06
2026-03-121190.81211.61

LINQ — Window Function NTILE()

Distributes rows into N equal-sized buckets based on a sort order. Implemented via index arithmetic after OrderBy.

var symbolCount = ohlcv.Select(r => r.Symbol).Distinct().Count();
var quartiles = ohlcv
    .GroupBy(r => r.Symbol)
    .Select(g => new { Symbol = g.Key, AvgClose = g.Average(r => r.Close) })
    .OrderBy(s => s.AvgClose)
    .Select((s, idx) => new { s.Symbol, s.AvgClose, Quartile = idx * 4 / symbolCount + 1 })
    .OrderByDescending(s => s.AvgClose);
 
var results = quartiles.Take(10).ToList();
new DataFrame(
    Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
    Series.From("AvgClose", results.Select(s => Math.Round(s.AvgClose, 2)).ToArray()),
    Series.From("Quartile", results.Select(s => s.Quartile).ToArray()))
SymbolAvgCloseQuartile
RMS.PA1761.564
ADYEN.AS1545.984
ASML.AS671.354
MC.PA662.44
RHM.DE544.664
ARGX.BR413.694
OR.PA377.544
MUV2.DE374.664
RACE.MI289.754
ALV.DE252.194

LINQ — HAVING

Filters groups after aggregation. A Where clause applied after GroupBy + Select acts as the SQL HAVING clause.

var highVol = ohlcv
    .GroupBy(r => r.Symbol)
    .Select(g => new { Symbol = g.Key, AvgVol = g.Average(r => (double)r.Volume) })
    .Where(s => s.AvgVol > 5_000_000)
    .OrderByDescending(s => s.AvgVol);
 
var results = highVol.ToList();
new DataFrame(
    Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
    Series.From("AvgVolume", results.Select(s => (long)s.AvgVol).ToArray()))
SymbolAvgVolume
ISP.MI87588601
SAN.MC41770987
ENEL.MI24678699
BBVA.MC16654456
UCG.MI13903710
ENI.MI12976208
INGA.AS12803589
IBE.MC12034835
DTE.DE7575084
NDA-FI.HE5375454
... 1 more rows ...

LINQ — STDEV()

Standard deviation of daily returns, annualized by multiplying by sqrt(252). No built-in LINQ StdDev — computed manually.

double StdDev(IEnumerable<double> v)
{
    var l = v.ToList(); var a = l.Average();
    return Math.Sqrt(l.Sum(x => (x - a) * (x - a)) / (l.Count - 1));
}
 
var vol = ohlcv.GroupBy(r => r.Symbol).Select(g =>
{
    var s = g.OrderBy(r => r.Date).ToList();
    var ret = s.Skip(1).Zip(s, (c, p) => (c.Close - p.Close) / p.Close).ToList();
    return new { Symbol = g.Key, AnnVol = StdDev(ret) * Math.Sqrt(252) * 100 };
}).OrderByDescending(s => s.AnnVol).Take(10);
 
var results = vol.ToList();
new DataFrame(
    Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
    Series.From("AnnualVol%", results.Select(s => Math.Round(s.AnnVol, 2)).ToArray()))
SymbolAnnualVol%
ADYEN.AS50.3
ENR.DE50.05
RHM.DE40.85
PRX.AS39.72
ARGX.BR39.31
ASML.AS37.62
IFX.DE37.25
UCG.MI35.55
VOW.DE35.51
ADS.DE34.37

LINQ — JOIN

Combines two collections on a matching key. Each OHLCV aggregate row is paired with its corresponding score row by symbol.

var joined = ohlcv
    .GroupBy(r => r.Symbol)
    .Select(g => new { Symbol = g.Key, AvgClose = g.Average(r => r.Close), AvgVol = g.Average(r => (double)r.Volume) })
    .Join(scores, o => o.Symbol, s => s.Symbol,
        (o, s) => new { o.Symbol, s.Sector, o.AvgClose, o.AvgVol, s.CompositeScore, s.CompositeRank, s.YtdChangePct })
    .OrderBy(r => r.CompositeRank)
    .Take(10);
 
var results = joined.ToList();
new DataFrame(
    Series.From("Symbol", results.Select(r => r.Symbol).ToArray()),
    Series.From("Sector", results.Select(r => r.Sector).ToArray()),
    Series.From("Rank", results.Select(r => (int)r.CompositeRank).ToArray()),
    Series.From("Score", results.Select(r => Math.Round(r.CompositeScore, 2)).ToArray()),
    Series.From("YTD%", results.Select(r => Math.Round(r.YtdChangePct, 1)).ToArray()),
    Series.From("AvgVol", results.Select(r => (long)r.AvgVol).ToArray()))
SymbolSectorRankScoreYTD%AvgVol
BNP.PAFinancial Services10.680.13096879
BNP.PAFinancial Services10.660.13096879
BNP.PAFinancial Services10.680.13096879
DTE.DECommunication Services20.520.27575084
DTE.DECommunication Services20.520.27575084
VOW.DEConsumer Cyclical20.58-0.162021
DTE.DECommunication Services30.490.27575084
IFX.DETechnology30.510.24186778
VOW.DEConsumer Cyclical30.46-0.162021
TTE.PAEnergy40.390.35138099

LINQ — Window Function LEAD()

Accesses the value from the next row in a sorted sequence. Implemented via Zip with a Skip(1) shifted copy. Used here to detect date gaps.

var gaps = ohlcv
    .Where(r => r.Symbol == "ASML.AS")
    .OrderBy(r => r.Date)
    .Zip(ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).Skip(1),
        (curr, next) => new { FromDate = curr.Date, ToDate = next.Date, GapDays = (next.Date - curr.Date).Days })
    .Where(g => g.GapDays > 3)
    .OrderByDescending(g => g.GapDays)
    .Take(10);
 
var results = gaps.ToList();
new DataFrame(
    Series.From("From", results.Select(g => g.FromDate.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("To", results.Select(g => g.ToDate.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("GapDays", results.Select(g => g.GapDays).ToArray()))
FromToGapDays
2021-04-012021-04-065
2022-04-142022-04-195
2023-04-062023-04-115
2023-12-222023-12-275
2024-03-282024-04-025
2025-04-172025-04-225
2025-12-242025-12-295
2022-12-232022-12-274
2023-04-282023-05-024
2023-12-292024-01-024

LINQ — Nested GroupBy

Groups by a key and computes nested aggregates including the best element per group via OrderBy + First().

var sectorSummary = scores
    .GroupBy(s => s.Sector)
    .Select(g => new
    {
        Sector = g.Key,
        AvgScore = g.Average(s => s.CompositeScore),
        BestStock = g.OrderBy(s => s.CompositeRank).First().Symbol,
        BestRank = g.Min(s => s.CompositeRank),
        Count = g.Count(),
    })
    .OrderByDescending(s => s.AvgScore);
 
var results = sectorSummary.ToList();
new DataFrame(
    Series.From("Sector", results.Select(s => s.Sector).ToArray()),
    Series.From("AvgScore", results.Select(s => Math.Round(s.AvgScore, 2)).ToArray()),
    Series.From("BestStock", results.Select(s => s.BestStock).ToArray()),
    Series.From("BestRank", results.Select(s => (int)s.BestRank).ToArray()),
    Series.From("Count", results.Select(s => s.Count).ToArray()))
SectorAvgScoreBestStockBestRankCount
Technology0.15MU175
Energy0.11DVN134
Industrials0.098001.T166
Communication Services0.05DTE.DE236
Basic Materials0.044063.T718
Healthcare0.022269.HK545
Consumer Defensive-0.08ABI.BR436
Financial Services-0.09BNP.PA196
Utilities-0.1ENEL.MI256
Consumer Cyclical-0.14VOW.DE254

LINQ vs Polars.NET — Side-by-Side

Every operation shown first in LINQ (C# collections), then in Polars.NET (Rust DataFrame engine). Both operate on the same OHLCV data loaded from SQL Server.

Load data into Polars DataFrame

Polars.NET reads Parquet files directly into a columnar DataFrame backed by the Rust Polars engine — same data as the Dapper-loaded OHLCV list, but in a format optimized for vectorized operations.

var df = DataFrame.ReadParquet(@"C:\Users\aperi\DEV\LANG\data\eurostoxx50_ohlcv.parquet");
Console.WriteLine($"  Polars: {df.Height} rows x {df.Width} columns");
df.Head(3)
66355 rows x 12 columns
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
21160ABI.BR2021-01-0458.1558.8556.7857.2153.5761151393700false
21161ABI.BR2021-01-0556.957.9856.7557.1853.548138272200false
21162ABI.BR2021-01-0657.9658.9457.3958.7755.037137020400false

Basic Operations

LINQ — Select columns

var linqSelect = ohlcv.Select(r => new { r.Symbol, r.Date, r.Close }).Take(5).ToList();
new DataFrame(
    Series.From("Symbol", linqSelect.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqSelect.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", linqSelect.Select(r => r.Close).ToArray()))
SymbolDateClose
ASML.AS2021-01-04406.25
ASML.AS2021-01-05406.9
ASML.AS2021-01-06402.85
ASML.AS2021-01-07403.9
ASML.AS2021-01-08416.05

Polars DataFrame — Select columns

df.Select("symbol", "date", "close").Head(5)
symboldateclose
ABI.BR2021-01-0457.21
ABI.BR2021-01-0557.18
ABI.BR2021-01-0658.77
ABI.BR2021-01-0758.4
ABI.BR2021-01-0857.86

LINQ — Filter rows

var linqFilter = ohlcv.Where(r => r.Symbol == "ASML.AS" && r.Close > 600).Take(5).ToList();
new DataFrame(
    Series.From("Symbol", linqFilter.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqFilter.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", linqFilter.Select(r => r.Close).ToArray()))
SymbolDateClose
ASML.AS2021-07-14609.1
ASML.AS2021-07-22620.8
ASML.AS2021-07-23638.8
ASML.AS2021-07-26638
ASML.AS2021-07-27623

Polars DataFrame — Filter rows

df.Filter((Col("symbol") == Lit("ASML.AS")) & (Col("close") > Lit(600.0)))
  .Select("symbol", "date", "close").Head(5)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
136ASML.AS2021-07-14599.5611.8597.2609.1582.970864158500false
142ASML.AS2021-07-22610625.9608.2620.8594.16978809900false
143ASML.AS2021-07-23622.9639617.5638.8611.396783373700false
144ASML.AS2021-07-26635.2647631.5638610.63164069100false
145ASML.AS2021-07-27634.2641.1622.3623596.274570556000false

LINQ — Sort

var linqSort = ohlcv.OrderByDescending(r => r.Volume).Take(5).ToList();
new DataFrame(
    Series.From("Symbol", linqSort.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqSort.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Volume", linqSort.Select(r => r.Volume).ToArray()))
SymbolDateVolume
ISP.MI2023-08-08376391539
SAN.MC2021-10-20367211467
ISP.MI2023-05-31317362978
ISP.MI2023-03-13311886033
SAN.MC2021-11-03306973344

Polars DataFrame — Sort

df.Sort("volume", descending: true).Head(5)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
31078ISP.MI2023-08-082.42.41652.32852.3381.896137639153900false
10783SAN.MC2021-10-203.363.3763.3223.362.837936721146700false
31029ISP.MI2023-05-312.19252.22552.1332.15551.748131736297800false
30975ISP.MI2023-03-132.47052.4782.2792.33051.819631188603300false
10793SAN.MC2021-11-033.2753.313.2363.312.837730697334400false

LINQ — Add computed column

var linqComputed = ohlcv.Take(5).Select(r => new { r.Symbol, r.Date, r.Close, Range = r.High - r.Low }).ToList();
new DataFrame(
    Series.From("Symbol", linqComputed.Select(r => r.Symbol).ToArray()),
    Series.From("Close", linqComputed.Select(r => r.Close).ToArray()),
    Series.From("Range", linqComputed.Select(r => Math.Round(r.Range, 2)).ToArray()))
SymbolCloseRange
ASML.AS406.258.75
ASML.AS406.910.9
ASML.AS402.858
ASML.AS403.97.45
ASML.AS416.055.7

Polars DataFrame — Add computed column

df.WithColumns((Col("high") - Col("low")).Alias("range")).Select("symbol", "close", "range").Head(5)
symbolcloserange
ABI.BR57.212.07
ABI.BR57.181.23
ABI.BR58.771.55
ABI.BR58.40.98
ABI.BR57.860.97

Aggregations

LINQ — GroupBy with aggregates

var linqAgg = ohlcv.GroupBy(r => r.Symbol)
    .Select(g => new { Symbol = g.Key, AvgClose = Math.Round(g.Average(r => r.Close), 2), Count = g.Count() })
    .OrderByDescending(s => s.AvgClose).Take(5).ToList();
new DataFrame(
    Series.From("Symbol", linqAgg.Select(s => s.Symbol).ToArray()),
    Series.From("AvgClose", linqAgg.Select(s => s.AvgClose).ToArray()),
    Series.From("Count", linqAgg.Select(s => s.Count).ToArray()))
SymbolAvgCloseCount
RMS.PA1761.561331
ADYEN.AS1545.981331
ASML.AS671.351331
MC.PA662.41331
RHM.DE544.661324

Polars DataFrame — GroupBy with aggregates

df.GroupBy("symbol").Agg(
    Col("close").Mean().Alias("avg_close"),
    Col("close").Count().Alias("count")
).Sort("avg_close", descending: true).Head(5)
symbolavg_closecount
RMS.PA1761.5557481331
ADYEN.AS1545.9764091331
ASML.AS671.34891061331
MC.PA662.40450791331
RHM.DE544.66153321324

LINQ — HAVING

var linqHaving = ohlcv.GroupBy(r => r.Symbol)
    .Select(g => new { Symbol = g.Key, AvgVol = g.Average(r => (double)r.Volume) })
    .Where(s => s.AvgVol > 5_000_000)
    .OrderByDescending(s => s.AvgVol).ToList();
new DataFrame(
    Series.From("Symbol", linqHaving.Select(s => s.Symbol).ToArray()),
    Series.From("AvgVol", linqHaving.Select(s => (long)s.AvgVol).ToArray()))
SymbolAvgVol
ISP.MI87588601
SAN.MC41770987
ENEL.MI24678699
BBVA.MC16654456
UCG.MI13903710
ENI.MI12976208
INGA.AS12803589
IBE.MC12034835
DTE.DE7575084
NDA-FI.HE5375454
... 1 more rows ...

Polars DataFrame — HAVING

df.GroupBy("symbol").Agg(
    Col("volume").Mean().Alias("avg_vol")
).Filter(Col("avg_vol") > Lit(5_000_000.0)).Sort("avg_vol", descending: true)
symbolavg_vol
ISP.MI87588601.04
SAN.MC41770987.15
ENEL.MI24678699.42
BBVA.MC16654456.88
UCG.MI13903710.14
ENI.MI12976208.15
INGA.AS12803589.45
IBE.MC12034835.18
DTE.DE7575084.131
NDA-FI.HE5375454.051
... 1 more rows ...

Window Functions

LINQ — LAG

var asmlLinq = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).ToList();
var linqLag = asmlLinq.Skip(1).Zip(asmlLinq, (curr, prev) => new
    { curr.Date, curr.Close, PrevClose = prev.Close,
      Return = Math.Round((curr.Close - prev.Close) / prev.Close * 100, 2) })
    .TakeLast(5).ToList();
new DataFrame(
    Series.From("Date", linqLag.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", linqLag.Select(r => r.Close).ToArray()),
    Series.From("PrevClose", linqLag.Select(r => r.PrevClose).ToArray()),
    Series.From("Return%", linqLag.Select(r => r.Return).ToArray()))
DateClosePrevCloseReturn%
2026-03-0611471186-3.29
2026-03-091147.611470.05
2026-03-1012001147.64.57
2026-03-111198.81200-0.1
2026-03-121190.81198.8-0.67

Polars DataFrame — LAG

df.Filter(Col("symbol") == Lit("ASML.AS"))
  .Sort("date")
  .WithColumns(Col("close").Shift(1).Over("symbol").Alias("prev_close"))
  .WithColumns(((Col("close") - Col("prev_close")) / Col("prev_close") * Lit(100.0)).Alias("return_pct"))
  .Select("date", "close", "prev_close", "return_pct")
  .Tail(5)
datecloseprev_closereturn_pct
2026-03-0611471186-3.28836425
2026-03-091147.611470.05231037489
2026-03-1012001147.64.566050889
2026-03-111198.81200-0.1
2026-03-121190.81198.8-0.6673340007

LINQ — Cumulative SUM

var linqCum = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date)
    .Aggregate(new List<(string D, long V, long C)>(),
        (acc, r) => { acc.Add((r.Date.ToString("yyyy-MM-dd"), r.Volume,
            (acc.Count > 0 ? acc[^1].C : 0) + r.Volume)); return acc; })
    .TakeLast(5).ToList();
new DataFrame(
    Series.From("Date", linqCum.Select(r => r.D).ToArray()),
    Series.From("Volume", linqCum.Select(r => r.V).ToArray()),
    Series.From("CumVol", linqCum.Select(r => r.C).ToArray()))
DateVolumeCumVol
2026-03-06857271942889692
2026-03-09689086943578778
2026-03-10800815944379593
2026-03-11562904944942497
2026-03-12128223945070720

Polars DataFrame — Cumulative SUM

df.Filter(Col("symbol") == Lit("ASML.AS"))
  .Sort("date")
  .WithColumns(Col("volume").CumSum().Over("symbol").Alias("cum_vol"))
  .Select("date", "volume", "cum_vol")
  .Tail(5)
datevolumecum_vol
2026-03-06857271942889692
2026-03-09689086943578778
2026-03-10800815944379593
2026-03-11562904944942497
2026-03-12128223945070720

LINQ — Rolling average

var W = 20;
var asmlSorted = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).ToList();
var linqSma = Enumerable.Range(W - 1, asmlSorted.Count - W + 1)
    .Select(i => new { asmlSorted[i].Date, asmlSorted[i].Close,
        SMA = Math.Round(asmlSorted.Skip(i - W + 1).Take(W).Average(r => r.Close), 2) })
    .TakeLast(5).ToList();
new DataFrame(
    Series.From("Date", linqSma.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", linqSma.Select(r => r.Close).ToArray()),
    Series.From("SMA20", linqSma.Select(r => r.SMA).ToArray()))
DateCloseSMA20
2026-03-0611471214.02
2026-03-091147.61211.16
2026-03-1012001211.51
2026-03-111198.81211.06
2026-03-121190.81211.61

Polars DataFrame — Rolling average

df.Filter(Col("symbol") == Lit("ASML.AS"))
  .Sort("date")
  .WithColumns(Col("close").RollingMean("20i").Over("symbol").Alias("sma_20"))
  .Select("date", "close", "sma_20")
  .Tail(5)
dateclosesma_20
2026-03-0611471214.02
2026-03-091147.61211.16
2026-03-1012001211.51
2026-03-111198.81211.06
2026-03-121190.81211.61

LINQ — ROW_NUMBER / Rank

var linqRank = ohlcv.GroupBy(r => r.Symbol)
    .SelectMany(g => g.OrderByDescending(r => r.Volume)
        .Select((r, i) => new { r.Symbol, r.Date, r.Volume, Rank = i + 1 })
        .Where(r => r.Rank <= 1))
    .OrderByDescending(r => r.Volume).Take(5).ToList();
new DataFrame(
    Series.From("Symbol", linqRank.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqRank.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Volume", linqRank.Select(r => r.Volume).ToArray()))
SymbolDateVolume
ISP.MI2023-08-08376391539
SAN.MC2021-10-20367211467
BBVA.MC2021-09-17228528294
NDA-FI.HE2022-09-16140675854
PRX.AS2021-08-17114772834

Polars DataFrame — ROW_NUMBER / Rank

df.WithColumns(Col("volume").Rank(descending: true).Over("symbol").Alias("vol_rank"))
  .Filter(Col("vol_rank") == Lit(1))
  .Sort("volume", descending: true)
  .Select("symbol", "date", "volume")
  .Head(5)
symboldatevolume
ISP.MI2023-08-08376391539
SAN.MC2021-10-20367211467
BBVA.MC2021-09-17228528294
NDA-FI.HE2022-09-16140675854
PRX.AS2021-08-17114772834

Joins

LINQ — Inner Join

var linqJoin = ohlcv.GroupBy(r => r.Symbol)
    .Select(g => new { Symbol = g.Key, AvgClose = Math.Round(g.Average(r => r.Close), 2) })
    .Join(scores, o => o.Symbol, s => s.Symbol,
        (o, s) => new { o.Symbol, o.AvgClose, s.Sector, Rank = (int)s.CompositeRank })
    .OrderBy(r => r.Rank).Take(5).ToList();
new DataFrame(
    Series.From("Symbol", linqJoin.Select(r => r.Symbol).ToArray()),
    Series.From("AvgClose", linqJoin.Select(r => r.AvgClose).ToArray()),
    Series.From("Sector", linqJoin.Select(r => r.Sector).ToArray()),
    Series.From("Rank", linqJoin.Select(r => r.Rank).ToArray()))
SymbolAvgCloseSectorRank
BNP.PA60.94Financial Services1
BNP.PA60.94Financial Services1
BNP.PA60.94Financial Services1
DTE.DE22.43Communication Services2
DTE.DE22.43Communication Services2

Polars DataFrame — Inner Join

var dfAvg = df.GroupBy("symbol").Agg(Col("close").Mean().Alias("avg_close"));
 
var dfScores = new DataFrame(
    Series.From("symbol", scores.Select(s => s.Symbol).ToArray()),
    Series.From("sector", scores.Select(s => s.Sector).ToArray()),
    Series.From("composite_rank", scores.Select(s => (int)s.CompositeRank).ToArray()));
 
dfAvg.Join(dfScores, new[] { Col("symbol") }, new[] { Col("symbol") })
    .Select("symbol", "avg_close", "sector", "composite_rank")
    .Sort("composite_rank")
    .Head(5)
symbolavg_closesectorcomposite_rank
BNP.PA60.93771225Financial Services1
BNP.PA60.93771225Financial Services1
BNP.PA60.93771225Financial Services1
DTE.DE22.43009743Communication Services2
DTE.DE22.43009743Communication Services2

CRUD-like Operations

LINQ — Add rows with Concat()

Concat appends one IEnumerable to another lazily — no copy, no allocation. It returns a new sequence that yields elements from both, equivalent to SQL UNION ALL.

var newRows = new[] { new Ohlcv("TEST.XX", DateTime.Today, 100, 105, 95, 102, 102, 50000) };
var linqInsert = ohlcv.Concat(newRows).TakeLast(3).ToList();
Console.WriteLine($"  LINQ: {ohlcv.Count} + {newRows.Length} = {ohlcv.Count + newRows.Length} rows (Concat)");
new DataFrame(
    Series.From("Symbol", linqInsert.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqInsert.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Close", linqInsert.Select(r => r.Close).ToArray()),
    Series.From("Volume", linqInsert.Select(r => r.Volume).ToArray()))
66355 + 1 = 66356 rows (Concat)
SymbolDateCloseVolume
WKL.AS2026-03-1267.32210379
DSY.PA2026-03-1218.37434417
TEST.XX2026-03-2810250000

Polars DataFrame — Add rows with VStack()

VStack vertically stacks two DataFrames (appends rows). Both must have identical column names and types. Equivalent to SQL UNION ALL.

var newDf = new DataFrame(
    Series.From("id", new[] { 0L }),
    Series.From("symbol", new[] { "TEST.XX" }),
    Series.From("date", new[] { DateOnly.FromDateTime(DateTime.Today) }),
    Series.From("open", new[] { 100.0 }),
    Series.From("high", new[] { 105.0 }),
    Series.From("low", new[] { 95.0 }),
    Series.From("close", new[] { 102.0 }),
    Series.From("adj_close", new[] { 102.0 }),
    Series.From("volume", new[] { 50000L }),
    Series.From("dividends", new[] { 0.0 }),
    Series.From("stock_splits", new[] { 0.0 }),
    Series.From("is_filled", new[] { false }));
 
var dfInserted = df.VStack(newDf);
Console.WriteLine($"  Polars: {df.Height} + {newDf.Height} = {dfInserted.Height} rows (VStack)");
dfInserted.Tail(3)
66355 + 1 = 66356 rows (VStack)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
66877WKL.AS2026-03-1167.569.667.0267.2267.22114253100false
66929WKL.AS2026-03-126767.5466.2867.3267.3221037900false
0TEST.XX2026-03-28100105951021025000000false

LINQ — Update column

LINQ doesn’t mutate in place — Select projects each element into a new anonymous type with the modified property, leaving the original collection untouched.

var linqUpdate = ohlcv.Where(r => r.Symbol == "ASML.AS").Take(5)
    .Select(r => new { r.Symbol, r.Date, AdjClose = r.Close * 1.05 }).ToList();
new DataFrame(
    Series.From("Symbol", linqUpdate.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqUpdate.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("AdjClose", linqUpdate.Select(r => Math.Round(r.AdjClose, 2)).ToArray()))
SymbolDateAdjClose
ASML.AS2021-01-04426.56
ASML.AS2021-01-05427.24
ASML.AS2021-01-06422.99
ASML.AS2021-01-07424.1
ASML.AS2021-01-08436.85

Polars DataFrame — Update column

WithColumns replaces or creates a column by expression — Polars is also immutable, returning a new DataFrame rather than modifying the original.

df.Filter(Col("symbol") == Lit("ASML.AS"))
  .WithColumns((Col("close") * Lit(1.05)).Alias("adj_close"))
  .Select("symbol", "date", "adj_close")
  .Head(5)
symboldateadj_close
ASML.AS2021-01-04426.5625
ASML.AS2021-01-05427.245
ASML.AS2021-01-06422.9925
ASML.AS2021-01-07424.095
ASML.AS2021-01-08436.8525

LINQ — Delete rows

Deleting is the inverse of filtering — Where keeps non-matching rows, effectively excluding the “deleted” ones from the resulting sequence.

var linqDelete = ohlcv.Where(r => r.Symbol != "ASML.AS");
Console.WriteLine($"  LINQ: {ohlcv.Count} - ASML rows = {linqDelete.Count()} remaining");
66355 - ASML rows = 65024 remaining

Polars DataFrame — Delete rows

Same pattern — Filter with a negated condition returns a new DataFrame without the excluded rows.

var dfFiltered = df.Filter(Col("symbol") != Lit("ASML.AS"));
Console.WriteLine($"  Polars: {df.Height} - ASML rows = {dfFiltered.Height} remaining");
66355 - ASML rows = 65024 remaining

LINQ — Drop column

LINQ has no native Drop — project only the columns you want via Select, omitting the unwanted properties.

var linqDrop = ohlcv.Take(3).Select(r => new
    { r.Symbol, r.Date, r.Open, r.High, r.Low, r.Close, r.AdjClose, r.Volume }).ToList();
new DataFrame(
    Series.From("Symbol", linqDrop.Select(r => r.Symbol).ToArray()),
    Series.From("Date", linqDrop.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
    Series.From("Open", linqDrop.Select(r => r.Open).ToArray()),
    Series.From("High", linqDrop.Select(r => r.High).ToArray()),
    Series.From("Low", linqDrop.Select(r => r.Low).ToArray()),
    Series.From("Close", linqDrop.Select(r => r.Close).ToArray()),
    Series.From("AdjClose", linqDrop.Select(r => r.AdjClose).ToArray()),
    Series.From("Volume", linqDrop.Select(r => r.Volume).ToArray()))
SymbolDateOpenHighLowCloseAdjCloseVolume
ASML.AS2021-01-04404411402.25406.25387.709789502
ASML.AS2021-01-05406.55412.05401.15406.9388.3294798787
ASML.AS2021-01-06406.8407.2399.2402.85384.4644875711

Polars DataFrame — Drop column

Select all columns except the ones to remove — Polars doesn’t have a .Drop() method, so you filter the column name list and pass it to Select.

var dropCols = new HashSet<string> { "dividends", "stock_splits", "is_filled" };
var keepCols = df.Columns.Where(n => !dropCols.Contains(n)).ToArray();
df.Select(keepCols).Head(3)
idsymboldateopenhighlowcloseadj_closevolume
21160ABI.BR2021-01-0458.1558.8556.7857.2153.57611513937
21161ABI.BR2021-01-0556.957.9856.7557.1853.5481382722
21162ABI.BR2021-01-0657.9658.9457.3958.7755.0371370204

C# Generics and LINQ Warnings

LINQ deferred execution — query re-executes on each enumeration

A LINQ query like var q = data.Where(x => x > 5) is a description, not a result. Every foreach, .Count(), or .ToList() re-executes the full pipeline from scratch — including any side effects or database calls.

Correct pattern

Materialize early with .ToList() or .ToArray() when you need the results more than once: var results = data.Where(x => x > 5).ToList();.

Forgetting generic constraints causes compile errors

Calling item.CompareTo(other) on an unconstrained T doesn’t compile — the compiler doesn’t know T has that method.

Correct pattern

Add the constraint: where T : IComparable<T>. Only add constraints you actually use — over-constraining reduces reusability.

GroupBy returns IGrouping, not a dictionary

The result of GroupBy is a lazy sequence of IGrouping<TKey, TElement>. Accessing .Key gives the group key; enumerating gives the group’s elements. It’s not a Dictionary.

Correct pattern

To get a dictionary, chain .ToDictionary(g => g.Key, g => g.ToList()). To process groups lazily, iterate the IGrouping directly.

Aggregate throws on empty sequences

The seedless overload data.Aggregate((a, b) => a + b) throws InvalidOperationException if the source is empty.

Correct pattern

Use the seeded overload: data.Aggregate(0, (acc, x) => acc + x). The seed serves as both the initial value and the return value for empty sequences.

Variance only applies to interfaces and delegates

class MyList<out T> doesn’t compile — covariance (out) and contravariance (in) only work on interface and delegate type parameters.

Correct pattern

Define variance on the interface: interface IReadable<out T>. The implementing class uses invariant T: class Readable<T> : IReadable<T>.

C# Generics and LINQ Recommendations

  • Prefer method syntax for most LINQ — it’s more composable and the dominant style in production C#. Use query syntax for complex joins and let bindings.
  • Materialize LINQ results with .ToList() when you need stable, reusable data — deferred execution re-evaluates on each enumeration.
  • Use generic constraints sparingly — only add constraints you actually need. where T : class or where T : IComparable<T> should serve a purpose in the method body.
  • Use IEnumerable<T> as parameter types — accept the broadest interface so callers can pass arrays, lists, LINQ queries, or any collection.
  • Use Dapper for SQL → LINQ pipelines — Dapper maps SQL results directly to strongly typed records, then LINQ operates on the typed collection in memory.
  • Use Polars.NET for large analytical workloads — when LINQ on in-memory collections isn’t fast enough, Polars.NET brings columnar Rust-backed processing to C#.
  • Use record types for LINQ projectionsrecord PriceRow(string Symbol, DateTime Date, double Close) gives free equality, ToString, and deconstruction.
  • Use covariance (out T) on read-only interfaces — enables IEnumerable<Dog> where IEnumerable<Animal> is expected, which is safe because you’re only reading.

C# Generics and LINQ Troubleshooting

ProblemCauseFix
CS0311: type 'X' cannot be used as type parameter 'T'X doesn’t satisfy the generic constraintAdd the required interface to X, or relax the constraint
LINQ query returns different results on second enumerationDeferred execution re-evaluates — source data changed between enumerationsMaterialize with .ToList() after the first evaluation
InvalidOperationException: Sequence contains no elementsCalled First(), Single(), or seedless Aggregate() on an empty sequenceUse FirstOrDefault(), SingleOrDefault(), or seeded Aggregate(seed, func)
GroupBy result is hard to work withIGrouping<K,V> isn’t a dictionaryChain .ToDictionary(g => g.Key, g => g.ToList()) to convert
CS1061: 'T' does not contain a definition for 'X'Missing constraint — compiler doesn’t know T has method XAdd where T : IInterface with the required method
Covariance/contravariance won’t compile on classVariance only works on interfaces and delegatesMove out/in to an interface definition
LINQ query is slow on large dataAll data loaded in memory, no optimizationConsider Polars.NET for columnar processing, or push filtering into SQL
Polars.NET DataFrame column access throwsColumn name mismatch or wrong typeCheck column names with df.Columns and types with df.Schema
Aggregate produces wrong resultSeed value is wrong, or accumulator function has a bugVerify seed and step through the accumulator logic manually
Query syntax let not available in method syntaxlet is a query-syntax-only keywordUse a .Select() to create an intermediate anonymous type