100 Commits
Author SHA1 Message Date
shadowinlife ee03474268 feat(factors): industry-neutralized CN value alphas + PIT SW2021 sector tag (csi300) (#1802)
* feat(bench): add horizon parameter for monthly-frequency IC revalidation

h-bar forward returns (close[t+h]/close[t]-1, no ffill — a NaN close never
bridges) sampled non-overlapping end-anchored, so the naive-iid t-stat and
the alive/dead thresholds stay honest without new statistics. Threaded
through run_bench / run_bench_strict / the alpha_bench tool + MCP wrapper /
CLI --horizon (argparse type + handler guard, exit 2 on bad input).
horizon=1 is byte-identical to the previous next-bar formula.

Follow-up #1 of issue #1789 (monthly-horizon revalidation of the 13
fundamental factors — the conventional reading for fundamental factors).

* fix(bench): exclude SEC-fundamental alphas off sp500 instead of failing the bench

--zoo fundamental --universe csi300 (the repro command printed in issue
#1789) hard-failed with 'Choose the S&P 500 universe' because the
validator rejects fund:* inputs on any non-sp500 universe, while the
mirror case (CN val:* alphas on sp500) skips cleanly per alpha. The
runners now split the selection up front, disclose the excluded ids in
the result envelope, and error only when nothing is left to bench; the
validator stays as the backstop for direct callers.

The CLI/tool JSON envelopes also state their own horizon,
forward_return_sampling and universe_excluded — a saved summary must
show the configuration its numbers came from.

* fix(bench): close review findings on horizon/exclusion disclosure

- multi-zoo CLI aggregation threads horizon / forward_return_sampling /
  universe_excluded from the sub-runs into the aggregate envelope (a saved
  all-zoos JSON now states its own horizon and reconciles its exclusions)
- alpha compare surfaces universe-excluded shortlist members as skipped
  entries with the exclusion reason instead of silently dropping them
- horizon upper bound (1.._MAX_HORIZON) enforced consistently at the CLI
  parser, the handler, and both runner entries BEFORE any panel load —
  previously only the tool/MCP capped it and an unbounded CLI run fetched
  the full panel before failing late
- non-int horizon can no longer TypeError in the entry schema construction
- categorise docstring notes the daily calibration of the |IC|>0.02 floor
  at h>1; the sampling comment no longer overclaims (residual
  autocorrelation from persistent factor exposures is not modeled)
- MCP wrapper uses _MAX_HORIZON instead of a hardcoded duplicate

Pinned by new tests: parser/handler/runner cap rejection (fetch not
called), multi-zoo disclosure aggregation, compare exclusion disclosure.

* feat(factors): industry-neutralized CN value alphas + PIT SW2021 sector tag

Three within-industry z-scored variants of the CN value factors
(fund_pb/fund_ps_ttm/fund_dividend_yield -> *_ind_neutral, zoo 475 -> 478):
raw A-share PB/PS/dy are heavily industry-confounded, so each name is
z-scored against its Shenwan L1 peers instead of the whole cross-section.

- industry_loader.py: tushare index_member_all (SW2021) membership with
  in_date/out_date -> point-in-time labels per trading day; deterministic
  overlap resolution; 2000-row truncation guard refetches per L1; degrades
  to an unmapped frame on provider failure, never invents a label.
- csi300 bench attaches panel['sector'] outside the pickled price cache,
  masked to the PIT membership cross-section, behind the same 0.9 coverage
  gate the sp500 GICS tag uses; meta discloses sector_source/coverage.
- base.zscore_by_group: the z-scoring sibling of alpha101's _ind_neutralize
  demean - no global fallback (an unlabelled cell stays NaN rather than
  wearing an industry-neutral label it did not earn).
- README x7 + SKILL.md counts (incl. the stale 462 prose sites), i18n zh/en.

Follow-up #2 of issue #1789.

* fix(loaders): index_member_all takes no src param; fetch PIT history via is_new Y+N

Live-verified endpoint contract (pinned by test): index_member_all accepts
only the documented l1/l2/l3_code, ts_code and is_new filters — an
undocumented src param silently returns an empty frame (HTTP 200, 0 rows),
which degraded the first csi300 sector attach to coverage 0.0. src now goes
to index_classify (truncation fallback) only.

The server also defaults is_new to 'Y' (current members, no out_date rows),
so the historical half ('N', ~2000 rows with out_date back to the 1990s) is
fetched separately and concatenated — without it every label would be
today's classification and the loader's PIT claim false.

Live smoke: 350/350 csi300 PIT constituents covered (100%), 0.9 gate passes.

* docs(factors): surface the SW2021 taxonomy-retroactivity caveat; pin cache immutability

Review follow-ups: the loader docstring disclosed that SW2021
classifications are applied retroactively, but no user-facing surface
did — one sentence added to each ind-neutral alpha's notes, the i18n
en/zh entries and SKILL.md (membership is point-in-time; the taxonomy
version itself is not). The sector-attach test now pins that the
original pickled panel dict is never mutated by the attach, and the
sector_coverage meta semantics (labelled-somewhere fraction, not
per-date) are documented at the attach site.

* fix(factors): align ind-neutral PB/PS with the upstream nonpositive-multiple guard

The maintenance batch (d3c76819) added a guard to fund_pb/fund_ps_ttm:
nonpositive multiples are excluded rather than ranked as cheap. The
industry-neutral variants follow suit — the within-group z-score sees
the same values.where(values > 0) pre-filter, the guard sentence leads
the notes/i18n entries, and the upstream parametrized test is mirrored
for the neutral stems.
2026-10-12 19:22:41 +08:00
shadowinlife 91ba5aefe3 feat(channels): give Discord a first-class Web UI config form
Hand-written _discord_hints() replaces the derived form: 12 localized
fields (enabled excluded) x 9 locales with labels and help, a required
mark on the bot token (start() refuses to connect without it), and
group_policy as a <select> via the FieldHint.choices contract,
drift-pinned to the adapter's Literal["mention", "open"] by two
contract tests. token/proxy_username/proxy_password stay secrets with no
audited subtraction. A guided-setup entry walks through the Developer
Portal flow including the privileged MESSAGE CONTENT INTENT switch that
the default intents (37377) require - the most common Discord setup
failure, which no REST probe can detect (it surfaces only at gateway
IDENTIFY, close code 4014).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-10 19:14:46 +08:00
shadowinlife 6f1ed7ebed feat(channels): add Discord connection test for the bot token
New discord_probe.py validates a (possibly unsaved) bot token with a
single GET to Discord's users/@me endpoint carrying the Authorization:
Bot header. token_key="bot" also catches the wrong-token-type failure
(a pasted OAuth user token answers 200 without the bot flag) at probe
time instead of at gateway connect. The shared probe_token_endpoint
gains an optional method parameter (GET sends no JSON body); existing
POST callers are untouched. The enable transition is gated by the probe
(failure blocks the persist with 422), and discord leaves the
default-unsupported candidate list in the dingtalk connection test.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-10 19:14:24 +08:00
shadowinlife 2bc8e368be feat(channels): extend Discord hot-reload noop keys with live-read admission policy
allow_channels and group_policy are read live from self.config on every
inbound message (the client-side admission helper and
_should_accept_inbound; _should_respond_in_group) and are never captured
into the discord.py client at start(), so a Web UI edit that only changes
them now applies through refresh_config without a gateway disconnect.
Transport-identity keys (token, intents, proxy, streaming) stay on the
hot-swap path. Mirrors the noop-key classification the other adapters
adopted in the #1625/#1737 lineage.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-10 19:14:22 +08:00
shadowinlife d7ad4c7adf test(channels): stub slack_sdk before importing the adapter in the connection test
CI installs no channel extras, so the module-level `from src.channels.slack
import SlackChannel` aborted collection with ModuleNotFoundError: slack_sdk
on both Python 3.11 and 3.14 jobs. Install the shared stand-ins from
tests/slack_stubs.py first, matching test_slack_dm_pairing.py and the other
Slack tests (see the #1524 import-order note in slack_stubs.py).

Verified in a slack_sdk-less env: the 10 connection-test cases pass, the
channel-related subset (383 tests) passes, and the full gate
`pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py`
is green (17255 passed, 89 skipped).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-10 11:35:53 +08:00
shadowinlife 214ba5abaa Merge remote-tracking branch 'origin/main' into feat/slack-ui-hot-update
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-10 11:28:01 +08:00
shadowinlife 4285c324de feat(channels): give Slack a first-class Web UI config form
Derived hints left Slack with raw key labels, unmarked required
credentials, free-text group_policy, and user_token_read_only masked as
a secret by the fail-safe regex; add hand-written hints with the audited
secret=False subtraction (websocket token_ttl_s precedent), extend
FieldHint with optional choices rendered as a select, localize 12 fields
in 9 locales; mode/webhook_path stay file-configured (socket-only
transport).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-09 18:17:15 +08:00
shadowinlife d7d0ec03b5 feat(channels): add Slack connection test for bot and app tokens
Slack was the only Web-UI channel without a credential probe:
POST /channels/slack/test returned "unsupported" and an enable
transition saved unverified tokens, so a wrong xoxb-/xapp- token
surfaced only when the channel failed to start.

Add a standalone two-leg probe per the token_probe add-a-channel
checklist: leg 1 validates the bot token against auth.test, leg 2
validates the app token and that Socket Mode is enabled via
apps.connections.open (the wss:// URL in its success body is discarded,
never returned, logged, or cached). The shared probe gains an optional
headers parameter because Slack authenticates with an Authorization:
Bearer header instead of a JSON body; every existing caller passes
nothing and is unaffected. Slack's 200-with-ok:false error shape rides
the existing "200 but no token" classification, so no new result codes
appear. Missing tokens short-circuit before any HTTP call, mirroring
the start() guard.

Precedent: Feishu connection test (#1572).
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-09 18:17:12 +08:00
shadowinlife 406b45419e Merge branch 'pr-1748' into HEAD 2026-10-09 17:34:15 +08:00
shadowinlife 4a9149289d Merge branch 'pr-1740' into HEAD 2026-10-09 17:34:13 +08:00
shadowinlife d71b28c743 feat(channels): explain degraded hot-apply resets to users
A failed per-channel hot swap silently degrades to a full runtime reset
today — every connection drops with only a server log to show why. The
PUT response now carries a sanitized reset_reason (exception type +
message, bounded to 200 chars with URL userinfo stripped), and the
changed channel's last chat gets a best-effort notice published on the
fresh runtime's bus after the restart, mirroring nanobot's /restart
completion notice back to the requesting chat.

Refs #1625.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-08 19:23:57 +08:00
shadowinlife 00a756bb06 feat(channels): record runtime channel errors into status surface
Start, send and reload-stop failures are log-only today, so a channel that
built fine but crashed on start shows running:false in the UI with no reason
anywhere. _record_error merges a scrubbed one-line summary (URL userinfo
stripped, capped at 200 chars), a frozen error context and a UTC timestamp
into the per-channel status that /channels/status and /channels/config
already expose; a clean rebuild clears all three fields. Precedent: AstrBot
PlatformStatus + record_error surfaced in its WebUI. Refs #1625.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-08 19:06:27 +08:00
shadowinlife aa814430bb feat(channels): apply noop config edits in place without reconnecting
Cosmetic and ACL keys — allow_from, emoji reactions, and the progress toggles — are consumed by live per-message reads rather than captured into the SDK client at start(), so editing one today needlessly drops the IM connection. Each adapter now declares hot_reload_noop_keys; a PUT that changes only declared keys (or the manager bool overrides) refreshes the config in place via ChannelManager.refresh_channel_config and reports applied: "refreshed". Undeclared keys, the enabled transition, and any refresh failure keep the full hot-swap path as a fail-safe. Precedent: OpenClaw plugin-declared reload.noopPrefixes. Refs #1625.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-08 18:44:03 +08:00
shadowinlife faac0d2bac feat(tools): read_run_artifact — manifest-driven structured reads of run artifacts
Goal: give MCP clients token-cheap structured access to backtest run
artifacts. read_file returns raw CSV text and truncates mid-file; this
tool parses the artifact once and serves typed JSON pages a client can
page through or chart directly. Implements #1645.

Design: the run_card.json artifacts manifest (path/sha256/size_bytes per
file) is the trust anchor. Friendly aliases (equity, trades, metrics,
positions, target_positions, ohlcv:<CODE>, run_card) resolve by a fixed
mapping and work without a card; any other value is treated as a
run_dir-relative path accepted only when the manifest lists it, so a new
artifact kind becomes readable with zero code change. Manifest entries
are themselves validated — no absolute paths, no drive prefixes, no '..'
segments under either separator style — and every resolution is
re-checked with resolve() + is_relative_to(run_root), so a tampered card
can never point the reader outside the run directory. Three formats:
rows (lossless offset paging with honest truncated/next_offset),
downsample (equal-stride, first+last pinned), meta (columns/total_rows/
size_bytes, plus the manifest sha256 and manifest_size_bytes when
listed). Envelopes stay within a 120K-character budget by shrinking
whole records following _result_paging's proportional-shrink pattern —
never mid-record, never broken JSON.

Contract: purely additive read-only surface (75th MCP tool, 108th
registry tool); errors return {"ok": false, "error", "hint"} and success
envelopes carry no ok/status field.

Tests: 44 cases — manifest acceptance and refusal (unlisted relative
paths, '..' and absolute tampered entries, symlinked manifest entries,
non-served suffixes), aliases with absent/corrupt cards, meta sha256
exposure, 3821-row downsample endpoint pinning, lossless paging walk,
traversal refusals, coercion, projection, budget shrink/re-stride/
floor, clamping, exact envelope key sets. README/SKILL count pins
updated (74->75 MCP, 107->108 registry); test_readme_counts,
test_distribution_skill_manifest and the MCP smoke suite are green.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-01 01:17:41 +08:00
shadowinlife d176035e93 feat(tools): structured summary in the backtest tool result
The backtest envelope carried metrics only as a JSON string inside
stdout, which is truncated to its last 2000 characters — with the
metrics JSON already ~1.4KB and growing (rebalance_*, risk_xray_*),
the authoritative numbers could be cut mid-structure, and consumers
had to re-parse stdout and guess run_id from the run_dir basename.

On success the envelope now gains a best-effort summary projected
from artifacts the run already writes (run_card.json + equity.csv):
schema_version, run_id, title, codes, date range, interval,
initial_cash, the full metrics object (non-finite floats sanitized
to null), an equal-stride equity_preview of at most 50 points with
the first and last row pinned, artifact_paths including per-symbol
ohlcv files, and warnings. The artifacts map also exposes the ohlcv
files the engine already writes.

Strictly additive: legacy fields stay byte-identical (golden-tested),
failure/timeout paths are unchanged, and a summary that cannot be
built is omitted rather than failing the run.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-10-01 00:53:35 +08:00
shadowinlife 8c2cbec00e docs(readme): add Feishu to the guided Web UI channel list in seven languages
Mirrors the DingTalk/QQ sentence in the "Configure from the Web UI"
paragraph across README.md and the six translations, in lockstep.

Part of #1569.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-25 18:49:21 +08:00
shadowinlife eefc019791 feat(channels): guided Web UI config for Feishu
Extends the #1520 channel-config surface to Feishu: hand-written field
hints (13 fields), a standalone tenant-access-token probe that is
domain-aware across open.feishu.cn / open.larksuite.com, a guided setup
guide localized in all nine locales, and enable-transition credential
verification through the existing frozen ok|invalid_credentials|network
contract.

stop() is now hot-swap safe. lark.ws.Client exposes no stop method and
its receive loop runs with auto_reconnect=True, so merely clearing
_running left a zombie WebSocket after reload_channel: the old and the
replacement adapter both answered events. stop() now best-effort closes
the SDK connection (bounded) and terminates the dedicated event loop,
killing the receive/ping/reconnect tasks; it stays idempotent and safe
when never started. Pinned by test_feishu_stop.py, the DingTalk
zombie-stream fix class from #1520.

The probe follows the token_probe checklist verbatim: feishu_probe.py
module, registry._INTERNAL entry, supports_connection_test plus a thin
delegate, FIELD_HINTS, frontend GUIDE_DEFS and fields/guides i18n keys
in all nine locales, and tests mirroring test_qq_connection_test.py.
Feishu answers HTTP 200 with an error body for bad credentials; the
shared 200-but-no-token branch classifies that as invalid_credentials
(the QQ precedent).

Part of #1569.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-25 18:45:26 +08:00
shadowinlife c67af90752 Merge branch 'main' into feat/email-websocket-web-config 2026-09-23 10:36:14 +08:00
shadowinlife 2242c412ca fix(security): verify email TLS certs and guard settings-write routes
Two defense-in-depth fixes from an adversarial review of the Email/WebSocket
guided-config surface (#1544). Both harden pre-existing patterns that the new
email probe — the first whose target host is caller-controlled while reusing
stored credentials — made relevant.

Email TLS certificate verification (CERT_NONE -> verified):
- The implicit-SSL paths (IMAP4_SSL/SMTP_SSL) in the connection probe and the
  polling/send adapter used Python's default unverified context (CERT_NONE, no
  hostname check), so the mailbox password was sent to a server whose
  certificate was never checked; an active MITM 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.
- New EmailConfig.verify_tls field (default True) is the documented opt-out
  for self-signed/internal-CA servers, surfaced in the guided Email setup with
  a localized label/help in all nine locales.

Settings-write CSRF guard:
- require_settings_write_auth was missing the cross-site browser guard its
  siblings (require_auth, require_event_stream_auth) enforce on unsafe
  methods. POST /channels/email/test merges stored credentials with a caller
  patch, so a cross-site page could steer 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 (no Origin) are
  unaffected.

Tests: 9 new email TLS regressions (helper + probe/adapter x verify_tls) and
5 new settings-write cross-site guard regressions; the email field-count
(29 -> 30) and i18n parity assertions were updated. Channel sweep 639 passed,
frontend 656 passed, test_readme_counts 108 passed; new/changed files are
black- and ruff-clean (email.py's 9 ruff errors are pre-existing import-block
debt, unchanged 9 -> 9).

Follow-up to #1519 (extends #1520, hardens #1544).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-22 18:28:19 +08:00
shadowinlife d4861f36e3 docs(readme): sync Email/WebSocket guided-config sentence to all locales
The English README's "Configure from the Web UI" paragraph gained the
Email/WebSocket guided-channel sentence in the previous commit; mirror it
into the six translated READMEs (zh, ja, ko, ar, es, id) at the same slot
— after the QQ sentence, before the Test-connection sentence — keeping
IMAP/SMTP/TLS/WebSocket/Test connection verbatim. Prose-only; no count or
roster change, so test_readme_counts stays green (108 passed).

Follow-up to #1519 (extends #1520).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-22 15:47:29 +08:00
shadowinlife de467063fd feat(channels): guided Web UI config for Email and WebSocket
Extend the contract-driven channel config surface from #1520 to the Email
and WebSocket adapters: both can now be configured, connection-tested and
hot-applied from Settings with no restart and no hand-edited JSON — the
"one hints dict entry" path #1519 designed for the long tail of adapters.

Backend
- Email: standalone IMAP + SMTP credential probe (email_probe.py) — login,
  mailbox select, SMTP connect/(STARTTLS)/login — classified into the frozen
  ok|invalid_credentials|network codes with secret-scrubbed, side-prefixed
  details; the adapter's test_connection delegates to it.
- WebSocket: local-server probe (websocket_probe.py) — TLS cert/key material
  validation plus a read-only bind of every resolved address (SO_REUSEADDR
  gated to POSIX, matching asyncio's create_server default); an address held
  by the already-running server reads as the expected state, not a failure.
  The config routes now build the WebSocket adapter with the gateway services
  it requires, so a WebSocket save or test no longer fails validation.
- config_meta: hand-written field hints for both channels. A hand-written
  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 — a form save can no longer silently flip the stored token requirement.
- Email polling skips stale-config delete/move post-actions once a stop or hot
  swap begins; the already-fetched batch is still delivered.

Frontend
- Localized labels/help and guided setup for both channels across all nine
  locales; secrets are masked and never round-trip to the browser.

Adapter fix found during a live end-to-end smoke test against a real 163.com
mailbox: NetEase (163/126/yeah.net) accepts LOGIN but rejects the first SELECT
with "Unsafe Login" until the client sends an RFC 2971 IMAP ID, so the Email
channel could not read the most common Chinese mailboxes. The probe and the
polling loop now send a static, best-effort IMAP ID after login
(utils.send_imap_id) — it carries no user data or secrets and is ignored by
servers that do not support it. Verified end to end: an inbound mail from an
allow-listed sender passed SPF/DKIM, reached the agent, and an auto-reply was
delivered back over SMTP; on the same run the anti-spoofing check rejected the
mailbox's own SPF-failing service mail.

Follow-up to #1519 (extends #1520).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-22 15:13:45 +08:00
shadowinlife 1dad6f47fe fix(channels): stdlib logging placeholders in weixin
Convert 23 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting including the {:.0f} -> %.0f spec,
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:47:04 +08:00
shadowinlife 8d1642b02d fix(channels): stdlib logging placeholders in wecom
Convert 17 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:47:02 +08:00
shadowinlife 73c8304bb2 fix(channels): stdlib logging placeholders in websocket
Convert 12 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:47:00 +08:00
shadowinlife 1bbb00bc7b fix(channels): stdlib logging placeholders in telegram
Convert 31 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting including the {:.1f} -> %.1f spec,
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:59 +08:00
shadowinlife 04fb433228 fix(channels): stdlib logging placeholders in signal
Convert 31 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting including the {:.0f} -> %.0f spec,
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:57 +08:00
shadowinlife 306b9e96a9 fix(channels): stdlib logging placeholders in msteams
Convert 13 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting including the {!r} -> %r spec,
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:55 +08:00
shadowinlife edb4da4765 fix(channels): stdlib logging placeholders in mochat
Convert 10 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:54 +08:00
shadowinlife bca9d3eeb4 fix(channels): stdlib logging placeholders in matrix
Convert 16 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:52 +08:00
shadowinlife 982c6f94b4 fix(channels): stdlib logging placeholders in feishu
Convert 44 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:50 +08:00
shadowinlife 6a357c7981 fix(channels): stdlib logging placeholders in email
Convert 25 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:49 +08:00
shadowinlife 58df75d2a5 fix(channels): stdlib logging placeholders in discord
Convert 25 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:47 +08:00
shadowinlife 61d5fe7625 fix(channels): stdlib logging placeholders in dingtalk
Convert 40 loguru-style "{}" format strings passed to self.logger (a
stdlib logging.Logger from BaseChannel) to %-style lazy formatting
with args present the old form raised TypeError inside handler
formatting - the message was lost and a logging-error traceback went
to stderr instead.

Verified: placeholder count == argument count at every converted site
(AST-checked), zero "{}" logger calls remain in the file, every diff
line is a pure placeholder conversion, and the full backend suite is
green (13809 passed).

Part of #1531.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 16:46:45 +08:00
shadowinlife 2917fe38c3 test(frontend): align the QQ fixture with the backfilled GET shape
qqEntry held download_chunk_size/download_max_bytes as strings, but
since the GET defaults backfill a pristine channel returns the
adapter's int defaults (262144/209715200) on the wire. The panel's
String(raw) seeding masked the drift; the fixture now matches the real
GET shape, so the suite keeps proving that int defaults seed the text
widgets and round-trip through buildPatch.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 15:53:07 +08:00
shadowinlife c1c2c9aede refactor(channels): drop dead dingtalk_probe helper surface
The probe extraction kept two helpers alive that nothing consumes: the
response_detail re-export and the scrub_detail config wrapper (both
superseded by token_probe.response_detail / token_probe.scrub_secrets,
which the shared probe applies internally). An adversarial review of
the QQ increment grepped src/ and tests/ and found zero callers; the
DingTalk contract suite - the extraction's regression pin - never
imported either symbol and stays green (22 passed) after the removal.

Keeping them would advertise a per-channel scrubbing API that the
shared seam deliberately owns in one place: the next probe author
should not have to decide between two scrubbing entry points.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 15:53:06 +08:00
shadowinlife d8092e24ed fix(channels): stdlib logging placeholders in slack and whatsapp
The same pre-existing bug class the QQ increment fixed in qq.py: both
adapters format self.logger calls (a stdlib logger from BaseChannel)
with loguru-style "{}" placeholders. With args present, stdlib's
msg % args raises TypeError inside handler formatting - the message is
lost and a logging-error traceback prints instead. Runtime-proven on
the converted whatsapp deprecated-bridge warning: it now renders
("Ignoring deprecated WhatsApp bridge config fields: bridge_url.
...") where the same call lost the message before.

- slack.py: all 14 sites converted (incl. the {:.0f} handshake-timeout
  spec -> %.0f); AST scan shows zero "{}" logger calls left.
- whatsapp.py: all 5 sites converted (incl. the multi-line unauthorized-
  sender pairing notice); zero leftover per the same scan.
- feishu.py: drop the stale "logging_bridge not needed" comment - the
  last reference to the ghost module the matrix fix removed.

The scan found the same class in the remaining 12 adapters (~321
sites, feishu/dingtalk/telegram largest); that sweep is out of this
increment's scope and tracked in #1531.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 15:53:05 +08:00
shadowinlife c45d3c95e8 fix(channels): QQ construction no longer creates the media directory
The web-config Test/Save routes construct an ephemeral channel instance
to validate a section before anything is persisted. QQChannel.__init__
mkdir'd its (user-controlled) media root, so every Test click and every
rejected Save still left a created directory behind - the ephemeral-
construction pattern presumes side-effect-free constructors, and QQ is
the first guided channel whose constructor violated that (adversarial-
review finding; heavier __init__s would join as the guided roster grows).

- _init_media_root keeps the path choice and the INFO log but no longer
  touches the filesystem; a new _ensure_media_root performs the mkdir
  and is called from start() only. Pinned by a construction-vs-ensure
  regression test.
- start() treats an unwritable media dir as a warning, not a startup
  failure: a text-capable bot must not die for a directory only inbound
  attachments need, and each download already re-attempts the mkdir and
  fails on its own (previously the OSError surfaced at construction and
  made the channel unloadable).

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 15:53:05 +08:00
shadowinlife 5e9883e058 fix(api): channel-agnostic form metadata and symmetric unknown-key rejection
Three hardening fixes from the adversarial review of the QQ increment,
all on the shared route/metadata seam every future guided channel rides
on:

- config_meta._derive_hints now skips dict-valued default keys. The
  generic form edits text/password/bool/list widgets only; a dict field
  derived as "text" rendered as "[object Object]" once the GET defaults
  backfill reached pristine forms (signal dm/group, whatsapp
  lid_mappings, napcat group_policy_overrides, mochat mention/groups).
  Such fields stay file-configured: excluded from hints, still present
  in GET values, secret masking unchanged (derived hints only ever
  marked what SECRET_KEY_RE catches unconditionally). Pinned by the
  updated fallback-derivation test and a signal-shaped regression.
- _effective_section's except branch now logs at debug with exc_info,
  matching _derive_hints: an adapter whose default_config() breaks no
  longer loses form seeding silently - the same class of invisible
  failure the backfill fix exists to eliminate.
- POST /channels/{name}/test shares PUT's unknown-key rejection via the
  extracted _unknown_keys helper. Before, PUT refused unrecognized keys
  with 422 while /test silently dropped them through pydantic
  extra="ignore" into the ephemeral probe instance; a future config
  model with extra="allow" would have accepted arbitrary keys through
  the unguarded path. Both routes now answer the same 422
  validation_error envelope, pinned by a /test regression.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 15:53:04 +08:00
shadowinlife cefc5ae3ac fix(channels): token probe survives non-object JSON 200 bodies
An adversarial review of the QQ increment reproduced a crash in the
shared probe seam: a token endpoint answering HTTP 200 with valid JSON
that is not an object (null, a list, a string - exactly what middleboxes
and proxied error pages emit) made resp.json().get(...) raise
AttributeError. _apply_update wraps the probe in try/except, but the
/test route does not, so POST /channels/{name}/test - the endpoint whose
purpose is honest ok/invalid_credentials/network envelopes - returned a
bare 500. Every future channel routed through the shared module would
inherit the hole.

- token_probe: parse the body, then guard isinstance(body, dict) before
  .get(token_key); non-object bodies classify as invalid_credentials.
  Pinned at both layers: 3 probe-level cases (null/array/string) and 3
  API-level regressions through POST /channels/dingtalk/test asserting a
  200 envelope, never a 500.
- token_probe docstring: the per-channel adoption checklist (probe
  module, registry._INTERNAL, adapter delegate, FIELD_HINTS, GUIDE_DEFS
  + i18n, tests + README - the real seam count the review measured) and
  the documented contract (bounded 200-char detail echo scrubbed of
  declared secrets only; success = truthy token_key value).
- qq_probe/dingtalk_probe: the missing-credentials short-circuit now
  carries sdk_available like every other envelope branch, matching both
  docstrings' promise; the DingTalk contract suite (untouched) stays
  green as the regression pin.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 15:30:10 +08:00
shadowinlife b851738741 docs(readme): QQ joins the guided web-config channels
One sentence per README in the existing "Configure from the Web UI"
paragraph: register a bot on the QQ Open Platform (q.qq.com), copy
AppID/AppSecret into the form, verify with Test connection before
saving, then enable - no public callback URL needed (WebSocket via the
official botpy SDK). The DingTalk "first fully-guided channel"
sentence stays; news entries are historical and untouched.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 12:22:05 +08:00
shadowinlife 413e2c2a03 feat(frontend): QQ setup guide via a per-channel guide registry
The guide useMemo branched on name !== "dingtalk"; adding QQ as the
second guided channel turns that into a GUIDE_DEFS registry keyed by
channel name - adding a future channel is one entry plus its i18n
keys, with no component logic touched. Builders receive t and use
literal keys only (typed i18n rejects interpolated ones), so copy
re-resolves on language change.

- QQ: 8 field label/help entries and a 5-step setup guide (QQ Open
  Platform q.qq.com, AppID/AppSecret, no public callback URL -
  WebSocket via the official botpy SDK) in all 8 locales.
- Tests: QQ fields render from generic metadata with the masked-secret
  placeholder, the guide expands with its steps and docs link, and a
  channel without a registry entry renders no guide.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 12:22:03 +08:00
shadowinlife 31b667d98d fix(api): backfill adapter defaults into channel config values
Found by the live QQ web-config E2E: a never-configured channel has no
on-disk section, so GET /channels/config returned empty values, the
generic form seeded "" into every widget, and Test/Save echoed those
empty strings into ephemeral validation - QQ's typed fields rejected
them (validation_error: msg_format, download_chunk_size,
download_max_bytes) before any network call. DingTalk never exposed
this because its fields are all str/bool/list, where "" is valid.

GET-shape values are now {**adapter default_config(), **stored
section}: the form shows the effective configuration, an untouched
form round-trips through validation, and typed fields of any future
channel are covered by construction. Secret masking is unchanged -
defaults are empty, so secrets still reflect only stored state.

Regression tests: defaults backfill for an unconfigured channel, and a
full pristine-form round-trip (GET values echoed back as a form patch
must pass the test endpoint) mirroring the frontend buildPatch.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 12:21:39 +08:00
shadowinlife 2095663239 fix(channels): drop the matrix ghost logging_bridge import
src.utils.logging_bridge has never existed in this repository's history
(no ref contains it), so matrix.py failed at import even with the
[matrix] extra fully installed - and the registry's ImportError handler
misreported the cause as a missing SDK, pointing operators at a pip
install that could never fix it.

The only use was redirect_lib_logging("nio", level="WARNING") in
start(); the stdlib equivalent logging.getLogger("nio").setLevel(
logging.WARNING) keeps the chatty nio SDK loggers suppressed with no
new module. Verified the adapter imports cleanly with stubbed nio/nh3/
mistune dependencies.

Found while wiring the QQ connection test (separately-labeled pre-
existing fix, per the one-line websocket Logger fix precedent in this
branch).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 12:21:37 +08:00
shadowinlife e389d3fbf4 feat(channels): QQ connection test on a shared token-endpoint probe
QQ becomes the second fully-guided web-config channel, and the shape of
the increment is the extensibility proof #1519 asked for: two new probe
modules plus one hints entry, with zero route, manager or component
changes.

- token_probe.py: DingTalk's OAuth accessToken exchange and QQ's
  getAppAccessToken are the same contract (POST credentials to a token
  endpoint, classify by status + token presence, scrub secrets from
  every detail, discard the token), so the request/classification logic
  moves here as the shared building block. dingtalk_probe keeps its
  public API and behavior unchanged and delegates; its existing
  contract suite is the regression pin for the extraction.
- qq_probe.py: standalone QQ probe (fresh httpx client, works pre-start,
  QQConfig only under TYPE_CHECKING so a credential check never pulls
  aiohttp/botpy). QQ answers HTTP 200 with an error body for bad
  credentials (observed live: {"code":100016,"message":"invalid appid
  or secret"}) - the shared 200-without-token branch classifies that
  as invalid_credentials, pinned by a QQ-shaped regression test.
- qq.py: supports_connection_test + thin test_connection delegate.
  Also converts 20 loguru-style "{}" log calls to stdlib %s lazy
  formatting: with stdlib logging the old form raised TypeError inside
  handler formatting, losing the message and printing a logging error
  on every INFO-level startup (verified fixed in production: the
  reconnect warning now renders). Import block cleaned (E702/E402) and
  the file brought to black compliance.
- config_meta: hand-written QQ hints (8 fields, secret/required flags,
  i18n help keys) with an exact-snapshot test.
- registry._INTERNAL gains qq_probe/token_probe; discovery stays at 16.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-21 12:21:12 +08:00
shadowinlife 90fb39a4f4 docs(readme): add web channel-config news entry in six languages
User-facing features of this size land a dated News bullet per repo
convention; the branch only carried the "Configure from the Web UI"
subsection. Add the 2026-09-20 entry (validate-before-save, masked
secrets, connection test, per-channel hot apply, DingTalk guided
setup) to all six READMEs, and note in the subsection that only
built-in adapters are listed — entry-points plugin channels remain
file-configured. Dated news bullets are excluded from the
test_readme_counts anchors, which stay green.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 15:26:59 +08:00
shadowinlife aba9c897c8 fix(frontend): structured API error details and dirty-form save gate
The 422 diagnostic chain died on the frontend: errorFromResponse
assigned a dict detail straight into the Error message ("[object
Object]"), ApiError could not carry the backend's code, and
describeApiFailure probed properties that never existed — so a
validation failure rendered "Unknown error" and the config_not_writable
branch was unreachable.

- ApiError gains an optional code; errorFromResponse parses structured
  {code, message} details (string detail/message/error handling is
  unchanged for every other endpoint).
- describeApiFailure reads status/code/message from the real error;
  the OPAQUE_DETAIL workaround is gone.
- Save is disabled until the form is dirty — a pristine Save used to
  fire a no-op PUT plus a full hot-swap cycle.
- Test connection sends pending secret clears, matching the "values
  currently in the form" label.
- Re-mock the enable-anyway test to the production error shape (the
  old assertion passed only because the mock stuffed the code into the
  message) and pin the new Save-gate and pending-clear behavior.
- Trailing newlines on the new components.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 15:26:43 +08:00
shadowinlife c5bc1ce266 fix(api): stop orphaned runtime on reload fallback, surface 422 diagnostics
Adversarial-review fixes for the channel-config write path:

- Hot-reload failure fallback reset the runtime singleton and started
  a new one while the old runtime (consume loop, dispatcher, connected
  adapters) kept running. The fallback now stops the old runtime
  first, bounded at 10s so a wedged adapter cannot stall the rebuild;
  the covering test used a no-op reset mock that could not observe the
  leak — rewritten against the real reset with stop tracking, plus a
  wedged-stop case.
- 422 envelopes carry a scrubbed, field-naming message (names only,
  never values); a raising enable-probe becomes 422/network instead of
  escaping as a bare 500.
- Unknown config keys are rejected with 422 instead of persisting
  unvalidated; documented manager override keys stay accepted, and
  merge-patch semantics for stored legacy keys are unchanged.
- GET returns a display-only, home-relative config_path instead of the
  absolute server path.
- POST /channels/{name}/test honors pending clear_ flags so the probe
  matches the UI's "values currently in the form" promise.
- config writer: a process-wide lock serializes the whole-file
  read-modify-write across channels and entry points (web PUT, Feishu
  QR login), closing the cross-channel lost-update window.
- Drop the dead _NON_CHANNEL_MODULES duplicate, document the
  plugin-channel scope cut, and replace a dangling local-plan
  reference in the test docstring with the issue link.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 15:26:24 +08:00
shadowinlife 29cb5330af fix(channels): snapshot channel iteration in manager lifecycle loops
stop_all() iterated manager.channels while awaiting channel.stop() in
the loop body — and a DingTalk stop can take seconds. A config
hot-swap (reload_channel pops/inserts the same dict synchronously)
interleaved with /channels/stop raised "dictionary changed size during
iteration" mid-loop, leaving every later channel running after the
operator requested a full stop. start_all()'s post-gather status loop
had the same exposure.

- Iterate list(...) snapshots in stop_all and start_all; the tolerated
  race (a channel inserted after the snapshot) is documented: reload
  only spawns starts while the dispatcher is live, and stop_all
  cancels it first, so a late insert sits loaded-but-inert.
- Add the two regression pins #1519 claimed but did not ship:
  start_all() with zero enabled channels still creates the outbound
  dispatcher (a later reload_channel spawns the channel), and
  reload_channel returns promptly while the replacement's start()
  blocks forever (no inline await; the spawned task is tracked).

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 15:26:02 +08:00
shadowinlife 4022e94781 refactor(channels): split dingtalk probe and media helpers out of the adapter
The connection-test feature pushed dingtalk.py from 779 to 901 lines,
past the repo's 800-line hard cap. Both helper clusters are stateless
apart from the config, so they move cleanly:

- dingtalk_probe.py: the credential probe (test_connection, scrub and
  response-detail helpers, IPv4-pinned transport builder, token URL).
  DingTalkChannel.test_connection becomes a thin delegate; a
  compatibility alias keeps _build_http_transport importable for the
  existing transport pins.
- dingtalk_media.py: URL classification and upload packaging helpers
  (is_http_url, guess_upload_type, guess_filename, zip_bytes) with
  their extension sets; call sites updated, no behavior change.
- registry._INTERNAL gains both module names so channel discovery
  stays locked at 16.

dingtalk.py is now 787 lines; the probe and media modules are 124 and
82. All existing DingTalk tests pass unchanged.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 15:25:37 +08:00
shadowinlife fd27a98061 fix(channels): deny-by-default secret masking for credential-shaped keys
An adversarial review of the web config surface found the read path
violating its own "secrets never cross the wire" invariant: the
secret-key regex (secret|token|password|api_key) missed Feishu's
encrypt_key — a live event-subscription shared secret returned in
cleartext by GET /channels/config — plus Discord's proxy_username and
proxy URLs carrying embedded user:pass@ userinfo. _mask also disclosed
secrets of <=4 characters in full ("****" + last-4 echoed the value).

- Extend SECRET_KEY_RE to a deny-by-default credential family
  (encrypt/signing keys, credentials, private values, proxy usernames)
  so derived hints and the split both classify them as secrets.
- Strip URL userinfo from non-secret values (malformed URLs pass
  through unchanged), keeping proxy hosts editable but credential-free.
- Mask secrets of <=8 characters with a fixed "****" (no suffix).
- Add a sweep pinning every credential field of all 16 adapters into
  secrets, an identifier-visibility pin against over-masking, and
  boundary tests for the mask and the userinfo stripping.
- Drop the local _NON_CHANNEL_MODULES duplicate: registry._INTERNAL
  already excludes helper modules from discovery.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 15:25:09 +08:00
shadowinlife 634a9de733 docs(readme): document web-based IM channel configuration in six languages
Add a 'Configure from the Web UI' subsection to the IM channels section of
all six READMEs: expandable per-channel panel, DingTalk Stream Mode setup
guide, credential probing before save, immediate hot-apply enable/disable,
masked secrets, and the JSON-only editing constraint.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:31:31 +08:00
shadowinlife b859cbaf46 feat(frontend): web IM channel configuration with test and hot toggle
Replace the read-only IM Channels section with ChannelSettings (status
table plus expandable rows) and a generic ChannelConfigPanel driven by the
backend field metadata: text/password/bool/list-tag inputs, masked-secret
placeholders with a clear flag, a Test-connection button that probes dirty
unsaved values, a non-destructive Enable toggle with an explicit
skip_verify override after a failed verification, a collapsible DingTalk
setup guide, YAML read-only and missing-SDK banners. Rendering is
field-driven so a secret smuggled into values cannot reach the DOM
(asserted). New keys land in all 8 locales; existing Settings tests keep
covering the migrated section.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:31:28 +08:00
shadowinlife 42f5f07948 feat(api): channel config routes with validate-on-save and hot apply
New sibling module (channels_routes.py untouched, same host-resolution and
local-model conventions):

- GET /channels/config: availability + live status + field metadata over
  the on-disk section; secrets only as {set, masked}; reports writable
  (JSON-only) and runtime_running without forcing a singleton build.
- PUT /channels/config/{name}: merge-patch (absent secret = keep,
  clear_<field> removes, empty-string secret ignored); ephemeral adapter
  construction validates before any write (422, file byte-unchanged); the
  enable transition auto-verifies credentials unless skip_verify; atomic
  write then hot apply: running -> reload_channel (hot_swapped),
  built-but-stopped -> singleton reset (reset), never built -> deferred.
- POST /channels/{name}/test: probes unsaved credentials on an ephemeral
  instance, never persists, honest codes, detail scrubbed.

Per-channel asyncio locks serialize save/test/toggle; a global lock guards
runtime rebuilds. 23 route tests include a concurrency-serialization case
and a secret-hygiene sweep over every raw response body.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:31:26 +08:00
shadowinlife d568b7a956 feat(channels): config field metadata with fail-safe secret masking
A generic web form needs machine-readable field metadata. config_meta
provides hand-written hints for DingTalk (i18n help keys owned by the
frontend) and derives hints for every other adapter from default_config()
with type inference. split_values_secrets() masks secrets as ****+last4
and strips every secret-heuristic key from values unconditionally, so a
future adapter cannot leak a credential through an unhinted field; a
parametrized sweep asserts this across all 16 adapters.

Also stop discovery from listing the targets and config_meta helper
modules as channels (they surfaced as bogus unavailable rows in
/channels/status).

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:30:47 +08:00
shadowinlife 2774969cf1 feat(channels): per-channel hot reload and channel-runtime reset
Config edits never applied without a process restart: the manager built
channel instances from a constructor snapshot and the API cached the
runtime singleton in module globals plus host attrs.

- Extract _build_channel() and add ChannelManager.reload_channel(): stop
  the old instance (bounded, never wedges the swap), rebuild from the new
  section, start it iff the dispatcher is live, and keep _status and the
  config snapshot in sync. Other channels' instances are untouched.
- start_all() now always creates the outbound dispatcher; the previous
  early return on zero enabled channels left a later-enabled channel
  unable to start.
- state.reset_channel_runtime() clears both cache surfaces so the next
  access rebuilds from disk.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:30:45 +08:00
shadowinlife 9d8d7171a0 feat(channels): add connection-test hook and harden the DingTalk adapter
BaseChannel gains supports_connection_test plus an async test_connection()
defaulting to an honest 'unsupported' envelope. DingTalk overrides it with a
standalone accessToken probe (fresh httpx client, works before start(),
token discarded, detail scrubbed of credentials) so the web UI can verify
unsaved credentials.

Live end-to-end QA against a real DingTalk org app surfaced three adapter
defects, each pinned by a regression test:
- _on_message never forwarded is_dm, so unapproved DMs hit the group deny
  log and the pairing-code flow was dead code for DingTalk.
- stop() never terminated the SDK stream loop (it swallows the first
  CancelledError and reconnects), leaving a zombie WebSocket after disable
  and hanging graceful process shutdown; stop now double-cancels the
  tracked stream task.
- New force_ipv4 option binds outbound API calls to IPv4: the robot-send
  API enforces the app egress-IP whitelist and a rotating IPv6 prefix on
  dual-stack networks keeps falling out of it (observed as 403
  IpNotInWhiteList), while the IPv4 egress is stable.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:30:41 +08:00
shadowinlife 0ed47b589e feat(config): add atomic agent.json channel-section writer
Channel credentials could only be persisted by hand-editing agent.json; the
only in-repo writer was the Feishu QR-login persister. Extract a generic
update_channel_section() that merges one channels.<name> section of the raw
JSON dict (no schema round-trip, unknown keys preserved), applies clears,
and writes atomically (tmp + fsync + os.replace, 0600/0700, JSON-only with
ConfigNotWritableError otherwise). Feishu now delegates to it, keeping its
ValueError public contract.

Part of #1519.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-20 11:30:04 +08:00
shadowinlife aba968dbc0 fix(frontend): welcome quick actions keep a real selected state
The quick-action chip row styled its first entry with the primary-color
"selected" look unconditionally, while clicking any chip only filled the
composer without changing the chip. Users read the permanent highlight
as a selection that never moved when they clicked another action (e.g.
the investment-committee debate chip stayed neutral while the first chip
kept glowing).

Selection is now component state keyed by the action title: clicking a
chip marks it aria-pressed and moves the primary styling to it, and no
chip is highlighted before the first click. Adds a test covering the
initial, clicked, and moved selection states; verified in a real browser
against the dev server.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-19 19:59:51 +08:00
shadowinlife f3af89db77 fix(tools): interval "1M" (one month) no longer silently case-folds to 1m (one minute)
get_market_data normalized interval spellings through
_INTERVAL_CANON = {v.upper(): v for v in _VALID_INTERVALS}. Because
_VALID_INTERVALS mixes cases ("1m" minutes vs "1H" hours), the uppercase
key for "1m" is "1M", so a caller passing interval="1M" -- the universal
monthly convention in pandas resample('1M'), ccxt and TradingView --
silently received 1-minute bars labelled as the answer to a monthly
question. The is-None guard never fired because the fold always resolved,
so the wrong-unit substitution was invisible to both the agent loop and a
human MCP client.

Resolve a case-exact match against _VALID_INTERVALS first, then fall back
to the fold for unambiguous case variants only ("1d" -> "1D",
"30M" -> "30m"). The unserved month/week spellings "1M"/"1W" are now
rejected with a pointed message naming the minute collision instead of
being mis-served. The case-exact-first ordering keeps this correct if
monthly bars are ever added to _VALID_INTERVALS (#1479).

Closes #1480

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-18 18:36:34 +08:00
shadowinlife 70bea5b164 feat(tools): get_southbound_flow — the only live Stock-Connect net-flow signal
Southbound (mainland → HK) is the half of Stock Connect whose daily net
buy is still officially disclosed: HKEX publishes per-channel Buy/Sell
Turnover every trading day, and Eastmoney's datacenter series
cross-checks against it exactly. The northbound net ended 2024-08-30
(#1481), which leaves southbound as the only live net-flow signal — and
its tool surface was missing: the hk-connect-flow skill's southbound
methodology could only point at hand-written tushare code whose
southbound fields (ggt_ss/ggt_sz/south_money) have themselves drifted
to cumulative net-bought stock (#1483).

- southbound_tool.py: get_southbound_flow, mirroring the northbound
  envelope. Primary: Eastmoney datacenter RPT_MUTUAL_DEAL_HISTORY
  (MUTUAL_TYPE 002/004) via the shared throttled client, NET_DEAL_AMT
  in 100M HKD. Fallback: the keyless HKEX official daily-statistics
  report (latest trading day, net = Buy − Sell, million → 100M HKD),
  walking back a week past 404s. No tushare path; the envelope note
  says why.
- mcp_server.py: hand-written wrapper (Surfaces 75) + family docstring.
- agent/SKILL.md: manifest heading 74→75 + tool table row.
- hk-connect-flow SKILL.md: Data Access gains the tool route and a
  caveat that tushare's southbound fields are cumulative stock.
- six READMEs: MCP tool list + counts (74→75), repo-tree agent-tool
  count (107→108), MCP prose counts — all six locales.
- tests: 12 cases — merged envelope, trim/clamp, one-channel-missing,
  HKEX fallback triggers (failure + empty rows), 404 walk-back,
  both-down error, a decoy top-10-table regression pinning the live
  HKEX payload shape, parser guards, metadata/description anchors.

Live-verified 2026-09-18: Eastmoney and HKEX agree to the cent on
2026-09-15/16/17 (e.g. 09-17 港股通沪 net 25.0239亿 == HKEX Buy
26,387.65M − Sell 23,885.26M HKD); an incidental live EM SSL failure
exercised the HKEX fallback end to end with identical figures.

Closes #1483

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-18 17:18:36 +08:00
shadowinlife b2af4a494d fix(tools): northbound flow stops serving a silent empty envelope off a dead feed
Eastmoney's kamt northbound net-flow feed died with the 2024-08-30 HKEX
disclosure change (all-zero nets, frozen cumulative, drifted payload
shape), but its HTTP calls still succeed, so get_northbound_flow
returned ok:true with null realtime and empty history and never reached
the tushare fallback (#1481).

- northbound_tool: detect the dead state (realtime AND history both
  empty/all-zero) and route it through a shared tushare-fallback
  envelope; without a usable fallback the tool now returns an honest
  ok:false naming the disclosure change instead of an empty success.
- tushare_fallbacks: drop the blanket x100 rescale. Since 2024-08-30
  tushare's hgt/sgt/north_money carry turnover in million CNY (verified
  equal to HKEX official Total Turnover to the cent on three anchor
  days); earlier rows are net buy in the same unit. Values now pass
  through unchanged with unit "CNY million" and a note documenting the
  semantics boundary.
- tests: dead-feed fall-through, honest error without tushare, healthy
  payload guard, one-sided-dead guard; the x100 pin is replaced by the
  passthrough+note pin.

Closes #1481

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-18 16:52:40 +08:00
shadowinlife 9acae9c8fe fix(tools): record timeout failures in the backtest tool's state.json
Review follow-up to #1413: Runner.execute propagates
subprocess.TimeoutExpired, so a timed-out engine bypassed the
lifecycle block entirely — the run directory kept partial artifacts
with no state.json, indistinguishable from a never-executed run (the
evidence gate fail-closes either way, but the reason was lost and the
caller got a raw exception instead of an envelope).

Catch the timeout, record {"status": "failed", "reason": "backtest
engine timed out after 300s"} via the same RunStateStore contract, and
return a clean error envelope. A new test pins the state payload and
the envelope.

Part of #1412.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-11 18:07:29 +08:00
shadowinlife 2856f7bc5e fix(loader): invalidate stale tencent cache entries and harden empty-page handling
Two follow-ups from the review of #1411, both protecting the guarantee
the pagination fix establishes — a served series is the complete window
or the fetch fails loudly:

1. Bump _LOADER_CACHE_VERSION 6 -> 7. The cache key is content-addressed
   on (source, symbol, timeframe, start, end) with no TTL, so entries
   written by the forward walk during the bug window (#1154, 2026-08-20,
   onward) hold tail-truncated 500-bar series that would keep being
   served after the fix. Follows the v4/v5/v6 precedent of bumping
   whenever cached semantics change.

2. Harden the two paths where a throttled-but-HTTP-200 reply could still
   truncate silently: _request_page now raises on an error-shaped body
   (non-zero code, no payload) so it enters the page retry budget instead
   of reading as an empty window, and _fetch_one re-requests a mid-walk
   empty page once before accepting it as the genuine start of history.

Three new tests pin all three behaviors.

Part of #1410.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-11 17:41:15 +08:00
shadowinlife d971e08754 fix(swarm): enforce per-worker skills allowlist in load_skill
SwarmAgentSpec.skills documents a per-worker skill boundary, but the
boundary was only applied to the skill descriptions injected into the
worker prompt; the load_skill tool itself would load any skill by name,
so a worker could pull in documentation its preset never granted.

LoadSkillTool now accepts an optional allowed_skills set and fails
closed with the allowed names when a skill outside it is requested;
None keeps the historical unrestricted behavior for the main agent.
build_filtered_registry and build_swarm_registry take a matching
skill_allowlist parameter and rebuild load_skill with the boundary
baked in (register() replaces the auto-discovered instance by name),
and run_worker delivers agent_spec.skills through it. An empty skills
list remains unrestricted, matching the prompt-side filter semantics.

Also adapts the four build_swarm_registry test doubles in
test_swarm_m4_e2e.py to the new keyword-only parameter (this
adaptation was carried by the feature commit in #1286; the fix
is not self-contained against the current test suite without it).

Resubmitted standalone as requested in the #1286 review ("please do
re-submit it separately, and it does not need to wait on anything");
gap re-verified on main @ f9cb061b — models.py still documents the
boundary, the worker.py prompt-text filter is still the only reader
of spec.skills, and load_skill_tool.py still has no enforcement.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-11 17:30:47 +08:00
shadowinlife 5435a7ac26 fix(tools): record state.json from the backtest tool so tool-driven runs are ingestible as evidence
refresh_strategy_evidence fail-closes on run directories without state.json,
and only the runtime loop wrote that file — so every run produced by the
backtest MCP tool was rejected as unknown-provenance despite complete,
hash-manifested artifacts ("state.json missing or unreadable (run status
unknown)"). Observed at scale during a strategy-validation campaign: 11/11
healthy tool-driven runs skipped, recoverable only by hand-deriving
state.json from run cards.

run_backtest already knows the outcome (result.success / result.exit_code),
so record the lifecycle status right after engine execution by reusing
RunStateStore.mark_success / mark_failure — the same contract and fsync'd
writer the runtime uses, keeping one source of truth for the state.json
format. Validation errors return before the engine starts and intentionally
record nothing: no execution happened, and artifact-health gates reject
those runs anyway. The ingestion gate is untouched and stays fail-closed
for runs whose status was never recorded.

Tests pin all three paths (success / engine failure with reason / validation
error writes no state). Manual E2E on this branch: a fresh yfinance run
through run_backtest produces state.json {"status": "success"} and
refresh_strategy_evidence_core accepts it (runs:1, strategies:1, rows:1,
skipped:[]) with no bridging.

Closes #1412.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-11 15:35:29 +08:00
shadowinlife 940f57a8de fix(loader): paginate tencent history backward — the API serves the LAST 500 bars per window
#1154 added forward pagination assuming each fqkline response carries the
first <=500 bars from the cursor. The live API does the opposite: it returns
the last <=500 bars ending at the requested end date (verified 2026-09-11:
param=sh000300,day,2018-01-01,2026-06-30,500,qfq -> 500 bars spanning
2024-06-06..2026-06-30; the start clamp is honoured, so a sub-500-bar
window is served in full). With forward advancement the first page already
ends at end_date, the cursor steps past the window, the next page is empty
and the loop exits cleanly — any multi-year window silently degrades to its
most recent ~2 years with exit code 0 and no warning.

Walk backward instead: start_date stays fixed, the end cursor moves to the
day before each page's oldest bar, and the walk stops on a short page or
when the cursor passes start_date. Page-cap exhaustion and failed pages
still raise rather than truncate.

Tests now mock the real last-500 semantics (the previous mocks encoded the
first-500 assumption, which is why the truncation passed CI) and add a
multi-year regression asserting the served window starts at start_date.
Live smoke after the fix: 000300.SH 2018-01-01..2026-06-30 -> 2058 bars
from 2018-01-02 (was 500 bars from 2024-06-06).

Closes #1410.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-11 14:24:30 +08:00
shadowinlife ae68820a71 feat(agent): add evidence population, decay monitoring and stale refusal to strategy discovery
Implements #969 Phase 2 over the Phase 1 facade (#978). The evidence cache
is no longer empty-by-construction and no longer silent about age:

- refresh_strategy_evidence: agent tool (primary, scheduler-reachable) +
  MCP wrapper + 'vibe-trading strategy-evidence refresh --manifest' CLI.
  Manifest-driven only — runs carry no attribution, so explicit
  {strategy_id, run_dir, position_size} specs are the honest path; entries
  outside the runtime runs root / allowed run roots are skipped with a
  stable path-outside-allowed-roots reason. Writes only the disposable
  facade-owned cache.
- backtest-diagnose hookup: the skill's Hard-Gate Checklist becomes the
  evidence ingestion gate — state.json success, metrics.csv present,
  trade_count > 0, equity present and NaN-free, each with a stable
  hard-gate:* skip token. NaN-equity runs are refused entirely instead of
  yielding partial-curve evidence (fixes the Phase-1 silent-skip latent).
- atomic rebuild: compute all rows first, then one transaction swaps the
  store — a failed rebuild can no longer leave the cache empty.
- decay status surfaced in query_strategies / get_strategy_evidence:
  freshness computed at read time from the evidence-window age
  (fresh/aging/stale, 90/180-day thresholds tied to the quarterly
  re-backtest cadence; last_verified reported but never gating). Stale rows
  fail closed out of default recommendations (include_stale opt-out);
  sdm:* rows mirror the SDM lifecycle (decayed/disabled never recommended,
  sdm-lifecycle: prefix); unparseable windows fail closed to stale.
  Decay is never persisted — a stored decay field would go stale by
  definition.
- docs + count sync: strategy-discovery SKILL.md gains the Decay &
  Freshness Contract and Populating & Refreshing Evidence sections;
  backtest-diagnose SKILL.md documents the gate hookup; phantom-guard test
  now asserts every tool named in the SKILL.md recipe is registered;
  MCP tools 73 -> 74 across five READMEs + agent/SKILL.md (65 -> 66
  decorators).
2026-08-19 15:04:15 +08:00
shadowinlife bbfd82c87d feat(agent): add evidence-gated strategy discovery facade 2026-08-19 14:57:06 +08:00
shadowinlife 1b92f8924f fix(frontend): sync chart legend colors, factor spread format, and payoff axis
Visual QA of the P0 panels found three cosmetic rendering defects:

- Attribution cumulative and rolling beta/alpha charts set only
  lineStyle.color, so ECharts legend swatches fell back to the default
  palette and mismatched the rendered lines. Set itemStyle.color to the
  same color on every series (legend swatches are driven by itemStyle).
- Factor research rendered the long-short spread as a bare decimal
  (0.4168) while sibling stats used percentages; it now renders as a
  signed percent (+41.68%), and signed IC stats (IC mean, IR, spread)
  take positive/negative tones via RunCardStat.
- Options payoff x-axis used the default nice domain, extending to zero
  and leaving a blank dead zone left of the curve; pin min/max to the
  curve's data extents.

Adds ChartOptionConsistency tests asserting legend/line color parity and
the payoff axis extents; updates the factor panel test to the signed
percent format.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-17 14:48:56 +08:00
shadowinlife 564c783d4b fix: guard zero-weight Brinson divisions, contain artifact symbols, unify chart lifecycle
Post-review hardening for the attribution panel and cross-panel audit:

- attribution: an asset class whose long/short weights net to zero
  (market-neutral pair books) divided by zero and 500-ed the whole
  endpoint; it now degrades to the invested/cash fallback. Also guard
  benchmark_equity starting at a non-positive value, and fix the
  misleading skip note when ohlcv artifacts are missing.
- attribution: symbols from positions.csv headers / config codes are
  interpolated into ohlcv_<symbol>.csv paths; reject path separators,
  .. sequences and NUL bytes so artifact-derived names cannot escape
  the artifacts directory.
- positions/sectors: reject a symlinked positions.csv (defense in
  depth with the artifacts-dir and cache-file guards) and write the
  sector_map.json cache atomically via temp file + os.replace so
  concurrent refreshes cannot tear it.
- frontend: migrate the three attribution charts to the shared
  useChartLifecycle hook introduced by this series, and escapeHtml the
  CandlestickChart trade-marker tooltip fields (trades.csv side/price/
  qty/reason), closing the last artifact-derived tooltip sink.

Regression tests: market-neutral pair degrades instead of crashing,
zero-start benchmark_equity skips Brinson with a note, traversal-shaped
symbols never reach the path join, and a symlinked positions.csv is
treated as no positions artifact. All four fail on the pre-fix code.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-17 10:02:53 +08:00
shadowinlife 7ba36fcb4b fix: count sector lookup budget by A-shares and reject symlinked cache file
The 200-lookup cap gated on the symbol's index in the full positions
list, so a mixed book with 200+ non-A-share names in front pushed every
A-share past the cap and resolved zero industries while the budget sat
unused. Gate on the A-share lookup count instead, matching the
documented contract (at most 200 network lookups, excess degrade).

The symlink guard covered the artifacts directory but not the
sector_map.json cache file inside it, so a planted cache symlink was
still followed by the cache read and rewrite. Reject a symlinked cache
file the same way as a symlinked artifacts dir.

Regression tests: mixed book resolves A-shares after a 205-symbol
non-A-share prefix, the cap still bounds mixed books at exactly 200
lookups, and a symlinked cache file returns the no-positions note
without touching the symlink target.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-17 10:02:51 +08:00
shadowinlife 9da94eef2a feat: add attribution panel and apply cross-panel audit fixes
Second-round changes following a five-agent post-implementation audit,
extending this PR from the Positions panel to a comprehensive
optimization across all five visualization panels.

Security (cross-panel):
- Add escapeHtml and apply it to every chart tooltip formatter that
  interpolates artifact-derived strings (position symbols and dates,
  factor directory names, equity timestamps). ECharts renders custom
  formatter output via innerHTML unescaped, and with the API key in
  localStorage a crafted run directory's tooltip was an escalation
  path to full API control.

Attribution panel (new, gap-analysis P0 #4):
- GET /runs/{run_id}/attribution: factor decomposition (OLS alpha/beta
  with t-statistic, rolling window, cumulative portfolio/benchmark/
  active) plus Brinson-Fachler attribution (symbol / asset_class /
  invested_cash modes), computed entirely from persisted artifacts with
  per-section degradation notes, <=500-point downsampling with the last
  point pinned, 6-decimal rounding, and threadpool offload.
- Attribution tab in Run Detail: stat cards, Brinson table, waterfall,
  rolling beta/alpha and cumulative charts; i18n in all six locales.

Fixes touching code already on main:
- GET /runs/{run_id}/factor: artifact scan offloaded via
  run_in_threadpool (was blocking the event loop with synchronous IO).
- Options payoff route: unexpected tool exceptions now return the 502
  error envelope instead of a raw 500.
- CorrelationMatrix and EquityChart tooltips XSS-hardened (affects the
  Factor Research panel and the tearsheet equity chart).
- Tearsheet equity chart data memoized; FactorResearchPanel <-> RunDetail
  circular import resolved (RunCard components extracted); chart
  lifecycle promoted to a shared useChartLifecycle hook.

Positions hardening:
- Sector cache rejects a symlinked artifacts directory.
- Industry resolution reuses one shared bounded executor instead of
  spawning a thread pool per request.

Tests: attribution API suite, symlink / shared-executor / threadpool-
offload / payoff-envelope regressions, escapeHtml unit tests,
attribution tab states, resolve-button gating. Frontend 554 passed;
backend contract suites 89 passed.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-15 21:30:57 +08:00
shadowinlife da5b0db9c0 fix: address review feedback for positions structure visualization
Resolve the short-book correctness issues raised in review:

- latestHoldingDate now detects holdings by gross exposure
  (sum |weight|) so market-neutral books render instead of showing
  the empty state.
- withCashSlice derives the residual cash slice from the same gross
  basis as the pie slices (1 - sum |weight|), omitting it when the
  book is fully committed or levered instead of fabricating cash
  from the signed sum. Card subtitles state each chart's denominator
  (gross |weight| vs net signed weight, margin / NAV).
- Sector and evolution charts use signed auto/symmetric axes instead
  of a hard-coded [0, 1] window; evolution ranks symbols by average
  absolute exposure and splits the remainder into separate long/short
  series so omitted legs never cancel.
- GET /runs/{run_id}/positions/sectors runs its blocking work in the
  FastAPI threadpool and is bounded: at most 200 network lookups per
  resolve, 4 concurrent workers, shared client 15s per-request
  timeout; the envelope reports total_symbols/symbol_limit.

Adds regression tests for market-neutral, short-only, levered,
net-short axes, non-cancelling remainder, and capped large books.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-15 19:52:41 +08:00
shadowinlife 1451135541 feat: add positions structure visualization to Run Detail
New Positions tab renders the portfolio book from the existing
positions.csv artifact: per-symbol weight pie/treemap with a date
slider, sector distribution, and a weight-evolution stacked area
chart. Weights arrive as filled fractions per date; the pie shows
gross composition (short legs rendered as labelled slices, uninvested
remainder as a cash slice) while the sector bars show net exposure.

Sector distribution starts from an offline asset-class grouping and
upgrades to Eastmoney Shenwan industries through a new read-only
GET /runs/{run_id}/positions/sectors endpoint, resolved on demand,
cached in artifacts/sector_map.json, and degraded per-symbol so one
failed lookup never aborts the batch. Non-A-share symbols never reach
Eastmoney.

Registers PieChart/TreemapChart in the shared ECharts bundle, adds
the positions i18n keys to all six locales, and covers the transforms
and the tab with vitest plus the endpoint with pytest.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-15 16:58:56 +08:00
shadowinlife b4f7e98341 feat: factor research panel in Run Detail — IC series, IC stats, quantile equity, IC correlation
Close the P0 gap where factor_analysis artifacts (ic_series.csv / ic_summary.json /
group_equity.csv) were computed but never rendered. Run Detail gains a Factor
Research tab, gated on a new has_factor_artifacts flag, that lazily fetches
GET /runs/{run_id}/factor: a recursive scan of the run's artifacts directory
for factor bundles (one directory per factor, standardized via the
factor-research skill), serving IC series, IC summary stats, quantile group
equity curves, and a pairwise IC time-series correlation matrix.

The tab renders the standard factor-research figure set: IC statistics cards,
daily IC bars with a 20-period mean line, quantile group equity curves with
the extreme quantiles emphasized, and the IC correlation heatmap reusing
CorrelationMatrix. CorrelationMatrix cell labels now pick their text color
from the interpolated RdBu cell luminance so values stay readable on
saturated cells in both themes.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-15 03:38:24 +08:00
shadowinlife cb56dd2d07 fix: keep api_server.py under the thin-assembler line budget
The options route registration pushed api_server.py to 403 lines, over
the <400 guard in test_api_server_is_thin_assembler. Normalize the
scheduled-routes banner to the file's one-line section-marker
convention to make room.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-14 11:32:53 +08:00
shadowinlife dad36b50c8 feat: add Options Lab panel with payoff diagram, spot-IV scenarios, Greeks and live chain
Backend: new /options/payoff and /options/chain HTTP routes wrapping the
existing options_payoff and get_options_chain tools (parity with MCP),
plus portfolio Greeks (delta/gamma/theta/vega/rho, multiplier-scaled)
computed from src.quantlib.options.bs_greeks.

Frontend: new Options Lab page — strategy builder with 12 presets,
expiry payoff chart with breakeven/spot markers and profit/loss fills,
spot x IV scenario heatmap, Greeks cards with plain-language
interpretations, and a live US options chain table (bid/ask spread,
volume, OI, IV, ITM/ATM highlighting). Full six-locale i18n.

Research only; does not place orders.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-14 10:49:12 +08:00
shadowinlife f07e0c3a69 Merge remote-tracking branch 'origin/main' into feat/tearsheet-monthly-returns
Signed-off-by: shadowinlife <shadowinlife@gmail.com>

# Conflicts:
#	frontend/src/i18n/locales/ar.json
#	frontend/src/i18n/locales/en.json
#	frontend/src/i18n/locales/es.json
#	frontend/src/i18n/locales/ja.json
#	frontend/src/i18n/locales/ko.json
#	frontend/src/i18n/locales/zh-CN.json
#	frontend/src/pages/RunDetail.tsx
2026-08-14 09:34:25 +08:00
shadowinlife 3970dc26e5 feat(frontend): add tearsheet tab with monthly returns heatmap and top-N drawdowns
The Run Detail page showed the equity curve and scalar metrics but none
of the tearsheet views every comparable platform (QuantConnect, pyfolio,
QuantStats) ships: a monthly returns heatmap, annual returns bars, and a
top-N drawdown breakdown with peak/trough/recovery dates.

Everything is derived client-side from artifacts_equity_csv — the full
equity.csv rows the run response already delivers — so there is no
backend or API change and no new dependency. The derivation lives in
lib/tearsheet.ts as pure functions (normalize, monthly/annual returns,
running-peak drawdown episodes) with 21 unit tests covering the
hand-computed cases, gap months, non-positive bases, unrecovered tails,
and unparseable timestamps.

The new Tearsheet tab (hidden when a run has no equity data) renders the
annotated equity curve (top-5 drawdown zones as ranked markArea regions),
the monthly heatmap with a full-year column and locale-aware up/down
colors, the annual bar chart, and the drawdown table with duration spans
and a not-recovered state. EquityChart gains one additive optional prop
(drawdownZones); existing call sites are unchanged.

Keys added to all six locales; month axis labels come from
Intl.DateTimeFormat so no per-locale month keys were needed.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-13 21:26:07 +08:00
shadowinlife b544428e41 fix(data): normalize baostock volume to board lots + consistency guard
Phase 2 of #1062. BaoStock was the single A-share source reporting
volume in single shares while tencent/eastmoney/akshare/mootdx/tushare
report board lots (1 lot = 100 shares) — a silent 100x discrepancy
whose value depended on which fallback source served the request.

- baostock_loader: divide native share volume by 100 at the loader
  boundary; fractional lots preserved (odd-lot trades are real);
  volume_units declaration now "lots" like every other A-share source
- Loader cache version 3 -> 4 so entries cached under the
  pre-normalization unit are never served again
- New empirical cross-source consistency test: the same settled
  trading day fetched from every reachable A-share loader must agree
  within 1% (network-guarded — skips when fewer than two sources are
  reachable); this is the runtime lock against future unit drift
- Pin tests updated to the post-normalization matrix

Verified end-to-end: 600519.SH 2026-07-31 — tencent 55,128 lots vs
baostock 55,127.52 lots (ratio 0.999991; the 0.48-lot gap is baostock's
exact share count including odd lots, within the 1% tolerance).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-11 15:39:52 +08:00
shadowinlife da7ae2e1ae fix(data): declare per-market volume units in market-data provenance
A-share loaders return volume in each source's native unit with no
metadata: tencent/mootdx/eastmoney/akshare/tushare report board lots
(1 lot = 100 shares) while baostock reports single shares — a silent
100x discrepancy whose value depends on which fallback source served
the request (see #1062).

Phase 1 (non-breaking; no data values change):
- Loaders may declare volume_units keyed by market; tencent/eastmoney
  are market-dependent (A-share lots vs HK shares, empirically
  verified 2026-08-11); baostock pinned to shares, the single outlier
- fetch_market_data surfaces _provenance.volume_unit per symbol from
  the serving loader ("lots" / "shares"; null when undeclared)
- get_market_data tool descriptions (agent + MCP) state the contract
  so LLM consumers read the unit instead of assuming one

Undeclared markets (crypto, futures, US via unverified sources) stay
null rather than guessed. Pin tests lock the audit matrix from #1062.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-11 15:11:57 +08:00
shadowinlife 5848544e99 fix(shadow): run multi-market backtests per settlement currency (#997)
Split Shadow backtests into CNY, HKD, and USD settlement pools, preserve source-currency headline attribution, isolate pool failures, and report per-pool metrics without FX aggregation.
2026-08-08 21:31:20 +08:00
shadowinlife 5b50f92590 fix(mcp): tolerate JSON-string list/dict arguments from MCP clients (#993)
FastMCP publishes optional list/dict parameters as ``anyOf`` schemas, and some
MCP clients (observed with Claude Desktop / Claude Code) do not surface
``anyOf`` to the model as a concrete type — the model then serializes the
argument as a JSON string (``'["us"]'``). Strict pydantic validation rejected
those strings before the tool body ran, so parameters such as
``run_shadow_backtest.markets`` could not be passed over MCP at all.

Attach a pydantic ``BeforeValidator`` (``_coerce_json_string``) to every
list/dict MCP parameter so a JSON-encoded string is decoded before type
checking. The published JSON schema is unchanged, real arrays/objects keep
working, and a non-JSON string still fails with pydantic's normal type error.
Adds a regression suite covering the schema guard and both coercion paths.

Closes #987

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-07 12:30:01 +08:00
shadowinlife 972a8983e5 fix(shadow-account): make generated signal engines pass the backtest sandbox validator (#992)
Every run_shadow_backtest call failed with "backtest failed" in all markets
(issue #985). The generated signal engine could never clear the runner's AST
sandbox validation, for two independent reasons:

1. The codegen template emitted @staticmethod decorators on _compute_rsi,
   _compute_prior_return and _conditional_entry, while the runner's
   _validate_function_def rejects every decorator. codegen's own looser
   validate_generated passed, then the runner's stricter validator rejected
   the same file with "Decorators are not allowed on function '_compute_rsi'".

2. Mined negative thresholds (e.g. prior_5d_return_min = -0.08) land in the
   top-level RULES literal, where "-0.08" parses as UnaryOp(USub, Constant).
   _is_literal_node only accepted bare constants, so the RULES assignment was
   rejected as "Executable top-level statement Assign is not allowed" — the
   same user-visible failure through a second path, hit whenever a rule carries
   a negative bound.

Fix both at the source instead of loosening the sandbox:

- Drop the three @staticmethod decorators and make the helpers instance
  methods (add self). All call sites already used self.-style calls, so
  behavior is unchanged and the validator's decorator ban stays intact.
- Teach _is_literal_node that a USub/UAdd UnaryOp over a numeric constant is
  still a compile-time literal: no name lookup, call, or attribute access runs
  at import, so the import-time-execution threat model is unaffected.

Tests:
- test_shadow_codegen_security.py: render a profile with RSI + negative
  prior-return bounds through the real runner loader
  (_load_module_from_file) and assert generate() fires entries — pins #985
  end to end.
- test_backtest_runner_security.py: signed numeric literals are accepted at
  top level, and @staticmethod/@classmethod/custom decorators are still
  rejected, pinning the unchanged security posture.

Closes #985

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-07 12:29:03 +08:00
shadowinlife a4ef999989 fix(backtest): route bare US tickers to us_equity in market detection (#994)
_MARKET_PATTERNS only matched US equities in the suffixed "XXX.US"
form, so bare tickers (AAPL, MSFT, ...) fell through to the a_share
default. The Shadow Account US candidate basket uses bare tickers, so
its backtest walked the A-share loader chain and raised
NoAvailableSourceError before reaching yfinance.

- Append a lowest-priority ^[A-Z]{1,5}$ catch-all to _MARKET_PATTERNS
  so bare US tickers reach the us_equity chain; every suffixed equity /
  futures / crypto / forex pattern above still wins first, and 6-char
  bare forex (EURUSD) / longer crypto codes keep the historical default
- Normalize the Shadow Account US basket to the suffixed convention
  (AAPL.US), in line with the china_a / hk / crypto baskets
- Add regression coverage: bare-ticker routing, priority guards,
  boundary cases, code_currency, and a basket-to-market consistency
  guard; align shadow scanner price-frame stubs with the new basket

Closes #986

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-07 12:28:59 +08:00
shadowinlife 31869da68c fix(data): repair HK fallback routing + tencent HK source (closes #998, #999) (#1000)
* fix(data): route market-data fallback by the symbol's market

get_market_data picked the fallback chain by source name alone, and
"yahoo" matched the us_equity chain first, so .HK symbols exhausted the
3-attempt budget on the US-only stooq/sina loaders and never reached
eastmoney/akshare — HK fetches failed outright where Yahoo is blocked
(#998).

- resolve the fallback chain from the symbol's market, keeping the old
  first-match lookup for sources outside that market's chain
- group fetches by (source, market) so mixed-market requests each walk
  their own chain
- hk_equity chain: akshare (Eastmoney-backed, never IP-banned) now
  precedes the Yahoo-SDK family; tushare hk_daily joins after the free
  sources (the tushare loader now declares hk_equity)
- default max_fallback_attempts 3 -> 5 so akshare is reachable within
  the budget

Backtest auto routing already walked the full per-market chain and is
unchanged. tencent HK support is tracked in #999.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

* feat(data): tencent loader HK support, head of the hk_equity chain

Tencent's ifzq.gtimg.cn endpoint serves HK daily bars through the same
fqkline API as A-shares (hk00700 symbol form), and the source has no
observed IP ban — the most reliable mainland-reachable HK source (#999).

- map NNNNN.HK to hkNNNNN (zero-padded) and parse the shared fqkline
  response; daily bars only, matching the loader's A-share contract
- declare hk_equity on the tencent loader
- lead the hk_equity chain with tencent and route .HK auto-detection
  to it, consistent with the A-share routing

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

---------

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-07 03:17:31 +08:00
shadowinlife 397c76c524 fix(memory): skip compression pipeline in run_gc dry runs (#973)
run_gc(dry_run=True) promises to log actions without modifying files:
Tier-1 archive/delete honors the flag, but the Tier-2 compression pass
ran unconditionally whenever VT_MEMORY_COMPRESSION was enabled. The
pipeline rewrites entry bodies and frontmatter in place (TF-IDF
sentence extraction, compression_level update), so an audit-type
invocation mutated exactly the entries it was inspecting.

Gate the Tier-2 pass on not dry_run and add two regression tests:
dry_run=True leaves an eligible entry byte-identical, while
dry_run=False still compresses it from raw to daily.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-08-07 03:16:50 +08:00
shadowinlifeandQoder b9507ee12b feat(memory): implement Tier 2 structural organization (#815)
* feat(memory): implement Tier 2 structural organization

Add four independent modules for memory structural organization:
- H-MEM hierarchical directory routing (hierarchy.py)
- A-MEM semantic linking via BM25 (semantic_links.py)
- Auto-compression pipeline via TF-IDF (compression.py)
- FTS5 full-text search index (search_index.py)

Integration:
- 4 feature flags in MemoryConfig (default: disabled)
- MemoryEntry extended with category + compression_level fields
- persistent.py wired with hierarchy routing, FTS5 search, link expansion
- lifecycle.py triggers compression in GC cycle
- remember_tool.py attaches related links in recall results
- FTS5 CJK bigram+unigram indexing for better Chinese recall
- Lazy auto-rebuild on first FTS5 search

Testing:
- 163 unit tests + 9 integration tests + 23 benchmarks (all passing)
- Benchmark evaluated on 200 real production memories + 50 labeled queries
- Compression retention: daily 91.4%, digest 97.1%
- Hierarchy routing: 93.3% P@5 parity with full scan

Closes #732 (Tier 2 scope)

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

Co-Authored-By: Qoder <noreply@qoder.com>
AI-Contributed/Feature: 1582/1871
AI-Contributed/UT: 2123/2159

* feat(memory): add VT_MEMORY=off|on|full business-friendly preset

Simplify memory configuration from 7 individual boolean flags to a
single business-friendly environment variable:
  - off:  all features disabled (default, backward compatible)
  - on:   Tier 1 quality + decay + garbage collection
  - full: Tier 1 + Tier 2 hierarchy + links + compression + FTS

Individual VT_MEMORY_* flags still work as explicit overrides with
higher priority than the preset baseline.

Also adds a dependency-conflict warning in lifecycle.py when
compression is enabled without GC.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/138
AI-Contributed/UT: 0/0

* docs: add Memory Tier 2 and VT_MEMORY preset to CHANGELOG

Document the new structural organization features (hierarchy, semantic
links, compression, FTS5) and the business-friendly VT_MEMORY=off|on|full
configuration preset.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/15
AI-Contributed/UT: 0/0

---------

Co-authored-by: Qoder <noreply@qoder.com>
2026-07-24 22:13:16 +08:00
shadowinlife 7adb2c6fb8 fix(mt5): convert numpy scalars to native Python types for JSON serialization
MetaTrader5 SDK copy_rates_from_pos() returns numpy structured arrays
containing numpy.int64/float64 scalars. These types are not natively
JSON-serializable, causing TypeError when trading_history responses
are serialized downstream.

Add _int_or_none() and _float_or_none() helpers in reads.py and apply
them to all numeric fields in _rate_to_dict(). Add regression tests
covering numpy, plain-dict, and None input scenarios.

Closes #774

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-22 21:25:31 +08:00
shadowinlife d92e362cd6 style(memory): apply black formatting per CONTRIBUTING.md
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 17:13:39 +08:00
shadowinlife 6f9e304987 test(memory): add retrieval quality benchmark suite
Add P@5/MRR/NDCG@5 evaluation framework for memory retrieval A/B testing.
Benchmark compares Baseline (pure token overlap) vs Treatment (BM25 + importance
decay) on a 200-entry corpus with 50 annotated queries.

The benchmark suite is NOT intended for CI — it requires corpus files
(tmp/benchmark_corpus/) that are not version-controlled. The suite
automatically skips when corpus files are absent.

Corpus is derived from anonymized production agent sessions.

Usage:
    python -m pytest agent/tests/memory/benchmarks/ -v

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 16:18:21 +08:00
shadowinlife 9ad5c9acf4 chore: remove test_hotness_decay.py (hotness decay out of scope)
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 15:22:31 +08:00
shadowinlife fc6ee69f4b fix(memory): gate features behind flags per maintainer review
- Gate find_relevant importance multiplier behind VT_MEMORY_DECAY
- Gate content deduplication behind VT_MEMORY_QUALITY
- Remove compute_hotness / VT_MEMORY_HOTNESS_DECAY (out of scope)
- Remove hotness_decay test file
- Update tests to enable required feature flags

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 15:17:29 +08:00
shadowinlife b2953a86bb chore: remove benchmark suite from Phase 1 contribution
Evaluation framework (benchmarks, eval tests) moved to separate
contribution scope. Community discussion will determine whether
these are wanted as part of the memory lifecycle work.

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 15:01:46 +08:00
shadowinlife a8bf593d94 test(memory): skip benchmark suite when corpus files are absent
Benchmark corpus lives outside version control (tmp/benchmark_corpus/).
CI runners do not have these files. Skip the entire benchmark module at
collection time rather than failing individual tests.

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 14:58:11 +08:00
shadowinlife 8033844364 fix(memory): address important-level code review findings
- Move _HOTNESS_HALF_LIFE_DAYS and _HOTNESS_DECAY_RATE to module-level constants
- Move datetime import to module level in persistent.py
- Remove capacity gate from run_gc (caller decides when to trigger)
- Log warnings when _update_frontmatter_field cannot find frontmatter delimiters

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 14:46:38 +08:00
shadowinlife c79d9a3e56 fix(memory): address code review findings for Phase 1
- Handle add() returning None (dedup blocked) in remember_tool.py
- Call track_access() in _recall() to enable importance decay
- Periodically clean up expired entries from _recent_hashes
- Remove duplicated is_hotness_decay_enabled() from lifecycle.py
- Use atomic write-then-rename in _update_frontmatter_field
- Protect _EVENT_DELTAS with MappingProxyType

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 14:36:21 +08:00
shadowinlife 4cbb9c3243 chore: remove bench_report.json and revert .gitignore changes
These files are not part of the community contribution scope.
Benchmark results are kept locally for validation only.

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 14:15:50 +08:00
shadowinlife 6ffa63be8d feat(memory): add content dedup, hotness decay and P@5 benchmark
- Implement SHA-256 content-hash deduplication with 30s sliding window
- Add compute_hotness() with sigmoid frequency × exponential recency decay
- Register VT_MEMORY_HOTNESS_DECAY feature flag (default off)
- Add P@5/MRR/NDCG@5 evaluation framework with A/B comparison
- Benchmark shows +12.9% P@5 improvement with lifecycle-aware retrieval
- 35 new tests (17 dedup + 8 hotness + 10 benchmark)

Refs: #732
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-21 14:12:30 +08:00
shadowinlife 57087d2ac5 test(memory): add lifecycle latency benchmarks and GC accuracy tests
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-20 00:23:36 +08:00