Chaos run · part three · the actual job

Does the Backtest Tell the Truth?

I built projects from nothing — connect a source, build the graph, write an alpha, backtest it, get a PnL table — first on data where I already knew the right answer, then on seven real public feeds off the web. The arithmetic matches to thirteen decimal places. Four times an alpha could still see the future, and those four are the ones that matter.

Method known-answer data + live public APIs Projects built 25+, all from scratch Version qanat 0.1.1 Machine Asia/Seoul
58 fixed 6 partly fixed 9 still open

Everything below was fixed in v0.1.2, except where a badge says otherwise

Each finding carries its own status. Every fix was checked by re-running the script that found it, and the ones that mattered most are now tests — tests/test_audit_fixes.py holds one case per finding, so each of these mistakes fails loudly if it comes back. The full ledger, with the repro scripts, is in audit/.

One finding cannot be closed and is recorded as a limit rather than a fix: a step is arbitrary Python, so it can always read a source file off disk itself and reach data the as-of views hide. The three doors that were qanat's to close are closed.

The numbers

14 hypotheses · 7 real sources · 38 problems
14Hypotheses tested
7Real web sources
8Exact matches
4Ways to see the future
38Problems found
4 very bad 11 bad 23 medium

How I tested it

the oracle trick

Testing a backtester is hard, because you cannot tell a wrong answer from a right one just by looking at it. So I did not look. I made the data first.

Example. Two symbols. Every symbol rises exactly +0.1% every single day. No randomness. An alpha that always holds A. Over n periods the answer must be (1.001)ⁿ − 1 and nothing else. If qanat says something different, qanat is wrong.

Then I did the same for turnover, for fees, for blending two alphas, for purge, for embargo. And for the one that matters most: I built data where seeing the future is worth about 1,300% and being blind is worth about 3%. Then I tried six different ways of cheating. The number tells you which ones worked.

What is exactly right

read this first

This part matters as much as the failures. The core of this tool is correct, and precisely so.

What I checkedExpectedqanat said
Net over 143 periods at +0.1%/day0.15364736790.1536473679exact
Gross per period0.0010.001exact
Turnover, first period (buy from flat)1.01.0exact
Turnover, holding unchanged0.00.0exact
Fees on turnover 2.0 at 10 bps0.0020.002exact
Blend of two alphas, 3:1 allocation0.0016250.001625exact
Purge 5d — how far back the alpha can see5 days5 daysexact
Embargo 3d — when the period fills+3 days+3 daysexact

Can an alpha see the future?

6 ways tried · 4 work

The data: every day one of two symbols jumps +2% and the other falls 2%, in a pattern with nothing to learn from the past. A blind alpha earns about 3%. An alpha that can see two days ahead earns about 1,300%. So the net is the leak detector.

How the alpha tried to reach the dataNetResult
ctx.read("normalized.prices") — the sanctioned way3.40%blocked
ctx.sql("… FROM normalized__prices")3.40%blocked
ctx.sql("… FROM raw__bars")3.40%blocked
ctx.read("raw.bars") with raw declared in from:3.40%blocked
ctx.sql("… FROM main.raw__bars")1292.59%leak
ctx.store.read("raw.bars")1292.59%leak
pd.read_csv(ctx.root / "seed/bars.csv")1292.59%leak
a price stamped -05:00 instead of UTCleak
All four leaks produced zero failures, zero warnings and zero notes. The report reads as a clean, spectacular alpha. The lookahead guard did not fire, because the alpha wrote an honest as_of date while cheating on the pick — which is exactly the shape an accidental leak takes.
Very badW-01 fixed in 0.1.2 store.py:314 · context.py:66

Naming the schema steps around the as-of views

Why the other doors are shut

During a replay, qanat shadows every table with a view holding only the rows that existed at that moment, and puts that view first on the search path. So a plain table name resolves to the past. That works.

It also happens to have a second wall: a replay rewrites the derived tables at each as-of date, so even a bypass reads truncated data. But a replay never re-polls a source — which is correct — so the raw table still physically holds the whole history. Raw is the one place where the view is the only defence.

