Design notes
This page is the "why" behind the code: the shapes that were chosen, and the alternatives that were rejected. The user-facing behaviour is in Functions and Options; this is what users do not need.
The project started from DuckDB's official
extension-template-rs and has been reshaped to
follow duckfn's skeleton conventions (entry module, EXTENSION_NAME, dependency list).
Two SQL names, one signature each
The symbol column is the grouping key, so there is no GROUP BY in SQL: the function groups
inside the aggregate state itself (see "the symbol table" below) and a single call produces the whole
set of reports. Each name therefore has exactly one signature, with the argument order fixed to "data
columns first (symbol, date, value), options last".
The price branch needs its own name: (symbol, date, price, options) and
(symbol, date, period_return, options) have exactly the same type sequence
(VARCHAR, DATE, DOUBLE, STRUCT), so one name could not dispatch them.
The registered name is never written by hand: the attribute macro generates a SQL_NAME constant per
signature (the function name itself, now that there is a single signature), and error prefixes read it
— kind.rs points at it instead of repeating the literal. The price of that is that such functions
have to be pub(super), because the generated module inherits the function's visibility.
The symbol table: the function does its own grouping
The aggregate state is not "one point array" but a HashMap<String, SymbolSlot>, one slot per symbol:
that symbol's points plus that symbol's copy of the options. Three things come out of that:
- the caller writes no
GROUP BY— oneSELECTyields the whole set of reports; - the benchmark is in the same state — it is another symbol of the table, so it is right there to pair up (see below);
- the options' granularity drops to the symbol — reports are per instrument (each with its own title and display name), so the options are too.
The price is an aggregate state holding the whole table's points (the same order as one point array
per group), while the number of reports rendered in result() is still the number of instruments.
The argument slots: options are lazy per symbol
options is read through DuckLazy: every row only builds an O(1) token, and the single real parse
happens the first time a symbol appears (once the table holds it, later rows merely append a
point). This is not a nicety — duckfn's adapter reads arguments per row, so parsing the struct on
every row would be O(rows) parses, whereas parses = number of symbols is what this API should cost.
The parse result is cached in the slot through duckfn's DuckLazySlot: a DuckLazy token is only
valid inside the callback that produced it, so the state can hold the parse result and nothing else.
"Is this the symbol's first row?" needs no extra flag — the key of the table is that marker, because
only the path that inserts a fresh slot reads the options column.
One report per symbol
The report is rendered in result(), one per instrument per call. 100 instruments render 100 full
reports (each with a dozen inline SVGs), so time and memory grow linearly with the number of
instruments — the same order as the old "one report per GROUP BY group", only with the grouping
moved from SQL into the function. The result order is settled in result() by sorting on the symbol;
it does not follow HashMap iteration or DuckDB's merge order.
No ORDER BY is needed: the aggregate only concatenates and lets ReturnSeries::new sort by date
(the price branch sorts by date first, to difference).
Why the benchmark is a symbol in the table (a list of them, in fact)
The benchmark is already in the same long table (it is a symbol like any other), so naming it in the
benchmark option and letting the function look it up is the shortest path: one SELECT, no
GROUP BY, no cross join, and with/without a benchmark differs by a single key.
The earlier design passed the benchmark in as a list argument (list(...) plus cross join plus
GROUP BY, a three-step dance). It did evaluate the benchmark exactly once, but the price was a "with
a benchmark" case that — the norm rather than the exception — needed an extra argument, an extra
overload and two extra SQL steps.
benchmark is a list (['SPX', 'NDX']) because the real scenario is "one instrument against
several benchmark series": quantstats-rs' HtmlReportOptions holds exactly one
Option<&ReturnSeries> (the metrics column, the benchmark line in the plots and rolling beta are all
built around it), so several benchmarks can only become several reports — 1 instrument × M
benchmarks = M rows, told apart by the benchmark field, with the list order being the report order.
Note that "several instruments against one benchmark" is a different thing: that is one report per instrument and needs no list. A "N strategies × M benchmarks" cartesian product is left out of the API on purpose — whoever wants it groups/filters and calls again.
The symbols named as benchmarks are input only and get no report; each benchmark's series is
converted once (differenced first, on the price branch) and shared by every instrument. The
benchmark option additionally has to be the same list across the whole call (entries and order) —
otherwise "which one is the benchmark" would have no single answer, so a disagreement is an error.
Problems inside the list itself (an empty string, a NULL element, a duplicate) are caught in the
options type, see below.
The benchmark's display name (benchmark_title) is a list too, paired by index with benchmark.
It is presentation only, hence lenient: a missing entry (shorter list, NULL, empty string) falls back
to that report's benchmark symbol and extra entries are ignored — neither is an error.
The result row type registers no named type
QuantstatsHtmlReport in html_report.rs deliberately leaves create_type off: the aggregate's
return type already carries the full anonymous STRUCT(symbol VARCHAR, benchmark VARCHAR, strategy_title VARCHAR, benchmark_title VARCHAR, html VARCHAR, file_path VARCHAR)[], so SQL can read
it by field name (unnest / list_transform / [1].html) — a type name on top would only add another
surface to maintain.
The field names are the Rust field names verbatim (duckfn's DuckStruct derive has no field-level
renaming) and none of the six is an SQL keyword, so DuckDB renders typeof without quotes. There are
only three Options: benchmark is NULL for a single-series report (none configured),
benchmark_title goes with it (it is that report's benchmark's display name), and file_path is NULL
when nothing was written — the other three are always there.
Both display names are echoed into the row (strategy_title / benchmark_title): they are the very
ones the legend and the file name used (each falling back to its own symbol), so printing or comparing
them needs no HTML parsing.
The same type doubles as DuckAggregateState::Output = Vec<QuantstatsHtmlReport> — duckfn's list write
path (create_writer_batch / write_valid / write_finish in duck_list.rs) attaches a child writer
and the elements go into the child vector through the write path #[derive(DuckStruct)] generates, so
"an aggregate returning an array of structs" needs no extra machinery at all.
The options type
#[duck(create_type = "replace")] makes duckfn run
CREATE OR REPLACE TYPE "qs_html_report_options" AS STRUCT(...) at load time, which is what makes
{'title': 'x'}::qs_html_report_options (and the JSON form) possible in SQL. Replace rather than
true (CREATE TYPE IF NOT EXISTS) so a definition change is not shadowed by a leftover old type;
the qs_ prefix already makes a name clash all but impossible.
Every field is an Option<T> on purpose: DuckDB fills the missing keys of a struct literal with NULL,
and duckfn turns the whole struct into NULL when a non-Option field reads NULL — so a user's
{'rf': 0.1} would silently fall back to all defaults and their rf would be dropped.
to_report_options(strategy_title, benchmark_title) converts by starting from quantstats-rs'
HtmlReportOptions::default() and overriding only the fields the user actually wrote, so the defaults
have a single source of truth. The two arguments are the already-resolved display names
(report.rs gets them from strategy_title_or / benchmark_title_or): the same names also go into the
file name, so resolving once and using them twice is what keeps the report legend and the file on disk
in agreement. The fallback rules themselves are simple — strategy_title defaults to the symbol,
benchmark_title is taken by index and falls back to that benchmark's symbol — but they cannot be
dropped: with dozens of reports out of one call, the default 'Strategy' is identical for every one of
them, so the legend, the temporary file name and the returned display name would all lose their
distinguishing power.
output_dir is deliberately not forwarded: quantstats-rs writes files itself, under names of its own
choosing, while here the name has to carry "which instrument, against which benchmark" and the whole
write has to be skipped on wasm (see below) — the directory is handled by report.rs/storage.rs once
the report has been rendered.
benchmark and benchmark_title are both list fields (Option<Vec<Option<String>>>), but only
benchmark is validated, in one place, QuantstatsHtmlOptions::benchmark_names(). periods_per_year = 0, output_dir = '' and an output_dir carrying a NUL byte are configuration errors reported right
here as well — all of them before anything is rendered or any file-system call happens.
Persisting the reports
output_dir takes a directory and naming.rs builds the file name:
<time>-<strategy>-<benchmark>-<random>.html (the benchmark part is absent when none is configured).
Naming lives in the function for three reasons:
- a name has to carry "which instrument, against which benchmark, at what time", which only the function knows — and with one instrument against several benchmarks, a path built from the instrument alone would necessarily overwrite itself, which is exactly where the old "the caller writes the full path" design broke down;
- the last two parts are the display names the report itself uses (
strategy_titleand the benchmark's), so a directory full of reports still says which is which; - the random suffix (
fastrand) plus an existence check (Path::existsinstorage.rs, before the write) makes "nothing existing is overwritten" a guarantee rather than a probability: a collision means another suffix is tried, and two calls each write their own files.
Legality and randomness are both delegated: sanitize-filename owns the illegal/control characters,
the Windows reserved device names and the trailing dots and spaces, fastrand the random suffix (the
very random source tempfile uses internally). This file only adds two policies of its own: spaces
become _, and a part is capped at 32 characters.
The write itself is std::fs::write (storage.rs): one call that creates, truncates to zero and writes,
so the file holds exactly the report afterwards — read_text() returning exactly what the function
returned, which the test suite pins with md5.
It used to go through DuckDB's VFS instead (duck_vfs::write_string), and there were reasons for that:
the VFS reaches s3:// / http(s):// once httpfs is loaded, and it was the only way an aggregate
could write at all — DuckDB's C API gives aggregate functions no client context (no bind callback, no
duckdb_aggregate_function_get_client_context), so the VFS route needed duckfn to keep an owned
long-lived connection from registration time. Two things outweighed them:
- it does not work on wasm, where the report write has to be skipped anyway (see WebAssembly), and the failure mode there was an error the caller could do nothing about;
- it cost a feature: the host VFS is what
owned-connectionbrings, andstd::fsneeds no client context at all, which is exactly why an aggregate can call it. One feature less for a capability this extension never used elsewhere.
Two consequences are worth naming:
output_diris now a local path only.s3://bucket/reportsused to be reachable and now ends in a write error, which is what the documentation says;- the directory is joined with
/(storage.rs::join), deliberately not withPath::join: the latter joins with the platform separator, so the samefile_pathwould readout\name.htmlon Windows andout/name.htmlelsewhere — and that string is what the caller sees.
One call may write several files (instruments × benchmarks). The tail in report.rs runs in two
passes: it first settles every (symbol, benchmark) pair's ReportTarget (validating output_dir and
open_in_browser on the way), and only then renders, writes and opens each one, filling the path that
was actually written into the result row.
Opening the report in a browser
open_in_browser hands the report to the system default browser once it has been generated. A browser
needs a local file that actually exists, which decides the rest:
- with
output_dirset, those files are written there and then opened; - without it, every report is written to a temporary file first —
<temp dir>/<time>-<strategy>-<benchmark>-<random>.html, created by tempfile. The stem (time + the two display names) is shared withnaming.rs; tempfile picks the random suffix, appends the.html(what makes the browser render the file instead of downloading it) and guarantees the name was free at that moment, so nothing existing is overwritten and two reports from the same second cannot collide. The time comes first so that sorting the temp directory by name sorts it by time; - an
output_dirthat is not a local path (s3://…,memory://…) is an error rather than a silent no-op, since no browser can open it. That is checked before anything is rendered.
Launching is open's job, and it is the non-blocking that_detached
variant: the reports are already on disk, so the query neither waits for the browser nor looks at what
the browser does with the files. On Windows that is a single ShellExecute call (the
shellexecute-on-windows feature, rather than the crate's PowerShell-based default); on macOS and
elsewhere it is open / xdg-open plus the crate's fallback list. The only failure reported is the
launcher itself not starting.
WebAssembly
A wasm build skips the whole file operation (storage.rs): output_dir is accepted and then
ignored — no error, no file, file_path NULL — while the report itself comes back in the html column
for the host page to show. Two things make that the honest answer rather than a lazy one:
- a wasm build's file system is not a faithful one. Any path that does not exist still comes back
as a phantom one-byte entry —
glob,read_textandfile_sizeall report it as present — so "is this name free" has no answer that can be trusted, and the never-overwrite guarantee could not be kept. A raw write offset is wrong on that target too (a file comes out one byte long / shifted). It is a limitation of the platform, not of duckfn or of this extension. - there is no way around it from SQL.
COPY … TOcan only export query results in a format (CSV / JSON / parquet), and none of those carries an arbitrary HTML document through byte for byte (CSV would quote it, a line break would split it).
The history is worth one line: an earlier version wrote through DuckDB's VFS, which made wasm itself a
problem — there the never-overwrite guard could never find a free name, so output_dir failed with
could not find a free report file name in 8 attempts. Trading the VFS for std::fs (see
Persisting the reports) turned that into an explicit, documented skip.
The naming logic (naming.rs) is still shared: it is compiled for every target even though nothing
calls it on wasm, which is why sanitize-filename and fastrand are ordinary dependencies rather than
non-wasm ones — keeping that module free of cfgs is worth more than dropping two small crates from a
wasm build that names no files.
open_in_browser is the other deliberate exception: a wasm build has no browser process to launch, so
the option is ignored there — no browser, and no temporary file either. The report string comes back to
the host as it is, and showing it is the host page's job. The two crates behind that option (open,
tempfile) are declared under [target.'cfg(not(target_arch = "wasm32"))'.dependencies], so a wasm
build does not compile them at all — a hard requirement, in fact, since open has no emscripten
implementation and would not build.
just build_wasm (cargo build --release --target wasm32-unknown-emscripten --example duckfn_quantstats) compiles fine; the file operation simply never runs there.