Rolls up 492 commits / 116 merged pull requests since 0.1.15, from 16
contributors.
The final-answer grounding gate stopped guessing what a number is from
the words around it: the model declares each figure's role (observed,
derived, proposed, cited or count), the gate checks the declaration
against the session's tool evidence, and a figure that fails is cut from
the answer instead of the whole answer being refused. A backtest's own
output now grounds the report written about it, run cards cite a metric
only where the saved CSV agrees, and the generated broker matrix states
each profile's permissions as declarations. Research runs keep what they
fetched: compaction follows the model's real context window instead of a
fixed 40K estimate. The missing-data sweep reached 59 more alphas.
Argentina (BYMA), weekly and monthly bars, Bahasa Indonesia, Gildata,
OpenCode, Email and WebSocket channels and four broker connectors (KIS,
Upbit, Toss Securities, Scalable Capital) land alongside.
Verified before release: the reporter's risk-parity prompt, which had
stopped with no_progress after 93 seconds on DeepSeek V4 Pro, completed
on 4a40a7f6's parent in 8m13s with both backtests run, 13 steps saved and
the report released with its core figures grounded.
Version declarations bumped in pyproject.toml, agent/SKILL.md,
Dockerfile, frontend/package.json, both package-lock.json entries, all
nine locales and the Layout test mock. CHANGELOG cites 116/116 merged
pull requests and credits 16/16 authors. README News carries the release
in seven languages, three entries visible.
A live DeepSeek V4 risk-parity run after c8c2dc38 completed and released
its report, with every declared backtest figure grounded. Its second
draft still lost eight differences: the prose wrote "−1.51pp" (risk
parity minus equal weight) beside "12.87% − 11.36%", the size right and
the direction reversed. That stays refused, because the sign is part of
the claim, but the correction said only "its own note evaluates to
1.51%", and the model rewrote the numbers instead of the formulas. The
issue now carries sign_reversed and the correction says the formula runs
the other way round. The hint for a figure declared in the other unit
matches by size and says when the declared value has the opposite sign
("0.0151" for "−1.51pp"), where before it said nothing at all.
The same draft opened with "The rejection was because several figures
... were not declared", in English to a user writing Chinese, and that
sentence was released. The correction now asks for the corrected answer
alone, in the user's language, without mentioning the rejection, the
check or the figures block.
A live risk-parity run on DeepSeek V4 stopped with no_progress after 93
seconds, before any backtest ran: the model went looking for the backtest
engine's source (../../agent/src/backtest, the checkout's absolute path,
skills/../backtest/engine.py) and every attempt got "File not found or
path escapes workspace". Each spelling was a new call, so none was
blocked, and none was an observation, so eight rounds exhausted the
no-progress budget. This is the reporter's 1m03s failure: same prompt,
same shape, no compaction involved.
The refusal now says which it was (error_code outside_readable_roots or
not_found), where read_file may open files (the run directory, skills/),
and that the engine's contracts are in the skills (load_skill lists a
skill's sections). A path that names a directory inside those roots
returns its entries instead of an error. The boundary is unchanged.
The same replayed risk-parity drafts, after d2dd7006:
- A derivation note with labels glued to its operands ("等权 Sortino
1.2115 − 风险平价 1.1329") or an aside in brackets ("0.09297 − 0.10469
(收益差,约−1.17pp)") was "not arithmetic". When the note as written
fails, its words and bracketed asides are now removed, never read, and
the arithmetic between them evaluated: formula_not_evaluable 11 -> 1.
- An operand written as a percent is the fraction the evidence holds:
"13.28% − 12.98%" is 0.1328 − 0.1298 (it was 13.28 − 12.98, which no
observation anchors), and "0.666 × (1 − 3%)" is 0.666 × 0.97.
- A square or cube (HHI is a sum of squared weights) is evaluated; the
exponent is not an operand and anchors nothing, and any other power
stays unsupported (9^9^9 would never return).
- A declared value may be a fraction ("1/3") or carry a multiple mark
("5.5x", "3 倍"); one unreadable line fails the whole draft.
- A draft ending in DeepSeek's closing tool-call tags
("</||DSML||invoke>") is recognised as tool-call syntax; the
fullwidth pattern only matched opening tags, so the tags reached the
gate as figures-block lines.
The formulas that now evaluate and still fail are the model's own errors
(operands rounded before subtracting, a reversed sign, 0.1357 − 0.1287
written as 0.0069).
a71d5312 refused every bare letters-and-digits code under source=auto as a
US ticker without its suffix. Chinese futures codes (RB0, rb2501, IF2412)
and joined crypto pairs (BTCUSDT) have that shape too; the market detector
the loaders route by reads them as futures and crypto, and they were
answered "US equity symbols must include the .US suffix". The check now
asks the same detector and refuses only what it reads as a US equity.
A risk-parity vs equal-weight backtest report ended in the canned refusal in
12 of 15 replayed runs. Replaying all 26 stored drafts through the gate
(880 of 882 recorded rejections reproduced) showed why:
- Only kind-named metrics (Sharpe, return, drawdown ...) became evidence.
Sortino, Calmar, turnover, final value, Monte Carlo p-values, the risk
X-ray and the optimiser's weights were refused although the backtest
wrote every one of them. The run card, metrics row, risk X-ray,
validation and rebalance summaries are now recorded whole (lists are
not descended; files other than metrics.csv/json must match the run
card's artifact manifest). A per-bar table counts only for the rows a
read_file showed, while it is still byte for byte what the engine wrote.
A file the model wrote is never evidence.
- A ref naming the run (rp, rp/artifacts/metrics.csv, rp/metrics.csv,
"risk_parity target_positions.csv", backtest::rp, rp::sortino) resolved
to nothing. The run directory now names that backtest's output and no
other run's; one run's same-named fields are one source, two runs are
two (correction candidates are written rp::field).
- Naming a symbol in the request made it a market answer, and a market
answer with no fetched price could never be released with its failing
figures cut. A completed analysis now can; the refusal for a price
question with neither prices nor analysis is unchanged, and the refusal
after an analysis no longer claims the draft cited prices.
- Derivations: formula constants parse unsigned, so a negative observation
(a drawdown) could never anchor; operands now match by magnitude. A field
ref two runs hold anchors both for arithmetic. With several instruments'
bars and no symbol, symbol-less evidence the ref names may anchor;
instrument prints still may not.
- A figure declared as a fraction but written as a percent stays
undeclared (spec section 2), and the correction now says which value
was declared.
Replay: value_mismatch 196 -> 1, not_in_referenced_call 167 -> 7,
formula_not_anchored 35 -> 18; releasable final drafts 3/15 -> 15/15.
Two-sided on the same runs (decimals): a real value declared with its run
directory passes 97% (was 23%), the same value moved 2-10% passes 0.7%.
The undeclared path is unchanged: widening it to the metrics row would
have passed 16% of invented values (4.6% today).
A user on gpt-6-sol saw "failed · 0 steps · no_progress" for a risk-parity
backtest and a 12-company comparison. Reproduced on v0.1.15 with
deepseek-v4-pro: layer 1 fired from iteration 3 (the system prompt alone
estimates ~12.5K of the fixed 20K trigger), cleared every tool result but the
last three, and the run re-fetched the same filings 4-5 times each until
no_progress at iteration 16. Raising the threshold alone took re-fetches from
54 to 0 across 8 runs.
- Compaction budget: window from VIBE_TRADING_CONTEXT_WINDOW > limit learned
from a context-length error > context_windows.json (sourced: Codex models
272K, deepseek-v4-pro/flash 1M) > 128K default; capped by
VIBE_TRADING_CONTEXT_MAX_TOKENS (200K); measured in real input tokens.
Layer 1 clears oldest-first only back to its line. A context-length error
compacts and retries once. TOKEN_THRESHOLD is deprecated and ignored.
- No-progress limits are unchanged; the stop message now names the tool,
reason and count instead of one fixed compaction-era sentence.
- Failed and cancelled attempts keep their tool trail (history said 0 steps).
- Codex: mid-run system messages become developer input instead of replacing
instructions; encrypted reasoning items are replayed; no fabricated fc_0
ids; prompt_cache_key per session; default model gpt-6-sol (gpt-5.4 is
rejected for ChatGPT accounts).
- get_market_data: no symbol resolved is an error envelope; grounding still
records each symbol as unavailable.
- loop.TOKEN_THRESHOLD test hook is a real attribute: monkeypatch used to
write the lazy 40000 back on undo and switch later tests to the old path.
Bound BaoStock messages and serialize SDK sessions; verify Copilot credential
lookup expiry without changing the SDK's per-call credential resolution.
Run public-loader health checks in isolated, credential-free processes and a
separate dependency environment, with explicit failure reports and a weekly
workflow. Render broker capabilities per profile and document verification
requirements. Update September 28 News in all seven README languages.
Closes#1626Closes#1623Closes#1617Closes#1492
The BYMA / TSX fix could not close Hong Kong. The hk_equity chain starts with
Tencent and Eastmoney, which declare no currency, so a guard on Yahoo's
declared currency only moved a counter to the next source. The venue gives a
better handle. HKEX's Stock Code Allocation Plan (updated 2026-03-12) assigns
80000-89999 to "Products traded in Renminbi", and 09000-09199, 09200-09399,
09400-09499, 09500-09599, 09700-09799, 09800-09849, 10900-10999 and
41500-41599 to products traded in USD. Every other .HK code trades in HKD, and
that includes 09600-09699 and 09850-09999 (9988.HK, 9618.HK). Checked against
the currency Yahoo declared for 24 codes on 2026-09-24: 6 CNY, 10 USD and
8 HKD, all matching.
Before: 80700.HK (Tencent's RMB counter, 375.40 CNY) or 9834.HK (a USD ETF)
ran in the HK engine's HKD pool next to 00700.HK with no check at all, because
Composite's mixed-currency guard only runs across markets. The grounding gate
also required the answer to call their prices HKD.
- _market_hooks.hk_counter_currency(): the one table. code_currency() uses it,
so Composite's guard sees CNY / USD counters.
- runner._create_market_engine refuses a Hong Kong code traded in another
currency, for single-market and cross-market runs alike, before any engine
prices it.
- grounding identity._infer_currency reads the same table (lazy import, as
context.py already imports from backtest), so 80700.HK is quoted in CNY
whichever source served it.
Tests cover the 24 live codes, both sides of every range boundary, the refusal
(mixed, pure RMB, USD, cross-market) and an HKD-only run that still builds.
Mutants each fail: no RMB range (12), a USD range one code too wide (1),
code_currency ignoring the counter (1), the runner guard off (4), grounding
ignoring the counter (1). Related suites: 2769 passed.
With imap_use_ssl off, the Email adapter and its connection probe logged in
over an unencrypted IMAP connection. That predates #1544, but #1544 put the
switch in the guided setup. SMTP already had smtp_use_tls, STARTTLS by
default when not on implicit SSL. IMAP had no equivalent.
- imap_use_tls (default true) upgrades a plain IMAP connection with STARTTLS
before LOGIN, in the adapter and the probe. A server that offers no
STARTTLS, or a failed handshake, is reported as network-class before any
LOGIN, so the password is never sent. Turning it off is the one explicit way
back to a plaintext login, and its help text says so in nine locales.
- verify_tls now governs every TLS path. Both STARTTLS calls used their own
always-verifying context, so a self-signed server had an opt-out on implicit
SSL but not on STARTTLS. email_tls_context serves all four paths.
The panel's label map in ChannelConfigPanel.tsx is a second hand-written list
beside FIELD_HINTS. A hint missing from it still renders, as its raw key with
no help, so the plaintext warning would have been dropped silently. A test now
requires every hand-written hint to have a panel entry and an en label and
help. It passes for all four existing tables and fails if imap_use_tls's
entry is removed.
Mutants each fail: no STARTTLS in the adapter (1) or the probe (5),
imap_use_tls ignored (1), probe or adapter SMTP STARTTLS ignoring verify_tls
(1 each). Channel sweep: 448 passed. Frontend i18n + settings: 64 passed.
Upgrade note in the CHANGELOG: an imap_use_ssl=false setup against a server
without STARTTLS stops connecting until imap_use_tls is set to false.
openai 3.19.0 still built request headers with the case-sensitive
_merge_mappings; 3.19.2 switched to _merge_headers. Main's pre-fix code with
langchain-openai 1.6.5 passes 9/9 on openai 3.19.0 and fails the one test on
3.19.2. The comments, test docstring, CHANGELOG and news said 3.x.
db78d0c3 converted every symbol's index with as_unit('ns'), and did it twice
per symbol. as_unit copies even when the unit already matches, so _align's
5000x50 benchmark rose about 1.5 ms (51.7-53.3 against 50.0-51.7 ms with
the change reverted, interleaved on the same machine). Each index is now
converted once, and only when it is not nanoseconds: 50.4-53.6 against
50.9-51.7 ms, within noise. Both still exceed the 50 ms gate at load average
3.8 on this laptop. The pre-change code fails it the same way, so that is the
machine, not the change.
#1544 made a hand-written hint's secret flag authoritative over SECRET_KEY_RE.
That was needed: token_ttl_s and websocket_requires_token were being stripped
from GET values, and every save flipped the stored token requirement to false.
It also makes the hint table the one place a key the regex calls secret can be
sent to the browser unmasked. Email and WebSocket each pin their own secret
set. A new test pins the union across every channel's hints (today the three
WebSocket keys), so the next exemption, Feishu's included, has to be edited
into a reviewed list. A mutant marking token_issue_secret non-secret fails
it with four others.
Also: #1544 moved the fallback-derivation test's second case from websocket
to telegram. telegram needs the optional python-telegram-bot SDK, so on an
install without it the case failed ('should expose default_config()')
instead of skipping as the file's other adapter-loading tests do.
#1571 added latest / 20-bar mean / ratio volume and chose not to infer a unit.
The pipeline does not ask for inference, though. fetch_market_data's contract
(#1062) is that consumers read provenance.volume_unit instead of assuming one:
the A-share sources count board lots of 100 and the Yahoo family counts shares,
so an unlabelled latest volume can be read 100x wrong. The tool now requests
provenance and returns volume.unit as the serving loader declared it (lots /
shares), or null when undeclared. ratio_20 was already unit-free.
Also folds the new _extract_volume_series into the close extractor it copied,
about 30 lines, as one _extract_series(keys, keep_missing=). Every existing
test passes unchanged. keep_missing had no test that could fail: with dates,
the tool reindexes volume onto the close dates, so a gap comes back as NaN
either way. It matters only for an undated payload, where dropping the gap
shifted bar 28's volume into 'latest'. That case is now tested. Mutants (no
provenance / gaps dropped) each fail.
Same contract as the previous commit, on the next venue it covers. The TSX
lists US-dollar units beside the Canadian ones: Yahoo declared CAD for DLR.TO
and USD for DLR-U.TO (10.16) and XUS-U.TO (48.34) on 2026-09-24. ca_equity is
one CAD pool, and its chain is yahoo -> yfinance -> local like ar_equity's, so
the declared currency decides there too. The single-currency venues are now
one table.
Hong Kong is left as it is. Yahoo declares CNY for the RMB counters (80700.HK,
82318.HK), but the hk_equity chain puts Tencent and Eastmoney ahead of Yahoo
and neither declares a currency. A guard on the Yahoo line would only move
such a counter to the next source.
#1576 kept Yahoo's declared quote currency on every frame. Probing what it
declares for .BA showed the case the ar_equity market (#1543) had missed. BYMA
lists an issuer in pesos and again in dollars. Yahoo declares GGAL.BA / YPFD.BA
/ GOOGL.BA in ARS, and SPYD.BA / GGALD.BA / AAPLD.BA in USD (13.37 / 4.20 /
17.53 on 2026-09-24). The engine books every .BA line in one static ARS pool,
so a dollar line was priced as pesos. The trailing D is not a rule: YPFD.BA is
a peso line.
This is the LSE contract (#1206) on a second venue. normalize_declared_quote_
currency admits a .BA frame only when it is declared ARS and still sends .L
through the GBp/GBP normalizer. Every other declared currency is recorded as
#1576 intended. The Yahoo loader calls it for every frame. The yfinance loader
reads the declared currency for .L and .BA only (a metadata request each) and
passes it through the same function. The ar_equity chain is yahoo -> yfinance
-> local, so a dollar line can no longer reach the pool through a fallback.
Grounding, same commit:
- evidence.py: a market-data record takes the currency the provenance
declares and falls back to the suffix. The answer must name the record's
currency, so the suffix guess was the one enforced before.
- policies.py: GBP / VND / ARS aliases (£, ₫, AR$, 英镑, 越南盾, 阿根廷比索).
The previous commit gave .L / .VN / .BA a currency. Without the aliases an
answer writing £1.17 took a correction round for not naming GBP.
Mutants each fail: no ARS check (4 tests), yfinance not probing .BA (3), the
record ignoring provenance (1), no aliases (4).
#1575 taught identity and symbol search the .BA suffix. It was the third market
to arrive this way. .L (#1206) and .VN had also landed in the backtest's
_MARKET_PATTERNS / _MARKET_CURRENCY with no canonical-symbol scan, no venue and
no currency in grounding. Symbol search labelled every result for India,
Korea, the UK, Vietnam and Argentina "global". The two tables had nothing
tying them to the one the backtest prices from.
- identity: .L / .VN beside .BA in the scan, venue (lse / hose) and currency
(GBP / VND). GBP matches the UK loaders' contract, which admits only a
declared GBP/GBp quote and returns GBP.
- symbol search: _MARKET_BY_SUFFIX, which the Eastmoney path already used,
now has a row for every routed suffix, and the Yahoo path reads it instead
of hand-written branches: in / kr / ca / ar / uk / vn.
- test_market_identity_parity makes the backtest's currency table the list of
markets. Each market needs a sample row checked against _detect_market, the
identity scan, venue, currency (== _MARKET_CURRENCY) and the search label.
Run against main's two files, it fails for ar, india, kr, uk and vietnam.
Removed from #1575: the "ar" branch and aliases in candidate_market. That
function is read only at identity.py:724, to match explicit market
constraints. _extract_market_constraints produces only cn / hk / us, so an
"ar" candidate was never compared to anything. Constraint words for more
markets would be a separate change, and it fails closed on a mis-scoped
constraint.
#1562 dropped the asset-noun list (shares / contracts / btc ...) because it
refused research like "should the fund sell its GOOGL shares". That list was
also the only thing refusing an order written without a timing cue.
"Buy 100 shares of NVDA", "Short 2 ES contracts" and "buy 0.5 BTC" were all
accepted as research goals. A goal's objective is replayed into every
continuation turn while the trading tools stay available, and paper orders
skip the mandate gate.
The filter now reads the order's shape, not the noun. It rejects an objective
that opens with the verb ("Buy NVDA", "Please sell ..."), a verb followed
directly by a quantity ("then sell 50 TSLA", "short 2 ES"), and 买入/卖出
with a quantity and unit ("买入100股"). #1562's four research phrasings still
pass, along with "whether to buy", "Short interest ...", "Short-term ..."
and 研究…是否值得买入. A leading "short" is not an order signal, because it
opens research phrases, so a bare "Short TSLA" passes. Dropping any one of
the three patterns fails the tests.
#1560 fixed the rebalance mask, which compared an index's asi8 against
nanoseconds. The engine's _align has the same pattern three times: it merges
every symbol's calendar through asi8, then places closes and signals with
searchsorted on view('i8'). Both count in the index's own unit.
A non-nanosecond index is reachable: the local loader's duckdb source returns
datetime64[us] (probed, duckdb 1.5.5). Read as nanoseconds, a microsecond run
was dated 1970-01-21. Beside a nanosecond source, the merged calendar had 10
dates and symbol A's close column was all NaN.
Every index is converted to nanoseconds once. us+us, ns+us and s+ms now give
the ns+ns dates, closes and positions exactly. Removing either conversion
fails 3 tests.
#1559 rejects a split outside the loaded prices. But train and test are sliced
on each alpha's IC series. That series starts after the alpha's warmup and ends
a forward-return horizon before the last price, so a split inside the prices
can still leave a side empty. With 80 daily bars and a one-day forward return,
a split on 2024-03-19 left 0 test observations and one on 2024-01-01 left 1
train observation. t_stat reads fewer than two as 0.0, the run said ok, and
every alpha was given a category measured on nothing.
That alpha is now skipped with the counts in its reason. Tested on both sides:
0 test / 1 train skip, exactly 2 on the test side is measured. Mutants at < 1
and < 3 each fail.
#1558 made fit_yield_curve reject non-finite observations. The rest of the
module shares the reason it needed to: every range check is a comparison
(face <= 0, 1 + ytm/freq <= 0, lam <= 0, bump <= 0, tau < 0), and a comparison
with NaN is False. A NaN face, coupon, yield, decay, maturity or beta passed
each check and came out as a NaN price, duration, DV01 or curve.
One helper at the shared entry points covers all of it: bond_cashflows (face,
coupon), _discount_factors (ytm), _as_tau (maturities), _decay_factors
(lambdas), the Nelson-Siegel / Svensson betas, and effective_duration.
A probe of every public function now raises with the input's name, and the
test table fails 18 ways if the helper is a no-op.
#1556 validated the Series branch's index. The docstring and the positional
branch also refuse a label that ends before its own start. The Series branch
clipped that label up to its start instead: ends [1, 0, 3, 3] came back as
spans [1, 1, 3, 3] with no error.
NaT stays allowed. The existing timestamp test builds its ends with
shift(-5), which leaves the last five NaT, so NaT means a label that resolves
after the sample ends. searchsorted puts it last, the most conservative
purge. The comment and a test now state that.
#1555 aligned a supplied corr to cov's labels. The covariance frame is read
positionally too, so rows in another order than its columns are the same
misalignment one step earlier. Swapping AAPL and MSFT's rows moved their
weights from 0.0351 / 0.0403 to 0.0375 / 0.0375 with no error. The same rule
now applies: align by label, and reject a row label set that differs from the
columns.
CI went red on every PR because openai 3.19.2 (pulled in unlocked on
2026-09-24; main was last green on 3.19.0) merges headers case-insensitively:
the per-request omit for an ambient `x-case` now also dropped the provider's
explicit `X-Case`.
#1568 stopped omitting any ambient name that matches an explicit one in
another case. That turns CI green, but it holds only for 3.x.
openai 2.53.0 (the version requirements-lock.txt and the Docker image
install) merges OPENAI_CUSTOM_HEADERS into default_headers with a plain dict.
Without the omit, the ambient twin goes on the wire beside the explicit header.
The test that went red on CI then fails on 2.53 with a UnicodeEncodeError for
the ambient `x-case: ambient-à`. On 3.x, the one explicit header we send in
production is still lost: the SDK's own `User-Agent` key comes first, so an
ambient `user-agent` merged after it wins over the Kimi/NVIDIA/OpenCode
User-Agent.
So every ambient spelling is still omitted, and an explicit header whose name
matches one in another case is re-set after those omits. Under a plain-dict
merge the omit removes only the ambient key. Under a case-insensitive merge,
the later entry wins.
The new test checks the wire on whichever openai is installed. It also checks
the override mapping itself, because CI installs 3.x and would never see a
2.x-only regression. Mutants (no restore / #1568's exclusion / restore
before the omits) each fail it on both 2.53 and 3.19. Provider sweep: 527
passed on 2.53 and on 3.19.
#1444 closed the declared-field half: a ref naming a field is checked against
that field only. This closes the rest, as the policy the issue asked for rather
than a patch — a call- or tool-scoped ref pooled every tail-risk field of that
call, and an undeclared tail-risk percent pooled every tail-risk value in the
session, so an ES 95% could quote the VaR 95% value and pass.
The rule is keyed on the evidence, never on prose: `tail_risk_identity()` reads
(measure, confidence) off the FIELD name a tool returned, so var_95 and es_95
are two identities while cvar_99 and es_99 are one. When a scope holds two or
more and a figure's value matches one of them, the figure is refused with
`tail_risk_needs_field_ref`, whose correction names every identity the session
holds. A figure that matches no tail-risk value keeps its old reason, so a
fabricated number is still reported as a mismatch rather than sent looking for
a ref. The system prompt states the rule so a risk report declares the ref on
its first attempt.
The accepted cost, which is the issue's own: an undeclared VaR 95% beside a
VaR 99% reads correctly today and now costs one correction round. Pinned from
both sides — a single-identity session still grounds an undeclared figure, and
an invented value in it is still rejected. Four mutants of the new code (fire on
one identity, let a call ref through, keep the values in the pool, ignore the
value match) are all killed by the new tests.
#1543 added the backend routing only, and a market here is full-stack: the
Settings source-priority list would have shown Argentina as a raw i18n key, and
a .BA position would have been grouped as "other" in a run's positions tab.
Both gaps already existed for the markets that landed earlier, so this closes
the class rather than the instance:
- `positions.ts`: `ar_equity` / `uk_equity` / `vietnam_equity` join `AssetClass`
and the suffix map gets BA / L / VN. PositionsTab's label map is
`satisfies Record<AssetClass, string>`, so tsc now demands a key per class.
- `api.ts`: `SectorAssetClass` is what `_detect_market` can return over the
sector endpoint; it was missing those three and `index`.
- `SourcePrioritySettings`: labels for `ar_equity`, `uk_equity` and `index`
(the last two have been falling back to their raw key).
- Six new keys across all nine locales.
- `EnvConfig.data` gains `MARKET_DATA_ORDER_AR_EQUITY` / `_UK_EQUITY`, and the
test that guarded that block now derives the market list from
`FALLBACK_CHAINS` instead of hand-listing it -- the hand list is why
uk_equity never had a typed field.
Frontend: 656 tests pass, `tsc -b` clean.
#1548 signed ``mu`` and left ``cov`` on raw asset returns, which put the
objective's numerator in position space and its variance term in asset space:
``(w @ mu) / sqrt(w @ cov @ w)`` then reads a long/short pair of correlated
assets as correlated when the positions actually hedge. Measured on the new
fixture (asset correlation +0.92): the asset covariance funds the long with the
whole book (1.00 / 0.00), the position covariance splits it 0.514 / -0.486.
``BaseOptimizer.optimize`` now negates a short's column before calling
``_build_context``, so every context is built from position returns: ``mu`` is
the position's expected return, ``cov`` is D Sigma D, and risk-parity /
max-diversification contexts get the position covariance for free. This also
retires the ``self._active_signs`` instance state the PR introduced between the
loop and the hook. Volatility-only contexts are unaffected -- std(-r) == std(r).
#1545 fixed the MEMORY.md index row; the semantic-link block beside it keyed
candidates by path.name for the same reason and kept the same collision. Under
VT_MEMORY_HIERARCHY two entries sharing a title live at
"{memory_type}/{slug}.md", so discover_links() excluded the OTHER entry as
"self" (it excludes by that key) and any target it wrote named a file that
existed twice.
The reader side accepts all three shapes a sidecar can hold: a relative path as
written from now on, an absolute path as older callers wrote, and a bare
filename, which can only ever be matched by stem.
The index-caliber comment said fqkline returns only "day" for all seven
indices measured; that holds for the six SSE/SZSE ones, and 899050.BJ
comes back empty.
Weekly and monthly bars (#1479), price indices stamped raw (#1541), the
compaction replay (#1488), field-ref tail-risk identity (#1444) and the
QVeris refusal (#1494). The 2026-09-20 entry, which announced that 1M was
rejected, moves under Earlier news.
Finishing the merged tail-risk identity work, from the two-sided probe on
a two-bar ledger (real quantlib_call envelopes, the risk_xray key shape):
- A field-only ref counted the calls that returned the field before
dropping other symbols' records, so "0.666 | observed | close | close"
failed in a run holding two instruments' bars. The symbol filter now
runs first.
- A short ref such as var_99 named no exact field and fell through to the
whole evidence pool, where a VaR 99% claim quoting the 95% value found its
match. A ref now names a field by its full path or its trailing part
(var_99, tail_risk.var_99).
- An ambiguous ref (historical_var returned at 95% by q1 and at 99% by q2)
still fails closed, but the correction said "call historical_var returned
no such value". It now names the sources to choose from
(q1::historical_var, q2::historical_var). Two calls returning the same
value leave nothing to choose and are not ambiguous.
- The prompt taught var.var_95 and q1::var.var_95, a path no tool produces
(quantlib has no var function). It now names data.tail_risk.var_95 /
var_95 from portfolio_risk_xray and historical_var / q1::historical_var
from quantlib_call, and the tests use those real shapes.
Against current main the probe closes four identity leaks (a VaR 99% or ES
claim quoting another field's value under an exact, short or call-qualified
field ref) and turns one legitimate answer into a correction round (the
ambiguous bare field ref). A claim scoped to a call or tool, or not
declared at all, is still matched against every tail-risk value that call
or session holds, so #1425 stays open for that half. No regex added.
Three gaps in the merged replay, each reproduced on the merge with the
review probe before this change.
- Once MAX_READONLY_REPLAY_RECOVERIES was spent, a lost call was no longer
reopened: it stayed in the success set and the duplicate gate answered
"already completed successfully. Use the previous result." for a payload
compaction had removed, including a non-repeatable call lost for the
first time after other calls used the budget. That is the dead end the
reopen exists to prevent. Every lost call reopens again, and the budget
only decides, when the call is made, whether it is restored or run again,
as it was before replay existed; identical re-runs still end at the
no-progress limit.
- The budget was checked when keys were reopened, so one compaction with
one recovery left restored all four keys it lost (9 of 6). It is now
checked per restore: one replay, three re-runs.
- A result cached before a write could be replayed after it, e.g.
factor_analysis on a factor file the agent had just regenerated returned
the old analysis. Any successful non-readonly call now empties the replay
cache.
A key is protected against an immediate re-fetch only once it has actually
been restored, and a key whose payload is visible again leaves the ready
set, so the "just restored" refusal is only ever said of a restored result.
The spacing check now covers 1W and 1M, and dropping 1M from its table
went unnoticed: nothing asserted that daily bars under a monthly
declaration annualise as daily and say so.