And the view is easy to step around

# resolves through search_path to the as-of view — blocked
ctx.sql("SELECT date, symbol, close FROM raw__bars")        3.40%

# names the schema, so the view is skipped entirely
ctx.sql("SELECT date, symbol, close FROM main.raw__bars")   1292.59%

ctx.store.read("raw.bars") does the same thing without any SQL, because Store._q() pins every name to the home schema by design — a fix for a Postgres problem that quietly disables the as-of views for anything that calls it. And ctx.store is a public attribute on the object handed to every step.

This is not an exotic path. ctx.read() refuses a table you did not declare in from:, and the error tells you to add it. A researcher who hits that friction and reaches for ctx.store.read() instead gets a silent time machine.

How to fix it

  • While store.as_of is set, make Store.read() resolve through the as-of schema instead of _q(), or refuse outright.
  • Stop exposing the raw Store on Context. If steps need escape hatches, give them named ones that respect the clock.
  • In ctx.sql(), reject a schema-qualified reference to a project table during a replay, with a message that points at ctx.read.
  • Add a leak test to the suite: this exact battery, asserting that a cheating alpha earns the same as a blind one.
Very badW-02 fixed in 0.1.2 store.py:331, 381, 416

A timezone offset is thrown away, so rows arrive early

What I did

Landed three rows that are the same instant, written three ways: 21:00Z, 16:00-05:00, and 06:00+09:00 the next day.

What happened

as_of 2024-01-01T17:00 (UTC) -> visible: ['NY_ROW']        4 hours BEFORE it happened
as_of 2024-01-01T20:00 (UTC) -> visible: ['NY_ROW']
as_of 2024-01-01T22:00 (UTC) -> visible: ['NY_ROW', 'UTC_ROW']
as_of 2024-01-02T07:00 (UTC) -> visible: ['NY_ROW', 'SEOUL_ROW', 'UTC_ROW']

through open_pit() — the real replay path:
as-of view at 17:00 UTC -> ['NY_ROW']

Why it matters

Every as-of comparison does CAST(col AS TIMESTAMP), which drops the offset and keeps the wall-clock digits. So a New York close is treated as 5 hours earlier than it was, and a Seoul close as 9 hours later. One leaks the future; the other hides real history.

Any project mixing venues, or any feed that switches format, gets this. And it is inside the machinery that exists specifically to stop leakage.

How to fix it

  • Cast through TIMESTAMPTZ and normalise to UTC at all three sites (read, _shadow, max_time).
  • Warn at land time when a time column contains mixed offsets.
  • Set TimeZone='UTC' on the store connection so behaviour does not depend on the laptop.
Very badW-03 fixed in 0.1.2 rest.py:137

REST rows are invisible to a replay for hours, but only off UTC

What happened, on this machine

fetched_at column type: TIMESTAMP WITH TIME ZONE
store.max_time  : 2026-09-09 15:14:51      Seoul wall clock
true utcnow     : 2026-09-09 06:14:51+00
duckdb TimeZone : Asia/Seoul

rows visible at as_of = "now, in UTC": 0 of 1

Why it matters

The REST connector stamps fetched_at with an aware UTC datetime, which lands as TIMESTAMPTZ and then renders in the session timezone. The docstring says this stamp is what makes retention and replay correct. On a UTC server it is. Nine time zones east it is off by nine hours in the wrong direction.

The worst part is the shape of the bug: it passes CI, it passes on the maintainer's server, and it silently misbehaves on a user's laptop. Every REST-landed row is hidden from a replay for the length of the UTC offset.

How to fix it

  • Write datetime.now(timezone.utc).replace(tzinfo=None) so the stamp is naive UTC, like every other timestamp in the store.
  • Or pin the connection to UTC, which fixes this and W-02 together.
  • Add a CI job on a non-UTC timezone. This class of bug is invisible otherwise.

Right-looking numbers that are wrong

no error, no warning
BadW-04 fixed in 0.1.2 backtest.py · score_period

A holding with no price earns nothing, and is still counted as held

What I did

