v0.1.16

supercorp-ai/superinterfacev0.1.16Sep 29, 2026by warren618

Upgrading?

AI Summary

This release focuses on grounding numerical outputs in financial backtests to reduce hallucinations. It significantly expands market data support with Argentina BYMA, Bahasa Indonesia, and Gildata, and introduces Web UI configuration for Email and WebSocket channels.

Key Highlights

  • Grounding logic for numbers (observed, derived, proposed, cited) to prevent hallucinations
  • New market data sources: Argentina BYMA, Gildata, Bahasa Indonesia
  • Weekly and monthly bar support
  • Web UI configuration for Email and WebSocket channels
  • Volume statistics in technical indicators

New Features

  • Stock profiles carry listing identity
  • Volume statistics in technical indicators
  • Weekly and monthly bars support
  • IM channels configured from Web UI
  • Email and WebSocket guided setup in Web UI
  • Portfolio valuation accepts any ISO-4217 currency

Full Release Notes

Rolls up 492 commits / 116 merged pull requests since 0.1.15, from 16
contributors.

The theme of this cycle is a number that can show where it came from. 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 that 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 (its
Sortino, turnover, weights and Monte Carlo p-values, not only its Sharpe)
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 rather than as runtime verification. Research
runs stopped losing what they fetched: compaction follows the model's real
context window instead of a fixed 40K estimate that began clearing tool
results from the third step. The missing-data sweep reached 59 more alphas:
15 qlib158 count factors, 20 that filled a missing input with a constant
and 24 comparison gates. 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.

### Added

- **Stock profiles carry the listing's own identity** (#1577, thanks @Shizoqua).
  `get_stock_profile` returns a `listing` block beside the issuer's
  fundamentals: Yahoo's symbol, exchange, quote type and quote currency, plus
  the financial currency and underlying symbol when Yahoo declares them. A
  secondary listing no longer reads as the primary one. `GOOGL.BA` is an ARS
  line in Buenos Aires carrying Alphabet's USD financials, and `GGALD.BA` is a
  USD line whose issuer reports in ARS. Nothing is inferred from the ticker.

