Skip to main content

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​

ValueWhat 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 languageThe 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 the Benchmark is … / Generated by … line;
  • every metric row name (Sharpe, Max Drawdown, CAGR﹪, …) and the Metric column 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 Mean guide 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, 2026 while Chinese and Japanese get 2021年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 writeYou 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 = NULLThe 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.