Four names, equally weighted. On day 100 the feed simply stops sending D — which is what a real feed does when a name delists.

What happened

D stops being priced after 2024-04-09

as_of        holdings      gross
2024-04-09          4   0.001877
2024-04-14          4   0.001877   <- D has no price here
2024-04-19          4   0.001877
2024-04-24          4   0.001877

total notes in the whole report: 0

three live names at 1/4 each would give ~0.001877   <- this is what it earned
four live names would give                ~0.002503

Why it matters

A quarter of the book silently became non-earning cash. The alpha said hold 25% D; the engine held 25% nothing, and reported holdings = 4 anyway.

There is code to note a missing price — but it never fires. The price table is a pivot, so D is still a column after it dies, just full of nulls. The intersection that decides "did I have a price for this name" therefore still contains D, the note is skipped, and the null return quietly contributes zero to the sum.

This is the bias a point-in-time engine is sold to prevent, arriving through a side door. Compare two alphas where one happens to pick names that delist, and the comparison is distorted with no warning at all.

How to fix it

  • Build the held-and-priced set from non-null prices, not from the column index. The existing note then fires and holdings becomes honest.
  • Say it in the report: "12 rebalances held a name with no price; that sleeve earned nothing."
  • Let a project choose what a vanished name means — carry the last price, or take it to zero. Right now it silently means "cash", which is the one answer nobody would choose on purpose.
BadW-05 fixed in 0.1.2 backtest.py · digest_of

The digest ignores the data, then compare blames the engine

What I did

Ran a backtest. Changed the numbers inside the source CSV — same path, same project, same window. Re-polled and ran it again.

What happened

run 1  digest 0dc91cb420e4d8af  net 0.197002   A last close 148.23
       (the source file is edited; A now drifts three times as fast)
run 2  digest 0dc91cb420e4d8af  net 0.713240   A last close 324.94

qanat compare says: same_question=True
  "same inputs and same window, so any difference here is the engine"

Why it matters

The digest is described as "what this run was computed from". It hashes the job specs, the backtest block and the window. For a CSV source the spec contains the path, never the contents. So the one thing that actually changes from day to day — the data — is not in it.

Then compare takes the matching digest and tells the researcher the difference must be the engine. It sends them to debug a bug that does not exist, while the real cause is sitting in the data folder.

How to fix it

  • Fold a cheap fingerprint of every input table into the digest — row count plus newest timestamp is enough to catch this, and costs one query each.
  • Record that fingerprint in conditions so compare can say "the data also moved: raw.bars went from 400 to 400 rows, newest 2024-07-18 both times" rather than guessing.
  • Soften the sentence. "Any difference here is the engine" is only true if the data is in the digest.
BadW-06 fixed in 0.1.2 api.py · project.py · alphas.py

The console's own "add an alpha" flow makes an alpha that cannot run

What I did

The whole journey through the API, nothing by hand: new project, connect a CSV source, add a normalize step, add momentum from the shelf, run, backtest.

What happened

[200] POST /api/sources (csv)            ok
[200] POST /api/steps  (normalize)       ok
[200] GET  /api/check                    {"ok": true, "errors": []}
[200] POST /api/alphas (momentum)        {"id":"alpha_mom","writes":"weights.mom"}

run the pipeline:
   alpha_mom  failed  ValueError: step 'alpha_mom' has no universe set

backtest it:
   HTTP 200   totals {}   periods 0   failures 37

Why it matters

Every alpha on the shelf calls ctx.universe() without checking. But universe is optional on the request and on the step, and validate() only checks that a universe you named exists — never that a step which needs one has one.

So the shortest path a new user can take, entirely inside the console, ends with a green check and a broken alpha. The API already refuses an alpha pointed at a table with no price column; this is the same class of check and it is missing.

And the backtest returns HTTP 200 for a run with no periods and 37 failures. The store records status: failed correctly, but the response says success with an empty body, so the console draws an empty report instead of an error.

