Translation
Reports are English by default — that is what quantstats-rs renders. Set the lang option and the
report's own fixed texts (headings, metric names, month names, chart titles, the legend) come out in
another language, each one carrying a short note the browser pops up from the element it sits on:
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
WHERE symbol IN ('GOOGL', 'SPX')
)
SELECT (r).symbol AS symbol, (r).html AS html
FROM (
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'benchmark': ['SPX'],
'lang': 'zh-CN',
'title': symbol,
'strategy_title': symbol}::qs_html_report_options)) AS r
FROM prices
);
Hover a metric name, a chart title or a month in that report and the browser shows a short explanation of it, in the same language.
The lang option
| Value | What happens |
|---|---|
| not set (the default) | Nothing at all — not one character of the report changes |
'en' | The English text stays exactly as it is, and English notes are added |
| any other language | The text is replaced by that language, and its notes are added |
The option is called lang rather than language because language is a DuckDB keyword. Note that a
mistyped key inside a struct literal is silently ignored by DuckDB — {'language': 'zh-CN'} next to
a valid 'title' produces an untranslated report instead of an error, so spell it 'lang'.
The value has to be a language that has entries in the translation table (the six built-in ones below,
plus anything you added with qs_set_translation). A value with no entries is an error rather than a
quietly untranslated report:
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
WHERE symbol = 'GOOGL'
)
SELECT unnest(qs_html_reports_by_prices(symbol, date, price,
{'lang': 'english'}::qs_html_report_options))
FROM prices;
qs_list_translations() is how you find the right spelling.
What gets translated
A little over a hundred positions — everything the report itself says, and nothing the caller gave:
- the document
<title>, the three section headings, and theBenchmark is … / Generated by …line; - every metric row name (
Sharpe,Max Drawdown,CAGR﹪, …) and theMetriccolumn header; - the headers of the yearly and drawdown tables (
Year,Started,Days, …); - every chart title (
Cumulative Returns vs Benchmark,Rolling Sharpe (6-Months), …); - the SVG labels: the legend, the
Meanguide line, the five distribution group names, and the twelve month columns of the monthly heatmap; - the date range in the title bar, which is rearranged rather than word-swapped: the English report
writes
5 Jan, 2021 - 21 Sep, 2026while Chinese and Japanese get2021年1月5日 - 2026年9月21日.
Not translated — by design: anything that comes from your data or your options. symbol, title,
strategy_title and benchmark_title are yours, so a report whose title you set to 'GOOGL daily'
keeps exactly that string; only the library's default 'Strategy Tearsheet' shows through in English.
Write the title you want in the language you want it in.
The notes
Every translation carries a one-line note, and the report shows it through the DOM element the text sits in — the browser's own tooltip, with no JavaScript and no runtime i18n. The report stays one self-contained static file:
- HTML elements (metric cells, headings, chart titles) get a
<span title="…">around the text; - SVG labels (the legend, the months) get a standard
<title>child element, which SVG does not render; - the document
<title>gets only the text: a browser tab has nothing to hover.
Notes may use emoji (📊, 📉, 🎯), which makes them easier to scan.
Built-in languages
en, zh-CN, ja, de, fr and es ship with the extension, each covering every position.
They are the starting point, not a fixed set: the table lives in the running DuckDB process and
qs_set_translation rewrites it in place. Nothing is persisted, so reloading the extension (or restarting
DuckDB) restores the built-in data — which is also the way back if you change too much.
Listing the table
SELECT * FROM qs_list_translations() WHERE lang = 'de' AND key LIKE 'plot.%';
lang is the tag you write in the options, key names one position in the report, label is the text
that position gets, and description is the note. None of the four is a SQL keyword, so all of them can
be written bare (SELECT lang, label FROM …).
Rewriting it
qs_set_translation(lang, entries) takes a list of {key, label, description} and returns a boolean.
It applies to the current process only:
SELECT qs_set_translation('ja', [
{'key': 'metric.max_drawdown', 'label': '最大下落', 'description': '高値からの最大の落ち込み 📉'}
]);
SELECT label, description FROM qs_list_translations()
WHERE lang = 'ja' AND key = 'metric.max_drawdown';
Three rules cover the writes:
| You write | You get |
|---|---|
label (with or without description) | That key's text is replaced; omitting description keeps the existing note |
label = NULL or '' | That key is deleted — no text, no note, and no fallback to the built-in data |
the whole list = NULL | The whole language is deleted |
The boolean tells you whether the table actually changed, so deleting a key that is not there is a
harmless false:
SELECT qs_set_translation('nl', [{'key': 'month.feb'}]::qs_translation_entry[]) AS nothing_changed;
A struct literal has to spell out all three fields to match the signature; cast to qs_translation_entry
when you want to leave one out (::qs_translation_entry[] for the list) and the missing ones become
NULL. A key that is not in the catalog is an error — a key names one real DOM position in the report,
so an invented one would never do anything.
Adding a language is the same call, and a partial one is useful: positions you did not fill simply stay as they are (English, with no note) while the rest of the report is translated.
SELECT qs_set_translation('nl', [
{'key': 'metric.sharpe', 'label': 'Sharpe-ratio', 'description': 'Overrendement per eenheid volatiliteit ⚖️'},
{'key': 'metric.max_drawdown', 'label': 'Max drawdown', 'description': 'Diepste daling van piek naar dal 📉'}
]);
SELECT lang, key, label FROM qs_list_translations() WHERE lang = 'nl' ORDER BY key;
Deleting that language again is a NULL list, and the boolean says whether it was there:
SELECT qs_set_translation('nl', NULL) AS deleted_whole_language;
SELECT count(*) AS rows_left FROM qs_list_translations() WHERE lang = 'nl';
Where the translation happens
After the report has been rendered and before it is written or opened in a browser, so the file on
disk, the html in the result row and the page you see are the same thing — see
Output and browser. It is a one-pass rewrite of the HTML, and the only thing it
touches is the positions listed above.