- **Volume statistics in `technical_indicators`** (#1571, thanks @zeus229). The
  tool returns the latest volume, its 20-bar mean and their ratio, computed from
  the bars it already fetched. The mean needs a complete 20-bar window.
  `volume.unit` is the unit the serving source declares (board lots on the
  A-share sources, shares on the Yahoo family, `null` when undeclared), because
  the two are 100x apart and nothing in the numbers tells them apart.

- **Argentina (BYMA) market data** (#1543, thanks @zeus229). A `.BA` symbol — a
  BYMA listing or a locally traded CEDEAR — is its own market `ar_equity`,
  quoted in ARS, served by `yahoo` → `yfinance` → `local`, and reported as
  market `ar` by `get_stock_profile`. Execution is deliberately not modelled: an
  Argentine backtest raises instead of borrowing US or crypto commissions, lot
  sizes, settlement and short-selling rules. The Web UI carries the market too —
  Settings source priority, the positions asset-class grouping and the sector
  union — and with it `uk_equity`, `vietnam_equity` and `index`, which had been
  missing from one or more of those three since they landed. `EnvConfig.data`'s
  per-market `MARKET_DATA_ORDER_*` fields are now guarded against
  `FALLBACK_CHAINS` instead of a hand-written list, which is why `uk_equity`
  never had one.

- **Weekly and monthly bars** (#1479). `get_market_data` and backtests take
  `1W` and `1M` (and `1w` / `1wk` / `1mo`). Loaders are still asked for daily
  bars; `loaders.base.resample_bars` builds the period bar at the three fetch
  boundaries (runner, market data, benchmark), so a weekly bar is the same
  whichever source served the days, at that source's daily caliber. A week
  runs Monday to Sunday, a month is a calendar month, and each bar is dated on
  the last trading day inside it: first open, highest high, lowest low, last
  close, summed volume and amount, volume-weighted vwap, mean funding rate.
  Annualisation is 52 / 12; a weekly perpetual bar settles 21 funding periods.
  `1M` is matched before any case fold, which read it as one minute in
  strict-100x validation and in attribution (98,280 bars a year). The
  technical-indicator tool canonicalises its documented `1wk` / `1mo` and
  fetches whole periods instead of two days per bar.

- **IM channels are configured from the Web UI** (#1520, #1529; #1519, #1530,
  thanks @shadowinlife). The Settings panel renders each built-in channel's
  fields from backend metadata, masks secrets (`****` plus the last four
  characters, never sent back), tests the unsaved form against the provider
  (`ok` / `invalid_credentials` / `network` / `unsupported`), and hot-swaps only
  the saved channel with no restart. DingTalk and QQ have guided setups; a
  shared token probe backs both. Saves write `channels.<name>` in
  `~/.vibe-trading/agent.json` atomically at mode 0600; a YAML config is shown
  read-only. Reloads of one channel are serialized in `ChannelManager`, and
  `stop_all` cancels a reload's start that is still connecting.

- **Email and WebSocket join the guided Web UI channel setup** (#1544, after
  #1519, thanks @shadowinlife). Both channels get hand-written field metadata —
  localized labels, masked secrets — plus connection tests and per-channel hot
  apply. Email's test probes the real IMAP login, mailbox select and SMTP login
  without ever sending a message; WebSocket is a server channel with no remote
  credentials, so its test validates the TLS cert/key material and binds every
  resolved address locally — an address held by the already-running server reads
  as the expected state, not a failure. Saving a WebSocket config hot-swaps the
  server: connected clients (including the Web UI chat) briefly disconnect and
  reconnect. A hand-written field hint's secret flag is now authoritative for
  the keys it covers — the regex fail-safe still masks every unhinted key — so
  token-shaped non-secrets such as `websocket_requires_token` stay visible and
  editable instead of being masked, and a form save can no longer silently flip
  the stored token requirement. The web config routes now build the WebSocket
  adapter with the gateway services it requires; previously every WebSocket save
  or test failed validation. Email's polling loop also skips stale-config
  delete/move post-actions once a stop or hot swap begins, while the
  already-fetched batch is still delivered. Both the probe and the polling loop
  now send an RFC 2971 IMAP `ID` after login: NetEase mailboxes
  (163/126/yeah.net) accept the login but reject the first `SELECT` with `Unsafe
  Login` until the client identifies itself, so without this the Email channel
  could not read the most common Chinese mailboxes. The identification is static
  and carries no user data or secrets, and servers that ignore `ID` are
  unaffected; verified end-to-end against a live 163.com account.

- **Bahasa Indonesia** UI locale and `README_id.md` (#1482, thanks @Yoruxyv).
  The README count tests cover it, and `MANIFEST.in` now ships `README_es.md`
  and `README_id.md`.

- **Portfolio valuation accepts any ISO-4217 currency** (#1510, thanks
  @zeus229). A source whose currency has no FX rate fails with the currency
  named and is excluded from the totals; the healthy sources stay in.

- **Gildata (恒生聚源) joins the A-share fallback chain as a token-gated source**
  (#1474, thanks @sambazhu). The new `gildata` loader talks to the vendor's
  raw-api MCP endpoint (one JSON-RPC POST per call, token in the Authorization
  header) and serves A-share daily OHLCV through the `StockDailyQuote` tool with
  `restorationStatus=1` — forward, split-AND-dividend adjusted bars, stamped
  `split_dividend` in the price-caliber table after being measured against a
  live payload. Volume arrives in 万股 and is converted to shares (`volume_units:
  shares`); `avgprice`/`prevcloseprice` are ignored because the vendor keeps
  them on a different adjustment basis than the adjusted OHLC. An unresolvable
  symbol answers `rows: []` (never an error), so the chain keeps walking. Auth
  is `GILDATA_TOKEN` (Settings page field or env; `GILDATA_BASE_URL` overrides
  the endpoint, `VIBE_TRADING_GILDATA_MIN_INTERVAL` the 0.3s default spacing).
  Without a token the loader reports unavailable and the chain skips it — no
  behavior change for existing users beyond one reorderable entry at the chain's
  tail. One migration note: a `MARKET_DATA_ORDER_A_SHARE` value saved before
  this change is a permutation of the old 7-source chain, so it stops validating
  once `gildata` joins — the Settings card flags it and the default order
  applies until the saved order is re-saved (one click) with the new source
  included.

- **A Robinhood account can be a read-only portfolio source** (#1428). The new
  `robinhood-live-mcp-readonly` profile uses the same MCP server and OAuth
  grant as the trading profile. Its connection reads exactly one account,
  picked from Robinhood's own `get_accounts` list in the connection center or
  with `vibe-trading connector select-account <id>`. Nothing is preselected,
  and a source with no account errors before any broker call. Holdings come
  from `get_portfolio` and `get_equity_positions` and are mapped only from the
  shapes the reporter posted. A second page of positions, a null list or item,
  or an unknown field is an error, not a shorter account. Positions show
  quantity and cost but no price until the quote reply is mapped. An account
  that also holds options, crypto, futures, event contracts, mutual funds or
  fixed income fails the source instead of showing an equity-only view.

- **A weekly health check of every keyless data source** (#1627, closes #1623,
  thanks @he-yufeng) — `python -m backtest.loader_health` fetches one liquid
  symbol from each of the 15 keyless network loaders, each in its own process
  with a temporary home, no inherited keys and the loader cache off, and
  requires normalised OHLCV whose last bar is at most 14 days old. The
  `Public loader health` workflow runs it every Monday and on demand. An
  unreachable endpoint, a missing dependency, malformed bars and a stale
  series all fail the lane; as first proposed, an unreachable endpoint counted
  as a skip. An offline test fails when a new keyless loader has no canary
  symbol.

- **OpenCode is a built-in provider, and each request carries the session id
  OpenCode Go requires** (#1416, closes #1415, thanks @tonydo) —
  `LANGCHAIN_PROVIDER=opencode` with `OPENCODE_API_KEY` / `OPENCODE_BASE_URL`,
  in Web Settings and both CLI onboarding flows. OpenCode Go refused every call
  without `x-opencode-session` (400 `MissingSessionID`); the adapter now sends
  the run's session id to any `opencode*` provider or `opencode.ai` host, and
  the compaction summary call, made on its own thread, carries the same id
  (`fd6c7c7c`) instead of reaching OpenCode as a second conversation.

- **KIS (Korea Investment & Securities) is a connector for KOSPI and KOSDAQ
  equities** (#1407, thanks @as950118) — paper (모의투자) and live sit on
  separate hosts with separate OAuth tokens, so the paper-trade profile sends
  orders to KIS's own paper server rather than a local simulator. Live is
  read-only: no `kis-live-trade` profile exists, and `place_order` /
  `cancel_order` refuse a non-paper config before anything else. A finishing
  commit (`970c235f`) pins the paper sell and cancel TR_IDs, which no test
  covered, and makes a balance or order walk that hits its 20-page cap raise
  instead of returning a short position list that reads as complete.

- **Upbit is a KRW crypto connector, capped at paper plus read-only** (#1408,
  thanks @as950118) — one key pair reads the same account whatever profile is
  declared, so there is no runtime paper/live discriminator and no live order
  path. Paper orders are simulated against a live Upbit quote: no quote means
  no fill, a limit order fills only if immediately marketable, and cancel
  acknowledges only ids the simulator issued. Needs
  `pip install "vibe-trading-ai[upbit]"`.

- **Toss Securities is a read-only connector for Korean and US equities**
  (#1409, thanks @as950118) — Toss documents no sandbox and no paper/live
  discriminator, so, like Trading 212, it refuses every order and cancel,
  paper included. Reads follow the published schema (`/api/v1/holdings`,
  `/api/v1/orders`); closed-order history is paged to completion, and a walk
  past its cap, or a `hasNext` page with no cursor (`b5ff36ba`), is an error
  rather than a silently truncated list.

- **Scalable Capital is a read-only MCP connector that declares quotes only**
  (#1417, thanks @cgycorey) — Scalable has no paper account and publishes tool
  names without argument schemas. `scalable-live-mcp-readonly` withholds
  `account.read` / `positions.read` because nothing maps the holdings reply
  yet: declaring them made it a portfolio source whose refresh stored a
  complete snapshot with zero positions. The 29 published tools are pinned
  literally as 18 read and 11 write (unknown names resolve to write), and a
  hand-enabled order tool is wrapped by the live order guard and refused.

- **The README's broker capability matrix is generated from the profile
  registry** (#1629, thanks @he-yufeng) — one row per built-in profile (55
  across 18 connectors): environment, transport, read-only or write-enabled,
  declared reads, and whether placement requires a mandate. A test fails CI
  when the block and the registry disagree; regenerate with
  `PYTHONPATH=agent python -m src.trading.capability_matrix`. As merged it
  folded each connector into one row; `dec2c0e6` renders each profile on its
  own, so a paper order capability never reads as a live permission, and
  says the rows are declarations, not runtime verification.

- **Run cards record the backtest's execution and cite metrics against the
  saved CSV** (#1612, closes #1610, thanks @lorenzozanee) — `run_card.json`
  (schema `1.0`) carries a trace with UTC start and end, status, and SHA-256
  hashes of the config and the metrics, never the payloads. As merged, every
  scalar metric was cited whenever `artifacts/metrics.csv` existed; after
  `0244ecea` a metric is cited only when its value matches the file's single
  data row and the file matches its manifest checksum. The Markdown card and
  the Run Detail page show both sections.

- **A read-only extraETF export reader** (#1406, thanks @cgycorey) — the
  Investments export becomes positions and the Transaktionen export typed
  movements, so a `Dividende` row is never read as a holding and a
  transactions file cannot pass as a portfolio. It fails closed on anything it
  cannot read without guessing (unknown header or label, US-ordered numbers,
  more than 8 decimals, one instrument twice under one Portfolio ID). Nothing
  calls it yet; the portfolio container side is still open under #1170.

- **Strategy-store adaptation substrate** (#1391, thanks @modelpath-dev) — a
  child strategy points at its parent through `derived_from` (SQLite schema 3,
  an additive nullable column), and `AdaptationPatch` can change only
  universe, name and position sizing, never `signal_definition`. The child
  starts with no evidence and `UNVALIDATED`; `a7bd2e30` also clears the
  parent's validator, approver and versions, and makes `register_adaptation`
  require the parent's evidence, refusing a stale or insufficient parent.
  Nothing outside tests calls it yet (#1149 stays open).

### Changed

- **`source="qveris"` refuses markets with splits and dividends** (#1494). It
  picks a capability by search rank, which ignores adjustment: for 600519.SH
  the top pick was FMP's non-split-adjusted EOD, and a tool named adjusted
  returned raw closes. Stocks and ETFs are refused before anything is searched
  or billed; crypto, forex, futures, macro and index bars are served as before.
  Both QVeris budget gates reserve only a flat per-call quote now:
  `1 credits/result` had reserved 1 credit for a call that billed 9.66, and
  `qveris_execute` returns `quote_not_bounded` for any other shape.

- **A price index is stamped `raw`** (#1541). Eastmoney's fqt=1 equals fqt=0 on
  every bar of the A-share indices measured, Tencent serves only `day` for the
  SSE/SZSE ones, and Yahoo's adjclose equals close on ^GSPC / ^NDX / ^HSI, but
  the per-source table stamped an A-share index additive and a Yahoo index
  dividend-adjusted. `price_caliber` takes the symbol; A-share index codes are
  matched per exchange, since 000001.SZ is Ping An Bank.

- **Portfolio valuation version 3** (#1510, thanks @zeus229): snapshots stored
  under version 2 are no longer read, so portfolio history starts again after
  upgrading.

- **Tencent, Eastmoney and AKShare A-share prices are labelled
  `split_dividend_additive`** (#1497, #1493, thanks @cgycorey). Their forward
  adjustment subtracts cash dividends from the level instead of scaling by a
  ratio (on 600519.SH, qfq minus raw takes five values over 500 bars against 367
  distinct ratios), so a basket mixing them with multiplicative sources warns,
  and a run served only by them warns that its returns are not total returns.
  Tencent's HK series is labelled `raw`: it serves no adjusted HK bars. The
  prices themselves are unchanged.

- `connector account` renders Binance spot balances as Asset / Free / Locked /
  Total, Futu's per-currency `assets`, and Trading 212's `cash` and `metadata`
  (#1539); before, Binance printed blank rows and the other two printed "No
  account summary returned."

- **The final-answer grounding gate no longer guesses what a number is from the
  words around it.** Only a number's SHAPE is inferred — a decimal point, a
  percent sign, a currency mark or a table cell makes it a measurement; dates
  (including year-less "09-14"), years, security codes, line-leading ordinals
  and code in a language-tagged fence are structure; a plain integer is checked
  only as a price of an instrument quoted in the thousands — so a sentence gets
  the same verdict in every language. A measurement that equals a price or
  volume this session's tools returned needs nothing more. Any other one is
  declared in a fenced `figures` block at the end of the answer, one line per
  figure — `value | role | note | ref` — and checked by role: `observed` must
  appear in the referenced call or tool (`ref` may name either), filtered to the
  figure's own symbol, with a currency-marked figure compared only against
  money-denominated values and a percent only against non-price ones; `derived`
  needs an evaluable formula whose added or subtracted operands are all
  observed, matching the figure at the precision and sign it was written with;
  `proposed` is a price inside its own symbol's observed range or a derivation;
  `cited` must name its source on the figure's own line, because the block is
  stripped before anyone reads the answer; `count` covers counts, weights,
  thresholds and probabilities, while a currency-marked figure, or one inside
  its instrument's price range that no derivation uses as a factor, is checked
  as observed when declared `count`. No role other than `observed` may sit in a
  price column. The block is stripped from the released answer and never
  streamed; the artifact keeps it. The price-word, level-word, derivation-phrase
  and metric-subject catalogues are deleted with it (65 compiled regexes → 12,
  all shape), and `src/agent/grounding.py` is now the `src/agent/grounding/`
  package.

- **A rejected answer costs one correction round, not four, and is then
  released with its failing figures cut rather than refused.** The correction
  prompt lists every failing figure as written, with its declared role and what
  the evidence says (the observed range, the nearest observed values, or what
  its own formula evaluates to, in the figure's units), and names the three
  ways out: declare, rewrite to an observed value, or remove. If the second
  draft still fails, each failing figure is replaced by `(略※)` /
  `(omitted※)` at its own span, bare-integer restatements of a cut figure are
  swept too, a footnote says how many were omitted and why, and the whole
  document — footnote included — must pass the same gate before release. The
  canned fallback is left for an identity finding, a run that never observed a
  price, or a cut that still fails. Drafts that triggered bounded
  `search_symbol` / `get_market_data` recovery do not spend the correction
  budget. The run stays `degraded` with a reason naming the redaction, the
  release path's rechecks are not counted as rejected drafts, and the shipped
  document is recorded under `released` in `artifacts/grounding_evidence.json`.

- **A draft missing only a source or currency word gets a data note appended,
  not another model round.** The canonical symbol is deliberately not repaired
  this way: it is the figure's subject, and a note naming it under an answer
  about another instrument would footnote a misattribution.

- **The chat shows that the answer is being checked.** The agent loop emits
  `grounding_status` (`{stage: "revising", round, issues}` after a rejection,
  `{stage: "released_redacted", removed}` before a cut release), and the chat
  page shows "Checking the figures in this answer (round N)…" until answer text
  arrives, in all eight locales.

- MetaTrader 5 no longer picks a broker alias when several match
  (`47a036d6`, with #1597 and #1598). Quotes, history, orders, symbol search
  and the backtest loader took the shortest suffixed name (`EURUSDm` over
  `EURUSDz`); they now refuse and ask for the exact native symbol. The
  connector still tries a configured `symbol_suffix` first; the backtest
  loader does not read it.

- `run_card.json` `schema_version` is `1.0` (was `0.1`), with the optional
  `tool_traces` and `citations` keys from #1612.

- Broker connectors: 18, up from 14 (KIS, Upbit, Toss Securities, Scalable
  Capital). `test_readme_counts.EXPECTED_BROKERS` is checked against the
  profile registry, not incremented by hand.

- The opt-in loader cache is at version 7 (#1411, thanks @shadowinlife). Entries
  written under version 6, which include Tencent series the forward walk
  truncated, are never matched again and are fetched afresh.

- `VIBE_TRADING_BAOSTOCK_TIMEOUT` (default 30 seconds) bounds each BaoStock lock
  wait, connection and complete message (#1615, thanks @he-yufeng). It is not a
  deadline for a whole multi-symbol fetch.

- The `a_share` fallback chain is `tencent`, `mootdx`, `eastmoney`,
  `baostock`, `akshare`, `tushare`, `gildata`, `local` (#1474, thanks @sambazhu).

- Factor values change for 23 alphas, so an IC, bench or comparison stored
  before this release is not comparable with one computed after it:
  `qlib158_cntp*` / `cntn*` / `cntd*` (15 factors; the first value lands one
  bar later and windows touching a gap are NaN, #1459), `qlib158_wvma5` …
  `wvma60` (5; every value, #1594), and `gtja191_103` / `133` / `177` (3;
  direction and scale, #1604). Patell z for `market_adjusted` and
  `mean_adjusted` event studies moves slightly as well (#1449, thanks @Shizoqua).

- **A grounding correction turn is offered no tools** (#1600, thanks @zeus229)
  — after a rejected draft the model was handed every tool again, so revising
  a figure could restart research. Unless the gate asks for recovery (an
  unresolved symbol or a missing price), the correction turn gets none. A call
  made anyway is not run and, since `47a036d6`, spends one of the two
  correction rounds; at the cap the last real draft goes through the existing
  cut-and-recheck release.

### Removed

- **The Requesty built-in provider.** Its capabilities were OpenRouter's, with
  no adapter of its own. Same request shape: `LANGCHAIN_PROVIDER=openrouter`,
  `OPENROUTER_BASE_URL=https://router.requesty.ai/v1`, and the Requesty key as
  `OPENROUTER_API_KEY`.

### Fixed

#### Long research runs stop losing what they fetched

A user's two runs on the Codex provider (GPT-6 Sol) failed as "0 steps ·
no_progress": a risk-parity backtest after 1m03s and a comparison of A-share
and Hong Kong game companies after 4m50s. They were two different failures.

- **Context compaction follows the model's real context window** — compaction
  fired at a fixed estimate of 40,000 tokens (characters / 4 of the messages,
  not counting tool definitions), while the system prompt alone is about
  12,500 of them. From the third step it cleared every tool result but the
  last three, so a comparison across a dozen companies re-fetched the same
  filings until the no-progress stop: reproduced on v0.1.15 with DeepSeek V4
  Pro, seven A-share issuers' filings fetched four to five times each, stop at
  iteration 16; with a larger budget the re-fetches went from 54 to 0. The
  window now comes from `VIBE_TRADING_CONTEXT_WINDOW`, else a limit learned
  from the provider's own context-length error during the run, else
  `src/providers/context_windows.json` (272K for the Codex GPT-6 / GPT-5.6
  models, 1M for DeepSeek V4, each entry naming its source), else 128K, and
  `VIBE_TRADING_CONTEXT_MAX_TOKENS` (200K) caps what a prompt may cost. Usage
  is read from the input tokens the provider reports. The three layers fire at
  80 / 90 / 100% of what the system prompt and tool schemas leave, and clear
  the oldest results first, only down to the line. A context-length error is
  compacted and retried instead of failing the run. `TOKEN_THRESHOLD` is
  deprecated and ignored with a warning; remove it from `.env`.

- **`read_file` says where the agent may read, and lists a directory** — the
  1m03s failure: on the same prompt, a live DeepSeek V4 run went looking for
  the backtest engine's source (`../../agent/src/backtest`, the checkout's
  absolute path, `skills/../backtest/engine.py`) and every attempt got the same
  "File not found or path escapes workspace". Each spelling was a new call and
  none an observation, so eight rounds exhausted the no-progress budget in 93
  seconds, before any backtest ran. The refusal now names the case
  (`outside_readable_roots` or `not_found`), the readable roots (the run
  directory and `skills/`) and where the engine's contracts are documented
  (`load_skill`); a directory inside those roots returns its entries. The
  boundary itself is unchanged.

- **A failed or cancelled run keeps its steps** — only a completed run saved
  its tool trail, so the Web history showed "0 steps" for exactly the runs
  someone would want to inspect.

- **A no-progress stop says which calls produced nothing, and why** — each
  failing or repeated call is listed with its reason and count ("read_file
  failed (… not found) ×3"), instead of a message about previous summaries
  and artifact paths written for a different failure.

- **`get_market_data` fails when no requested symbol returned data** — an
  envelope holding only `_unresolved` was a success, so a run whose sources
  were all down counted it as progress. It is now `error_code:
  no_market_data`; a partial result is still a success, and grounding still
  records every symbol as unavailable.

- **Codex (ChatGPT sign-in) keeps the system prompt, its reasoning and its
  call ids** — a system message the loop adds mid-run (a retry hint, a
  write-the-file reminder) replaced the whole 49,000-character system prompt
  for every later request; it is now sent as a developer message, as the
  Codex CLI does. Reasoning items are carried from turn to turn the way the
  Codex CLI replays them, and resent without them if the backend refuses;
  tool calls keep the item ids the backend issued instead of a fabricated
  `fc_0`; the prompt-cache key is stable for the session instead of changing
  on every request. The default Codex model is `gpt-6-sol`: `gpt-5.4` is
  refused for ChatGPT accounts.

#### A backtest report is checked against the backtest

A risk-parity versus equal-weight 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 that four in five refused figures were
values the backtest itself had written.

- **A backtest's own output grounds the report about it** — only metrics
  whose names the gate knew (Sharpe, return, drawdown, volatility, win rate)
  were evidence. Every scalar of the run card, the metrics row, the risk
  X-ray, the Monte Carlo validation and the rebalance summary now is (lists
  such as the 1,000 simulated Sharpes are not descended, and a file other than
  the metrics must match the run card's SHA-256 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
  figure may name the run it came from — `rp`, `rp/artifacts/metrics.csv`,
  `risk_parity target_positions.csv`, `rp::sortino` — and a value from one
  backtest does not ground a figure declared from another. Replayed:
  `value_mismatch` 196 → 1, `not_in_referenced_call` 167 → 7. On the same
  runs a real value declared with its run passes 97% of the time and the
  same value moved 2–10% passes 0.7%; the undeclared path was deliberately
  not widened (the whole metrics row there would have passed 16% of invented
  values).

- **A comparison's arithmetic is read as it is written** — a formula glued to
  its labels ("等权 Sortino 1.2115 − 风险平价 1.1329") or followed by a
  bracketed aside was "not arithmetic"; an operand written as a percent was
  read as a whole number, so "13.28% − 12.98%" anchored on nothing and
  "0.666 × (1 − 3%)" computed the wrong value; a sum of squared weights
  (HHI) could not be written at all; a formula's negative operand (a
  drawdown) could never anchor; and a field two runs both hold anchored
  neither. All now evaluate (only a literal square or cube is accepted as a
  power), and the ones that still fail are the model's own arithmetic.
  A declared value may be a fraction (`1/3`) or a multiple (`5.5x`).

- **A report that still fails after a completed analysis is released with the
  failing figures cut** — naming a symbol made any request a market answer,
  and a market answer with no fetched price was never released cut. A
  completed analysis now is; a price question with neither prices nor an
  analysis still gets the refusal, and the refusal after an analysis no
  longer says the draft cited prices.

- **A draft ending in DeepSeek's closing tool-call tags is caught** —
  `</||DSML||invoke>` slipped past the pattern for opening tags and reached
  the gate as unreadable declaration lines.

- **A correction says what went wrong with a figure the model did declare** —
  `0.3897` and `38.97%` stay two assertions, and so do `−1.51pp` and a formula
  that computes `+1.51`, but the model is now told which value it declared
  (also when it has the opposite sign) and that its formula runs the other way
  round, instead of "not declared" or "its own note evaluates to 1.51%". In a
  live run after the evidence fix, eight differences were still cut because
  the second draft rewrote the numbers rather than the formulas. The
  correction also asks for the corrected answer alone, in the user's
  language: that draft had opened with "The rejection was because…", in
  English to a user writing Chinese, and the sentence was released.

#### The grounding gate

- **A tail-risk figure names its own field once a session holds more than one**
  (#1425, after #1444, thanks @he-yufeng). A call- or tool-scoped ref (`ref x1`)
  pooled every tail-risk field that call returned, 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 read off the evidence, never off
  prose: `tail_risk_identity()` takes (measure, confidence) from 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 the figure's value
  matches one of them, it is refused with `tail_risk_needs_field_ref`, whose
  correction names every identity the session holds; a figure matching no
  tail-risk value keeps its old reason, so a fabricated number is still a
  mismatch rather than a missing ref. Accepted cost, stated in the issue: an
  undeclared VaR 95% beside a VaR 99% is correct today and now costs one
  correction round. The system prompt states the rule so a risk report declares
  the ref on its first attempt.

- **Tail-risk figures keep their identity under a field ref** (#1444, thanks
  @he-yufeng). A declared `ref` may name an evidence field by its full path
  (`data.tail_risk.var_95`) or trailing part (`var_95`), or one call's field
  (`q1::historical_var`); a VaR 99% quoting the 95% value, or ES quoting VaR, is
  rejected under such a ref. A field two calls returned with different values is
  ambiguous and the correction names the refs to choose from. A claim scoped to
  a whole call or not declared is still matched against every tail-risk value it
  can see (#1425 stays open).

- The grounding gate reads decimal commas (#1517, thanks @zeus229): `17,93 %` and
  `1.410,00 CNY` ground against tool results, two unspaced numbers such as
  `1400,1777` stay two numbers, and a written figure must match its evidence
  within half a unit of its last written digit (a truncated `30.20%` for
  30.2052% is now sent back for correction). One shape regex added (14 → 15).

- **A redacted answer keeps its row numbers and its labels** (#1471). When the
  grounding gate released a draft with its unverified figures cut, a ranked
  table came back with `(omitted※)` in place of 1, 2, 3, of the `12m` in its
  headers and of "3 names" in the prose. Every integer in a table cell was a
  measurement, so the rank column was cut; and each cut figure's digits were
  then swept out of the rest of the page, single digits included. Three shape
  rules, no word list: a column whose numbered cells read 1, 2, 3 … in row
  order is the table's index; a plain integer in a cell that also holds a
  word (`12m mean return`, `Mean beta (9 reported)`) is read as it would be
  in a sentence, while a cell that holds only a number, any decimal, percent
  or currency figure, and every cell under an OHLC column stay measurements;
  and the sweep never keys on a single digit. An integer price of an
  instrument quoted in the thousands is still checked inside a worded cell.

- **A real VaR result grounds the VaR it returned** (#1464). `quantlib_call`
  records a scalar result under the function name. For `historical_var` and
  `parametric_var` that name reduces to `var`, which the grounding gate accepts
  only as a whole field name, so the result recorded no evidence and a correct
  answer quoting it was rejected. Both names are now tail-risk aliases.
  Variances such as `residual_var` stay unmapped.

- **Plain integers in prose are not price claims.** A list number ("输出原则
  4"), a window length or a count was checked like a quoted price and could be
  cut from a released answer.

- **A drawdown against an observed high passes wherever the endpoints are
  written.** "较 5 月高点 1.053 元已回撤约 37%" was rejected when its endpoints sat
  on another line; a `derived` declaration does not depend on layout.

- **Price-denominated indicator values are evidence, by registration.**
  Moving averages, Bollinger bands and similar levels returned by
  `technical_indicators` are registered per tool as observed price evidence;
  RSI, MACD and other non-price leaves are not, and nothing is decided from a
  field name's words.

- **A year-less date is a date, and "24.6M" is 24.6 million.** Two real runs
  that followed the contract were still released redacted: "2026-09-11 /
  09-14" in a table cell shipped as "2026-09-11 /(略※)-(略※)", and
  "24.6M → 22.6M lots" as "(omitted※)M → (omitted※)M". A zero-padded MM-DD,
  or one opened by a full date in the same cell or line, is structure (an
  unpadded "| 11-12 |" is still checked, since it may be a price range). K / M
  / B / 千 / 万 / 亿 glued to a figure scale its comparison with the evidence,
  never its shape, and a cut removes the mark with the figure. Replayed on
  both runs' tool results, each second draft now passes as written.

- **Metadata counts are not metrics, and a tool's tail-risk result can ground
  a VaR** (#1420, #1426). `return_observations`, `n_returns`, `return_window`,
  `vol_lookback`, `max_drawdown_duration`, `aligned_days` and their `*_window` /
  `*_lookback` / `*_obs` / `n_*` / `*_count` / `*_days` / `*_duration` families
  no longer ground a percentage ("年化收益 81%" from 81 observations); `var`,
  `var_95`, `var_99`, `cvar`, `es` and `expected_shortfall` leaves are tail-risk
  evidence, so a correct VaR is no longer refused. A field's metric comes from
  the head of its name, so `sharpe_sample_size` and `drawdown_threshold` are
  no longer a Sharpe ratio or a drawdown.

- **A correction names the prints of the figure's own instrument** (#1433). The
  "nearest observed" values come from that symbol (and its table column or
  `ref`), not from every field of every symbol in the run.

- **A number is read the way the chat renders it** (#1418). Zero-width
  characters, a full-width or Arabic decimal point, a no-break space before "%",
  the U+2212 minus sign, a backslash escape ("0\\.888"), an HTML entity and an
  ISO code glued to the digits ("CNY0.888") no longer split a price into
  unchecked integers; a decimal comma ("0,666 CNY", "12,5 %") is a decimal while
  a valid grouping ("-1,250.00") stays grouped, and an answer that writes a
  marked decimal comma reads "2,237" and "−5,132%" beside it as decimals too.
  Fences follow CommonMark, and an unterminated code fence no longer exempts the
  rest of the answer.

- **A year is not a price shield.** "$2050", "1999 元" and a 2031 under a close
  column are checked; a price placed in a date or code column is checked; a
  compact date, a dotted date and a time are structure. 円 and 원 are currency
  marks, and "52pp" / "5200bp" are measurements.

- **A declaration cannot relocate or launder a figure.** A figure the sentence
  writes about one instrument cannot be declared another's close; a
  currency-marked result cannot be derived from an RSI or a volume; `count`,
  `derived` and `proposed` cannot sit in a price column.

- **A general answer with no tool evidence is cut, not refused.** A question
  about no instrument ("印花税怎么收") whose figures the gate cannot check is
  released with those figures omitted and a footnote, instead of a refusal
  about prices it never mentioned; a market answer that observed no price
  still falls back. A malformed figures block no longer forces the fallback
  either: the block is dropped and the draft is checked as written.

- **The tool-call-syntax fallback reaches the chat once.** When a model answered
  the forced-text iteration with tool-call markup in a run whose output is
  buffered, the replacement message was streamed by its own branch and again as
  the released answer, so the chat and the CLI showed it twice.

- **No unchecked number reaches the chat before the gate runs.** An unbuffered
  answer (no instrument asked about, no tool evidence yet) streamed its first
  draft live, numbers included, and a rejection then replaced it; the stream now
  stops at the first measurement-shaped number, and a rejected draft's shown
  prefix is cleared with `stream_reset`.

- **A trading plan in a bare code fence is checked.** Only a fence tagged with a
  language holds code; an untagged fence is read as prose.

- **An integer price of an instrument quoted in the thousands is checked**
  ("600519.SH 最新收盘 1520"), within half to twice its observed range; a
  window such as "200 日均线" stays unchecked.

- **A price declared `count` is checked.** A number inside its instrument's
  observed price range may be a `count` only when a declared derivation uses it
  as a factor.

- **A redaction footnote the model wrote itself is removed** before the real
  cuts, so a released answer never carries two contradictory footnotes or a
  marker for a cut that did not happen.

- **A figure about a second instrument is checked against that instrument's
  evidence** (#1584, thanks @zeus229) — symbol resolution saw only
  price-comparable records, so a line naming an instrument the session held
  only fundamentals for took the answer's primary symbol and failed as
  `not_in_referenced_call`. Any observed evidence with an explicit symbol now
  resolves it; `47a036d6` scopes analysis metrics likewise, so a metric from a
  call about another symbol, or several, cannot ground it.

- **A field ref may contain `|`, and a correction for an ambiguous field lists
  the exact refs to use** (#1586, thanks @zeus229) — the `figures` parser
  split on every `|`, cutting such a ref short; a declaration is four columns
  now, the last taking the rest of the line. When several calls returned a
  field, the correction lists each valid `call_id::field` (only the figure's
  own symbol's since `47a036d6`), and a `tool_name::field` ref is refused as
  `field_ref_needs_call_id` with that list.

- **A decimal-comma answer's dot-grouped integers are read as thousands**
  (#1588, thanks @zeus229) — `AR$5.165; volumen 502.408` was read as two
  decimals and failed as `figure_undeclared`. Once a document writes a decimal
  comma, now including a `0,` with two or more digits, `5.165` is 5165 and is
  still checked; without that evidence it stays 5.165. The PR's wider 1%
  rounding band was not kept (`47a036d6`): at 0.5%, 0.82467 has to be written
  `0,825`, not `0,82`.

- **Figures from `technical_indicators` keep the date the tool reported**
  (#1590, thanks @zeus229) — the tool dates its snapshot as `latest_date`, a
  key evidence ingestion did not read, so `latest_close`, the indicators and
  the volume metrics landed in `grounding_evidence.json` with
  `timestamp: null`. It is inherited now, below a row's own date fields and
  above `as_of`; an undated payload stays undated.

#### Market data

- **`get_market_data` asks for `AAPL.US` instead of returning empty frames for
  a bare US ticker** — only codes the market detector reads as US equities
  are refused; a Chinese futures code (`RB0`, `IF2412`) or a joined crypto
  pair (`BTCUSDT`) is served as written. The public-source health canary
  probes Yahoo with `AAPL.US` for the same reason.

- **A mixed-market backtest falls back per market** — a symbol missing from
  its primary source was retried along the fallback chain of the first
  symbol's market, so a Hong Kong gap in an A-share-led universe was never
  retried on a Hong Kong source. Each missing symbol now follows its own
  market's chain, and the incomplete-data error lists which symbols were
  served.

- **A dollar-quoted BYMA or TSX line, or a Hong Kong RMB or USD counter, no
  longer enters a single-currency backtest** (#1576, after #1543, thanks
  @Shizoqua, @zeus229). Both venues list USD lines beside their home-currency
  ones. On 2026-09-24 Yahoo declared USD for GGALD.BA (4.20), SPYD.BA and
  AAPLD.BA, and for DLR-U.TO and XUS-U.TO. `ar_equity` and `ca_equity` are each
  one static-currency pool, so such a line was priced as pesos or Canadian
  dollars. As on the LSE, the loaders now read the declared currency. They admit
  a `.BA` line only in ARS and a `.TO` / `.V` line only in CAD. A trailing `D`
  does not decide it: YPFD.BA is a peso line. Every other declared quote
  currency is recorded in market-data provenance, and the grounding gate asks an
  answer to name that currency rather than the one the suffix implies. Hong Kong
  is decided by the code, because the Hong Kong chain starts with sources that
  declare no currency: HKEX's Stock Code Allocation Plan assigns 80000-89999 to
  products traded in renminbi and several 09xxx ranges (plus 10900-10999 and
  41500-41599) to products traded in USD. So an RMB counter (80700.HK, 375.40
  CNY on 2026-09-24) or a USD ETF (9834.HK) is refused in a Hong Kong or
  cross-market backtest, is counted in its own currency by the mixed-currency
  guard, and is quoted in that currency by the grounding gate, whichever source
  served it. The table matched the currency Yahoo declared for all 24 codes
  probed across the ranges.

- **Grounding identity and symbol search know every market the data layer
  routes** (#1565, #1575, thanks @Shizoqua). `.BA`, `.L` and `.VN` symbols had no
  canonical-symbol scan, venue or currency in grounding. Symbol search
  labelled Indian, Korean, British, Vietnamese and Argentine results `global`.
  A parity test now takes the market list from the backtest's own currency
  table. An answer may write £, ₫ or AR$ for the currency it has to name.

- **A `local:` code in a backtest is served from your own dataset or not at
  all** (#1467). The market-data tool and the README already treated
  `local:AAPL.US` this way, but the backtest runner did not. With
  `source="local"` it counted the served `AAPL.US` as missing and sent it down
  the A-share network fallback chain. With `source="auto"` it ignored the
  prefix and fetched the symbol from network loaders. The prefix now picks the
  local loader only, a symbol the dataset lacks fails with
  `incomplete data for source=local`, and the rest of the run sees the bare
  symbol: market rules, artifacts (`ohlcv_AAPL.US.csv`, no colon in the file
  name) and the run card. A `local:` code requested from a network source, or
  one symbol requested both with and without the prefix, is refused up front.

- Block trades, margin trading and financial statements no longer read
  Eastmoney rejecting a stale query (code 9501) as an empty result; all six
  datacenter callers share `eastmoney_client.datacenter_rejection`.

- Lockup expiry: `free_shares` is the unlocking quantity
  (`CURRENT_FREE_SHARES`), not the float; units are stated (万股 / 万元) (#1513,
  #1501, thanks @lorenzozanee). The report is `RPT_LIFT_STAGE`; the old one
  rejected six columns.

- Dragon-tiger seats come from the buy/sell detail reports, surface a provider
  rejection, and rank within each listing reason (#1512, #1502, thanks
  @lorenzozanee).

- Shareholder-count history returns real periods from `RPT_HOLDERNUM_DET`,
  states each row's previous period, and warns when the average-holding join
  fails (#1518, #1503, thanks @he-yufeng).

- QVeris resolves adjusted fields as a complete set, one family per response,
  and no longer gives an unadjusted bar the adjusted volume (#1527, #1494,
  thanks @cgycorey).

- **Tencent history walks backward from the end date** (#1411, closes #1410,
  thanks @shadowinlife) — `fqkline` serves the *last* 500 bars of a window,
  not the first: `sh000300` over 2018-01-01..2026-06-30 comes back as 500 bars
  starting 2024-06-06 (verified live). The forward walk that shipped in 0.1.15
  (#1154) therefore returned only the last 500 bars of any longer window, with
  exit code 0. The cursor now moves back from `end_date`, and an error-shaped
  or mid-walk empty reply is retried instead of being read as the start of
  history.

- **A BaoStock server that stops answering no longer hangs the fetch** (#1615,
  closes #1492, thanks @he-yufeng) — baostock 0.9.3 reads its socket with no
  timeout, so a silent server blocked forever and a closed connection spun at
  100% CPU. The connection, each complete message and the wait for the session
  are now bounded by `VIBE_TRADING_BAOSTOCK_TIMEOUT` (30 seconds by default),
  and a failed call is a per-symbol miss the chain moves past. After review,
  BaoStock sessions also run one at a time per process, because the SDK keeps
  one global socket, and a socket that failed mid-reply is closed so the next
  query cannot read its late answer.

- **Concurrent first lookups no longer find an empty loader registry** (#1405,
  closes #1403, thanks @alanwilhelm) — `_ensure_registered` marked itself done
  before importing the loader modules, so a thread arriving during a cold
  start returned with nothing registered and an available symbol came back
  `_unresolved`. The flag is published after the imports now, under a lock,
  and concurrent cold callers wait for them.

- **An explicit source that must not fall back no longer has its gaps filled**
  (#1441, thanks @chiww) — `local`, `fmp`, `qveris`, `tickerall`, `nobitex`
  and `wallex` were kept from degrading only when the loader was unavailable
  as a whole. A symbol the named source lacked was still fetched down the
  market's chain, so one run could mix a local snapshot with Tencent bars
  behind two log lines. The run now fails naming the missing symbols, pinned
  by a test for every source in that set.

- **The Stooq challenge warning names a variable that exists** (#1402, thanks
  @chiww, thanks @chiww) — it told operators to set
  `VIBE_TRADING_MARKET_DATA_ORDER_*`, which nothing reads, or to drop stooq from
  the chain, which override validation refuses. It names `MARKET_DATA_ORDER_*`
  now (for example `MARKET_DATA_ORDER_US_EQUITY`), suggests moving stooq to the
  end, and says an override must be a permutation of the default chain.

- **QVeris no longer sends the end date as `indicators`** (#1496, thanks
  @lorenzozanee, thanks @lorenzozanee) — end-date parameters were recognised by
  substring, so any capability parameter containing `to`, such as `indicators`,
  was given the end date. Only exact names are mapped now (`end`, `end_date`,
  `enddate`, `to`, `to_date`, `until`, `until_date`, with hyphens folded). The
  rest of #1494 was fixed separately.

- **`get_shareholder_count` answers again** (#1490, closes #1489, thanks
  @cgycorey, thanks @cgycorey) — it asked `RPT_HOLDERNUMLATEST` for
  `AVG_HOLD_AMT`, a column the report no longer has, so Eastmoney rejected the
  whole request and the tool reported "no shareholder-count disclosure found"
  for every A-share. It requests `AVG_MARKET_CAP`, the same per-account value
  (the output key `avg_hold_amount` is unchanged), and a rejected request now
  returns the datacenter's own message instead of an empty result.

- **`get_market_data` no longer answers `1M` with one-minute bars** (#1487,
  closes #1480, thanks @shadowinlife) — intervals were matched through an
  upper-case fold under which `1m` (a minute) and `1M` (a month) share a key,
  so a monthly request was served minute bars without an error. An exact-case
  match now wins before any fold. The PR refused `1M` and `1W`; since #1479,
  in this same release, they return monthly and weekly bars built from daily
  ones.

#### Backtest runner and engines

- **Backtests align calendars at any timestamp resolution** (#1560, thanks
  @Shizoqua). The engine merged symbol calendars and placed prices through raw
  nanosecond integers, but a local duckdb source serves microseconds. Such a run
  was dated 1970-01-21, and beside a nanosecond source one symbol's closes came
  back empty. The rebalance mask had the same unit mismatch.

- **Mean-variance style optimizers size a short by its own expected return**
  (#1548, thanks @Shizoqua). The optimizers scored every position as if it were
  held long, so the strongest shorts (most negative drift) looked worst and got
  the least capital. `BaseOptimizer.optimize` now negates a short's column
  before building the context, so `mu` is the position's expected return AND
  `cov` is the position covariance `D Σ D`: a long and a short of two +0.92
  correlated names hedge instead of reading as correlated (the old objective put
  the whole book on the long, the new one splits it 0.514 / -0.486). Risk-parity
  and max-diversification contexts get the position covariance with it;
  volatility-only contexts are unchanged, since std(-r) == std(r).

- **Monte-Carlo validation annualises at the venue's bars per year** (#1546,
  thanks @Shizoqua). It hardcoded 252 while `bootstrap_sharpe_ci` and
  `walk_forward_analysis` in the same report used the resolved value, so a
  crypto (365) or forex (260) run published two disagreeing Sharpes.

- **A futures order on a negative-price bar is no longer rejected outright**
  (#1547, thanks @Shizoqua). `FuturesBaseEngine` dropped the `abs(price)` its
  base class relies on under `allow_nonpositive_prices`, so size came out
  negative and the caller's `size <= 0` guard refused every order.

- **A backtest aborted when a funding debit left cash below zero** (#1542).
  CompositeEngine and CryptoEngine subtract crypto funding from capital with
  no floor, and the next open then fitted at no scale, not even an empty plan:
  the open-basket search kept the full-scale plan and the run died with
  "planned order … exceeds available capital", and a rebalance raised
  "insufficient capital for position rebalance". Such an open is now skipped
  and reported once as `insufficient_capital`, the bar's reductions still
  run, and a close whose loss exceeds its margin still aborts the bar (#1274).

- **One cash-starved open is one rejection, not twenty-six** (#1470). In
  `position_adjustment="hold"`, the search that scales a basket down to the
  cash available re-planned every open on each step, and every step whose
  size rounded to zero booked a `zero_size` rejection for the same symbol on
  the same bar — one contract the cash could not hold read as 26 lot-rounding
  failures. The trial plans are silent now; a sleeve that was a real order at
  full scale and left the basket is reported once, after the search, as
  `insufficient_capital`, which the rebalance path's dropped sleeves now use
  too. `zero_size` means the lot rule and nothing else.

- **A-shares from a source without its own engine branch run under A-share
  rules** (#1431, thanks @chiww) — for such sources the runner followed the
  instrument's market only for US, HK, Canadian and UK equities, so
  `600519.SH` served by `local`, `tencent`, `eastmoney`, `baostock`, `mootdx`
  or `sina` ran on `CryptoEngine`: no stamp tax, T+1, price limits or
  100-share lots, and a perpetual funding fee charged against the position.
  They route to `ChinaAEngine` now.

- **The declared interval is checked against the bars that arrived** (#1432,
  thanks @chiww) — a single-market run annualised at the interval it asked
  for, but a loader can serve coarser bars (the local loader cannot turn a
  daily file into `1H`), and CAGR, Sharpe and volatility then scale by the
  ratio. The runner compares the median bar spacing of the longest price frame
  with the declared interval; past a 1.5x gap either way it annualises as the
  interval the spacing matches, or from the calendar when bars four or more
  days apart match none, and the report is written to the run card beside the
  caliber warning. Median spacing replaced a bars-per-calendar-year count,
  which measures five daily bars starting on a Monday at 456 a year.

- **Options annual return is annualised over the elapsed bars** (#1458, thanks
  @Shizoqua, thanks @Shizoqua) — the options engine raised growth to
  `bars_per_year / (n - 1)` where the shared `calc_metrics` uses `bars_per_year
  / n`, which exaggerated annual return in both directions, most on short runs.
  Both use `n` now.

- **An India short is covered against the buy-side circuit band** (#1608,
  thanks @Shizoqua) — the India engine never passed the held position's
  direction to the shared band check, so covering a short, which is a buy, was
  tested against the lower band a long's sale uses, and a cover at a locked
  upper circuit filled. The direction is passed now, in single-market and
  composite runs alike.

#### The backtest tool and what a run leaves behind

- **The backtest tool honours `VIBE_TRADING_TOOL_TIMEOUT_SECONDS` and keeps a
  timed-out run's output** — the engine subprocess had a hardcoded 300-second
  limit regardless of the setting (default 1,800; 0 disables it), and a
  timeout discarded everything the engine had printed. The partial stdout and
  stderr are now written to the run's `logs/` and the last 2,000 characters
  returned with the error.

- **Runs from the `backtest` tool record `state.json`** (#1413, closes #1412,
  thanks @shadowinlife) — only the agent runtime wrote it and
  `refresh_strategy_evidence` refuses a run without one, so every run from the
  `backtest` tool, or the MCP tool that delegates to it, was rejected as run
  status unknown despite complete artifacts. The tool now records success,
  failure with the exit code, or a timeout, through the same `RunStateStore`
  writer; a config that fails validation records nothing, since the engine
  never ran.

- **The `backtest` tool names the directory it looked in** (#1429, thanks
  @cgycorey, thanks @cgycorey) — a missing `config.json` or
  `code/signal_engine.py` was reported with no path, so a caller whose `run_dir`
  resolved somewhere unexpected could not tell that from an empty directory.
  Both errors now name the resolved directory and say where the file belongs.

- **Dict- and list-valued metrics reach the run card** (#1424, thanks
  @cgycorey, thanks @cgycorey) — the card kept only scalar metrics, so
  `unfilled_plan_rejections_by_symbol`, which names the rejected sleeve and
  the reason, was dropped while the anonymous count beside it stayed.
  Non-scalar metrics now go to a `structured_metrics` block, shown in the Run
  Card tab and the Markdown card, with `metrics` unchanged; a value over 4 KiB
  of card JSON, or one that cannot be serialised, is left out and named under
  `_omitted`.

- **A text column in `metrics.csv` no longer drops a run's metrics from the
  reply** (#1579, thanks @Shizoqua) — the session service converted the whole
  row at once and swallowed the error, so a run with an explicit benchmark,
  whose `benchmark_ticker` is text, lost every metric without a log line. Each
  field is converted on its own now, and only the unconvertible one is
  skipped.

#### Alpha Zoo

- **24 alphas emitted a value computed from a missing bar** (#1463, #1523,
  #1534, #1452, thanks @Shizoqua). A comparison over an operand that is NaN
  because its window holds the gap is False, and the #1463 sweep, which nudged
  the bar by a tick, could not see that. A second oracle compares the gapped run
  with the gap-free one. Comparison gates and `np.fmin`/`np.fmax` now mask on
  their inputs' reach (`factors.base.observed_over`) rather than on their
  operands' NaN, so a correlation undefined on complete data keeps its old
  verdict; the six fmax/fmin alphas converted on 2026-09-18 had lost up to 87%
  of their post-warmup values in a 5-symbol universe that way and are restored.
  On gap-free data no finite value changed.

- **Twenty alphas no longer fill a missing input with a constant** (#1463).
  A halt left a comparison reading 0 or False, a `.where(cond, 0)` reading a
  flat day, or an `np.fmax` returning the other side, and the value then fed
  every rolling window that reached back to the gap. Those cells are NaN now.
  On gap-free data every finite value is bit-identical to before. A
  perturbation sweep over all 462 alphas counts 35 still carrying a missing
  bar forward, down from 55, and every one of them is accounted for: 28 are
  recursive statistics (the GTJA `SMA(A, n, m)` smoothers and one running
  product), which by the policy set on #1463 skip a missing observation and
  continue from their last state — the operator layer's header now states
  that exception, and a test pins it; 3 declare their own partial window; 4
  are the sweep's own rank-tie artifact. Nothing rewarms a smoother after a
  gap, and the registry's look-back mask was measured and rejected: it hid
  4,270 legitimate cells to remove 41% of the fabricated ones.

- **Asset growth is year over year on the daily panel** (#1564, thanks
  @Shizoqua). Derived fundamentals run on a panel densified to the price
  calendar, where a one-row lag compared yesterday with today: 0% on ordinary
  days and a one-day spike at each filing. The lag is now 365 days, as-of.

- **A factor's IC ratio is unset for a negative baseline** (#1549, thanks
  @Shizoqua). Dividing two negative IC means gives a positive ratio, so a
  rolling IC that got further below zero reported as improvement. The signal
  itself took the worst of the available metrics and was not fooled; the
  published number was.

- **qlib158 `CNTP` / `CNTN` / `CNTD` stop counting a missing close as a flat
  day** (#1459, thanks @cgycorey) — the 15 factors (three families × 5 / 10 /
  20 / 30 / 60 windows) turn `close > prev close` into 0/1 and average it,
  and a NaN comparison reads `False`. The registry mask from #1377 blanks the
  gap bar itself, but every later window that still held the gap counted the
  halt as a day that neither rose nor fell, so `cntd` read closer to zero
  than the observed days say. The comparison is NaN now wherever either close
  is missing: a gap blanks `window + 1` rows and the value returns, equal to
  the gap-free run, once it scrolls out. The same rule moves the first value
  from row `window - 1` to row `window`, matching each factor's
  `min_warmup_bars`, because the first bar has no prior close. On sparse
  panels this can trip the registry's >95% NaN guard; each factor's `notes`
  states from what missing-bar fraction.

- **qlib158 `WVMA` takes the absolute return in its numerator, as upstream
  does** (#1594, thanks @Shizoqua) — `wvma5` … `wvma60` computed
  `ts_std(ret·v) / ts_mean(|ret|·v)`, a signed numerator over an absolute
  denominator, where qlib's formula takes `Abs` on both. Every value of the
  five factors changes. The finishing commit (`a5d47c20`) points the
  attribution header at the upstream file that actually defines WVMA
  (`qlib/contrib/data/loader.py`, MIT) instead of `handler.py`, and pins the
  formula against an independent scalar oracle, a gap and all-zero volume.

- **GTJA alphas 103, 133 and 177 score a fresh extreme high, not low**
  (#1604, thanks @Shizoqua) — `HIGHDAY` / `LOWDAY` count bars since the
  extreme, but the port put the 0-based window index, counted from the oldest
  bar, in their place. Alpha 103 read a low set today as 5 and a low 19 bars
  old as 100: the recency signal was inverted. The PR restored the direction
  one bar off (95 and 0). The finishing commit (`0244ecea`) counts days since
  the extreme with today as 0, so alphas 103 and 177 run from 5 to 100 over
  a 20-bar window, and takes the most recent bar on a tie. It also corrects
  `gtja191/LICENSE.md` to cite the Guotai Junan report of 2017-06-15 by Li
  Chen and Liu Fubing, where the zoo had named a 2014 report.

- **A custom zoo root no longer replaces the bundled alpha of the same name**
  (#1468, fixes #1465, thanks @cgycorey) — `Registry(zoo_root=...)` loaded
  custom alpha files into `sys.modules` under the bundled module's own name.
  Once a custom root had computed its own `qlib158/cntp5.py`, a default
  `Registry()` in the same process computed the custom code too. The import
  cache is keyed by module path and zoo root now; module names and relative
  imports inside a custom root are unchanged. No shipped caller passes
  `zoo_root`; it is the path tests and plugins use.

#### Quant Library and analysis tools

- **A strict-bench OOS split needs two IC observations on each side** (#1559,
  thanks @Shizoqua). A split outside the loaded prices is refused. An alpha
  whose IC series leaves fewer than two observations on either side is skipped,
  with the counts in the reason. The IC series ends one forward-return horizon
  before the prices, so a split one bar from the end used to publish verdicts
  measured on nothing.

- **Quant inputs are checked before they are used** (#1555-#1558, thanks
  @Shizoqua). HRP aligns a supplied correlation matrix, and the covariance's own
  rows, to the covariance's column labels; swapping two rows used to move the
  weights with no error. Purged cross-validation refuses an unordered or
  duplicated timestamp index, and a label that ends before it starts. Impact
  models and every fixed-income entry point refuse NaN and infinities instead of
  returning NaN prices, durations and curves.

- **Shadow-account overtrading uses the whole trading window** (#1563, thanks
  @Shizoqua). A long hold that closed before later short trades no longer
  shrinks the span.

- **Event-study z-statistics no longer over-reject** (#1466). A CAR's standard
  error summed each day's variance, ignoring that every day is forecast with
  the same estimated parameters, so the days' abnormal returns are correlated.
  On null data, the standardised CAR's variance was 1.11 with a 120-day
  estimation window and 1.41–1.46 with a 30-day one. It is now 1.02 and 1.07,
  which is only the t correction for an estimated residual variance. Patell and
  BMP inherit the fix; `market_adjusted` was already right.

- Shadow account: an `other`-market rule no longer matches every symbol (#1538,
  thanks @Shizoqua), and overtrading PnL no longer double-counts trades already
  explained (#1475).

- Quantlib: comps refuses negative bridge magnitudes like the DCF bridge (#1535,
  thanks @Shizoqua); accrued interest is exact on coupon dates under 30/360 and
  rejects an unknown day count there too (#1509); Kyle's lambda fits an
  intercept (#1477).

- ML strategy: a single-class training window is skipped and the previous model
  keeps predicting (#1522, thanks @Shizoqua). Memory: two entries with one title
  and different types keep separate index rows, and `forget` names the type when
  ambiguous (#1525). Swarm run and task files use unique temp names (#1536).

- **Patell z uses each normal-return model's own degrees of freedom** (#1449,
  thanks @Shizoqua) — the `df / (df - 2)` correction in `event_study`
  assumed the market model's two estimated parameters for every event, so
  `market_adjusted` (none estimated) and `mean_adjusted` (one) over-stated
  the variance and reported a slightly smaller |z|, and a larger p-value,
  than their own fits support. The market model is unchanged. The finishing
  commit (`844b13b9`) has `estimate_market_model` read its residual degrees
  of freedom from the same table, so the two cannot drift apart.

- **Microstructure estimators refuse NaN and infinite inputs** (#1451,
  thanks @Shizoqua) — a non-finite price came back from
  `roll_effective_spread` as a `0.0` spread, `kyles_lambda` returned `nan`,
  and `vpin` produced a series from NaN or negative volumes. All three raise
  now, and `vpin` also refuses a non-finite `bucket_size`.
  `amihud_illiquidity` drops an infinite row the way it already dropped a NaN
  one; before, an infinite return made the ratio infinite and an infinite
  volume counted as a zero-illiquidity day. The accept side is pinned too
  (`c6010208`): constant prices are a zero spread, all-zero order flow a zero
  lambda, and zero-volume bars leave VPIN unchanged.

- **Copula pseudo-observations give ties one rank and leave NaN missing**
  (#1453, thanks @Shizoqua) — the double `argsort` ranked tied values by
  position (`[1, 1, 3]` became 0.25 / 0.5 / 0.75) and sorted a NaN last,
  handing it the column's top rank and counting it in the normaliser.
  `pseudo_observations` uses average ranks now, keeps a NaN as NaN with each
  column normalised by its own count of observations, and refuses infinite
  values.

- **`spread_term_structure` keeps tenor `5` and `5.0` in one column** (#1507,
  thanks @Shizoqua) — the column name was built from the raw key, so issuers
  quoting the same tenor as an int and as a float were split across
  `5Y_spread_bp` and `5.0Y_spread_bp`, each NaN for the other group. The
  reference-curve lookup already treated the two as one tenor.

- **Purged cross-validation refuses a timestamp label index it cannot map**
  (#1556, thanks @Shizoqua) — label end times passed as a Series are turned
  into positions with `searchsorted`, which assumes a sorted index. An
  unordered or duplicated index mapped label ends to the wrong rows with no
  error, so the purge protected the wrong rows; it raises now. The finishing
  commit (`250ef746`) closes the neighbouring gap: a timestamp label ending
  before its own start was clipped up to it (ends at positions `[1, 0, 3, 3]`
  came back as `[1, 1, 3, 3]`) and now raises, as the positional form already
  did. A `NaT` end stays allowed and purges through the last observation.

- **Market-impact models refuse infinite inputs** (#1557, thanks @Shizoqua)
  — NaN was already refused, but positive infinity passed every positivity
  check: an infinite ADV priced a `linear_impact` or `sqrt_impact` order at
  zero impact, and an infinite price, traded volume or `bps` returned an
  infinite buy fill. Price, volume, ADV, `bps`, impact coefficient,
  volatility and `eta` must all be finite now.

- **The correlation-analysis cointegration examples call the Quant Library**
  (#1404, thanks @Shizoqua) — the skill's Engle-Granger example carried its
  own statsmodels fit and raised `KeyError` on an unnamed Series, because it
  looked the hedge ratio up by the series' name. The PR named the regressor;
  the finishing commit (`bd29a187`) replaced the local fit with
  `find_hedge_ratio`, `cointegration_test` and `adf_test` from
  `src.quantlib.timeseries`, and the half-life example with
  `compute_half_life`. The example now inherits their refusal to pair two
  legs on different indices positionally, keeps its result keys and adds
  `half_life`.

- **`portfolio_risk_xray` works on windows longer than 250 bars** (#1462,
  closes #1461, thanks @he-yufeng) — the tool fetched prices under the shared
  250-row cap. Above it `fetch_market_data` returns a sampled envelope rather
  than the bar list the tool parses, so a window of 251 bars or more failed
  with "no close prices returned for any requested symbol", which is about
  anything over a year of daily bars. It requests every row now
  (`max_rows=0`, as `technical_indicators` does); volatility, VaR/ES and
  drawdown need consecutive bars regardless.

- **`benford_check` reads the leading digit from the number as written**
  (#1591, thanks @Shizoqua) — `financial_rigor`'s Benford check took the
  digit from `10 ** frac(log10(v))`, which lands just under an integer for
  some exact round values, so `5000` counted as a leading 4. Among the
  integers 1 to 100,000, twelve were misread (8, 30, 40, 50, 80, 500, 600,
  900, 5000, 6000, 9000, 50000). The PR rounded before truncating, which
  would have read `4999.9999999` as a 5; the finishing commit (`d0ef98a6`)
  takes the first significant digit of a `Decimal` built from the supplied
  value instead, with no float round-trip. Zero, NaN and infinite values are
  still skipped.

- **The sentiment scorer reads "low" as bearish only** (#1448, thanks @Shizoqua,
  thanks @Shizoqua) — "low" was in both the bullish and the bearish word lists,
  so it cancelled itself and `shares tumbled to a new low today` scored neutral.
  It was the only word in both; a test now keeps the lists disjoint.

- **Head-and-shoulders detection checks shoulder symmetry on negative
  series** (#1456, thanks @Shizoqua) — the `pattern` tool divided the
  shoulder gap by the signed shoulder average. On a series below zero the
  ratio went negative and always passed the 5% test, so clearly asymmetric
  shoulders were flagged as a pattern. It divides by the absolute average
  now, as `double_top_bottom` already did.

#### Live trading, brokers and the strategy store

- **Robinhood live trading reads and trades the account the mandate names**
  (#1442). The runner, the pre-trade gate and the commit-time ceiling fetch
  called Robinhood with no `account_number`, and read replies one level too
  shallow (`data.positions` where Robinhood nests `data.data.positions`). So
  every reconciliation aborted, and the gate refused every order because it
  could not read positions. The tests stayed green only because their fake
  replies had a shape Robinhood never sends. A Robinhood mandate is now bound
  at commit to one account from `get_accounts`: listed, not deactivated, and
  open to agentic trading. The Web confirm dialog and the CLI both ask for
  it, and never take it from the agent-written proposal. Every runner and gate
  call sends that account, and an order naming another account is refused.
  Held positions, which Robinhood reports without a price, are priced the way
  a quantity order already was, and one that cannot be priced fails the
  exposure check closed. Existing Robinhood mandates must be committed again
  with an account.

- **SDM decay metrics treat NaN and infinite bench results as missing**
  (#1447, thanks @0xouzm) — three stored `sharpe=inf` rows read as `healthy`,
  so a non-dry `sdm_decay_scan` moved a strategy in monitoring back to active,
  and `-inf` read as critical. SQLite keeps infinities as written, so the
  persistent store was affected too. `compute_decay_metrics` now drops
  non-finite values, independently for IC and Sharpe, before the
  three-observation minimum.

- **A mandate narrowed to zero exposure is stored as zero** (#1593, thanks
  @Shizoqua, thanks @Shizoqua) — narrowing `max_total_exposure_usd` or
  `account_funding_usd` to `0` at commit is legal, but the cap mapping used `x
  or default`, so the saved mandate got the order cap back as its exposure
  limit: the commit widened what the user had just narrowed. An explicit zero
  now persists, and an order checked against that mandate breaches
  `max_total_exposure_usd`.

- **A malformed Robinhood `buying_power` is a shape error, not an unknown**
  (#1526, thanks @lorenzozanee) — a non-null `buying_power` that was not an
  object (a bare number, string or list) was read as unknown and passed; it
  now raises, so the portfolio source errors instead of carrying the reply.
  An omitted or null value stays unknown and zero stays valid. As merged the
  PR also refused an omitted `buying_power`; `46892136` restored that case to
  unknown, which is what the PR description specified.

- **Interval and event triggers carry `market=None` again** (#1599, thanks
  @woshi77777stars, thanks @woshi77777stars) — `Trigger.market` was both a
  dataclass field and a classmethod factory, so every non-market trigger carried
  the bound method as its `market`, and `dataclasses.asdict()` + `json.dumps()`
  raised on it. `due_now()` was unaffected. The factory is bound after the class
  body (`d0ef98a6` keeps subclass binding intact), and a repo-wide static test
  fails on any dataclass or NamedTuple field shadowed this way.

- **With an MT5 profile selected, `search_symbol` resolves the broker's own
  symbols** (#1597, closes #1589, thanks @lorenzozanee) — it returned no MT5
  candidate for an explicit symbol such as `XAUUSD`, so the identity gate
  blocked `trading_quote`, `trading_history` and `get_market_data`. Search
  now reads the configured terminal's catalog by `terminal_path` with no login
  credentials. After `47a036d6` every query is answered from that terminal
  while MT5 is selected, a web-search hit is never substituted, and an
  ambiguous alias is refused.

- **Backtests attach to the running MT5 terminal without seeing its
  credentials** (#1598, thanks @he-yufeng) — the sandbox's ephemeral HOME had
  no `mt5.json`, so the loader attached bare and a forex backtest could fall
  through to akshare while `get_market_data` used MT5 in-process. The sandbox
  now gets only `terminal_path` and `timeout` (validated by `47a036d6`), never
  `login`, `password` or `server`, and a bare attach warns that the SDK may
  pick another installation logged into another account.

- **Dhan paper orders reject fractional share quantities** (#1595, thanks
  @Shizoqua, thanks @Shizoqua) — `0.5` passed the positive check and truncated
  to `0`, returning a successful simulated fill for zero shares, and `1.5`
  silently became `1`. Only whole numbers of at least one share are accepted;
  `a5d47c20` parses with `Decimal`, so `1.00000000000000001` is refused too, and
  NaN, infinity or text return an error instead of raising.

#### Swarm workers

- **A swarm worker can load only the skills its spec lists** (#1414, thanks
  @shadowinlife, thanks @shadowinlife) — `SwarmAgentSpec.skills` was read only
  to filter the prompt, so `load_skill` loaded any skill a worker named. The
  worker's `load_skill` now refuses a skill outside the list and names the ones
  allowed. An empty list stays unrestricted, as the prompt filter treats it; the
  main agent is unchanged.

- **A swarm tool that declares `run_dir` gets the directory the model named,
  confined to the agent's workspace** (#1430, thanks @cgycorey) — the worker
  overwrote every call's `run_dir` with the workspace, so `backtest`, exposed
  to 19 preset agents, never saw the directory it was given. A relative path
  now resolves under the workspace, an absolute one inside it is kept, and one
  outside is refused with an error naming the workspace. Tools that do not
  declare `run_dir` still get the workspace.

- **Every swarm worker that writes its summary also writes its message log**
  (#1439, thanks @cgycorey) — `messages.json` is the only record of the
  arguments the model asked a tool for, and the completed, token-limit,
  content-filter and incomplete exits wrote `summary.md` without it, so the
  most common outcome left no log. Both files now go through one writer. A
  failed LLM call still writes neither.

#### Agent loop, providers and goals

- **An explicit provider header survives an ambient twin** (#1573, #1568, thanks
  @zeus229). When `OPENAI_CUSTOM_HEADERS` names a header the provider also sets
  (a `user-agent` beside the Kimi / NVIDIA / OpenCode `User-Agent`), openai
  3.19.2's case-insensitive merge dropped the provider's value. That turned CI
  red for every PR on 2026-09-24. The first fix proposed would have sent both
  headers under openai 2.53, the version the lock file and the Docker image
  install. Every ambient spelling is now omitted and the explicit value re-set
  after the omits, which holds under both.

- **Read-only results lost to context compaction are restored, not refetched**
  (#1488, thanks @zeus229). A successful read-only call whose payload compaction
  removed is replayed from the run's own cache, at most six times a run; past
  the cap it runs again, as it did before, instead of being refused with "use
  the previous result" for a payload the model cannot see. The cap is checked
  per restore, and any successful write empties the cache, so a regenerated file
  is read afresh. `read_url` opts in; `no_cache=true` always fetches.

- **OpenAI's gpt-5.6 models run with their reasoning on the agent's default
  configuration** (#1473). `/v1/chat/completions` refuses function tools for
  `gpt-5.6-terra` and `gpt-5.6-luna` unless `reasoning_effort` is `none`,
  including when no effort is configured and the model applies its own
  default, so a plain `LANGCHAIN_PROVIDER=openai` setup failed on its first
  tool call with `Function tools with reasoning_effort are not supported`.
  The adapter now treats that refusal the way it treats a rejected
  `temperature` or `stream_options`: the request is retried on
  `/v1/responses`, where the effort travels as `reasoning.effort`, and the
  model is remembered for the rest of the process so later calls go there
  first. Nothing changes for a model whose chat endpoint accepts tools, and
  `LANGCHAIN_USE_RESPONSES_API=true` still selects the route up front and
  skips the one failed request.

- **A research goal that is an order is still refused** (#1562, thanks
  @Shizoqua). The execution filter no longer rejects research that mentions
  shares or coins ("should the fund sell its GOOGL shares"). It refuses an
  objective that opens with buy or sell, puts a quantity right after the verb,
  or writes 买入 / 卖出 with a quantity and a unit. The first version of the change
  had made "Buy 100 shares of NVDA" an accepted goal.

- **Research goals refuse an order that puts "now" or "immediately" before
  the verb** (#1605, thanks @Shizoqua) — the execution filter matched only
  the verb-then-cue order, so `Immediately buy TSLA`, `Now sell GOOGL` and
  `Please immediately short TSLA` were accepted as research objectives. The
  mirrored rule requires the cue to sit directly before the verb, with only
  separators and an optional `please` between them. The first version allowed
  up to 40 characters there and also refused `Now research whether to buy
  AAPL`; the finishing commit (`0244ecea`) keeps that and similar research
  phrasings accepted. The check runs on a goal's objective, on each of its
  criteria and on an objective update.

- **The tail a full compaction keeps counts a thinking model's reasoning**
  (#1582, thanks @Shizoqua) — the layer-3 tail is sized to a 20K-token budget,
  but the sizing read `content` and tool-call arguments and skipped
  `reasoning_content`, so on reasoning-heavy turns the kept tail could run far
  past its budget and compaction freed less than intended. The reasoning text
  is counted now, as the loop's token estimate already counted it.

- **`technical_indicators` results are restored after compaction instead of
  fetched again** (#1601, thanks @zeus229) — the tool is read-only but
  `repeatable`, and a repeatable tool is replayed from the run's memory only if
  it opts in; it did not, so an identical request after compaction re-ran the
  market-data fetch. It opts in now, like `web_reader`, within the loop's
  existing replay budget.

- **Token counts and Polymarket CLOB token ids are no longer redacted as
  credentials** (#1606, thanks @Shizoqua) — the `token` credential marker is a
  substring match, so `token_budget`, `token_usage`, `total_input_tokens`,
  `total_output_tokens` and `clob_token_id` read `[redacted]` in traces, event
  previews and the live audit ledger. Since `0244ecea` each is released only in
  its expected shape (a non-negative integer, a map of counts, a decimal
  uint256 id); any other value under those names stays redacted.

- **A remote MCP tool with a malformed `properties` schema still gets a valid
  one** (#1607, thanks @Shizoqua) — `normalize_mcp_tool_schema` dropped a
  non-object `properties` such as a string or a list and then skipped the `{}`
  fallback, so the tool reached the model as an object schema with no
  `properties` key at all. It gets `{}` now, unless the top level composes
  `anyOf` / `oneOf` / `allOf`.

- **The Copilot credential fallback re-reads the token after 60 seconds**
  (#1619, closes #1617, thanks @lorenzozanee) — when neither
  `COPILOT_GITHUB_TOKEN` nor `OPENAI_API_KEY` is set, `get_llm_credentials`
  falls back to `GH_TOKEN` / `GITHUB_TOKEN` or `gh auth token`, and it cached
  that answer for the life of the process. The cache now expires after 60
  seconds. Its consumer is provider diagnostics: Copilot requests go through
  the SDK client, which resolves credentials each time it is created.

#### Channels, memory and configuration

- **The Email channel verifies TLS certificates by default** (#1544, thanks
  @shadowinlife). The implicit-SSL paths (`IMAP4_SSL` / `SMTP_SSL`) in both the
  connection probe and the polling/send adapter used Python's default unverified
  context (`CERT_NONE`, no hostname check), so the mailbox password travelled to
  a server whose certificate was never checked — an active network attacker
  between the operator and their mail provider could harvest it. All four call
  sites now build their context through a shared `email_tls_context()` that
  verifies the certificate and hostname against the system CA bundle, matching
  the STARTTLS path, which already did. A new `verify_tls` field (default
  `true`, surfaced in the guided Email setup in all nine locales) is the
  documented opt-out for self-signed or internal-CA servers. **Upgrade note:** a
  mail server with such a certificate connected before this change and now fails
  the TLS handshake until `verify_tls` is set to `false` for it.

- **Plain IMAP no longer sends the mailbox password in clear text** (after
  #1544). With `imap_use_ssl` off, the Email channel and its connection test
  logged in over an unencrypted connection, and the guided setup put that
  switch one click away. A new `imap_use_tls` (on by default, the IMAP twin of
  `smtp_use_tls`) upgrades the connection with STARTTLS before `LOGIN`; a
  server that offers no STARTTLS is reported as a network failure before the
  password is sent. `verify_tls` now governs STARTTLS on IMAP and SMTP as well
  as implicit SSL, so a self-signed server can opt out on any path. **Upgrade
  note:** an `imap_use_ssl: false` setup against a server without STARTTLS
  stops connecting until `imap_use_tls` is set to `false` for it, which sends
  the password in plain text again.

- **Settings-write routes reject cross-site browser requests** (#1544, thanks
  @shadowinlife). `require_settings_write_auth` did not apply the cross-site
  guard that its siblings `require_auth` and `require_event_stream_auth` enforce
  on unsafe methods, so a malicious web page could attempt a CSRF write against
  any settings route — including the new `POST /channels/email/test`, which
  merges stored credentials with a caller-supplied patch and could be steered to
  send the stored mailbox password to an attacker-chosen host. The guard now
  runs first on every settings-write route; same-origin Web UI calls and
  non-browser clients (CLI/curl, no `Origin`) are unaffected.

- Channel adapters log through stdlib again: `{}` placeholders, napcat's 17
  sites and loguru-only `.opt()` calls lost their messages (signal's
  `_safe_handle` raised instead of swallowing) (#1533, #1531, thanks
  @shadowinlife). Slack DMs from unapproved senders get a pairing code (#1524,
  thanks @Shizoqua); Discord no longer pings `@everyone` or roles (#1537);
  duplicate-reply suppression covers integer message ids and is bounded (#1476);
  the WebSocket channel starts again (from #1521).

- **Two memories sharing a title keep separate index rows and links** (#1545,
  thanks @Shizoqua). Under `VT_MEMORY_HIERARCHY` an entry lives at
  `{memory_type}/{slug}.md`, so two entries with one title had the same
  `path.name`: the second `add()` overwrote the first's `MEMORY.md` row, and the
  semantic-link block beside it excluded the other entry as "self" and wrote an
  ambiguous target. Both key on the path relative to the memory dir now; a
  sidecar written earlier, with a bare filename or an absolute path, is still
  read.

- `/agent.json`, `/agent.yaml` and `/agent.yml` are gitignored at the runtime
  root; the Feishu QR login already wrote channel credentials there.

- **Signal channel: stripping the bot's mention no longer removes the wrong
  character after an emoji** (#1478, thanks @Shizoqua) — Signal reports a
  mention's `start` and `length` in UTF-16 code units, and the channel sliced
  the Python string with them directly. Each character outside the Basic
  Multilingual Plane (most emoji) before the mention shifted the slice by
  one, so the mention placeholder stayed in the text handed to the agent and
  the neighbouring character was cut instead. Offsets are converted to string
  indices before a mention is stripped or tested as a leading mention.

- **A file received over DingTalk is saved inside its download directory
  whatever it is named** (#1506, thanks @Shizoqua) — the adapter joined the
  inbound `fileName` to the per-sender download path unsanitized, so a name
  with `../` segments, or an absolute path (which replaces the whole path when
  joined), could write outside it. The name goes through `safe_filename` now,
  as the other adapters already sanitize a remote file name.

- **A channel that fails to load logs which one and why** (#1580, thanks
  @Shizoqua, thanks @Shizoqua) — two channel-registry log calls had two `%s`
  placeholders and one argument, so an external plugin that failed to import, or
  a built-in channel skipped for a missing SDK (at debug level), printed `---
  Logging error ---` instead of its name and the exception.

- **The README says which process owns Telegram polling** (#1583, closes
  #1553, thanks @lorenzozanee) — Telegram allows one `getUpdates` poller per
  bot token. `vibe-trading serve` owns the channel runtime, and
  `channels start` asks the running server (`VIBE_TRADING_API_URL`, default
  `http://127.0.0.1:8000`) to start it rather than polling itself, so the
  conflict to avoid is a second server process with the same token enabled.

- **Memory garbage collection no longer compresses an entry it just archived**
  (#1450, thanks @Shizoqua) — the compression pass reused the entry list read
  before archiving, so an entry just moved to `archive/` was compressed from a
  path that no longer existed and logged a failure. `451c554a` skips by whether
  the file still exists, not by id: ids are six hex characters two files can
  share, and an archive step that failed leaves its file to be compressed.

- **An unparseable optional numeric setting falls back to its default instead
  of failing config load** (#1592, thanks @Shizoqua) — the coercion that drops
  a bad `int` / `float` env value skipped `int | None` fields, so
  `ANTHROPIC_MAX_TOKENS=not_a_number` raised while building `EnvConfig`.
  Optional fields get the same fallback now; a parseable value outside the
  field's bounds (`ANTHROPIC_MAX_TOKENS=0`) is still rejected.

#### Web UI and locales

- **Leaving a chat cancels its pending history scrolls** — a scroll scheduled
  while a conversation's history loaded could still fire after leaving it,
  and a history load still in flight could schedule another.

- **A welcome-screen quick action stays highlighted after you click it**
  (#1500, thanks @shadowinlife) — the first chip was styled as selected by its
  position, whatever had been clicked, so the chip you chose never lit up. The
  highlight now follows the click and moves with the next one, and each chip
  reports `aria-pressed`.

- **Broker SDK diagnostics no longer name Longbridge for every broker**
  (#1436, thanks @tingkk) — the shared `sdk_missing` and `network_unreachable`
  messages on the Runtime page and in the chat connector status said
  Longbridge whichever broker raised them, so an unreachable Futu OpenD gateway
  sent the user to check Longbridge. They now name the affected broker's SDK,
  service or local gateway, in every locale.

Related Repositories