How to fix it

  • Mark shelf entries that need a universe, and refuse POST /api/alphas without one — the same way a bad reads table is already refused.
  • Add a rule to validate(): a step whose script calls ctx.universe() with no universe: set is an error, not a runtime surprise.
  • Return 422 from POST /api/backtest when a run produces no periods, with the first failure in the body.

Building the graph

10 problems
Very badG-01 fixed in 0.1.2 project.py · runner.py:241

A loop between two steps multiplies your numbers by ten on every run

What I did

Built a working pipeline, then edited it into a loop — the realistic case. feat_a reads normalized.b, feat_b reads normalized.a. Both live in one features stage, which is allowed to chain, so no rule catches it.

What happened

clean pipeline, sum of normalized.a = 43,263

qanat check errors: NONE

run 1: all ok? True  sum(normalized.a) = 432,633
run 2: all ok? True  sum(normalized.a) = 4,326,330
run 3: all ok? True  sum(normalized.a) = 43,263,299

Why it matters

Every step reports ok. The console is green. And the value of your feature depends on how many times you happened to run the pipeline. Run it once more before a backtest and every number changes by an order of magnitude.

The code knows this hole exists. runner.order() gives up on a loop and appends the leftovers in file order, with the comment: "a cycle; qanat check is what reports it". qanat check does not report it. That comment describes a guarantee nobody wrote.

How to fix it

  • Run the same fixpoint loop order() already uses inside validate(). If anything is left unready, error and name the steps in the loop.
  • Make order() raise rather than return an order it knows is wrong.
  • Fix the comment either way — a false promise in a comment is worse than none.
ProblemHow badID
Two steps can write the same weights table, and the book counts them as two alphas. check passes; project.alphas returns [('alpha_g','weights.h'), ('alpha_h','weights.h')]; both backtest to last_net 0.040789972052 — identical to the last digit. The contract says the weights stage holds "one table per alpha, each written by exactly one step", and the implemented rule only checks that one step doesn't write two tables. Nothing checks the other direction. A blend of the two would count the same edge twice, which is the exact thing that rule exists to prevent.BadG-02
fixed in 0.1.2
A downstream step runs after its upstream failed, and reports success on stale data. feat_a failed with a vendor error; feat_b then ran, recomputed from yesterday's table, and returned ok, rows=60 with the sum unchanged. Worse, success stamps it into the applied state — so qanat plan then calls it up to date. One failure leaves a store that is green everywhere with mixed-vintage data underneath. The engine used the dependency to order the steps and then ignored it.BadG-03
fixed in 0.1.2
A misspelled option leaves ${var} in the SQL, and the table comes back empty with status ok. A step filtering WHERE symbol = '${sym}' with the option written as other: ran clean and wrote 0 rows. The substitution falls back to leaving the text alone, so it stays a valid string literal that matches nothing. No error, no warning, no event — and every number downstream is then computed from an empty table. A typo in a YAML key is one of the most common things a person does.BadG-04
fixed in 0.1.2
A .sql step can read a table it never declared, so the graph you are shown is not the graph that ran. ctx.read enforces declaration properly and its error message is excellent. But the same step gets the table one line later through ctx.sql, and a plain .sql body can join anything. Measured consequences: the console draws the wrong arrow; check warns that the table "is never read by anything" while two steps read it; and plan().stale() never marks the consumer, so it stays one generation behind forever while reporting ok. The declaration discipline is an honour system presented as a guarantee.BadG-05
still open
qanat plan says "unchanged" after you edit rebalance, decay, when, or a source's key. The recorded job spec covers script, reads, writes, schedule, universe and options — and omits those four. Changing an alpha from a 1d to a 21d rebalance changes every number it produces, and the command whose whole job is to catch "still has rows, quietly means something else than last week" reports nothing.MediumG-06
fixed in 0.1.2
A when: chain does not fire when the upstream correctly computes nothing. The scheduler only wakes consumers when rows > 0, so a step that legitimately clears its table never tells the steps waiting on it. The store's own docstring says clearing is the whole point — otherwise "I computed nothing" reads downstream as "here is a fresh answer". That is exactly what happens one hop down, indefinitely, with every job showing green.MediumG-07
fixed in 0.1.2
A features stage can be placed after the weights stage — but only when a pnl stage exists. The "weights must be last" check sits on an elif, so it is skipped whenever pnl is present. The editor will build the illegal layout for you with no complaint, and an alpha's output can then escape into a general feature stage. The forward-only rule still blocks feeding it back, so this is not full alpha-stacking — but the documented contract is not enforced.MediumG-08
fixed in 0.1.2
A step that writes two tables and fails partway leaves the first one written. The check that all declared tables were produced happens after the writes begin, so which tables survive depends on dict order. The step is marked failed and the graph is half updated — which then feeds G-03.MediumG-09
fixed in 0.1.2
An option named as_of is silently overridden during a replay. The substitution map takes the step's options and then overwrites as_of with the replay clock, so a step means one thing under qanat run and a different thing under a backtest, with no warning.MediumG-10
fixed in 0.1.2
The graph layer is mostly solid. The topological sort is correct for every declaration order tried, including fully reversed. Fan-in and fan-out work. qanat plan on a rename is exactly right — create, orphan, and readable diffs. stale() propagates the full length of a chain. Deleting a step something reads is caught at check time, not runtime. Self-loops, two weights stages, zero weights stages, and writing into the pnl stage by hand are all rejected with clear messages — the pnl one is the best sentence in the codebase. And real backtest PnL tables are correctly protected from prune, even after the alpha is renamed.

Six real sources off the public web

7 more problems · and one big pass

Everything above used price data I made myself. So I went and got real ones: no API keys, nothing private, just public endpoints anybody can call. Each has a different shape, and the shapes are where the trouble is.

SourceShapeLanded?
ECB reference rates · Frankfurter a date-keyed object of objects257 rows
Korean public holidays · Nager.Date a plain list, with Korean text16 rows
Seoul weather · Open-Meteo parallel arrays10 rows
World GDP · a 562 KB CSV on GitHub an ordinary CSV over HTTP13,979 rows
Earthquakes · USGS GeoJSON deeply nested objects34 rows, as structs
BTC spot price · Coinbase a single object, not a listwould not land
GDP by country · World Bank a list of [metadata, rows]would not land
The whole chain works on real web data. I wired ECB rates as the price source and Korean holidays as a feature, wrote an alpha that uses both — buy the currency that fell most against the euro, sit flat in the week of a Korean holiday — and ran it the whole way:
qanat check   ok, with one genuinely useful warning about survivorship bias

fx               ok   rows=257     fetched live from the ECB
holidays         ok   rows=16      새해, 설날 — unicode intact end to end
normalize_fx     ok   rows=771     unpivoted to symbol/date/close
feat_holiday     ok   rows=16
alpha_reversal   ok

backtest  ->  60 periods · net -3.33% · in sample +1.58% · out of sample -4.83%
pnl.reversal written · 60 rows · as_of, holdings, gross, fees, net, equity

Source to PnL table, on data pulled off the internet, with a non-price feature joined in. That is the product's whole promise and it holds.

BadR-01 fixed in 0.1.2 store.py · TIME_COLS

An epoch timestamp — the web's most common clock — breaks the as-of machinery

What I did

USGS puts its timestamp at properties.time as epoch milliseconds. I flattened it into a top-level column, which is exactly what a normalize step does.

What happened

time_column() finds it:      'time'                the name matches, so qanat adopts it

store.max_time()          -> ConversionException: Unimplemented type for cast (BIGINT -> TIMESTAMP)
store.read(as_of=...)     -> ConversionException: Unimplemented type for cast (BIGINT -> TIMESTAMP)
open_pit() -- the replay  -> succeeded, and built a view that throws when read

Why it matters

Every as-of comparison does a bare CAST(col AS TIMESTAMP). DuckDB refuses that for an integer, so any table whose clock is an epoch number is unusable for replay — and epoch seconds or milliseconds is what USGS, GitHub, Slack, Binance and most public APIs send.

The worst part is the third line. open_pit succeeds and reports the table as shadowed, because the view is lazy. The failure arrives later, mid-replay, as a DuckDB cast error that says nothing about which table or why.

How to fix it

  • Detect an integer time column and cast through to_timestamp, choosing seconds or milliseconds by magnitude. Let a project override it in time_columns:.
  • At minimum, refuse it at land time with a real sentence — "raw.quakes: 'time' holds integers, which cannot be a clock; convert it in a step or name another column" — rather than a cast error three layers down.
BadR-02 fixed in 0.1.2 store.py:292

A column that is empty in the first response is typed wrong forever

What I did

Polled the same holiday feed for two countries. Korea has no regional holidays, so counties is null in every row. The United States does.

What happened

poll 1 (KR): ok, 16 rows
   counties column typed: INTEGER          every value was null, so pandas guessed

poll 2 (US): FAIL  ConversionException: Unimplemented type for cast (VARCHAR[] -> INTEGER)
   table rows: still 16

Why it matters

The first response permanently decides the schema. A field that happens to be empty on day one becomes INTEGER, and the day it carries real data the feed stops landing — with an error about a cast, not about a schema.

This is not a corner case. Optional fields that are usually null are the normal shape of a public API, and adding a second country, a second exchange or a second ticker is the normal way a project grows. There is no way to recover except dropping the table by hand.

How to fix it

  • Let a source declare column types, and pass them through — the same fix that rescues zero-padded tickers (W-07).
  • On append, compare the incoming dtypes to the table's and widen rather than cast, or fail with a message naming the column and both types.
  • Land an all-null column as text rather than integer. It is the type least likely to be wrong.
ProblemHow badID
Nested JSON lands with no clock at all. USGS GeoJSON became properties:STRUCT(…) and geometry:STRUCT(…) — which is impressive — but qanat only looks at top-level column names for a timestamp, so it finds none. During a replay that table is never filtered: every row is visible at every as-of date, and retention can never expire it. Nothing warns.BadR-03
partly fixed
A column called Year is not recognised as a clock. 13,979 rows of real annual GDP data landed fine, and time_column() returned Noneyear is not in the list of names qanat accepts (as_of, ts, timestamp, datetime, date, time, fetched_at, created_at, updated_at). Same consequence as R-03, from a much more ordinary dataset. Yearly and quarterly columns are the normal shape of macro data.MediumR-04
fixed in 0.1.2
A JSON body that is a single object cannot be ingested. Coinbase answers {"data":{"amount":"79235.53","base":"BTC","currency":"USD"}}. With records: data the connector dies on ValueError: If using all scalar values, you must pass an index — a pandas message that names neither the source, the URL, nor the fix. "Current value of one thing" is one of the most common endpoints on the web.MediumR-05
fixed in 0.1.2
A body that is a list of mixed things cannot be ingested. The World Bank returns [{metadata}, [rows]]. records: is a dot-path only, so there is no way to say "take element 1", and the connector dies on dictionary update sequence element #0 has length 8; 2 is required. An index step in the path (records: "1") would fix it.MediumR-06
fixed in 0.1.2
The escape hatch works, but the docs point it at the wrong case. payload: true rescued both failures above cleanly. But the docstring offers it for "columns held as parallel arrays" — and that shape (Open-Meteo) landed perfectly without it, while the two shapes that genuinely need it are not mentioned anywhere. Neither error message suggests it.MediumR-07
still open
A slow public API times out inside the default 30 seconds and the failure is an httpx read timeout with no hint that options.timeout exists. The World Bank did this to me on a first call that curl completed fine.MediumR-08
fixed in 0.1.2
Confirmed on real data: polling the ECB three times with the default mode: append and no key took a table of 23 published rates to 69 rows. This is W-09, reproduced on a live feed rather than a fixture — and it is the default configuration.MediumW-09
fixed in 0.1.2
What handled real data better than I expected. Korean text survived the whole path — source, store, step, report — unchanged. A JSON list column (types: ["Public"]) landed correctly as VARCHAR[]. Nested GeoJSON became proper DuckDB structs instead of failing or stringifying. orient: index parsed the real ECB shape exactly as documented. Parallel arrays worked with no escape hatch. A 562 KB CSV over HTTP landed without ceremony. And the survivorship warning on my currency universe was correct, specific, and told me what file to edit.

The rest

15 more, shorter
ProblemHow badID
Connecting a data source
Zero-padded tickers are destroyed. The CSV connector calls pd.read_csv(path) with no options at all, so Samsung's KRX code 005930 lands as the integer 5930 and Tencent's 00700 as 700. The universe file lists them as text, so the join then finds nothing. There is no dtype option to prevent it — anything you put in options is silently ignored by this connector.BadW-07
fixed in 0.1.2
A reordered feed writes data into the wrong columns. The key is matched by name but the insert is INSERT INTO … SELECT *, which is positional. Poll 1 sends date,symbol,close; poll 2 sends symbol,date,close. Result: the date column holds "A" and "B", the symbol column holds dates, and the run reports ok.BadW-08
fixed in 0.1.2
mode: append is the default and doubles the table every poll when no key is set. Poll twice, get 8 rows from 4. Nothing warns — not at run time, not in qanat check. The store.write docstring describes this exact failure without enforcing it.MediumW-09
fixed in 0.1.2
A null in a key column defeats dedup forever. The match is t.k = incoming.k, and in SQL null never equals null, so a row with a blank key part is appended again on every single poll. Unbounded growth, no warning. Within one batch drop_duplicates does collapse it — so the two halves of the same feature disagree.MediumW-10
fixed in 0.1.2
One bad symbol loses the whole batch. The REST connector loops per symbol and concatenates at the end, so a single 404 discards every symbol that succeeded. One delisted ticker stalls the feed indefinitely — every poll lands zero rows until someone edits the list.MediumW-11
fixed in 0.1.2
mode: replace never clears when the feed goes empty. run_source short-circuits on an empty frame and never calls write, so the "an empty answer must clear the table" logic is unreachable from a source. The old rows stay and read downstream as current.MediumW-12
fixed in 0.1.2
A float landing in a column pandas first typed as int is silently rounded. First poll has whole-number closes → the column is BIGINT forever. After that 102.5 becomes 102 and a sub-dollar price becomes 0.MediumW-13
fixed in 0.1.2
A 200 response with the wrong shape replaces the price history. An expired session returns {error, message, code} with status 200; in replace mode that lands as the table. Two clean "ok" polls and the prices are gone. There is no schema contract on a source anywhere.MediumW-14
partly fixed
An unset ${VAR} becomes an empty string. That produces Illegal header value b'Bearer ', or a request sent with a blank API key, or the actively wrong message rest needs options.url when the option is set and the environment variable is not. Lowercase ${var} is never expanded at all, and is sent literally.MediumW-15
fixed in 0.1.2
Reading the result
Decay silently changes the size of the book, not just its turnover. For a long-short book that flips sign, decay_weights returns |weights| = 0.33 where the alpha wrote 1.00 — it averages and never renormalises. The sibling function combine(), used for blending several alphas, does renormalise. Two blending paths in one file, two different rules. The docstring promises "the signal survives, the turnover falls"; a third of the exposure does not survive.MediumW-16
fixed in 0.1.2
"What was held" is not what was priced. save_bt_weights stores the alpha's raw output, before decay. With decay: 3 the saved book has |weights| = 1.0 while the measured turnover proves the priced book was about 0.34. The drill-down shows a portfolio that was never traded.MediumW-17
fixed in 0.1.2
The "weights do not sum to 1" warning never reaches the report. I ran an alpha whose book was 1.9× the declared size. The event log says so. The report does not: status: ok, notes: 0, failures: 0, nothing in conditions, and a headline of 19.69% on a levered book.MediumW-18
fixed in 0.1.2
Duplicate symbols are silently deduped. The alpha wrote A twice (0.6 and 0.9). The replay keeps the last one and drops the other with no note, so three different book sizes exist for one rebalance — 1.9 written, 1.3 priced, and the 1.0 the contract expects.MediumW-19
fixed in 0.1.2
A half-failed run is ranked like a complete one. An alpha that crashes on down days scored 71 of 152 periods — the rest failed. The store marks it partial, but the strategy book filters only on periods > 0, so its net sits in the ranking with no marker, and notes is empty.MediumW-20
fixed in 0.1.2
A rebalance finer than the data quietly shrinks the run. Daily rebalance on weekly prices: 145 stops asked for, 20 scored, 125 skipped. The notes are all there and periods honestly says 20 — but nothing up front says the run you asked for is not the run you got.MediumW-21
partly fixed

What I would fix

in this order

Shut the two open doors

Make Store.read() respect the as-of clock, and stop handing steps the raw store. Then add the leak battery to the test suite so it can never reopen. This is the tool's central promise.

fixes W-01

One timezone, everywhere

Pin the connection to UTC and cast through TIMESTAMPTZ at the three as-of sites. Stamp fetched_at naive-UTC. Add a CI job on a non-UTC timezone — this bug is invisible without one.

fixes W-02, W-03

Never price a name you cannot price

Intersect on non-null prices so the missing-price note actually fires and holdings tells the truth. Then let the project say what a vanished name means.

fixes W-04

Put the data in the digest

Row count and newest timestamp per input table, folded into the hash and recorded in the conditions. Then "same question" means it.

fixes W-05

A data-quality block on every report

Weights that did not sum to 1, duplicates dropped, names held without a price, rebalances skipped, passes that failed. All of this is already computed and thrown into the event log. Put it in the report, where the number is.

fixes W-18, W-19, W-20, W-21

A schema contract on a source

Insert by name, not by position. Let a source declare expected columns and dtypes, and refuse a replace whose columns are disjoint from the table's. Warn on append with no key. That closes most of the source list at once.

fixes W-07 to W-14

Make check check the graph

Detect loops. Refuse two producers for one table. Error on a ${var} with no option behind it, and on a .sql body that reads a table the step did not declare. Four rules, and each one currently turns into a silent wrong number at run time.

fixes G-01, G-02, G-04, G-05

A failure should stop what depends on it

When a step fails, skip everything downstream of it and say why, instead of letting it recompute from yesterday and stamp itself as current. The dependency order is already computed one line earlier.

fixes G-03, G-09

Treat a clock as a real question

Accept epoch integers and year/quarter columns, look inside structs, and say out loud when a table has no clock — because "no clock" silently means "never filtered by a replay, never expired by retention". Right now it is the quietest thing in the system.

fixes R-01, R-03, R-04

Meet the web where it is

A single-object body and an array index in records: cover most of what the public web actually returns. Name the source and suggest payload: true when a body will not flatten, instead of passing a pandas message straight through.

fixes R-05, R-06, R-07
Where I would start.
  1. W-01. Two doors into the future, and one of them needs no SQL. Everything else in this report is a wrong number; this one is a wrong number that looks like a discovery.
  2. G-01 and G-03. Both make the answer depend on how and how often you ran the pipeline, while every light stays green. G-01 is the same fixpoint loop the code already has, called from one more place.
  3. W-02 and W-03. One UTC decision fixes both, and W-03 is already misbehaving on this machine right now.
  4. W-04. A dozen lines, and it removes a bias the tool advertises that it prevents.
  5. W-07. One dtype option. Without it this tool cannot read a Korean or Hong Kong ticker at all.

The honest summary

The engine's arithmetic is right. Net, gross, turnover, fees, slippage, blending, purge, embargo — every one of them matched a hand-computed answer exactly, several to thirteen decimal places. The fill rule that separates a backtest from a wish is correct. Reproducibility works. That is the hard part, and it is done.

What is not done is the boundary around that arithmetic: which data a step may reach, what a timestamp means, what happens when a price is missing, whether the graph you are shown is the graph that ran, and whether the report admits what it does not know. Every problem above lives on that boundary. None of them require rewriting the engine.

One pattern runs through almost all of them. When qanat cannot do the right thing, it usually does a thing and calls it ok: an unpriced holding earns zero, a missing option filters everything out, a failed upstream leaves yesterday's numbers, a loop keeps multiplying. The engine is careful. The reporting around it is too generous.