855 Commits
Author SHA1 Message Date
Haozhe Wu 43331c3221 docs(readme): v0.1.12 contributor credits across all 5 READMEs
Add the v0.1.12 cycle cohort (28 contributors) and demote the v0.1.11 list
into a collapsible block, in en/ja/ko/zh/ar. en/ja/ko keep English
descriptions; zh/ar translated.
2026-07-22 21:13:25 +08:00
Haozhe Wu 8cb536fa61 chore(release): 0.1.12 — version bump + CHANGELOG
Bump version 0.1.11 → 0.1.12 across pyproject, frontend package(+lock),
i18n app.version, SKILL.md, and Dockerfile OCI label. CHANGELOG [0.1.12]
covers the 0.1.11→0.1.12 window (correlation regime timeline, 3 new LLM
providers, MT5 connector, SDM skill, USD-M perps, ~90 correctness fixes,
and the #758 zai streaming/base_url fix).
2026-07-22 13:14:11 +08:00
Haozhe Wu a066e5545e fix(providers): resolve zai streaming + base_url resolution (#758)
Three fixes for the native zai provider on glm-5.1:

- get_llm_credentials now falls back to the provider catalog's
  default_base_url when no *_BASE_URL is set, so CLI/manual-.env users
  reach the canonical endpoint (Z.ai /api/coding/paas/v4) instead of
  silently defaulting to api.openai.com. Web Settings already did this;
  the backend credential path did not. Benefits all 22 providers.
- stream_chat falls back to a non-streaming invoke when a provider
  streams zero chunks ('No generation chunks were returned'), instead of
  raising ProviderStreamError. Scoped to the empty-stream case; genuine
  provider errors still raise.
- ProviderStreamError appends a base-URL hint when the endpoint returns
  an HTML error page (a common misconfiguration signal).

Fixes #758.
2026-07-22 12:59:07 +08:00
Haozhe Wu f2c7256d56 Merge PR #756: feat(correlation): regime timeline endpoint and UI (edge density + hysteresis) 2026-07-22 12:51:20 +08:00
Haozhe Wu 93fa241fc8 Merge PR #757: feat(data): decouple maintenance-bracket acquisition from the historical fetch path 2026-07-22 12:51:19 +08:00
Haozhe Wu f06d399388 Merge PR #770: fix(journal): normalize Excel float-stringified A-share codes 2026-07-22 12:51:08 +08:00
Haozhe Wu 5eb45502c7 Merge PR #769: fix(tools): accept unicode dashes in PDF page ranges 2026-07-22 12:51:07 +08:00
Haozhe Wu d538bfa369 Merge PR #768: fix(api): treat export KEY= as KEY in dotenv settings helpers 2026-07-22 12:51:04 +08:00
Haozhe Wu 3f0e6b8c94 Merge PR #767: fix(factors): keep single-day IC std finite in factor analysis JSON 2026-07-22 12:51:03 +08:00
Haozhe Wu f1697117c1 Merge PR #766: fix(backtest): print scalar metrics as strict JSON 2026-07-22 12:51:02 +08:00
Haozhe Wu 566eb86ba3 Merge PR #765: fix(metrics): keep Sharpe/Sortino finite when equity hits zero mid-path 2026-07-22 12:51:02 +08:00
Haozhe Wu be0d0a7c2c Merge PR #764: fix(pattern): emit strict JSON when trend_slope mean is undefined 2026-07-22 12:51:01 +08:00
Haozhe Wu 6fffd52541 Merge PR #763: fix(session): skip schema-bad messages.jsonl lines when reading 2026-07-22 12:50:59 +08:00
Haozhe Wu e8241034e9 Merge PR #762: fix(session): skip corrupt session.json when listing sessions 2026-07-22 12:50:58 +08:00
Haozhe Wu c350cddf8c Merge PR #761: fix(loaders): keep yahoo 1m as minute bars, not monthly 2026-07-22 12:50:57 +08:00
santhreal 2b01bf46d0 fix(journal): normalize float-stringified A-share codes before qualifying
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:19:15 -07:00
santhreal 91c7cb23da fix(journal): normalize Excel float-stringified A-share codes
Numeric CSV/Excel cells often stringify as 600519.0; the '.' made
_qualify_a_share treat them as already-qualified and skip .SH/.SZ.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:18:35 -07:00
santhreal ab3e8076d6 fix(tools): accept unicode dashes in PDF page ranges
Word/LLM paste often uses en/em/minus dashes in ranges like 1–10;
those were silently dropped because only ASCII '-' was recognized.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:18:22 -07:00
santhreal c0f794c2c9 fix(api): treat export KEY= as KEY in dotenv settings helpers
Shell-style export KEY=value lines were read as a literal key named export KEY, so Settings missed configured secrets and upserts appended a duplicate KEY= instead of updating the export line. Strip the export prefix on read/write like python-dotenv.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:17:28 -07:00
santhreal e76e1dc6fb fix(factors): keep single-day IC std finite in factor analysis JSON
One IC observation makes Series.std() (ddof=1) NaN, which leaked into ic_summary.json and the tool envelope. Treat single-sample std as 0.0 (same guard as metrics vol) and dump with allow_nan=False.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:17:25 -07:00
santhreal d88a591272 fix(backtest): print scalar metrics as strict JSON
Explosive equity paths can set annual_return to +inf. The engine stdout
dump used default allow_nan=True and emitted bare Infinity tokens.
Sanitize non-finite scalars to null and dump with allow_nan=False,
matching options and run_card.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:16:54 -07:00
santhreal 3074b684e1 fix(metrics): keep Sharpe/Sortino finite when equity hits zero mid-path
A 100 → 0 → 50 equity path yields non-finite pct_change values that
poisoned Sharpe/Sortino/IR with NaN/inf. Skip those ratios (report 0)
when bar returns are non-finite, matching the options metrics path.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:16:52 -07:00
santhreal bd610d0b67 fix(pattern): emit strict JSON when trend_slope mean is undefined
Halt-style NaN gaps can leave every polyfit window empty, so mean_slope
became NaN and leaked a bare NaN token into the tool JSON. Treat that as
0.0 and serialize with allow_nan=False, matching options_pricing.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:16:48 -07:00
santhreal afd0046cbd fix(session): skip schema-bad messages.jsonl lines when reading
get_messages already skipped JSONDecodeError lines but still raised on
valid JSON that was not a Message mapping (null, extra keys). Skip those
too so later good lines remain readable.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:13:47 -07:00
santhreal 0b9df59cfe fix(session): skip corrupt session.json when listing sessions
One unreadable or schema-invalid session.json raised during list_sessions
and aborted the whole listing; skip that entry and keep siblings.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:13:46 -07:00
santhreal 13f71e5a6a fix(loaders): keep yahoo 1m as minute bars, not monthly
_to_yahoo_interval uppercased intervals before the map lookup, so project
interval 1m collided with monthly 1M and was sent to Yahoo as 1mo.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:13:46 -07:00
zygora 5f9144946d feat(data): decouple maintenance-bracket acquisition from the historical fetch path
binanceusdm.fetch_leverage_tiers() routes to Binance's signed
GET /fapi/v1/leverageBracket USER_DATA endpoint and requires an API key,
even though the response carries no account-specific data. #716 called it
unconditionally for every -PERP fetch, so on merged main today any
perpetual fetch hard-crashes for callers without a Binance key.

- Remove the unconditional fetch_leverage_tiers() call (and the now-unused
  _fetch_maintenance_brackets live-fetch method entirely). Execution/mark
  OHLC and funding settlements go back to zero-credential, matching the
  rest of the stack.
- Introduce a caller-supplied, versioned bracket artifact as the only way
  to attach maintenance_brackets/maintenance_bracket_version: schema
  version, symbol, provenance timestamp, per-tier fields plus an optional
  notional_coefficient, and a content hash validated against the
  canonical bracket JSON. Never fetched live.
- fetch(..., bracket_artifacts={code: artifact}) attaches brackets only
  when a valid matching artifact is supplied for that code.
- fetch(..., require_brackets=True) makes a strict margin-risk consumer
  fail closed with an actionable error before any network call when no
  valid artifact is supplied for a -PERP code.

Per #462 / #716 follow-up discussion. No real bracket snapshot is
committed; test fixtures are synthetic. No Binance credentials are added
to the backtest path.

Signed-off-by: zygora <122336305+honginp@users.noreply.github.com>
2026-07-21 15:50:38 +08:00
Haozhe Wu a44e1be357 docs: add 2026-07-21 news across all 5 READMEs 2026-07-21 14:58:18 +08:00
ebujinovch 555dcec560 feat(correlation): regime timeline endpoint and UI (edge density + hysteresis)
Implements the proposal in #719: Mode 1 of the correlation-regime skill
(#557) wired into the /correlation surface. New GET /correlation/regime
(same auth, shared rate-limit budget) returns edge density, causally
smoothed density, hysteresis FUSED states and episodes over the requested
window; the Correlation tab gains an opt-in timeline strip above the
matrix. The loader fallback-chain fetch and close-series extraction are
extracted from compute_correlation_matrix for reuse (pure code motion);
existing /correlation behavior is unchanged.

Signed-off-by: ebujinovch <ch.ebujinov@gmail.com>
2026-07-21 08:22:28 +02:00
Haozhe Wu 251e7e0987 Merge PR #689: complete partial market-data results through fallback
Rebased onto main and deduped — reuse the existing _restore_original_codes helper instead of the PR's parallel _map_result_to_requested; apply the same missing-symbol completion to both _fetch_auto and the explicit-source path. Fixes #681.
2026-07-21 13:42:04 +08:00
xkam7ar 89ac9eb896 fix(data): fall back for missing symbols
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-21 13:38:21 +08:00
Haozhe Wu bcf318d676 Merge pull request #644 from tyj147454413-cmd/codex/pr543-okx-loader-robustness
fix(okx): bound history fetches and handle rate limits
2026-07-21 12:26:29 +08:00
Haozhe Wu cab87bd40a Merge pull request #731 from octo-patch/octopatch/minimax-cn-endpoint
feat: expose MiniMax regional API endpoints
2026-07-21 12:26:23 +08:00
Haozhe Wu 2fe20faaf9 Merge pull request #748 from Robin1987China/shadow-daily-entry-hour-gate
fix(shadow): skip the entry-hour gate on daily bars
2026-07-21 12:26:18 +08:00
Haozhe Wu 857ceac313 Merge pull request #749 from Robin1987China/fix-journal-parsers-empty-code
fix(tools): skip blank/NaN symbol rows in futu and generic journal parsers
2026-07-21 12:26:12 +08:00
Haozhe Wu 171185ffd5 Merge pull request #750 from Robin1987China/fix-mcp-host-guard-ipv6
fix(mcp): accept IPv6 and case-variant hosts in the network guard
2026-07-21 12:26:07 +08:00
Haozhe Wu 2867365845 Merge pull request #725 from HKUDS/dependabot/pip/websockets-16.1.1
build(deps): bump websockets from 15.0.1 to 16.1.1
2026-07-21 12:17:13 +08:00
Haozhe Wu 9216e4baa3 Merge pull request #753 from HKUDS/dependabot/pip/pip-minor-patch-5ba2c1a785
build(deps): bump the pip-minor-patch group across 1 directory with 20 updates
2026-07-21 12:16:35 +08:00
Haozhe Wu f3069b2f03 Merge pull request #754 from HKUDS/dependabot/npm_and_yarn/frontend/npm-minor-patch-0b632d9036
build(deps): bump the npm-minor-patch group across 1 directory with 6 updates
2026-07-21 12:15:30 +08:00
dependabot[bot] 5066f99f4d build(deps): bump the npm-minor-patch group across 1 directory with 6 updates
Bumps the npm-minor-patch group with 6 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@fontsource/inter](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/inter) | `5.2.8` | `5.3.0` |
| [@fontsource/jetbrains-mono](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/jetbrains-mono) | `5.2.8` | `5.3.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.24.0` | `1.25.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.9` | `17.0.10` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.2` | `10.5.4` |
| [postcss](https://github.com/postcss/postcss) | `8.5.18` | `8.5.20` |



Updates `@fontsource/inter` from 5.2.8 to 5.3.0
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/inter)

Updates `@fontsource/jetbrains-mono` from 5.2.8 to 5.3.0
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/jetbrains-mono)

Updates `lucide-react` from 1.24.0 to 1.25.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.25.0/packages/lucide-react)

Updates `react-i18next` from 17.0.9 to 17.0.10
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.9...v17.0.10)

Updates `autoprefixer` from 10.5.2 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.2...10.5.4)

Updates `postcss` from 8.5.18 to 8.5.20
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.18...8.5.20)

---
updated-dependencies:
- dependency-name: "@fontsource/inter"
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@fontsource/jetbrains-mono"
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 13:13:25 +00:00
Haozhe Wu 585b8f69cd fix(frontend): render LaTeX math in chat via remark-math + KaTeX
LLM answers with \(...\)/\[...\] or $$ formulas rendered as plain text --
the markdown pipeline had no math plugin. Normalize LLM delimiters to $$
outside code segments (streaming-safe for unclosed fences), parse with
remark-math (single-$ disabled so dollar amounts like "$150 to $120"
never become formulas), render with rehype-katex.
2026-07-20 20:56:55 +08:00
Haozhe Wu 7abc2a8a03 docs: add 2026-07-20 news across all 5 READMEs
Providers (Anthropic native/SiliconFlow/iFlytek), MetaTrader 5 connector + mt5
source, llm-vision OCR, 80x signal vectorization, USD-M funding data, reliability
consolidation (#584, 13 issues), options partial-close fix, and more. Keeps 3
entries visible; 2026-07-17 moved into the earlier-news details.
2026-07-20 19:07:11 +08:00
dependabot[bot] 76e8f518f0 build(deps): bump the pip-minor-patch group across 1 directory with 20 updates
Bumps the pip-minor-patch group with 20 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [langchain](https://github.com/langchain-ai/langchain) | `1.3.13` | `1.3.14` |
| [pypdfium2](https://github.com/pypdfium2-team/pypdfium2) | `5.11.0` | `5.12.1` |
| [ccxt](https://github.com/ccxt/ccxt) | `4.5.64` | `4.5.67` |
| [fastapi](https://github.com/fastapi/fastapi) | `0.139.0` | `0.139.2` |
| [matplotlib](https://github.com/matplotlib/matplotlib) | `3.11.0` | `3.11.1` |
| [caio](https://github.com/mosquito/caio) | `0.9.25` | `0.10.2` |
| [cyclopts](https://github.com/BrianPugh/cyclopts) | `4.21.0` | `4.22.0` |
| [jaraco-functools](https://github.com/jaraco/jaraco.functools) | `4.5.0` | `4.6.0` |
| [joserfc](https://github.com/authlib/joserfc) | `1.7.3` | `1.7.4` |
| [langsmith](https://github.com/langchain-ai/langsmith-sdk) | `0.10.2` | `0.10.6` |
| [narwhals](https://github.com/narwhals-dev/narwhals) | `2.23.0` | `2.24.0` |
| [openai](https://github.com/openai/openai-python) | `2.45.0` | `2.46.0` |
| [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) | `1.43.0` | `1.44.0` |
| [peewee](https://github.com/coleifer/peewee) | `4.2.1` | `4.2.6` |
| [platformdirs](https://github.com/tox-dev/platformdirs) | `4.10.0` | `4.10.1` |
| [pydantic-core](https://github.com/pydantic/pydantic) | `2.46.4` | `2.47.0` |
| [regex](https://github.com/mrabarnett/mrab-regex) | `2026.7.10` | `2026.7.19` |
| [soupsieve](https://github.com/facelessuser/soupsieve) | `2.8.4` | `2.9` |
| [tqdm](https://github.com/tqdm/tqdm) | `4.68.4` | `4.69.0` |
| [yarl](https://github.com/aio-libs/yarl) | `1.24.2` | `1.24.5` |



Updates `langchain` from 1.3.13 to 1.3.14
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain==1.3.13...langchain==1.3.14)

Updates `pypdfium2` from 5.11.0 to 5.12.1
- [Release notes](https://github.com/pypdfium2-team/pypdfium2/releases)
- [Commits](https://github.com/pypdfium2-team/pypdfium2/compare/5.11.0...5.12.1)

Updates `ccxt` from 4.5.64 to 4.5.67
- [Release notes](https://github.com/ccxt/ccxt/releases)
- [Commits](https://github.com/ccxt/ccxt/compare/v4.5.64...v4.5.67)

Updates `fastapi` from 0.139.0 to 0.139.2
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.139.0...0.139.2)

Updates `matplotlib` from 3.11.0 to 3.11.1
- [Release notes](https://github.com/matplotlib/matplotlib/releases)
- [Commits](https://github.com/matplotlib/matplotlib/compare/v3.11.0...v3.11.1)

Updates `caio` from 0.9.25 to 0.10.2
- [Release notes](https://github.com/mosquito/caio/releases)
- [Commits](https://github.com/mosquito/caio/compare/0.9.25...0.10.2)

Updates `cyclopts` from 4.21.0 to 4.22.0
- [Release notes](https://github.com/BrianPugh/cyclopts/releases)
- [Commits](https://github.com/BrianPugh/cyclopts/compare/v4.21.0...v4.22.0)

Updates `jaraco-functools` from 4.5.0 to 4.6.0
- [Release notes](https://github.com/jaraco/jaraco.functools/releases)
- [Changelog](https://github.com/jaraco/jaraco.functools/blob/main/NEWS.rst)
- [Commits](https://github.com/jaraco/jaraco.functools/compare/v4.5.0...v4.6.0)

Updates `joserfc` from 1.7.3 to 1.7.4
- [Release notes](https://github.com/authlib/joserfc/releases)
- [Changelog](https://github.com/authlib/joserfc/blob/main/docs/changelog.rst)
- [Commits](https://github.com/authlib/joserfc/compare/1.7.3...1.7.4)

Updates `langsmith` from 0.10.2 to 0.10.6
- [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases)
- [Commits](https://github.com/langchain-ai/langsmith-sdk/compare/v0.10.2...v0.10.6)

Updates `narwhals` from 2.23.0 to 2.24.0
- [Release notes](https://github.com/narwhals-dev/narwhals/releases)
- [Commits](https://github.com/narwhals-dev/narwhals/compare/v2.23.0...v2.24.0)

Updates `openai` from 2.45.0 to 2.46.0
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.45.0...v2.46.0)

Updates `opentelemetry-api` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.43.0...v1.44.0)

Updates `peewee` from 4.2.1 to 4.2.6
- [Release notes](https://github.com/coleifer/peewee/releases)
- [Changelog](https://github.com/coleifer/peewee/blob/master/CHANGELOG.md)
- [Commits](https://github.com/coleifer/peewee/compare/4.2.1...4.2.6)

Updates `platformdirs` from 4.10.0 to 4.10.1
- [Release notes](https://github.com/tox-dev/platformdirs/releases)
- [Changelog](https://github.com/tox-dev/platformdirs/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/platformdirs/compare/4.10.0...4.10.1)

Updates `pydantic-core` from 2.46.4 to 2.47.0
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/commits)

Updates `regex` from 2026.7.10 to 2026.7.19
- [Changelog](https://github.com/mrabarnett/mrab-regex/blob/hg/changelog.txt)
- [Commits](https://github.com/mrabarnett/mrab-regex/compare/2026.7.10...2026.7.19)

Updates `soupsieve` from 2.8.4 to 2.9
- [Release notes](https://github.com/facelessuser/soupsieve/releases)
- [Commits](https://github.com/facelessuser/soupsieve/compare/2.8.4...2.9)

Updates `tqdm` from 4.68.4 to 4.69.0
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.68.4...v4.69.0)

Updates `yarl` from 1.24.2 to 1.24.5
- [Release notes](https://github.com/aio-libs/yarl/releases)
- [Changelog](https://github.com/aio-libs/yarl/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/yarl/compare/v1.24.2...v1.24.5)

---
updated-dependencies:
- dependency-name: langchain
  dependency-version: 1.3.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: pypdfium2
  dependency-version: 5.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: ccxt
  dependency-version: 4.5.67
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: fastapi
  dependency-version: 0.139.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: matplotlib
  dependency-version: 3.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: caio
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: cyclopts
  dependency-version: 4.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: jaraco-functools
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: joserfc
  dependency-version: 1.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: langsmith
  dependency-version: 0.10.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: narwhals
  dependency-version: 2.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: openai
  dependency-version: 2.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: opentelemetry-api
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: peewee
  dependency-version: 4.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: platformdirs
  dependency-version: 4.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: pydantic-core
  dependency-version: 2.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: regex
  dependency-version: 2026.7.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
- dependency-name: soupsieve
  dependency-version: '2.9'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: tqdm
  dependency-version: 4.69.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: yarl
  dependency-version: 1.24.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 11:03:53 +00:00
Haozhe Wu aaa1061b78 fix(options): honor partial-close quantity instead of flattening the lot
The options engine close branch computed cash/PnL from the full matched lot and
removed the whole position, ignoring the leg's requested qty — any partial close
wiped the entire lot with full PnL. Now an explicit leg qty closes only that many
contracts (clamped to the open size), cash/PnL scale to the closed fraction, and
the remainder stays open as a reduced-qty position; a close leg with no qty still
closes the whole lot (legacy). Closes #577.
2026-07-20 19:00:53 +08:00
Haozhe Wu c1f9f96a60 Merge PR #548: feat(ocr): pluggable engine architecture + LLM vision OCR + configurable threshold
Self-fix on merge: the qwen-vl → llm-vision deprecation warning now calls out the
credential-source change (DASHSCOPE_API_KEY → configured LANGCHAIN_PROVIDER) so
existing DashScope users aren't surprised. Generalizes the shipped qwen-vl engine
into a provider-agnostic llm-vision engine + entry-point plugin discovery + OCR
threshold + quality metrics. Closes #547.
2026-07-20 18:56:15 +08:00
Haozhe Wu 2400bbcbaa Merge PR #695: feat(providers): add native Anthropic Messages API support
Self-fix on merge: moved langchain-anthropic from core dependencies to an
[anthropic] optional extra (like [deepseek]) — _build_anthropic lazy-imports it
and raises an actionable install hint, so the base install stays lean. Resolved
llm.py conflicts against #563's centralized get_llm_credentials + the anthropic
native adapter-mode branch. Closes #694.
2026-07-20 18:55:21 +08:00
dependabot[bot] 8d27badecd build(deps): bump websockets from 15.0.1 to 16.1.1
Bumps [websockets](https://github.com/python-websockets/websockets) from 15.0.1 to 16.1.1.
- [Release notes](https://github.com/python-websockets/websockets/releases)
- [Commits](https://github.com/python-websockets/websockets/compare/15.0.1...16.1.1)

---
updated-dependencies:
- dependency-name: websockets
  dependency-version: 16.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 10:54:10 +00:00
Haozhe Wu 0c86356a77 docs(skill): market-data source count 22 → 23 (mt5 loader, #481) 2026-07-20 18:51:50 +08:00
Haozhe Wu 11b4f62340 Merge PR #481: feat: MetaTrader 5 (Exness) broker connector + mt5 market-data source
Red-lines verified PASS (mandate gate, structural trade_mode discriminator, no MCP
order exposure, sandbox-barred loader). Resolved additive conflicts: registry (mt5
loader alongside longbridge), pyproject (mt5 extra), CHANGELOG (kept both entries);
README/SKILL counts reconciled in the news pass. market_data =F/=X (yahoo) and mt5
forex routing coexist.
2026-07-20 18:48:32 +08:00
Haozhe Wu 5278469d0d Merge PR #584: Fix reliability across packaging, web, scheduler, swarm, and CLI
Consolidation resolving #603 #605 #611 #613 #617 #639 (named) + folded #583 #585 #587 #589 #591 #595 #599.
Kept both dev-extra and slack-version packaging tests at merge.
2026-07-20 18:45:20 +08:00
Haozhe Wu 949ccad594 Merge PR #726: fix: Robinhood connector account_number wiring + remote-MCP display shape
Reconciled the connector renderers with #735 (Longbridge schema-tolerant balances):
_print_connector_account now handles balances (broker_sdk) → remote-MCP nested
(Robinhood) → IBKR summary; positions use both remote-MCP shape and _first_present aliasing.
2026-07-20 18:44:45 +08:00
Haozhe Wu b7c62c68a1 Merge PR #716: feat(data): add historical USD-M funding settlements + maintenance brackets
Stray docs/superpowers/pr-drafts/pr1b-funding-brackets.md dropped at merge (repo never carries docs/ from contributor PRs).
2026-07-20 18:42:41 +08:00
Haozhe Wu c2151a2269 fix(backtest): make vectorized _align writable (follow-up #698)
The vectorized _align did in-place np.nan_to_num/clip on arrays taken from
pandas via .values/astype(copy=False), which are read-only views under pandas
copy-on-write, raising 'ValueError: assignment destination is read-only' on
current main. Force a writable copy for the per-symbol signal array and use the
copy-returning nan_to_num for the position matrix. Vectorization stays numerically
equivalent to the scalar path (assert_frame_equal tests pass).
2026-07-20 18:39:21 +08:00
Haozhe Wu 099948ec01 Merge PR #698: perf: Signal alignment vectorization — 80x speedup 2026-07-20 18:37:45 +08:00
Haozhe Wu 8c627b00f7 test(cli): update provider-menu indices after SiliconFlow(#565)+Spark(#537) inserts
Both PRs insert providers ahead of ollama/openai-codex in the init menu; the
combined order puts ollama at 17 and openai-codex at 18.
2026-07-20 18:33:20 +08:00
Haozhe Wu add7b3f364 Merge PR #563: refactor(providers): centralize credentials + fix gateway misroute (closes #549, #553)
# Conflicts:
#	agent/src/providers/capabilities.py
#	agent/tests/test_llm.py
2026-07-20 18:32:08 +08:00
Haozhe Wu 7ba3bf2f5d Merge PR #537: feat(providers): add iFlytek Spark as an OpenAI-compatible provider 2026-07-20 18:31:33 +08:00
Haozhe Wu 3b9dba55eb Merge PR #565: feat: add SiliconFlow CN and Global providers 2026-07-20 18:31:33 +08:00
Haozhe Wu e796a34dd6 Merge PR #663: fix(codex): classify stream HTTP failures 2026-07-20 18:31:33 +08:00
Haozhe Wu 0802a535a6 Merge PR #641: Honor cancellation before the first AgentLoop iteration (closes #638) 2026-07-20 18:31:33 +08:00
Haozhe Wu 205c940c99 Merge PR #704: perf(swarm): cache MCP tool discovery specs to avoid redundant RPC calls 2026-07-20 18:31:33 +08:00
Haozhe Wu 015e1f7e3e Merge PR #717: fix(frontend): prevent insertBefore DOM race during streaming output 2026-07-20 18:31:33 +08:00
Haozhe Wu 7eff24ddf0 test(api): preserve #738 commented-key upsert coverage after folding into #742 2026-07-20 17:49:20 +08:00
Haozhe Wu 8b4dcaf313 fix(data): resolve Yahoo futures (=F) and forex (=X) suffix tickers
get_market_data could not resolve tickers like GC=F or EURUSD=X: detect_source
matched no pattern and fell to the 'tushare' default, routing them to
China-market loaders that cannot serve them; and the yahoo direct loader's
_is_supported guard rejected any non-.US/.HK/.NS/.BO symbol even though Yahoo's
public chart endpoint serves =F/=X verbatim.

Add =F/=X source patterns (route to yahoo) and accept =F/=X in the yahoo loader.

Closes #718
2026-07-20 17:46:14 +08:00
Haozhe Wu 2b31effe13 fix(cli): make connector positions/account renderers schema-tolerant
The shared 'connector positions'/'connector account' CLI renderers assumed the
IBKR result shape (position/avg_cost/sec_type/summary). broker_sdk connectors
such as Longbridge emit quantity/cost_price/market/balances, so every
non-matching key rendered as an empty cell.

Read key aliases (position|quantity, avg_cost|cost_price, sec_type|market) via a
None-aware _first_present helper (keeps a real zero qty), and render a balances
table when a connector returns 'balances' instead of IBKR 'summary' rows.

Closes #735
2026-07-20 17:46:14 +08:00
Haozhe Wu 57034a0fbc Merge PR #743: fix(api): keep hash characters inside quoted dotenv values
# Conflicts:
#	agent/tests/test_api_infrastructure.py
2026-07-20 17:36:16 +08:00
Haozhe Wu 7558b8b86a Merge PR #742: fix(api): upsert last duplicate active dotenv key (also skips commented keys, folding in #738) 2026-07-20 17:35:24 +08:00
Haozhe Wu 707ceb1c2b Merge PR #744: fix(options): treat non-positive spot/strike as intrinsic in BS helpers 2026-07-20 17:35:16 +08:00
Haozhe Wu 45b21ff516 Merge PR #741: fix(pattern): reject window=1 for trend_line_slope 2026-07-20 17:35:16 +08:00
Haozhe Wu 2c679b2a23 Merge PR #740: fix(options): catch OverflowError in options metrics annualization 2026-07-20 17:35:16 +08:00
Haozhe Wu dd5494f92c Merge PR #739: fix(metrics): avoid OverflowError annualizing explosive equity paths 2026-07-20 17:35:16 +08:00
Haozhe Wu f72f3ff822 Merge PR #737: fix(ui): skip non-positive MA periods in indicator overlays 2026-07-20 17:35:16 +08:00
Haozhe Wu 2d35e8000a Merge PR #736: fix(channels): avoid hang splitting telegram fenced markdown chunks 2026-07-20 17:35:16 +08:00
Haozhe Wu 3bd64c3b1f Merge PR #734: fix(composite): fall back to first available sub-engine for unknown symbols 2026-07-20 17:35:16 +08:00
Robin1987China 63dee9a081 fix(mcp): accept IPv6 and case-variant hosts in the network guard
The GHSA-p3c9 guard wrapped network MCP transports in Starlette's
TrustedHostMiddleware, whose plain split(":") Host parsing mangles
bracketed IPv6 ([::1]:8900 -> "[") and matches case-sensitively. An
MCP server bound to --host ::1 rejected every request with 400, and no
VIBE_TRADING_MCP_ALLOWED_HOSTS entry ([::1] or ::1) could fix it — the
only way out was '*', disabling the protection entirely. LOCALHOST was
rejected the same way, while the Origin guard (urlparse().hostname)
already lowercased, so the two layers disagreed.

Replace the middleware with a repo-owned _HostGuardMiddleware that
normalizes the header (brackets/port stripped, bare IPv6 kept whole,
lowercased) before matching the allow-list, normalize configured
entries the same way, and add ::1 to the loopback default. Allow-list
semantics (exact, *. wildcard, *) and the 400 rejection are unchanged;
the Origin guard is untouched.

Refs GHSA-p3c9

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-07-20 14:41:09 +08:00
santhreal d84c00f778 fix(api): scan quoted dotenv values before stripping hash comments
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:35:01 -07:00
santhreal 1260ce4704 fix(options): clarify non-positive S/K intrinsic fallback comment
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:35:01 -07:00
santhreal 6ffdc220f4 fix(api): preserve trailing comments after closed dotenv quotes
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:24:40 -07:00
santhreal d1f83c096c fix(api): upsert last duplicate active dotenv key
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:20:47 -07:00
santhreal 3fda400b76 fix(pattern): reject window=1 for trend_line_slope
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:20:47 -07:00
santhreal be7cf96383 fix(api): unquote dotenv values before stripping hash comments
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:20:47 -07:00
santhreal b52f2edd74 fix(options): catch OverflowError in options metrics annualization
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 22:20:36 -07:00
santhreal a0546c5b19 fix(metrics): avoid OverflowError annualizing explosive equity paths
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 21:44:39 -07:00
santhreal aeff42d776 fix(options): treat non-positive spot/strike as intrinsic in BS helpers
Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 21:44:38 -07:00
santhreal a63a880c92 fix(ui): skip non-positive MA periods in indicator overlays
Planner/design artifacts can emit ma_window=0; build_indicator_series then divided by zero. Drop non-positive periods when inferring and when building series.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 20:53:14 -07:00
santhreal 497274c688 fix(channels): avoid hang splitting telegram fenced markdown chunks
Long or mid-size cuts inside an opening fence could land on the fence line and reopen the same chunk forever. Advance past the fence header and bail when reopen does not shrink the buffer.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-19 20:51:47 -07:00
Jelech 39ea99a4d9 feat(providers): support ANTHROPIC_MAX_TOKENS configuration 2026-07-20 10:51:58 +08:00
Marnie0415 1ba3565819 fix(composite): add unit tests and edge case guard for empty sub-engines
Address review feedback:
- Add pytest tests for unknown symbol handling (round_size, commission, leverage)
- Add defensive check for empty _rule_engines dict
- Verify known symbols still route to correct sub-engine

Signed-off-by: Marnie0415 <Marnie0415@users.noreply.github.com>
2026-07-20 04:05:08 +08:00
Marnie0415 4834bddaed fix(composite): fall back to first available sub-engine for unknown symbols
CompositeEngine._rule_for() defaults to 'a_share' for symbols whose market
type is not in _symbol_market. When _rule_engines does not contain an
'a_share' key (because no A-share codes were in the original request),
the method raises KeyError: 'a_share'.

Replace the hard index with .get() and fall back to the first available
sub-engine when the resolved market has no dedicated rule engine.

Signed-off-by: Marnie0415 <Marnie0415@users.noreply.github.com>
2026-07-20 03:57:35 +08:00
octo-patch 6dc76e1ad3 feat: expose MiniMax regional endpoints 2026-07-19 20:06:43 +08:00
Haozhe Wu 97e762e51c docs: add 2026-07-19 news across all 5 READMEs
Real US/HK Yahoo Finance news articles (#730), MCP factor_analysis contract
fix (#715/#635), Kimi K-series temperature=1 forcing (#701), and inverted/
degenerate-input fail-fast across split_message/PDF pages/journal filters
(#727-#729). Keeps 3 entries visible; 07-16 moves into the details block.
2026-07-19 19:50:02 +08:00
Haozhe Wu cf51bc322b ci: correct setup-node pin comment to v7.0.0 (follow-up #720)
Dependabot bumped the setup-node SHA to v7.0.0 (820762786...) but left the
pin comment reading # v6. Align the comment with the actual pinned version.
2026-07-19 19:41:57 +08:00
Haozhe Wu 668389bdea fix(tools): surface inverted journal date filter as error envelope (follow-up #729)
#729 made _apply_filter raise on inverted/degenerate date ranges (fail-fast
over silently returning an empty frame). But analyze_trade_journal called it
unwrapped, so the ValueError escaped as a raw traceback instead of the
{status: error} envelope the rest of the function uses; and the pre-existing
test_apply_filter_preserves_full_date_and_mixed_precision still asserted the
old silent-empty behavior. Wrap the call to return the error envelope and
update the stale assertion to expect the raise.
2026-07-19 19:41:57 +08:00
Haozhe Wu 1845e41122 Merge pull request #730 from yxhuang/fix/us-hk-stock-news
fix(tools): return real Yahoo news articles for US/HK stock news
2026-07-19 19:40:53 +08:00
Haozhe Wu 9d185ba357 Merge pull request #729 from santhreal/fix/journal-inverted-date-filter
fix(tools): reject inverted trade-journal date filters
2026-07-19 19:40:48 +08:00
Haozhe Wu 7b2b75fb5c Merge pull request #728 from santhreal/fix/doc-reader-inverted-pages
fix(tools): reject inverted PDF page ranges in doc_reader
2026-07-19 19:40:45 +08:00
Haozhe Wu 41ba61bcc8 Merge pull request #727 from santhreal/fix/channels-split-message-nonpositive
fix(channels): avoid hang in split_message when max_len <= 0
2026-07-19 19:40:41 +08:00
Haozhe Wu f42d1b3af0 Merge pull request #720 from HKUDS/dependabot/github_actions/actions/setup-node-7.0.0
build(deps): bump actions/setup-node from 6.4.0 to 7.0.0
2026-07-19 19:40:38 +08:00
Haozhe Wu 5c6ec6c4c3 Merge pull request #715 from Robin1987China/fix-mcp-factor-analysis-contract
fix(mcp): align factor_analysis wrapper with the registered tool contract
2026-07-19 19:40:34 +08:00
Haozhe Wu c1277e197b Merge pull request #701 from sambazhu/fix/kimi-k-series-temperature
fix(providers): force temperature=1 for the whole Kimi K-series
2026-07-19 19:40:30 +08:00
Haozhe Wu d55de48d42 fix(skills): use API-native decimal funding rate in perp-funding-basis
The annualized example used 0.01 with a '0.01%' comment, which reads as
1% (→1095% annualized), not the intended 0.01% (→10.95%). Standardize the
whole skill on the per-8h decimal convention OKX/Binance actually return
(0.0001 = 0.01%): fix the annualized example, funding_regime and
oi_funding_matrix thresholds, the cross-exchange dict, and the carry-trade
example; add an explicit units-convention note. Human-readable % tables
kept as-is.
2026-07-19 19:23:23 +08:00
Robin1987China 1af03bb312 fix(tools): skip blank/NaN symbol rows in futu and generic journal parsers
parse_tonghuashun and parse_eastmoney already drop rows with empty
securities codes, but parse_futu stringified a NaN Symbol cell into a
literal "NAN" symbol (classified as a US trade since "NAN".isalpha())
and parse_generic did the same with a "nan" symbol. Rows with
blank/NaN symbols now skip through the same _is_empty_code guard,
keeping phantom symbols out of FIFO pairing and journal stats.

Refs #714

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-07-19 14:32:41 +08:00
Robin1987China bb7a2cc00e fix(shadow): skip the entry-hour gate on daily bars
Rules mined from real journals carry an entry_hour window from actual
fill times (p10-p90, e.g. 9-11 for market-hours trading). The generated
signal engine replays on daily bars stamped at midnight, so gating on
bar_hour rejected every bar: any profile mined from real intraday fills
produced a silently flat strategy with zero entries and no error.

On daily (or coarser) bars the mined hour window carries no
information, so the generated engine now applies it only when the bar
index has more than one distinct time-of-day (intraday data). Other
rule conditions (RSI, prior return, holding) are unchanged.

Regression tests cover: market-hours window entering on midnight daily
bars, intraday data still enforcing the window, and date-only journal
windows ({0, 0}) still entering.

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-07-19 13:43:00 +08:00
Carls a08525ed18 fix(tools): return real Yahoo news articles for US/HK stock news
The stock news tool documented that US/.HK codes could not get articles
because yahoo_client.search() only exposes the search endpoint's
instrument quotes, so it returned related-instrument matches instead of
news. The same v1 search endpoint also carries a news array; expose it
via a new throttled yahoo_client.search_news() helper and project the
items into the same article shape the Eastmoney path already returns
(title/url/source/published/snippet).

The stopgap matches contract (result_type='matches') is removed; no
other code in the repo consumes it.

Signed-off-by: Carls <yxhuang@uibe.edu.cn>
2026-07-19 13:24:26 +08:00
santhreal a4c3652935 test(tools): fix journal filter fixture month window
Use Jan-Feb so the exclusive upper MonthBegin bound matches two rows.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-18 17:59:58 -07:00
santhreal 159bffbef9 fix(tools): reject inverted trade-journal date filters
filter_expr like "2026-03 to 2026-01" silently emptied the frame and
ran analysis on zero rows. Raise ValueError, matching period inversion.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-18 17:59:40 -07:00
santhreal b8ccf2bc3f fix(tools): reject inverted PDF page ranges in doc_reader
pages like 10-5 produced an empty index list and silently returned no
text. Raise ValueError, matching alpha_bench period inversion.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-18 17:53:50 -07:00
santhreal 78d51b4eb5 fix(channels): avoid hang in split_message when max_len <= 0
When max_len is 0 or negative, the cut pointer never advances and split_message loops forever.
Return the content unsplit so outbound IM adapters cannot wedge.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-18 17:09:32 -07:00
Naresh d4beae4b59 fix(cli): render Robinhood's real remote-MCP account/positions shape
See PR description for full detail.
2026-07-18 12:47:16 -07:00
Naresh b64d73ea32 fix(trading): forward account_number to Robinhood remote MCP calls
See PR description for full detail.
2026-07-18 12:47:16 -07:00
dependabot[bot] 743a498255 build(deps): bump actions/setup-node from 6.4.0 to 7.0.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 18:12:45 +00:00
shadowinlife fe141023da fix(test): relax perf gate to 50ms for CI runner variance
Design target remains 15-35ms on dev machines. The 50ms gate still
guarantees >40x improvement over the pre-optimization 2-2.5s baseline
while accommodating GitHub Actions shared-runner variability.

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

AI-Contributed/Feature: 0/0
AI-Contributed/UT: 0/18
2026-07-18 22:47:59 +08:00
Haozhe Wu b79e4c54bd docs: add July 18 news (Binance loader + parallel-exec / correctness fixes) 2026-07-18 22:39:31 +08:00
shadowinlife 0b6bf15b65 test: add E2E fund panel compatibility tests for vectorized _align()
Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/0
AI-Contributed/UT: 0/225
2026-07-18 22:39:31 +08:00
shadowinlife e743a41de2 test: add gold standard regression test for _align() vectorization
Add TestAlignGoldStandard class that runs both the old pandas scalar
path and new vectorized path with identical inputs, asserting
element-wise output equivalence.

Covers: basic sizes (100-2000 bars), high NaN gaps (20%),
cross-market (ffill_limit=10), all-NaN column drops, and optimizer
callback scenarios.

Ref: reviewer request on PR #698
Signed-off-by: shadowinlife <shadowinlife@gmail.com>


Signed-off-by: shadowinlife <shadowinlife@gmail.com>
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 0/232
2026-07-18 22:18:05 +08:00
shadowinlife 5efdc7c0f5 fix: ensure array column order matches codes parameter
The numpy arrays extracted from target_pos and close_df must be
reindexed by the codes parameter to ensure _code_to_col mapping
is consistent regardless of DataFrame internal column order.

Fixes test_rotation_is_independent_of_close_open_symbol_order

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

AI-Contributed/Feature: 0/6
AI-Contributed/UT: 0/0
2026-07-18 22:18:02 +08:00
shadowinlife c304485dd1 perf: vectorize signal alignment and bar execution (~80× speedup)
Replace pandas reindex/at operations with numpy array indexing:
- _align(): searchsorted + direct fill replaces per-symbol reindex
- _execute_bars(): pre-extract .values arrays, use row/col indexing
- _safe_price(): add keyword-only fast-path params (_arr/_row/_col)

Performance: 5000 bars × 50 symbols from ~2.5s to 29ms median
Tests: 20/20 existing + 16/17 new regression tests pass

Closes #697

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

AI-Contributed/Feature: 0/175
AI-Contributed/UT: 0/372
2026-07-18 22:17:59 +08:00
Haozhe Wu ec052ffab2 fix(backtest): block getattr indirection onto os in signal-engine sandbox (GHSA-jqmf F8)
The AST validator scanned os.<attr> but not getattr(os, "system")(...). Reject
getattr/setattr/delattr whose target is os or a forbidden module — keyed off the
target, not the attr string, so getattr(os, "sys"+"tem") is caught too.
Legit getattr(obj, name, default) on user objects (as the bundled harmonic
example uses) is unaffected.
2026-07-18 21:46:44 +08:00
Haozhe Wu a8f75628d6 fix(channels): operator-gate IM /pairing control plane (GHSA-fwpw)
IM /pairing ran with no authorization, so any allow-listed chat participant could
take over pairing across channels (+ Signal group members bypassed the sender
check, + code/sender_id leak). Add a config-driven operators allowlist (global +
per-channel, fail-closed when empty); gate every /pairing on operator identity in
runtime; scope store list/approve/revoke by channel; require per-sender auth for
control-plane slash-commands in Signal groups (ordinary /new /reset unchanged).
README documents the operator requirement.
2026-07-18 21:46:44 +08:00
Haozhe Wu a1ec1c0cb4 fix(security): neutralize chat-template control tokens in external content (GHSA-v8fm)
Untrusted web/doc/search content entered the LLM context with no special-token
filtering, letting it forge system/assistant role boundaries. Add
neutralize_special_tokens() (hooked into with_security_warnings, applied to the 3
untrusted ingestion tools) that ZWSP-defangs the ChatML <|...|> family, the
DeepSeek fullwidth-bar <|...|> family (the shipped default model), Llama
[INST]/<<SYS>>/<s>, and Gemma role tokens. Text stays visually identical.
2026-07-18 21:46:44 +08:00
Haozhe Wu e4498d403f fix(mcp): Host/Origin guard on network transports (GHSA-p3c9)
sse / streamable-http bound a TCP port with no Host/Origin validation (fastmcp
3.2.4 ships none), so a DNS-rebinding browser page could reach every MCP tool.
Wrap the network ASGI app (mcp.http_app) with TrustedHostMiddleware + an Origin
allow-list (VIBE_TRADING_MCP_ALLOWED_HOSTS, default loopback) and serve via
uvicorn. stdio (default) untouched.
2026-07-18 21:46:44 +08:00
Haozhe Wu 2f35bce832 fix(api): require the configured key on loopback too (GHSA-7wgj)
_validate_api_auth / require_event_stream_auth trusted the loopback peer BEFORE
checking API_AUTH_KEY, so a same-host reverse-proxy/sidecar reached key-protected
routes with no token. Invert to key-first (mirrors require_settings_write_auth):
when a key is set every peer incl. loopback must present a valid bearer/ticket;
only the no-key dev mode keeps loopback trust. No-key default UX unchanged; the
bundled frontend already sends the bearer/ticket when a key is stored. Updates
3 tests that codified the loopback-bypass behavior.
2026-07-18 21:46:44 +08:00
Haozhe Wu 106edbdb41 fix(mcp): stop stdio transport from force-enabling shell tools (GHSA-6wjh)
MCP stdio previously force-enabled bash/background_run with no opt-out and the
module-level default was fail-open. Default to fail-closed and gate every
transport behind explicit opt-in (--enable-shell-tools flag or
VIBE_TRADING_ENABLE_SHELL_TOOLS). Local CLI is unaffected (still opts in
explicitly). Also shrinks the network-reachable surface of the bash
OS-command-injection sink (GHSA-m768). Adds regression tests + doc alignment.
2026-07-18 21:46:43 +08:00
Haozhe Wu 7cd998da1f docs(skill): bump market-data source count to 22 for Binance loader (#643) 2026-07-18 19:28:33 +08:00
Haozhe Wu 4275b85fe6 Merge PR #643 2026-07-18 19:25:53 +08:00
Haozhe Wu 1ad07f316c Merge PR #645 2026-07-18 19:25:52 +08:00
Haozhe Wu eb518947aa Merge PR #636 2026-07-18 19:25:52 +08:00
Haozhe Wu 9cc7d273ab Merge PR #634 2026-07-18 19:25:52 +08:00
Haozhe Wu dbaefaa00e Merge PR #705 2026-07-18 19:25:52 +08:00
Haozhe Wu 0f88c50fbe Merge PR #707 2026-07-18 19:25:52 +08:00
Haozhe Wu 96f7a9174c Merge PR #714 2026-07-18 19:25:52 +08:00
Haozhe Wu 3aa7aebef7 Merge PR #713 2026-07-18 19:25:52 +08:00
Haozhe Wu 0bcc2d5937 Merge PR #712 2026-07-18 19:25:52 +08:00
Haozhe Wu b1964cdedb Merge PR #711 2026-07-18 19:25:52 +08:00
Haozhe Wu 7d69947327 Merge PR #710 2026-07-18 19:25:52 +08:00
Haozhe Wu ef680a4213 Merge PR #709 2026-07-18 19:25:52 +08:00
Marnie 64e748035f test(frontend): add @testing-library/react component render test for streaming DOM stability
Add 6 component-level tests that render the streaming area and
simulate state transitions to verify no insertBefore errors occur:
- placeholder → streaming text transition
- streaming text → tool calls transition
- rapid reasoning → text → tools transitions (insertBefore monkey-patch)
- clean unmount when streaming ends
- stream_reset keeps wrapper mounted

All 287 tests pass (277 existing + 10 new).

Signed-off-by: Marnie <Marnie0415@users.noreply.github.com>
2026-07-18 17:42:55 +08:00
Marnie a8af0ab7ef test(frontend): add streaming DOM stability regression tests
Add 4 regression tests for the insertBefore DOM race condition fix:
- streaming wrapper stays mounted across state transitions
- rapid delta accumulation does not cause state inconsistency
- stream_reset clears streaming text without crashing
- concurrent tool calls and streaming text coexist

All 281 tests pass (277 existing + 4 new).

Signed-off-by: Marnie <Marnie0415@users.noreply.github.com>
2026-07-18 17:37:04 +08:00
Marnie 80afd09c8f fix(frontend): prevent insertBefore DOM race during streaming output
During streaming, the chat UI rendered two separate conditional blocks
(pre-stream placeholder and live streaming area) that mounted/unmounted
independently based on state transitions. When reasoningActive switched
to streamingText, React had to simultaneously unmount one div and mount
another, while scrollToBottom requestAnimationFrame inserted DOM
operations between React reconciliation cycles, causing the reference
node to disappear and triggering an insertBefore NotFoundError.

Fix by merging both blocks into a single stable wrapper div that only
checks status === streaming, with inner conditionals controlling
which content is shown. The wrapper itself never unmounts during
streaming, eliminating the DOM race.

Also debounce ConversationTimeline scroll handler DOM queries with
requestAnimationFrame to avoid conflicts with React batched updates.

Signed-off-by: Marnie <Marnie0415@users.noreply.github.com>
2026-07-18 17:30:48 +08:00
Robin1987China 5089373025 fix(mcp): align factor_analysis wrapper with the registered tool contract
The MCP factor_analysis wrapper forwarded codes/factor_name/dates/source/
top_n/bottom_n, but the registered FactorAnalysisTool requires pre-built
CSV artifact paths (factor_csv, return_csv, output_dir). Every MCP call
died on KeyError: 'factor_csv' before any analysis ran.

Mirror the registered tool's real contract in the wrapper signature and
forward exactly those keys, and pin the contract with regression tests
(including an end-to-end call through the real registry on synthetic
CSVs).

Closes #635

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-07-18 15:11:16 +08:00
santhreal a43c519fbb fix(journal): treat NaN securities codes as empty
CSV/Excel blank cells arrive as NaN; str(nan) was truthy and still
produced garbage tickers. Centralize empty-code detection with pd.isna.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:13:55 -07:00
santhreal e227a8b735 fix(validation): drop single-column equity.csv fallback
Only accept explicit equity/nav/value columns so an unrelated lone
column is not silently treated as the equity curve.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:13:53 -07:00
santhreal bae719209c fix(factors): validate n_groups before writing artifacts
Reject non-positive n_groups at the tool entrypoint instead of catching
any ValueError from compute_group_equity after IC files are written.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:13:52 -07:00
santhreal 1c34a5b725 fix(journal): reject empty A-share codes instead of 000000.SZ
Blank securities codes were zfill(6)-mapped to 000000.SZ. Reject empty codes in _qualify_a_share and skip blank-code rows when parsing Tonghuashun/Eastmoney exports.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:10:12 -07:00
santhreal fb4b7a365a fix(validation): accept equity.csv nav/value column aliases
validation CLI hardcoded df['equity'], so artifacts that store nav or value raised KeyError. Accept equity/nav/value (or a single numeric column).

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:10:09 -07:00
santhreal b9325075ee fix(pattern): reject non-positive detection window
pattern detectors passed window<=0 into peak scans and polyfit, causing IndexError, TypeError, or every-bar peak/valley garbage. Require window >= 1 and return a JSON error from run_pattern.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:10:06 -07:00
santhreal f684178dd2 fix(backtest): handle unnamed DatetimeIndex in correlation matrix
\_rolling_correlation_matrix assumed a trade_date column when the index was not named trade_date, so standard OHLCV frames with an unnamed DatetimeIndex raised KeyError. Accept DatetimeIndex close series directly.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:10:03 -07:00
santhreal 29a6ec2c89 fix(alpha): reject inverted period ranges in _parse_period
\_parse_period accepted inverted ranges like 2024-2020 and returned a reversed (start, end) pair. Reject start > end with ValueError, matching validate_date_range.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:10:00 -07:00
santhreal fddcd5b2b0 fix(factors): reject non-positive n_groups in factor analysis
compute_group_equity passed n_groups=-1 into pandas.qcut and raised
IndexError. Validate n_groups >= 1 and return a JSON error from the
factor_analysis tool instead of crashing.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-17 21:02:58 -07:00
shadowinlife af6e77a078 fix(swarm): include enabled_tools in MCP specs cache key
The cache key for MCP tool discovery results must include the
enabled_tools configuration. Without it, calls to the same server
with different enabledTools filters incorrectly return cached specs
from a prior call with a different filter, bypassing tool filtering.

This fixes the CI failures in test_mcp_stdio_integration,
test_mcp_sse_integration, and test_mcp_streamable_http_integration
where enabledTools=["echo"] was not being respected.

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

AI-Contributed/Feature: 0/1
AI-Contributed/UT: 0/0
2026-07-18 11:14:44 +08:00
sambazhuandClaude d76b819a5c fix(api): add fundamental zoo to _VALID_ZOOS whitelist
The fundamental factor zoo (zoo/fundamental/, 4 PIT-safe alphas) shipped
in v0.1.11 and has a frontend card in AlphaZoo, but the API whitelist was
never updated — GET /alpha/list?zoo=fundamental and POST /alpha/bench both
reject it with 400 "unknown zoo 'fundamental'".

Add "fundamental" to _VALID_ZOOS so both endpoints accept it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 09:55:37 +08:00
Michele Cerreto c4aff5e10a fix(ibkr): replace fixed 2s sleep with ib.sleep() polling loop that exits on real data
The single ib.sleep(2.0) call in _wait_for_tick was unreliable when
multiple quotes ran in parallel via ThreadPoolExecutor (8 workers).
Each thread has its own IB connection (thread-local pool), but the
fixed 2s sleep doesn't account for varying network latency -- some
ticks arrive in 200ms, others take longer, and 2s wasn't always
enough, causing empty/NaN fields in 7/10 parallel quote requests.

Replace with incremental ib.sleep(0.1) polling that checks
_tick_has_data after each pump and exits as soon as real market data
arrives, with a 5s total timeout. This gives each ticker dedicated
event-loop time and avoids the race where the LLM reads partial data.

Key: uses ib.sleep() NOT waitOnUpdate() for polling -- ib.sleep
reliably processes all pending socket messages, while waitOnUpdate
can return True for unrelated messages on the same connection.

Changes:
- Re-add _tick_has_data() with None + NaN rejection
- Rewrite _wait_for_tick() as ib.sleep(0.1) polling loop (5s timeout)
- Add test_quote_waits_for_delayed_tick_arrival: tick arrives after
  5 pump cycles, verifies correct bid/ask/last values
- Add test_quote_keeps_polling_when_ticker_is_nan: fields start NaN,
  continue polling until real data (200.0) arrives after 8 pumps
2026-07-18 00:20:11 +02:00
Michele Cerreto 81b8d0d331 fix(ibkr): replace waitOnUpdate polling with ib.sleep(2.0), remove _tick_has_data, raise on qualify failure
The incremental waitOnUpdate() polling in _wait_for_tick was unreliable
under ThreadPoolExecutor (8 workers). waitOnUpdate() returns True for any
socket message, not necessarily the targeted ticker, which meant parallel
quote requests across threads would often time out (2s) with NaN fields.

Replace the 20x100ms loop with a single ib.sleep(2.0), which processes
all pending socket messages in one shot -- the approach the original
pre-pool code used successfully.

Changes in local.py:
- Remove _tick_has_data() -- no longer needed (no polling loop)
- Simplify _wait_for_tick() to a single _sleep(ib, 2.0) call
- Update _wait_for_tick docstring to explain the single-sleep rationale
- Fix _qualify_contract() to raise IBKRConnectionError instead of
  silently swallowing exceptions (contracts like Samsung KRX fail
  qualification and previously returned garbage data)

Changes in test_ibkr_local.py:
- Remove the _wait_for_tick timing regression test section (3 tests:
  test_quote_waits_for_tick_arrival, test_quote_receives_tick_after_event_loop_pump,
  and the delayed-ticker/pump-count fixtures)
- Remove the NaN rejection test section (test_quote_rejects_nan_and_waits_for_real_data
  and _NaNTickerIB fixtures)
- Add test_quote_calls_sleep_for_tick_arrival: clean test verifying
  get_quote calls ib.sleep(2.0) and returns valid quote data
2026-07-18 00:03:05 +02:00
Michele Cerreto cf26d30aab fix(ibkr): reject NaN ticker values in _wait_for_tick
The _wait_for_tick loop checked `value is not None` before exiting,
but ib_async tickers return `float("nan")` for fields that have been
processed but contain no trade data (e.g. after-hours, illiquid
symbols).  Since NaN is not None, the loop exited immediately and
returned NaN bid/ask/last -- breaking all quote-dependent analysis.

## Fix

- Added _tick_has_data(ticker) helper that rejects both None and NaN.
  Only returns True when at least one of bid/ask/last is a non-NaN
  numeric value.
- _wait_for_tick now calls _tick_has_data() instead of inline None
  checks.
- Added test_quote_rejects_nan_and_waits_for_real_data -- mock returns
  NaN ticker; verifies the wait loop pumps until timeout (does not exit
  early), keeping the safety ceiling.

All 16 tests pass.
2026-07-17 23:18:17 +02:00
Michele Cerreto 6b61f9402d fix(ibkr): restore tick-wait in get_quote and add timing + concurrency tests
## Problem 1: Empty ticker in get_quote (timing regression)

Commit a1070a1 changed reqMktData(..., snapshot=True) to avoid the
2-second sleep and thread-safety deadlock, but the ib_async documentation
states: "The ticker will initially be empty and gradually (after a couple
of seconds) be filled" -- even with snapshot mode.  Reading the ticker
fields immediately after reqMktData returned None for bid/ask/last in
production, silently breaking all quote-dependent analysis.

## Problem 2: Existing test hid the bug

The _FakeIB.reqMktData mock returned a fully-populated ticker instantly
(bid=100.0, ask=100.2, ...), so the timing regression never surfaced in
CI.  The existing test_quote_and_history_are_readonly test passed despite
the bug.

## Solution

### agent/src/trading/connectors/ibkr/local.py

- Added _wait_for_tick(ib, ticker) helper that pumps the ib_async event
  loop via ib.waitOnUpdate() in 100 ms increments until at least one of
  bid/ask/last is populated, with a configurable 2-second timeout.
  Falls back to ib.sleep() for ib_async versions without waitOnUpdate.
- get_quote now calls _wait_for_tick() immediately after
  reqMktData(snapshot=True), ensuring the ticker fields are filled
  before serializing the quote response.
- Moved import itertools to the top-level import block (was previously
  added inline before the _TwsPool class in commit a1070a1).

### agent/tests/test_ibkr_local.py -- 5 new tests

Timing tests (2):
- test_quote_waits_for_tick_arrival -- mock returns an initially-empty
  ticker; the wait loop runs without hanging, exits gracefully (fields
  stay None if data never arrives -- no deadlock).
- test_quote_receives_tick_after_event_loop_pump -- simulates the real
  async-tick flow: ticker starts empty, after 2 event-loop pumps
  _obj_get is patched to return populated values.  Asserts bid=150.0,
  ask=150.5, last=150.25 are correctly read after the wait loop.

Thread-local pool concurrency tests (3):
- test_pool_thread_local_isolation -- 4 concurrent threads acquire the
  pool via ThreadPoolExecutor with threading.Barrier to force distinct
  worker threads.  Each thread gets a unique client ID (> base 77) and
  a distinct ib_async.IB object.  Verifies no cross-thread leakage.
- test_pool_refcount_disconnects_on_last_release -- nested
  acquire/release in the same thread: disconnect is called only when
  the refcount reaches zero after the final release.
- test_pool_release_idempotent_no_connection -- calling release() on an
  empty pool is a safe no-op.

## Impact

- Quotes now work correctly in production -- ticker fields are populated
  before reading.
- All 10 existing + 5 new = 15 tests pass.
- The wait loop adds ~100-300 ms per quote in the common case (tick
  arrives quickly), with a 2-second safety ceiling.
2026-07-17 22:34:31 +02:00
xkam7ar 69ad82b4e5 fix(web): ignore stale authenticated SSE connections
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:51:11 -07:00
xkam7ar b95c1c2a1a fix(cli): return failures from channel commands
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:50:52 -07:00
xkam7ar df24ecf955 fix(cli): clean up development server children
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:50:33 -07:00
xkam7ar d824b502e9 fix(cli): keep continuation history in one trace
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:50:20 -07:00
xkam7ar b7cd2c98b2 fix(swarm): redact task details in the REST API
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:50:14 -07:00
xkam7ar c2a20d2209 fix(swarm): save nested output files safely
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:49:23 -07:00
xkam7ar cd8acfc814 fix(background): stop timed-out process trees
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:48:39 -07:00
xkam7ar 23d87084d8 fix(scheduler): match standard cron day rules
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:47:05 -07:00
xkam7ar 435f1b85ff fix(web): clear stale correlation output
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:46:52 -07:00
xkam7ar 691a9bcc85 fix(web): isolate run detail state by route
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:46:39 -07:00
xkam7ar 2d82a300bf fix(web): ignore stale comparison responses
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:46:26 -07:00
xkam7ar 3705564ed3 fix(web): proxy auth routes in Vite
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:45:27 -07:00
xkam7ar adb5b4d6c6 fix(packaging): fix the Slack install version
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-17 10:44:55 -07:00
ebujinovch 919e96caf1 fix(factors): corr_rewire must keep missing bars missing under pandas 2.x
pct_change() without fill_method forward-fills gaps on pandas 2.x (the CI
pin), silently defeating the coverage guard that test_corr_rewire's
low-coverage case asserts. Explicit fill_method=None matches the bench
runner's own forward-return convention and behaves identically on pandas 3.

Signed-off-by: ebujinovch <ch.ebujinov@gmail.com>
2026-07-17 17:56:10 +02:00
ebujinovch eafdd4bf9f feat(factors): add academic_corr_rewire (correlation-rewiring stability score)
Signed-off-by: ebujinovch <ch.ebujinov@gmail.com>

# Conflicts:
#	agent/SKILL.md
2026-07-17 17:35:43 +02:00
shadowinlife d95626d4e6 perf(swarm): cache MCP tool discovery specs to avoid redundant RPC calls
Add a module-level thread-safe cache in src/tools/mcp.py that stores
MCPRemoteToolSpec lists keyed by (server_name, command, args, env).
Within a single Swarm run, identical MCP servers are discovered only
once regardless of how many workers or layers execute.

SwarmRuntime._execute_run() calls invalidate_mcp_specs_cache() at the
start of each run to ensure fresh discovery when operator config may
have changed between runs.

Reduces tool discovery overhead from O(Workers x Layers x Servers) to
O(Servers), yielding ~6x speedup in typical multi-layer DAG scenarios.

Closes #703

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-17 23:35:38 +08:00
Haozhe Wu d9cbce553b docs: add July 17 project news (correlation-regime skill + correctness pass) 2026-07-17 23:05:35 +08:00
Haozhe Wu ac9889a5ba revert(loaders): remove feitu / FTShare data source
#567 added feitu, a client SDK for the contributor-operated FTShare data
service. Reverting it: a 3-week-old, author-affiliated backend whose free tier
is at the operator's discretion is not a neutral public data source, and the
A/HK fallback chains already carry ample free coverage (tencent, mootdx,
eastmoney, baostock, akshare, yahoo, futu). Removed the loader, its test,
registry entries, fallback-chain slots, and doc/source-count references
(sources 22->21, free 20->19).
2026-07-17 22:41:55 +08:00
Haozhe Wu e1d6a3558b Merge PR #632 2026-07-17 22:15:12 +08:00
Haozhe Wu 3bb1ff2f84 Merge PR #630 2026-07-17 22:14:50 +08:00
Haozhe Wu c500ae93e0 Merge PR #686 2026-07-17 22:14:50 +08:00
Haozhe Wu 358166f3c7 Merge PR #685 2026-07-17 22:14:50 +08:00
Haozhe Wu 90f5dd0a25 Merge PR #628 2026-07-17 22:14:50 +08:00
Haozhe Wu 7d39cf3b49 Merge PR #626 2026-07-17 22:14:50 +08:00
Haozhe Wu 0476b710d6 Merge PR #594 2026-07-17 22:14:50 +08:00
Haozhe Wu cc51644b90 fix(swarm): redact absolute preset path in not-found error (P10 CWE-209) 2026-07-17 21:42:51 +08:00
Haozhe Wu 30a533add2 Merge PR #610 2026-07-17 21:34:39 +08:00
Haozhe Wu 0e814a8b43 Merge PR #616 2026-07-17 21:33:29 +08:00
Haozhe Wu 9a64a4f249 Merge PR #673 2026-07-17 21:33:29 +08:00
Haozhe Wu 682efbb40e Merge PR #672 2026-07-17 21:33:29 +08:00
Haozhe Wu 41785db633 Merge PR #660 2026-07-17 21:33:12 +08:00
Haozhe Wu 7ce5765bca Merge PR #659 2026-07-17 21:32:37 +08:00
Haozhe Wu 593dbe4f94 Merge PR #658 2026-07-17 21:32:24 +08:00
Haozhe Wu ee776133b7 Merge PR #657 2026-07-17 21:31:16 +08:00
Haozhe Wu 10e3be933c Merge PR #576 2026-07-17 21:31:03 +08:00
Haozhe Wu 10e9a8d9e5 Merge PR #574 2026-07-17 21:30:18 +08:00
Haozhe Wu 9577f2daca Merge PR #670 2026-07-17 21:30:03 +08:00
Haozhe Wu 8a8132aab8 Merge PR #669 2026-07-17 21:29:25 +08:00
Haozhe Wu facc97cec1 Merge PR #570 2026-07-17 21:29:25 +08:00
Haozhe Wu 618650c298 Merge PR #557 2026-07-17 21:29:12 +08:00
Haozhe Wu ff4effb16d Merge PR #569 2026-07-17 21:27:58 +08:00
Haozhe Wu 65446854f8 Merge PR #567 2026-07-17 21:26:15 +08:00
Haozhe Wu 06a90715f2 Merge PR #688 2026-07-17 21:25:53 +08:00
Haozhe Wu fd137d697d Merge PR #654 2026-07-17 21:25:02 +08:00
Haozhe Wu 2056e8fb57 Merge PR #687 2026-07-17 21:25:02 +08:00
Haozhe Wu 0f24c456df Merge PR #656 2026-07-17 21:25:01 +08:00
Haozhe Wu 1b2a84c34c Merge PR #552 2026-07-17 21:25:01 +08:00
Haozhe Wu 0fe877e1e2 Merge PR #692 2026-07-17 21:24:42 +08:00
Haozhe Wu a8289ac7ae Merge PR #691 2026-07-17 21:24:42 +08:00
Haozhe Wu f92c1a2c5c Merge PR #690 2026-07-17 21:24:42 +08:00
Haozhe Wu d892a53134 Merge PR #671 2026-07-17 21:24:42 +08:00
Haozhe Wu 3ca02b56fc Merge PR #655 2026-07-17 21:24:42 +08:00
Haozhe Wu c536ef5ef7 Merge PR #624 2026-07-17 21:24:42 +08:00
Haozhe Wu 20fdfb135a Merge PR #622 2026-07-17 21:24:42 +08:00
Haozhe Wu ef6712a83f Merge PR #620 2026-07-17 21:24:42 +08:00
Haozhe Wu 5d813c5953 Merge PR #608 2026-07-17 21:24:42 +08:00
Haozhe Wu 7fc5fbf43f Merge PR #602 2026-07-17 21:24:42 +08:00
Haozhe Wu 12b55a8b73 Merge PR #598 2026-07-17 21:24:42 +08:00
Haozhe Wu 2fb6dd7666 Merge PR #582 2026-07-17 21:24:42 +08:00
Haozhe Wu c5897e6758 Merge PR #580 2026-07-17 21:24:42 +08:00
sambazhuandClaude 03adce85a3 fix(providers): force temperature=1 for the whole Kimi K-series
kimi-k3 rejects any temperature other than 1 ("invalid temperature:
only 1 is allowed for this model"), same as kimi-k2, but the forcing
guard only matched the kimi-k2 prefix — so a default
LANGCHAIN_TEMPERATURE=0.0 config fails on every request with kimi-k3.

Replace the hardcoded prefix tuple with a K-family regex
(kimi-k<digits> + kimi-for-coding) so kimi-k3 and future K-series
reasoning models are covered without another whack-a-mole patch.

Verified against the Kimi for Coding endpoint: both kimi-k3 and its
k3 alias return the temperature error; moonshot-v1-* models are
unaffected. Note: bare aliases like "k3" are intentionally out of
scope for the name-based guard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 16:05:02 +08:00
Jelech f21d8620fd feat(providers): support ANTHROPIC message 2026-07-17 13:31:44 +08:00
xkam7ar f73aa90e55 fix(qveris): reserve credits before paid calls
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:29:24 -07:00
tyj147454413-cmd c7bff740b8 fix(codex): classify stream HTTP failures 2026-07-17 10:27:31 +08:00
xkam7ar 4c4f7df53a fix(backtest): enforce the QVeris credit budget
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:26:58 -07:00
xkam7ar 5c976c3f08 fix(data): fail incomplete mootdx pagination
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:25:17 -07:00
xkam7ar 70eaf95a4f fix(data): reject incomplete CCXT history
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:24:10 -07:00
xkam7ar e238a6b296 fix(data): reject unsupported Tencent intervals
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:22:41 -07:00
tyj147454413-cmd 2962790a43 fix(state): fsync persisted run state 2026-07-17 10:21:48 +08:00
tyj147454413-cmd 5ce9a04dca fix(okx): bound history fetches and handle rate limits 2026-07-17 10:21:29 +08:00
xkam7ar 0142f0d4f7 fix(live): halt on corrupt runtime state
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:21:08 -07:00
xkam7ar 0940c15b33 fix(live): halt on recorded-only positions
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:21:08 -07:00
xkam7ar 6a38df34ff fix(live): serialize daily-cap admission and submit
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:21:08 -07:00
xkam7ar 12aec9b257 fix(live): calculate gross exposure after each order
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:21:08 -07:00
xkam7ar ec86d9b90f fix(live): publish consent before mandate authority
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:21:08 -07:00
tyj147454413-cmd b364d793e4 feat(loaders): add Binance crypto fallback 2026-07-17 10:21:07 +08:00
xkam7ar f429063e61 fix(data): preserve requested crypto symbols
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:19:32 -07:00
xkam7ar 8d61745504 fix(backtest): size China futures by symbol margin
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:18:27 -07:00
xkam7ar 74c54de5b8 fix(backtest): route futures by submarket
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:16:42 -07:00
xkam7ar b0793637fe fix(portfolio): constrain risk-parity weights
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:15:40 -07:00
xkam7ar 869e78b294 fix(backtest): reuse the validated data snapshot
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:13:59 -07:00
xkam7ar 8dc13d0a79 fix(alpha): enforce sample floors per split
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:12:24 -07:00
xkam7ar 26af89352e fix(alpha): require the strict training threshold
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:10:58 -07:00
xkam7ar e5a7b70f52 fix(shadow): exclude incomplete buy-day bars
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:09:04 -07:00
xkam7ar e62582b554 fix(shadow): skip rules for unmatched markets
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:07:52 -07:00
xkam7ar b51b9413ce fix(agent): preserve queued cancellation signals
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 19:07:31 -07:00
Michele Cerreto a1070a1b12 fix(ibkr): use snapshot quotes and thread-local connection pool
## Problem

The previous shared-connection pool was incompatible with the agent's
parallel tool executor (ThreadPoolExecutor, up to 8 workers).  When the
agent fired 15 trading_quote calls in one iteration every worker thread
raced into the same `ib_async.IB` socket.  `ib_async` is not
thread-safe — its internal event loop is bound to one thread — so the
parallel access deadlocked the market-data stream.  Every session trace
ended with tool_call events but zero tool_result events for quotes.

Additionally, `get_quote` called `reqMktData` with `snapshot=False`
(streaming subscription) followed by a 2-second sleep and manual cancel.
This held the connection for 2+ seconds per symbol even when it worked.

## Solution

Two changes in `agent/src/trading/connectors/ibkr/local.py`:

1. **`reqMktData(..., snapshot=True)`** — requests a one-shot snapshot
   instead of a streaming subscription.  No sleep needed; the ticker
   delivers one tick and auto-cancels.  Drops the `_sleep(2.0)` and
   `cancelMktData` dance entirely.

2. **Thread-local pool** — replaces the shared `_TwsPool` with a
   `threading.local()`-based design:

   - Each worker thread gets its own `ib_async.IB` socket.
   - Unique client IDs via `itertools.count` + base config ID.
   - Per-thread reference counting so the connection persists across
     multiple tool calls in the same thread and disconnects only when
     the refcount drops to zero.
   - No socket is ever shared across threads — respects ib_async's
     single-thread-per-socket design.

## Impact

- No more hangs when the agent fires parallel trading_quote calls.
- Quotations complete in ~100 ms instead of 2+ seconds.
- All 10 `test_ibkr_local.py` tests pass without modification.
2026-07-16 23:03:55 +02:00
Michele Cerreto 3896822a14 fix(ibkr): replace per-call connect/disconnect with thread-safe connection pool
## Problem
The local IBKR connector opened a fresh `ib_async.IB()` connection and
disconnected it on every single tool call (get_account_snapshot,
get_positions, get_open_orders, get_quote, get_historical_bars).  When
the agent invoked multiple trading tools in parallel every call reused
the same hard-coded client ID (77), which caused TWS / IB Gateway to
reject the duplicates with error 326:

    Unable to connect as the client id is already in use.
    Peer closed connection. clientId 77 already in use?

This made the connector unreliable under the agent's parallel execution
model and surfaced as intermittent `TimeOutError` and `Could not
connect to TWS / IB Gateway` errors in the CLI and Web UI.

## Solution
Replace the module-level `_connect()` / `_disconnect()` helpers with
a thread-safe connection pool class `_TwsPool` backed by a
`threading.Lock` and reference counting.

- `_pool.acquire(config)` connects once on first use; subsequent
  concurrent callers reuse the same `ib_async.IB` socket without
  reconnecting.
- `_pool.release()` decrements the refcount; the underlying socket is
  disconnected only when the last active caller releases.
- Guards against mid-flight profile switching (different host / port /
  profile) with a clear error instead of a silent misconfiguration.

All five public functions updated to use `_pool.acquire(cfg)` /
`_pool.release()`.  Call-site contract is unchanged — same
`try: … finally: _pool.release()` pattern.

## Impact
- No more client-ID collision errors under parallel agent call patterns.
- No resource leak from orphaned `ib_async.IB` instances.
- All 10 existing `test_ibkr_local.py` tests pass without modification.
2026-07-16 22:01:49 +02:00
xkam7ar df10ac1eeb fix(cli): authenticate live API requests
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:43:23 -07:00
xkam7ar dcebbfe523 fix(cli): send complete mandate requests
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:42:58 -07:00
xkam7ar 41ad3396e3 ci: run environment gate unit tests
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:41:39 -07:00
xkam7ar 1e214ef1c8 fix(swarm): resume and close event streams correctly
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:41:35 -07:00
xkam7ar 086acc4230 fix(background): report failed command exit codes
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:39:29 -07:00
xkam7ar f6e88870c6 fix(options): handle unusual account values safely
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:39:17 -07:00
xkam7ar 165c0aded5 fix(futu): load every history page
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:38:23 -07:00
xkam7ar 0fecf31d73 fix(futu): use the requested minute interval
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:38:08 -07:00
xkam7ar 2ad7036d1c build: install Black and Ruff for contributors
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:48 -07:00
xkam7ar 60f3c63059 ci: run all repository safety checks
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar 2248737bff fix(journal): reject unknown trade directions
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar 9747eec2a1 fix(journal): include full ending months in filters
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar abc4300dd9 fix(hypotheses): reject blank required updates
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar a5e446e8e3 fix(journal): allocate entry fees across partial exits
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar ae2e110b4f fix(data): match local data result names
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar f39ec95ec6 fix(data): normalize local timestamps to UTC
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar 4e0e312b2b fix(swarm): report tool failures as errors
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:22 -07:00
xkam7ar 22867b01d9 fix(alpha): stop filling missing prices
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-16 11:37:21 -07:00
Ali EmadiandClaude Fable 5 e04c98ca5b feat(swarm): discover user presets from ~/.vibe-trading/swarm/presets
Preset YAMLs dropped into ~/.vibe-trading/swarm/presets/ are now discovered
alongside the bundled roster, mirroring the user-skills pattern
(src/agent/skills.py): the user directory is searched first, same-name files
override bundled presets, and user files survive pip install -U (previously
the only way to add a preset was copying into site-packages, which upgrades
wipe).

- load_preset()/resolve_preset_path(): user dir first, then bundled; the
  not-found error names both locations.
- list_presets(): union with user-wins-by-stem override; entries gain a
  source: "user"|"bundled" field (additive — CLI/MCP/API consumers read
  keys individually or serialize the whole dict).
- Preset names are validated to a single path segment before any filesystem
  lookup (rejects separators and '..'); the bundled lookup had the same
  latent exposure, so validation covers both paths.
- run_swarm(preset_name=...): an explicitly named user preset now resolves;
  keyword auto-routing intentionally stays limited to the curated table, so
  a user preset is reachable only by naming it.
- Packaging regression suite extended (6 new tests: discovery, override,
  absent-dir no-op, traversal rejection, error message, tool gating); the
  roster-count test now filters to bundled entries so a developer's own user
  presets cannot make it flap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ali Emadi <ali@gatekeeperai.com>
2026-07-16 16:50:15 +02:00
Haozhe Wu db2ff578ae docs: add July 16 project news 2026-07-16 20:12:02 +08:00
root eecb86e1bc feat: add Longbridge connector runtime connection card
- Runtime page: add Longbridge connection card with status, capability summary, verify/inspect
- RunnerStatus: filter connector-specific SDK-only connectors from top bar
- API: expose /live/connectors/{id}/verify with SDK credential validation
- i18n: add Longbridge runtime strings (en/zh-CN/ja/ko/ar) — parity test enforced
- Tests: Runtime.test.tsx (27 tests), RunnerStatus.test.tsx (5 tests), i18n parity (20 tests)
2026-07-16 19:31:22 +08:00
root ea89dd17d5 feat: Longbridge SDK security hardening and data source integration
- SDK: sanitize runtime metadata, reject non-readonly profiles, suppress unrepresentable timestamps
- API: expose Longbridge connector runtime status via /live/status, redact credentials in error paths
- Loader: redact credential boundaries, eliminate __context__ secret leakage
- Market data tool: share Longbridge credentials across data and trading
- Tests: cover safe SDK metadata vocabularies, loader redaction, credential flow
- README: document Longbridge as optional extra installation
2026-07-16 19:24:02 +08:00
root 0de5a78ccd build: add Longbridge credentials module and optional dependency
- Add credentials.py: unified credential resolution for data and trading
- Add test_longbridge_credentials.py: coverage for credential resolution
- Remove longbridge from requirements.txt (optional extra only)
- Update requirements-lock.txt
- Update .env.example with Longbridge credential hints
2026-07-16 19:23:54 +08:00
FenjuFu c5f6afabaf feat(providers): add iFlytek Spark as an OpenAI-compatible provider
Wire iFlytek Spark's OpenAI-compatible HTTP endpoint
(https://spark-api-open.xf-yun.com/v1, Bearer APIPassword) through the
provider registry, capabilities, legacy CLI onboarding, .env.example, and
the README provider list, with "iflytek" accepted as an alias. Spark rides
the generic OpenAI-compatible path with no special capability flags; the
default model is 4.0Ultra, which supports function calling.

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-07-16 18:11:28 +08:00
shadowinlife b5770489f3 fix(ci): align noqa env-gate markers with os.getenv call lines
The CI env var gate checks the AST node's start line for the noqa
comment. Move markers from the closing parenthesis line to the
os.getenv( call line so the gate recognizes the exemption.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-16 17:52:08 +08:00
shadowinlife d3aa5847a9 refactor(providers): use get_llm_credentials in _build_native_deepseek
Replace manual credential resolution in _build_native_deepseek() with
the centralized get_llm_credentials() helper, completing the
consolidation goal of #553.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-16 17:38:46 +08:00
JellyfishKnight bca9d49593 feat(data): add FTShare market data source
Signed-off-by: JellyfishKnight <knightjellyfish@gmail.com>
2026-07-16 16:49:52 +08:00
shadowinlife 021bcd502d fix(ocr): address review feedback — selective retry, remove unused confidence(), deduplicate import
- Only retry transient errors (timeout/connection/5xx) in LLM vision OCR;
  deterministic 4xx failures now raise immediately without delay
- Remove unused confidence() method that hid a redundant OCR call
- Remove duplicate model_validator import in env_schema.py

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

AI-Contributed/Feature: 0/46
AI-Contributed/UT: 0/0
2026-07-16 16:26:47 +08:00
shadowinlife 606f20d3c2 fix(ocr): address review comments — quality_flag misreport, magic constants, nit
- Track ocr_attempted_pages to distinguish "OCR attempted but empty"
  from "no OCR needed"; map former to quality_flag="degraded"
- Promote inline magic numbers to named module constants with rationale
  comments (_OCR_MAX_TOKENS, _OCR_TEMPERATURE, _OCR_JPEG_QUALITY)
- Replace no-op round(len(text) / 1, 1) with float(len(text))

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

AI-Contributed/Feature: 0/40
AI-Contributed/UT: 0/0
2026-07-16 15:49:18 +08:00
xor_xe 8e4fd16646 fix(backtest): guard local benchmark against swapped fallback loader
fetch_data_map()'s empty-result fallback can silently replace the local
loader with a network source (e.g. yahoo) while config['source'] still
reads local, so resolve_benchmark() fetched the benchmark through a
network loader before the allow_fallback check applied. Verify the
handed-in loader actually belongs to the local source before calling
fetch() on it, and fail closed otherwise.

Signed-off-by: xor_xe <bedauri.it@gmail.com>
2026-07-16 11:37:26 +04:00
shadowinlife d35e6d8f9b refactor(providers): centralize credentials + fix gateway misroute (#549, #553)
Two related provider architecture improvements:

1. Keep explicit provider choice when it is a gateway (OpenRouter/Requesty)
   (#549): The existing explicit-provider check already protects gateway
   users from silent misrouting — provider="openrouter" returns
   openrouter capabilities regardless of model name. Added regression
   tests covering all gateway + model-name-prefix combinations.

2. Centralize credential resolution (#553): Extract the duplicated
   provider→env_name→os.getenv→credential chain that was copy-pasted
   across _sync_provider_env() and provider_diagnostics() into a
   single get_llm_credentials() in capabilities.py. The new function
   inherits the existing provider resolution logic, so every consumer
   benefits from consistent behavior.

Additional fixes from code review:
- Remove unused base_env variable in provider_diagnostics() — use
  caps.api_key_env directly, eliminating the redundant
  provider_env_names() call.
- Add 3 base_url resolution tests covering the fallback chain
  (provider-specific → OPENAI_BASE_URL → OPENAI_API_BASE).
- Run black to fix formatting violations on import lines.

Note: _sync_runtime_env() in settings_routes.py is a WRITE operation
(saves Web UI settings to env vars) and follows a fundamentally
different pattern from the READ path above — intentionally left out
of this refactor.

Backward compatibility: The default provider="openai" + model-name
inference (e.g. model="glm-4.6" → zhipu) is preserved. Users who
rely on this convenience behavior are unaffected.

Closes #549, closes #553

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-16 15:30:48 +08:00
ebujinovch 038bf64ccb fix(skills): correlation-regime — cite only the public 8-event regression; mark internal replay numbers as unpublished
Signed-off-by: ebujinovch <ch.ebujinov@gmail.com>
2026-07-16 08:52:59 +02:00
Haozhe Wu 8b4bfc0da0 Merge pull request #561 from CRui5in/agent/windows-settings-fchmod
fix: support Web UI settings persistence on Windows
2026-07-16 14:42:46 +08:00
Haozhe Wu 2f49127a36 Merge pull request #564 from tianrking/agent/regenerate-runtime-lock
fix(deps): regenerate runtime lock
2026-07-16 14:42:38 +08:00
zhanghuiying 58bf8818a4 feat: add SiliconFlow CN and Global providers
Signed-off-by: zhanghuiying <20434979+UNHNQ@users.noreply.github.com>
2026-07-16 14:34:34 +08:00
tianrking 3eb598faf5 fix(deps): regenerate runtime lock
Signed-off-by: tianrking <10758833+tianrking@users.noreply.github.com>
2026-07-16 12:58:47 +08:00
QinRX baaf07d1ee fix: support Web UI settings persistence on Windows
Signed-off-by: QinRX <2474250045@qq.com>
2026-07-16 09:42:29 +08:00
ebujinovch c1dab8331f feat(skills): add correlation-regime skill (regime detection + crisis attribution)
Signed-off-by: ebujinovch <ch.ebujinov@gmail.com>
2026-07-15 20:38:36 +02:00
shadowinlife f036a2f29d fix(ocr, 547): backward-compat aliases + ollama-only fallback
Implement the backward compatibility layer mandated by Issue #547
(design-decision table):

- engine.py: add _CHOICE_ALIASES table ("qwen-vl" -> "llm-vision") applied
  in get_ocr_engine() before engine lookup, emitting a deprecation warning.
- env_schema.py: add Pydantic model_validator(mode="before") on OcrConfig
  that reads legacy VIBE_TRADING_OCR_QWEN_MODEL, forwards to the new
  VIBE_TRADING_OCR_LLM_MODEL field, and emits a deprecation warning.
  New env var takes precedence when both are set.
- llm_vision_ocr.py: restrict the "ollama" placeholder api_key to the
  ollama provider only. Unknown providers now get empty string so they
  no longer silently misconfigure (previously any unknown provider
  without OPENAI_API_KEY was given "ollama" as api_key, corrupting the
  OpenAI client for non-ollama providers).
- test_ocr_engine.py: TestBackwardCompatAliases (6 new tests): alias
  mapping, deprecation warning emission, env-var precedence,
  new-takes-precedence over legacy, ollama-only fallback, and
  unknown-provider empty-key behavior.

Tests: 30 passed, 1 skipped (rapidocr_onnxruntime not installed).
Full suite: 5412 passed, 4 failed (pre-existing tushare E2E auth, unrelated).

Part of #547.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-16 00:12:14 +08:00
xor_xe 100a832a42 fix(backtest): fail closed on local benchmark instead of yfinance fallback
An explicit source=local run whose loader returned no benchmark data
(or raised) silently fell back to yfinance, breaking the offline
contract with an unexpected network request. Local now returns no
external benchmark instead; other sources keep the yfinance fallback.

Signed-off-by: xor_xe <bedauri.it@gmail.com>
2026-07-15 19:10:45 +04:00
xor_xe 80b564436b Merge remote-tracking branch 'upstream/main' into fix/550-local-source-engine-benchmark
Signed-off-by: xor_xe <bedauri.it@gmail.com>

# Conflicts:
#	CHANGELOG.md
2026-07-15 19:07:34 +04:00
shadowinlife a45b02486e feat(ocr): pluggable OCR engine architecture with entry_points discovery
Replace hard-coded QwenVisionOCR with a plugin-based engine that discovers
OCR backends via Python entry_points. Ships with:
- LLMDocumentOCR: multi-modal LLM vision (Qwen/OpenAI/Gemini)
- RapidDocumentOCR: offline RapidOCR adapter
- Extensible base class for custom engines

Includes comprehensive test coverage, env-schema config for engine selection,
and updated doc-reader skill documentation.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-15 21:09:31 +08:00
Haozhe Wu bd51782647 docs: add July 15 project news 2026-07-15 21:06:04 +08:00
Haozhe Wu 682467cfcf Merge pull request #554 from HKUDS/codex/issue-541-envconfig-reset
fix(config): refresh cached EnvConfig after dotenv load
2026-07-15 20:37:15 +08:00
Haozhe Wu 1ece159b61 fix(config): refresh cached env after dotenv load
Closes #541.
2026-07-15 20:33:25 +08:00
xor_xe 83e482ef46 fix(backtest): honor explicit local source for engine routing and benchmark
Sources without a dedicated routing branch (e.g. local) sent US/HK
equity datasets to the crypto engine, and explicit benchmarks were
always fetched via yfinance even when all required price data was
available locally.

Route unbranched sources by detected instrument market, and fetch the
explicit benchmark through the configured source's loader first,
keeping yfinance as the fallback. source: auto keeps the yfinance
default.

Fixes #550

Signed-off-by: xor_xe <bedauri.it@gmail.com>
2026-07-15 14:12:24 +04:00
Haozhe Wu 5f5d97bf03 Merge pull request #532 from YZY0108/agent/realized-turnover
fix(backtest): report turnover from executed trades
2026-07-15 12:09:44 +08:00
Haozhe Wu 2eceb1ed80 Merge pull request #540 from Robin1987China/portfolio-exposure-caps
feat(optimizers): add per-name and per-group exposure caps
2026-07-15 12:06:01 +08:00
Haozhe Wu 89f1883016 Merge remote-tracking branch 'origin/main' into codex/pr-532-followup
# Conflicts:
#	CHANGELOG.md
2026-07-15 12:04:57 +08:00
Haozhe Wu 8d71bd74a3 Merge pull request #531 from YZY0108/agent/terminal-close-costs
fix(backtest): account for terminal close costs
2026-07-15 12:04:29 +08:00
Haozhe Wu c7975ea7f5 Merge pull request #539 from santhreal/fix/backtest-nonfinite-hardening
fix(backtest): keep backtest outputs finite and validation.json strict
2026-07-15 12:02:38 +08:00
Haozhe Wu 57c019453f Merge remote-tracking branch 'origin/main' into codex/pr-540-followup 2026-07-15 11:59:46 +08:00
Haozhe Wu c382d614b3 fix(optimizers): preserve uncapped initialization 2026-07-15 11:59:42 +08:00
Haozhe Wu 9fa3a641c0 Merge remote-tracking branch 'origin/main' into codex/pr-531-followup
# Conflicts:
#	CHANGELOG.md
2026-07-15 11:57:57 +08:00
Haozhe Wu fbcd335fcf Merge remote-tracking branch 'origin/main' into codex/pr-539-followup
# Conflicts:
#	agent/tests/test_validation.py
2026-07-15 11:57:33 +08:00
Haozhe Wu 92728ae3bc Merge pull request #530 from YZY0108/agent/open-rebalance-causality
fix(backtest): make open rebalances causal and order-independent
2026-07-15 11:55:45 +08:00
Haozhe Wu dcb2f757b8 Merge pull request #538 from santhreal/fix/validation-config-knob-guards
fix(backtest): validate validation-config knobs instead of crashing
2026-07-15 11:55:40 +08:00
Haozhe Wu e6d0617f83 Merge pull request #544 from Marnie0415/fix/marketable-tools-repeatable
fix(tools): mark core query tools as repeatable to prevent silent dedup
2026-07-15 11:51:06 +08:00
Haozhe Wu 415422ca1c Merge pull request #535 from xkam7ar/agent/vibe-preserve-run-source
fix: preserve run data source during chart reconstruction
2026-07-15 11:51:01 +08:00
Haozhe Wu e6032cc57d Merge pull request #536 from santhreal/fix/metrics-nan-sharpe-single-bar
fix(backtest): keep Sharpe and information ratio finite for single-bar runs
2026-07-15 11:50:38 +08:00
Haozhe Wu 624f496925 test(agent): cover every repeatable query tool 2026-07-15 11:21:51 +08:00
Haozhe Wu f9971f617e fix(data): isolate shared fetch configuration 2026-07-15 11:21:00 +08:00
Haozhe Wu d1a6c90434 fix(optimizer): reject boolean exposure caps 2026-07-15 11:20:31 +08:00
Haozhe Wu b09917debc fix(backtest): serialize numpy validation values 2026-07-15 11:19:41 +08:00
Haozhe Wu 63a26adf83 fix(backtest): isolate opening-plan failures 2026-07-15 11:10:56 +08:00
Haozhe Wu c8be539f8b fix(optimizer): fail closed on infeasible exposure caps 2026-07-15 11:08:19 +08:00
Haozhe Wu a5cb2c4827 fix(backtest): plan opening baskets without order bias 2026-07-15 11:07:29 +08:00
Haozhe Wu c95f64fbb4 test(backtest): define realized turnover across engines 2026-07-15 11:04:40 +08:00
Haozhe Wu 2a06987982 test(backtest): cover terminal costs across engines 2026-07-15 11:03:41 +08:00
Haozhe Wu 73c8ba9bca fix(ui): reconstruct charts through loader registry 2026-07-15 11:02:12 +08:00
Haozhe Wu e2a158f167 test(agent): cover repeatable queries across iterations 2026-07-15 10:58:16 +08:00
Haozhe Wu 772b5c090a fix(backtest): reject non-finite initial cash 2026-07-15 10:57:36 +08:00
Haozhe Wu 1b8f843505 fix(backtest): validate validation parameter types 2026-07-15 10:56:57 +08:00
Marnie0415 53d790a397 fix(tools): mark core query tools as repeatable to prevent silent dedup
Signed-off-by: Marnie0415 <xzh110415@gmail.com>
2026-07-15 07:08:38 +08:00
Robin1987China 1d4c0740a6 feat(optimizers): add per-name and per-group exposure caps
Add optional max_per_name, groups, and max_per_group parameters to the
turnover_aware optimizer. When set, these become additional SLSQP
constraints on each rebalance:

  w_i <= max_per_name                  (per-name cap)
  sum_{i in group_g} w_i <= max_per_group[g]  (per-group cap)

The caps are pure linear constraints, so they compose with the existing
turnover penalty without changing the objective. When max_per_name is
None and groups/max_per_group are empty, behavior is unchanged.

Config example:
  optimizer: turnover_aware
  optimizer_params:
    turnover_penalty: 0.1
    max_per_name: 0.25
    groups:
      AAPL: tech
      MSFT: tech
    max_per_group:
      tech: 0.5

Refs #456

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-07-14 22:47:41 +08:00
Santh 5c299c0ce4 fix(backtest): keep backtest outputs finite and validation.json strict
Two related non-finite-output robustness gaps:

1. initial_cash is not validated. BacktestConfigSchema uses extra="allow"
   and never declares initial_cash, so a config with initial_cash=0 (or
   negative) passes validation and reaches calc_metrics, where
   `equity[-1] / initial_cash - 1` yields inf total_return / annual_return.
   Declare initial_cash: float = Field(default=1_000_000, gt=0) so a
   non-positive value is rejected at the config boundary (the CLI runner and
   the MCP backtest tool both run through this schema).

2. The in-engine validation.json write bypassed strict-JSON serialization.
   run_card and the standalone `python -m backtest.validation` both sanitize
   with _json_safe + allow_nan=False, but BaseEngine.run_backtest wrote
   json.dumps(v_results, ...) with the default allow_nan=True. A non-finite
   validation metric therefore produced bare NaN/Infinity tokens (invalid
   RFC-8259 JSON) only on the automatic in-engine path. Extract the strict
   write into a shared write_validation_json helper and call it from both the
   engine and the standalone main so every writer of validation.json emits
   the same valid JSON (non-finite -> null).

Tests: reject non-positive initial_cash (and default/positive accepted);
write_validation_json serializes inf/NaN as null and parses under a strict
(parse_constant) loader.

Signed-off-by: Santh <64453045+santhreal@users.noreply.github.com>
2026-07-14 06:24:23 -07:00
Santh 2c72ff8f31 fix(backtest): validate validation-config knobs instead of crashing
The three validation tools read count/seed knobs straight from user
config["validation"] via run_validation, but none validated them:

  - walk_forward n_windows=0  -> ZeroDivisionError (len // n_windows)
  - walk_forward n_windows<0  -> silently returns empty windows with a
                                 NaN return_mean and negative consistency_rate
  - monte_carlo n_simulations<1 -> ZeroDivisionError (count / n_simulations)
  - monte_carlo/bootstrap seed<0 -> ValueError from np.random.default_rng
  - bootstrap n_bootstrap<1   -> IndexError from np.percentile on empty array
  - bootstrap confidence outside (0,1) -> ValueError from np.percentile

A single misconfigured knob either aborted the whole backtest with an
opaque error or returned NaN-laden garbage with no signal. Guard each knob
at function entry and return the same {"error": ...} shape these functions
already use for insufficient data, so a bad knob surfaces cleanly in the
validation results instead of crashing or silently corrupting the run.

Signed-off-by: Santh <64453045+santhreal@users.noreply.github.com>
2026-07-14 06:11:30 -07:00
Santh 960f186c04 fix(backtest): keep Sharpe and information ratio finite for single-bar runs
Series.std() uses ddof=1, so a single-observation return series (a one-bar
backtest, e.g. start_date == end_date) returns NaN. vol (Sharpe) and
active_std (information ratio) consumed it unguarded and produced NaN,
while downside_std (Sortino) was already guarded with the same pattern.
Apply the len > 1 guard to vol and active_std so all three risk metrics
stay finite; a one-bar run now reports 0.0 instead of NaN.

Signed-off-by: Santh <64453045+santhreal@users.noreply.github.com>
2026-07-14 04:14:57 -07:00
xkam7ar 8f7405a319 fix: preserve run data source
Signed-off-by: xkam7ar <kmoteallemi@icloud.com>
2026-07-13 22:55:28 -07:00
Haozhe Wu 7cd7dad3f1 docs: add July 14 project news 2026-07-14 13:46:31 +08:00
Haozhe Wu b4d859d8e9 fix: harden provider settings and NVIDIA NIM support
Refs #528

Refs #529
2026-07-14 13:02:03 +08:00
Haozhe Wu 8ff1a12fa0 Merge pull request #534 from HKUDS/codex/rehearsal-priority-integration
Integrate verified priority fixes and Longbridge data loader
2026-07-14 12:30:05 +08:00
Haozhe Wu 322fed6631 chore: clean integration lint findings 2026-07-14 11:45:26 +08:00
ShiroKSH 92e07677e2 fix(frontend): authenticate correlation requests
Signed-off-by: ShiroKSH <kushidashiro@gmail.com>
2026-07-14 11:38:36 +08:00
Haozhe Wu dde6e13a76 fix(longbridge): enforce complete historical data contracts 2026-07-14 11:38:36 +08:00
fanfpyandClaude Opus 4.6 < 0088fba682 fix(registry): move longbridge to key-gated section of fallback chains
Longbridge requires API credentials (key-gated), so it belongs in the
key-gated REST section of the fallback chain (after alphavantage), not
after yfinance (free public). Update test_chains_ordered_by_ip_ban_risk
to match the new ordering.

Fixes CI failure in PR #521.

Co-Authored-By: Claude Opus 4.6 <<EMAIL>>
2026-07-14 11:38:36 +08:00
fanfpyandClaude Opus 4.6 < 350e216bda fix(longbridge loader): remove ctx.close(), add date-window splitting, tz-naive index
- Remove all ctx.close() calls: LongPort QuoteContext has no close() method
  (verified against installed SDK). Calling it raised AttributeError.
- Add _date_windows() to split wide ranges into 180-day windows, avoiding
  the LongPort ~1000-bar per-call cap that silently truncated long backtests.
- Standardise _normalize_frame output to timezone-naive DatetimeIndex
  (LongPort returns UTC timestamps; downstream code assumes tz-naive).
- Replace is_available() probe from ctx.quote() to ctx.candlesticks() for
  a more reliable liveness check.
- Remove unused _SYMBOL_FORCE_US_SUFFIX empty frozenset.
- Use PEP 585 built-in generics (dict vs Dict) for consistency.

Co-Authored-By: Claude Opus 4.6 <<EMAIL>>
2026-07-14 11:38:36 +08:00
fanfpyandfanfpy 57c54a2b4e feat(backtest): add Longbridge historical OHLCV loader
Adds a backtest data loader wrapping the LongPort QuoteContext
(longbridge SDK) for US and HK equity historical bars. Registers
longbridge in VALID_SOURCES and the us_equity / hk_equity fallback
chains alongside yfinance and futu.

Requires LONGBRIDGE_APP_KEY, LONGBRIDGE_APP_SECRET and
LONGBRIDGE_ACCESS_TOKEN (declared in env_schema DataConfig).

Co-authored-by: fanfpy <fanfpy@gmail.com>
2026-07-14 11:38:36 +08:00
Haozhe Wu 3454e6a2bb test(config): cover Longbridge credentials 2026-07-14 11:38:36 +08:00
fanfpyandfanfpy eb0f71d7dd feat(config): add LONGBRIDGE_APP_KEY/APP_SECRET/ACCESS_TOKEN env vars
Declares the three LongPort credentials in DataConfig so they flow
through the centralized Pydantic env-schema layer. Also documents them
in agent/.env.example.

These fields back the new longbridge backtest loader
(backtest.loaders.longbridge) which reads credentials via
get_env_config().data.

Co-authored-by: fanfpy <fanfpy@gmail.com>
2026-07-14 11:38:36 +08:00
Haozhe Wu 63043ea4a1 fix(packaging): keep Longbridge SDK opt-in 2026-07-14 11:38:36 +08:00
fanfpy 5bf48ee854 feat: add longbridge SDK as a runtime dependency
Longbridge (>=0.2.5) is the official LongPort OpenAPI Python SDK
used by the longbridge trading connector. It was previously undeclared
(assumed to be installed by the connector's own requirements.txt),
which breaks pip install vibe-trading-ai users whose envs pins the lock
file and Docker builds that rely solely on requirements-lock.txt.

Changes:
  - agent/requirements.txt: add longbridge>=0.2.5
  - pyproject.toml: add longbridge>=0.2.5 to dependencies
  - requirements-lock.txt: regenerated (resolves to longbridge==4.3.3)

Connector code lives at agent/src/trading/connectors/longbridge/.
History data loader is out of scope (tracked for PR2).

Relates to: feat/longbridge-support (PR1 of 4)
Co-authored-by: fanfpy <fanfpy@gmail.com>
2026-07-14 11:38:36 +08:00
asahikiko 5fe512dcdf fix(tools): add tushare fallbacks for flow tools 2026-07-14 11:38:36 +08:00
HUANG Cheng 06d742c4b6 feat(mcp): add Streamable HTTP transport for modern clients
Add --transport http exposing the MCP Streamable HTTP transport (spec 2025-03-26+) as a single POST/GET /mcp endpoint, alongside the existing stdio and legacy SSE transports. The legacy sse transport is unchanged.

Modern clients (e.g. QwenPaw) negotiate by POSTing an InitializeRequest; the deprecated two-endpoint SSE transport rejects that POST on /sse with 405 Method Not Allowed. Streamable HTTP accepts POST on its single endpoint.

FastMCP 3.4.4 (already a dependency) provides the transport via mcp.run(transport=streamable-http). Adds a --host option scoped to the http transport (default 127.0.0.1).

Signed-off-by: HUANG Cheng <cheng@duck.com>
2026-07-14 11:38:36 +08:00
Haozhe Wu 93220830dd test(tools): cover recoverable write-file arguments 2026-07-14 11:38:36 +08:00
abhishekjaisinghaniandClaude Opus 4.8 7d7f294164 fix(tools): make write_file robust to aliased/missing path arg
write_file did `kwargs["path"]`, raising an uncaught KeyError when a model
emits the call with the path under an alias (file_path/filepath/filename/file)
or omits it entirely. In swarm runs this crash-loops the worker with no error
the model can read and self-correct from, so the DAG never completes.

Accept the common aliases for both `path` and `content`, and return a
structured {"status":"error", ...} the model can recover from instead of
raising an uncaught KeyError. Reproduced with deepseek-v4-pro on the
commodity_research_team preset; after this change the 3-agent DAG completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:38:36 +08:00
sTunnaSu ac426cc440 fix: filter unsupported hypothesis update fields 2026-07-14 11:38:36 +08:00
Santh fd59b4a2df Fix crash in backtest metrics on negative final equity
calc_metrics computed the annualised return as
(1 + total_return) ** (bpy / n). A leveraged or short book can finish at or
below zero equity, making total_return <= -1 and the base non-positive. A
negative base raised to a fractional power evaluates to a Python complex, so
the wrapping float(...) raised TypeError and took down the whole metrics
computation (and the backtest run reporting it).

Guard the non-positive case: a total wipeout annualises to -100%. Normal
runs are unchanged. Adds regression tests for a zero-equity wipeout and a
negative-final-equity book.

Signed-off-by: Santh <64453045+santhreal@users.noreply.github.com>
2026-07-14 11:38:36 +08:00
YZY0108 6b26f6bb0e fix(backtest): report turnover from executed trades
Signed-off-by: YZY0108 <u3629247@connect.hku.hk>
2026-07-14 09:33:51 +08:00
YZY0108 46e2e6cd7f fix(backtest): account for terminal close costs
Signed-off-by: YZY0108 <u3629247@connect.hku.hk>
2026-07-14 09:28:59 +08:00
YZY0108 cdd231cb41 fix(backtest): make open rebalances causal
Signed-off-by: YZY0108 <u3629247@connect.hku.hk>
2026-07-14 09:24:13 +08:00
zygora cfe579086f feat(data): add historical USD-M funding settlements and maintenance brackets
Part of #462, follow-up to merged #470 per maintainer guidance: "Still open
for a follow-up PR (1b): historical funding-rate settlement and
maintenance-margin brackets. Same constraints apply — opt-in, fail-closed,
spot untouched."

Extends the perpetual-only fetch path in ccxt_loader.py:

- Fetch historical funding-rate settlements for *-USDT-PERP symbols and
  align them to the required 00:00/08:00/16:00 UTC bars. Fails closed on a
  missing required settlement or a duplicate settlement timestamp.
- Fetch the current per-symbol maintenance-margin bracket schedule
  (notional_cap, maintenance_rate, cumulative_maintenance_amount per
  bracket, per #462's accounting boundary) via fetch_leverage_tiers, stamped
  with a content-hash version since CCXT/Binance expose no bracket history.
  Fails closed on a missing, incomplete, or non-monotonic bracket schedule.

Both are additive DataFrame columns on the existing perpetual frame, so they
survive the opt-in loader cache; spot symbols and the pre-existing
execution/mark-only perpetual path are unchanged.

Signed-off-by: zygora <122336305+honginp@users.noreply.github.com>
2026-07-13 17:45:41 +08:00
dependabot[bot] f82a21d843 chore(deps): bump the npm-minor-patch group in /frontend with 4 updates (#515)
Verified locally: build + full frontend suite (246/246) pass. First grouped minor/patch bundle under the new dependabot.yml config — react-i18next 17.0.8->17.0.9, zustand 5.0.12->5.0.14, @types/react patch, autoprefixer 10.4.27->10.5.2.
2026-07-13 13:07:59 +08:00
Haozhe Wu 7b3dc47107 chore: group Dependabot minor/patch bumps, lower open-PR limit 10->4
Closing a slot (merge/close) was causing Dependabot to immediately
backfill from its backlog rather than waiting for the next weekly
scan, producing a same-day cascade of individual one-dependency PRs.
Bundling minor/patch updates into one PR per ecosystem (majors stay
excluded and arrive individually, since they need real review) and
capping concurrent PRs lower should keep this from recurring.
2026-07-13 13:01:10 +08:00
Haozhe Wu 8855e654ed docs: log today's security-hardening + PR batch in CHANGELOG and news
Covers issue #476 (all 10 audit findings), #377/#478/#480/#487/#479/#484.
Propagated to all 5 locale READMEs' News section (3 visible + moved
2026-07-10 into <details>, per convention).
2026-07-13 12:55:36 +08:00
Yogesh ModiandYogesh Modi 4f0d5aa04a feat(factors): add Frazzini-Pedersen betting-against-beta (BAB) to ac… (#480)
Adds Frazzini-Pedersen (2014) "Betting Against Beta" to the academic zoo,
following the existing OHLCV-only proxy pattern: rolling 252-day per-stock
beta against an equal-weighted cross-sectional market return, cross-sectional
z-scored negative (so low-beta names rank highest, matching the long leg of
the original long-short construction). Proxy limitations vs. the original
(equal- vs value-weighted market, single window vs separate 1y-vol/5y-corr,
no explicit leverage) are disclosed in both the module docstring and
academic/LICENSE.md, matching the zoo's existing disclosure convention.

Verified locally: full purity + lookahead suites green (923 passed, 5
pre-existing skips), env-var CI gate passes, ruff clean.

One thing the PR's own test plan missed (it only ran the purity/lookahead
suites): SKILL.md has a manifest guard test
(test_alpha_zoo_counts_match_factor_modules) asserting the documented alpha
count matches the actual factor-module count. Adding a 461st alpha without
updating it fails that test. Folded in a fix bumping the count in SKILL.md
(460->461, academic 10->11) and propagated to all 5 locale READMEs' current-
state references (left the historical changelog entries describing past
counts untouched).

Co-authored-by: Yogesh Modi <YogeshModi24@users.noreply.github.com>
2026-07-13 12:47:58 +08:00
卫斯李and卫斯李 43f803dee2 feat(metrics): surface realized portfolio turnover in backtest metrics (#478)
Refs #456. After #466, turnover_aware's realized_turnover was computed but
discarded — the engine's optimizer loader keeps only the returned positions
frame, never the optimizer instance that accumulated it.

Rather than thread the optimizer instance out of the loader lambda, this
computes turnover generically from the executed position frame the engine
already holds (calc_turnover_series: per-bar 0.5*sum|w_t - w_t-1|), so it
works for every strategy/optimizer, not just turnover_aware, and measures the
actual post-normalization weight path that drives transaction cost. Purely
additive: calc_metrics gains an optional positions arg, no existing metric
values change.

Verified locally: full backend suite green (same pre-existing unrelated
failures as main), env-var CI gate passes, ruff clean, 63/63
metrics-and-turnover tests pass. Only one calc_metrics call site
(engines/base.py), inherited by every engine.

Co-authored-by: 卫斯李 <Robin1987China@users.noreply.github.com>
2026-07-13 12:39:35 +08:00
dependabot[bot] 8b00697a8c chore(deps): bump i18next from 26.3.1 to 26.3.6 in /frontend (#507)
Verified locally: merged onto current main, npm run build succeeds, full frontend suite 246/246 passes. Patch-level bump (26.3.1->26.3.6).
2026-07-13 12:27:08 +08:00
dependabot[bot] 41d971efac chore(deps-dev): bump @tailwindcss/typography in /frontend (#508)
Verified locally: merged onto current main, npm run build succeeds, full frontend suite 246/246 passes. Patch-level bump (0.5.19->0.5.20).
2026-07-13 12:24:19 +08:00
dependabot[bot] fc5d873d9d chore(deps): bump tailwind-merge from 2.6.1 to 3.6.0 in /frontend (#505)
Verified locally: merged onto current main, npm run build succeeds, full frontend suite 246/246 passes. v3 is primarily Tailwind v4 support + minor API additions; no usage of removed APIs in this codebase.
2026-07-13 12:23:51 +08:00
Haozhe Wu bd25a6dcfe chore(deps): bump postcss from 8.5.14 to 8.5.18 in /frontend
Applied manually — #501's branch was based on a stale main (predates
the other 11 dependabot merges from this batch) and GitHub's own
mergeability check got stuck reporting a conflict even after a clean
local rebase, so this recreates the identical bump directly against
current main instead.
2026-07-13 12:22:12 +08:00
dependabot[bot] a45f465e9e chore(deps): bump pydantic-core from 2.46.4 to 2.47.0 (#503)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:33 +08:00
dependabot[bot] 9ce904a388 chore(deps): bump anyio from 4.14.1 to 4.14.2 (#500)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:27 +08:00
dependabot[bot] b15c90640e chore(deps): bump react-router-dom from 7.17.0 to 7.18.1 in /frontend (#499)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:23 +08:00
dependabot[bot] eda3e767dd chore(deps-dev): bump @vitest/coverage-v8 in /frontend (#498)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:19 +08:00
dependabot[bot] b5defc41fa chore(deps): bump lucide-react from 0.460.0 to 1.24.0 in /frontend (#497)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:14 +08:00
dependabot[bot] cef21a58f7 chore(deps-dev): bump jsdom from 27.0.1 to 29.1.1 in /frontend (#496)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:10 +08:00
dependabot[bot] 4669bf1810 chore(deps): bump websockets from 15.0.1 to 16.1 (#495)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:06 +08:00
dependabot[bot] bddbce9fef chore(deps): bump peewee from 4.1.2 to 4.2.1 (#491)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:18:02 +08:00
dependabot[bot] f1f2822351 chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#490)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:17:58 +08:00
dependabot[bot] 5cd3a26419 chore(deps): bump caio from 0.9.25 to 0.10.2 (#489)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:17:54 +08:00
dependabot[bot] 9c5cbc4506 chore(deps): bump cloudflare/wrangler-action from 3.15.0 to 4.0.0 (#488)
CI green (test + build). Verified mergeable/clean against current main.
2026-07-13 12:17:50 +08:00
dependabot[bot] c5a4b9bca8 chore(deps): bump react-dom from 19.2.4 to 19.2.7 in /frontend (#493)
Verified locally: merged onto current main, `npm run build` succeeds, full frontend suite 246/246 passes. npm's lockfile resolver bumped react's locked version to 19.2.7 alongside react-dom to keep the pair in sync (React requires react + react-dom to match exactly), so this single PR resolves the pair cleanly.
2026-07-13 12:16:22 +08:00
Haozhe Wu 985f0e1b18 chore: ignore pandas major-version bumps in Dependabot
The <3.0.0 ceiling in pyproject.toml/requirements.txt is a deliberate
pin (pandas 3.x has real breaking API changes we haven't vetted), not
an oversight Dependabot should "fix" by widening it. #494 closed.
2026-07-13 12:14:44 +08:00
Yuan ZhiyiandYuan Zhiyi 9daccb427f fix(optimizers): exclude decision-bar returns (#487)
BaseOptimizer.optimize() built each estimation window with ret.loc[:dt], which
includes ret[dt] — a close-to-close return for the decision date. Since
weights at dt execute at that bar's open, ret[dt] isn't actually observable
yet at execution time, so every optimizer built on this base (mean-variance,
equal-volatility, max-diversification, risk-parity, turnover-aware) was
leaking one bar of future information into its own decision.

Fix restricts the window to ret.index < dt. Two regression tests prove it:
the window's latest timestamp always precedes the decision date, and shocking
only the decision-bar return leaves that bar's executed weights unchanged.
Backtests without an optimizer are unaffected; this does change historical
optimized results as intended (removing a look-ahead leak, not introducing
one).

Verified locally: full backend suite green (only 2 pre-existing/unrelated
failures, same as main), env-var CI gate passes, ruff clean, 18/18
optimizer-specific tests pass including both new causality tests.

Co-authored-by: Yuan Zhiyi <YZY0108@users.noreply.github.com>
2026-07-13 12:06:58 +08:00
Andrew BarnesandAndrew Barnes 7d912f7623 fix(providers): replace stale OpenAI base URL (#484)
_sync_provider_env() resolves the correct provider-specific base URL (e.g.
switching to OpenRouter) but wrote it to OPENAI_BASE_URL with setdefault, so a
stale value from a previous provider configuration silently won. The OpenAI
SDK reads OPENAI_BASE_URL directly, so requests kept hitting the old endpoint
with the new (still-valid) key attached — surfacing as a 401 "User not found"
from the wrong provider, not an actual key problem. Fixes #482.

Verified locally: full backend suite green (same pre-existing unrelated
failures as main), env-var CI gate passes, ruff clean, 13/13 provider-sync
tests pass including the new regression case.

Co-authored-by: Andrew Barnes <Bortlesboat@users.noreply.github.com>
2026-07-13 12:06:15 +08:00
AnanayandAnanay b410eba3cf fix(preflight): reset EnvConfig after dotenv load to pick up .env values (#479)
The EnvConfig singleton can get cached before _ensure_dotenv() loads the
project .env (e.g. theme.py's import-time _is_dark_terminal call runs first),
so `vibe-trading run` preflight would resolve provider/model against a stale
empty snapshot even though `provider doctor` (which calls get_env_config()
fresh) worked fine. Fixes #477 by resetting the EnvConfig singleton right
after dotenv loads, with a regression test + an explanatory comment on the
caching gotcha.

Verified locally: full backend suite green (same pre-existing unrelated
failures as main), env-var CI gate passes, ruff clean.

Note: #483 landed the identical one-line fix independently around the same
time — closing that one in favor of this one since it also carries the
regression test.

Co-authored-by: Ananay <ananaymital@users.noreply.github.com>
2026-07-13 12:01:34 +08:00
0xZKnwand0xZKnw fb6713e303 feat(trading): opt-in TAP mode — full Alpaca key isolation (reads + orders + cancel) (#377)
Opt-in TAP (Tool Authorization Protocol) mode for Alpaca: when TAP_PROXY_URL +
TAP_AGENT_KEY are configured, all broker egress (reads, order placement,
cancel) routes through a self-hosted TAP proxy instead of the direct SDK, so
the agent process never holds the raw Alpaca key — only a policy-scoped TAP
agent key. Writes block on human approval; the direct-SDK path is unchanged
when TAP isn't configured.

Reviewed: self-hosted deployment (holonym-foundation/tap-oss) resolves the
custody question; the TAP write path sits strictly downstream of the existing
mandate gate; with TAP env vars unset the direct path is byte-for-byte
unchanged; SSRF guard on the poll URL confirmed. 60/60 tests green
(agent/tests/test_tap_forward.py + test_alpaca_tap_routing.py).

Maintainer fix folded in before merge: tools/ci_env_var_gate.py (#440) landed
after this branch was cut and flagged TAP's raw os.environ reads — annotated
with the established `# noqa: env-gate` escape hatch (module is deliberately
independent of EnvConfig so it keeps working outside the app's normal
startup/config bootstrap).

Co-authored-by: 0xZKnw <0xZKnw@users.noreply.github.com>
2026-07-13 11:57:57 +08:00
Haozhe Wu 4e3c91c77a security: address all 10 findings from external audit (issue #476)
Implements VT-001 through VT-010 from the 2026-07-10 external security
audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment):

- VT-001 (Critical): AST hardening in backtest/runner.py rejects
  network/subprocess/eval/os.environ/unsafe-open calls reachable from
  generated SignalEngine method bodies (previously only checked
  top-level statements). Defense-in-depth: ephemeral per-run HOME,
  RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox
  user (currently inert under Docker's cap model - see session doc;
  accepted as documented residual gap, primary AST defense unaffected).
- VT-002: multi-stage Dockerfile split so build-essential never
  reaches the runtime image.
- VT-003: SSE auth moved from a long-lived API key in the URL query
  string to short-lived single-use tickets; access-log redaction for
  any remaining query secrets.
- VT-004: agent/.env split-brain fixed (bind mount + atomic 0600
  writes) between docker-compose and Web UI settings writes.
- VT-005: /correlation now requires auth + per-IP rate limit; generic
  exceptions no longer leak raw text to clients.
- VT-006: CSP-Report-Only + security headers middleware; Google Fonts
  replaced with self-hosted @fontsource packages.
- VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add,
  no-new-privileges, read_only rootfs, resource limits).
- VT-008: /health split into /live (liveness) and /ready (real
  provider-config readiness check); /health kept as a compat alias.
- VT-009: base images pinned by digest, GitHub Actions pinned by
  commit SHA, dependabot.yml added, hash-verified requirements-lock.txt
  now actually wired into the Docker build via --require-hashes.
- VT-010: universe-panel pickle cache sidecar upgraded from an
  unkeyed SHA-256 (forgeable by anyone with cache-dir write access)
  to a keyed HMAC.

Implemented via a 5-parcel Workflow (file-disjoint, independent
adversarial verification per parcel, then a holistic final audit).
The final audit caught a real regression (CI env-var-gate violation)
and confirmed a dependency-pinning gap, both fixed and re-verified
here, including a latent transitive-dependency bug the fix surfaced
(smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see
docs/sessions/2026-07-13_session_vt476_security_hardening.md (local,
gitignored) for full evidence trail.

Full suite: 5191 passed, 6 pre-existing/environmental failures
(unchanged from main). Docker build + hardened compose boot verified
end-to-end.
2026-07-13 01:40:53 +08:00
staniell 7296ef9ca7 docs: propagate MT5 connector + data source to all 5 locale READMEs
zh/ja/ko/ar gain the mt5 data-source table row, the forex/metals
fallback-chain bullet, the source-count bump (19 -> 20), and the full
translated MetaTrader 5 connector section. zh/ja/ko/ar lack the English
MCP Client Mode chapter, so the connector section anchors at the end of
the MCP Plugin section in all four (consistent placement). Also bumps
the QVeris paragraph built-in-source count to 20 in all 5 READMEs.

Signed-off-by: staniell <giostaniell12@gmail.com>
2026-07-13 00:37:15 +08:00
staniell b159284af4 docs+packaging: mt5 extra, source/connector docs, CHANGELOG
- pyproject: [mt5] extra with a sys_platform == win32 marker so ubuntu
  CI and non-Windows installs are unaffected.
- README: data-source table + forex fallback chain + MetaTrader 5
  connector section documenting mt5.json, the four profiles, the
  demo/live identity guard, lot-aware mandate sizing, hedging
  close-by-ticket semantics, and the halt path.
- agent/SKILL.md + data-routing SKILL.md: sources 20 -> 21 (test-
  enforced counts), forex routing priority mt5 > akshare.
- CHANGELOG [Unreleased]: connector (11 -> 12) + data source (20 -> 21).

Signed-off-by: staniell <giostaniell12@gmail.com>
2026-07-13 00:21:39 +08:00
staniell 84c57e8829 feat(data): MetaTrader 5 as a market-data source (mt5 loader)
Backtests and get_market_data can now use a local MT5 terminal feed
(e.g. Exness) with the broker exact symbols. Sources: 20 -> 21.

- New backtest/loaders/mt5_loader.py: copy_rates_range history with
  tz-aware UTC ranges, Exness-style suffix discovery via symbols_get
  (shortest-then-alpha, memoized), results keyed by the original input
  code, settled-range parquet cache participation, per-symbol failures
  degrade instead of raising so runner fallback engages.
- Routing: mt5 heads FALLBACK_CHAINS[forex]; forex/metal patterns
  (EUR/USD, XAUUSD.FX) added to market_data._SOURCE_PATTERNS -- they
  previously fell through to tushare and failed.
- akshare _is_forex/_fetch_forex now accept the canonical slash form,
  so the mt5 -> akshare degradation actually works for EUR/USD.
- metrics: mt5 annualisation columns (260 trading days, 24x5 bars).

Signed-off-by: staniell <giostaniell12@gmail.com>
2026-07-13 00:11:07 +08:00
staniell 9a746b9e95 feat(trading): MetaTrader 5 broker connector (Exness-style MT5 brokers)
New broker_sdk connector talking to a locally running MT5 terminal via
the Windows-only MetaTrader5 package (lazy-imported; ubuntu CI never
needs it). Connectors: 11 -> 12.

- Reads: check_status, account, positions (USD market_value per row,
  None-when-unpriceable so the gate fails closed), pending orders +
  optional deals, suffix-aware quote (zero last omitted), history.
- Writes: place_order (market/pending-limit, volume_step flooring,
  filling-mode negotiation, order_check pre-flight) and risk-reducing
  cancel_order that removes a pending ticket OR closes a position by
  ticket via an opposite deal pinned to position= (hedging-safe).
- Safety: bidirectional identity guard on every session (paper profile
  requires DEMO trade_mode, live requires REAL, contest rejected,
  login pinned); connector-level max_order_volume /
  max_order_notional_usd guards on demo AND live; live orders route
  through the mandate gate with the lot-aware quantity_notional_usd
  sizing hook; per-symbol forex/CFD order classification.
- Profiles: mt5-paper-sdk, mt5-live-sdk-readonly, mt5-paper-trade,
  mt5-live-trade (requires_mandate). Config in ~/.vibe-trading/mt5.json
  (0600 best-effort, redacted in every payload).

Signed-off-by: staniell <giostaniell12@gmail.com>
2026-07-13 00:06:15 +08:00
staniell 1b295344d2 feat(live): forex/CFD mandate vocabulary + lot-aware gate sizing hook
Groundwork for the MetaTrader 5 connector:

- InstrumentType gains FOREX and CFD; AssetClass gains FOREX. No schema
  bump: the store parses enums by value, so v1 mandates keep loading.
  CFD follows the OPTION precedent (no universe bucket; admitted only
  via an explicit cfd instrument allowance).
- _INSTRUMENT_ASSET_CLASS maps FOREX to its bucket; forex universe
  floors fail closed (no loader market wired).
- sdk_order_gate._normalize_notional now defers to an optional,
  authoritative quantity_notional_usd connector hook. Lot-sized
  quantities (1 lot EURUSD == 100k EUR) must never be priced as
  quantity x quote — that under-states notional ~contract-size-fold
  and would silently disarm every USD cap. Without the hook the legacy
  path is byte-identical.

Signed-off-by: staniell <giostaniell12@gmail.com>
2026-07-13 00:05:58 +08:00
Haozhe Wu 2f4ba89733 docs: remove dead Star History badge from all 5 READMEs
GitHub restricted the stargazers API on 2026-06-30 to repo admins/
collaborators only; api.star-history.com's SVG badge renders server-side
and can't authenticate as a collaborator on every embedding repo, so the
badge now fails with a server timeout instead of rendering (confirmed
live, no fix path per star-history.com's own postmortem).
2026-07-12 18:41:59 +08:00
Haozhe Wu 87b17daa55 docs: warn about unauthorized Vibe-Trading token 2026-07-12 17:35:11 +08:00
Haozhe Wu 9af8cb8c1e docs: log today's PR batch in CHANGELOG [Unreleased]
#457 SDM, #474 Requesty provider, #470 Binance USD-M slice 1,
#472 correlation bare tickers, #467 local loader interval, #469 FastMCP compat
2026-07-12 16:58:04 +08:00
Haozhe Wu c0e3bbd1ec docs: propagate Requesty provider (#474) to all 5 locale READMEs + today's news entry 2026-07-12 16:49:08 +08:00
Haozhe Wu dc5b901a75 Merge PR #474: add Requesty as an OpenAI-compatible provider 2026-07-12 16:47:22 +08:00
Thibault Jaigu 4b21586e10 feat(providers): add Requesty as an OpenAI-compatible provider
Mirror the existing OpenRouter wiring for Requesty (router.requesty.ai/v1,
REQUESTY_API_KEY/REQUESTY_BASE_URL, provider/model naming, opt-in
extra_body.reasoning). Add it to the capability registry, llm_providers.json,
onboarding + legacy CLI provider lists, .env.example, README, and add a
capability mirror test.

Signed-off-by: Thibault Jaigu <thibault.jaigu@gmail.com>
2026-07-12 09:36:11 +01:00
Haozhe Wu 39241e0ac3 docs: 2026-07-12 news entry + skill counts 86→87 for strategy-dev-manager (#457) 2026-07-12 15:54:41 +08:00
Haozhe Wu b92eecdb77 Merge PR #457: Strategy Development Manager skill with artifact store and decay monitoring (closes #455) 2026-07-12 15:54:35 +08:00
Haozhe Wu 75bcd0cf3e fix(sdm/ocr): cloud OCR strictly opt-in + duplicate-name guard + all-None metrics guard
- OCR auto mode is local-only (RapidOCR); qwen-vl requires explicit
  VIBE_TRADING_OCR_ENGINE=qwen-vl — document pages must never leave the
  machine without an explicit user choice
- register_artifact rejects duplicate (name, universe) with a friendly
  ValueError in both sqlite and in-memory stores
- bench histories with no evaluable metric family (all ic_mean/sharpe None)
  report insufficient_data instead of defaulting to HEALTHY
- tests for each (+ new test_ocr_engine.py)
2026-07-12 15:54:26 +08:00
shadowinlife 68b8434b1a fix: address maintainer review + CI env var gate failures
1. CI env var gate: replace os.getenv() with centralized config accessor
   in sqlite_store.py, ocr/engine.py, ocr/qwen_vision_ocr.py — add
   OcrConfig + dashscope_api_key + strategy_store_db_path to EnvConfig
2. is_readonly=False on sdm_decay_scan and sdm_status (maintainer #1)
3. read_document: skip low-text pages instead of hard-failing on first
   scanned page; only error when entire doc is image-only (maintainer #2)
4. Wire Sharpe metric into evaluate_decay() for strategy artifacts
   (maintainer #3)
5. UNIQUE(name) → UNIQUE(name, universe) in sqlite schema (maintainer minor)
6. Remove unused imports: asdict, DecaySignal x3 (maintainer minor)
7. Wire consecutive_warnings field in decay snapshots (maintainer minor)
8. Add non-dry-run active→monitoring transition test (maintainer minor)
9. Extract shared compute_decay_metrics() to strategy_store/metrics.py
   eliminating duplication between sdm_decay_scan and sdm_status
10. Update skill count 86→87 in SKILL.md and README.md

All 5055 tests pass. CI env var gate passes. LSP diagnostics clean.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 314/314
AI-Contributed/UT: 49/49
2026-07-12 13:17:06 +08:00
Haozhe Wu 5cd46bc521 Merge PR #470: Binance USD-M symbol routing + execution/mark split (#462 slice 1) 2026-07-12 12:16:18 +08:00
Haozhe Wu 4d81d6e647 Merge PR #469: support legacy FastMCP transport imports 2026-07-12 12:16:18 +08:00
Haozhe Wu ebb98a0a0b Merge PR #467: honor interval in local loader by resampling 2026-07-12 12:16:18 +08:00
Haozhe Wu e55b2c3a6e Merge PR #472: resolve bare tickers in the correlation matrix (closes #471) 2026-07-12 12:16:18 +08:00
shadowinlife 4f78bf9e3a Merge remote-tracking branch 'origin/main' into feat/strategy-dev-manager 2026-07-12 11:17:14 +08:00
CarlsandClaude Opus 4.8 e854606586 fix(correlation): resolve bare tickers so the matrix stops failing with "Fetched: []"
The /correlation endpoint passed user input straight to the data loaders,
which key US/HK/A-share instruments by exchange-suffixed symbols
(AAPL.US / 0700.HK / 600000.SH). Bare tickers — exactly what the UI hint
suggests (e.g. "BTC-USDT,ETH-USDT,AAPL,SPY") — matched no loader and the
request failed with "Could not fetch price data for at least 2 assets.
Fetched: []".

Three layered fixes:

1. infer_market: classify by explicit suffix first, then bare numeric
   codes by digit length (6 digits = A-share, <=5 = HK). The old prefix
   heuristics misrouted bare HK codes (0700 -> SZ, 9988 -> US), ChiNext
   codes (300750 -> HK), and even suffixed 830799.BJ (-> US).
2. _normalize_symbol (new): canonicalize bare tickers to the loader
   symbol form before fetching, keeping the user's input as the label.
3. Fetch loop: walk the market's full FALLBACK_CHAIN until a loader
   actually returns data instead of stopping at the first *available*
   one, and log every per-source failure instead of the bare
   "except: continue" that made this failure mode undiagnosable.

Tests: +10 cases (bare/suffixed classification, symbol normalization,
chain fall-through); 22 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:40:47 +08:00
shadowinlife 65fb112c5f feat(ocr): pluggable OCR engine interface with RapidOCR and Qwen-VL backends
Replace hardcoded RapidOCR dependency with a pluggable OcrEngine Protocol:

- OcrEngine Protocol (engine.py): abstract interface with recognize(image) method
- RapidOcrEngine (rapid_ocr.py): local ONNX-based OCR (existing capability)
- QwenVisionOcrEngine (qwen_vision_ocr.py): cloud vision via DashScope API
  using qwen-vl-plus model with OpenAI-compatible endpoint

Engine selection via VIBE_TRADING_OCR_ENGINE env var:
  auto (default) -> try rapid first, then qwen-vl
  rapid          -> local RapidOCR only
  qwen-vl        -> Qwen-VL cloud vision only
  none           -> disable OCR entirely

doc_reader_tool.py refactored to use the pluggable interface.
Fail-close behavior preserved: scanned pages error out with install hints
when no OCR engine is available.

E2E verified:
- Text PDF (21-page Chinese broker report): 28,130 chars extracted, 0 OCR pages
- Synthetic scanned PDF (3 image-only pages): all 3 pages OCR'd via Qwen-VL
- 60 existing unit tests pass

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 323/323
AI-Contributed/UT: 0/0
2026-07-11 21:03:35 +08:00
zygora 59aa5ee909 feat(data): separate USD-M execution and mark prices
Signed-off-by: zygora <122336305+honginp@users.noreply.github.com>
2026-07-11 13:19:39 +08:00
zygora dc8337d6c3 feat(data): add explicit Binance USD-M symbol contract
Signed-off-by: zygora <122336305+honginp@users.noreply.github.com>
2026-07-11 13:19:33 +08:00
Robert Tidball 0ec7f96fd7 fix: support legacy FastMCP transport imports 2026-07-11 15:18:47 +10:00
Lanre Shittu 2b55b41dfd chore(cache): bump loader cache version to 3 for interval + end-date fix 2026-07-10 19:36:47 +01:00
Haozhe Wu fa5e098143 fix(docs): propagate the fundamental zoo family + 0.1.11 counts to frontend, skills, and wiki
Post-release cross-audit findings: the 5th Alpha Zoo family never reached the
frontend (AlphaZoo card grid missing fundamental, academic still 6, '4 zoos'
i18n strings x5 locales), factors/__init__ docstring, or the asset-allocation
skill ('4 optimizers'). Wiki: 29->30 presets, docs version selector bumped to
0.1.11 (labels only; content is unversioned). README x5: locale parity for the
07-04 UTC news entry, india_broker in the loaders tree comment (20 sources),
ja/ko contributor headings translated.
2026-07-11 01:31:22 +08:00
Haozhe Wu 3a9e72f2f6 chore(release): sync infra version strings + requirements for 0.1.11
- Dockerfile OCI version label 0.1.10 -> 0.1.11
- frontend package.json / package-lock.json / 5 i18n locale UI versions -> 0.1.11
- agent/requirements.txt: add bottleneck, defusedxml, prompt_toolkit to match
  pyproject (Docker installs -e . afterwards so images were functionally fine,
  but the cached requirements layer and the file itself had drifted)
2026-07-11 01:17:12 +08:00
Haozhe Wu ecd43ad7d2 release: v0.1.11 — India equity, fundamental factors, IM channels roll-up
Version bump + release paperwork: detailed CHANGELOG for 0.1.11 (plus the
missing 0.1.10 backfill), README news + counts across all 5 locales
(30 presets / 460 alphas across 5 families / 19 free sources + india_broker
row and India fallback chain / roadmap), v0.1.11 contributor cycle credits,
SKILL.md version, and the opt-in scheduler flag documented in .env.example.
2026-07-11 01:11:35 +08:00
Haozhe Wu 85599573cd polish: post-merge review fixups for #461/#464/#466
- manifest guard test: ignore __pycache__ when counting zoo modules
- analyze_image: state the vision-capable-model requirement in the tool
  description; point the example path at the new uploads media root
- turnover_aware: drop the mathematically inert risk_free knob (additive
  constant on the simplex), fix the max-Sharpe docstring mislabel, document
  realized_turnover as class-API-only
- skills: surface turnover_aware in asset-allocation + strategy-generate
  so the agent can actually propose it
2026-07-11 00:29:08 +08:00
Haozhe Wu 3e0d95b469 fix(channels): save inbound IM media under the allowed uploads root (#465)
Inbound media now lands in ~/.vibe-trading/uploads/<channel>/ so the agent's
file-reading tools can open it without VIBE_TRADING_ALLOWED_FILE_ROOTS
configuration. The Matrix E2E store (credentials) moves to the runtime dir
instead, with a legacy-path fallback so existing E2E sessions survive.
2026-07-11 00:29:08 +08:00
卫斯李 d16716ee1f feat(optimizers): add turnover-aware portfolio optimizer (#466)
Fifth optimizer: mean-variance utility with an L1 turnover penalty against the previous rebalance (SLSQP, long-only simplex), so the portfolio only trades when expected improvement outweighs churn. Includes 12 seeded tests.
2026-07-11 00:28:52 +08:00
fei-moss a40f680b00 feat(tools): add analyze_image vision tool for charts and screenshots (#464)
Read-only tool that sends a local image to the session model as a multimodal message. Path-validated against the allowed file roots; complements read_document's OCR with semantic vision analysis. Requires a vision-capable model.
2026-07-11 00:28:48 +08:00
asahikiko 3090c418b7 fix(docs): sync packaged skill capability counts (#461)
Update agent/SKILL.md to the live counts (8 engines / 460 alphas / 86 skills / 30 presets / 20 sources) and add a guard test that keeps the packaged manifest in sync with source.
2026-07-11 00:28:44 +08:00
烦fpy 430b10f939 fix(longbridge): convert Decimal to float for JSON serialization (#459)
The Longbridge SDK returns Decimal for prices/quantities/balances; serialize them as floats across quotes, bars, balances, positions, orders, and executions.
2026-07-11 00:28:40 +08:00
fei-moss 2b27aed383 fix(napcat): send pairing codes for private messages (#463)
Pass is_dm for private messages so the pairing-code flow fires, matching every other channel adapter.
2026-07-11 00:28:35 +08:00
Haozhe Wu aaa9945792 docs: add July 10 news — India equity support, centralized env config, UX guard batch 2026-07-10 12:50:25 +08:00
Haozhe Wu ff04624d13 fix(providers): capture GLM reasoning content on the zhipu provider
GLM thinking models (glm-4.5+/glm-5.x) stream chain-of-thought as
reasoning_content; without capture the capability layer dropped it at the
ChatOpenAI boundary. Enable capture_reasoning (DeepSeek posture: no replay
on assistant turns until verified live against bigmodel).

Fixes #458. Refs discussion #337.
2026-07-10 12:48:23 +08:00
Haozhe Wu 1c3953ff05 Merge PR #305: first-class Indian equity (NSE/BSE) support
IndiaEquityEngine (T+1 delivery, opt-in shorts, circuit bands, config-driven STT/stamp/exchange/SEBI/GST cost stack), .NS/.BO routing to Yahoo with yfinance + read-only Shoonya/Dhan broker-data bridge fallbacks, equity_in factor universe (255 alpha101/qlib158 factors opted in), mandate asset-class wiring. Dhan/Shoonya stay paper + read-only per the runtime-discriminator red line. Conflict resolved in loaders/registry.py (keep both qveris + india_broker).

Closes #305.
2026-07-10 11:28:25 +08:00
wison1717-maker 8e7e16313e feat(frontend): clarify inputs, unify error feedback, guard mandate commit
- Add aria-labels and a Enter/Shift+Enter hint to the Agent chat input,
  goal editor, and previously label-less send button.
- Add a lightweight ConfirmDialog (no new dependency) and require a
  second confirmation step before committing a real trading mandate,
  with a limits summary shown before the user commits.
- Surface previously swallowed errors in Compare.tsx (api.listRuns /
  api.getRun failures now toast instead of silently showing "no data").
- Remove a duplicate toast+inline error in QVerisSettings' save flow.
- Add a "How it works" 4-step section to the Home page.
- Give the Agent page's kill-switch row a border to visually separate
  it from the lighter decorative status chips above it.
- Route several already-translated mandate strings (Cancel, Send
  adjustment, Committing…, error messages) through i18n instead of
  hardcoded English; add matching keys across all 5 locales.
- Fix a flex min-width bug that caused long Home page step titles to
  overflow instead of wrap.

No new dependencies. No changes to trading/API/auth logic beyond the
mandate confirm step and the two error-surfacing fixes noted above.
2026-07-10 11:24:54 +08:00
Haozhe Wu dbad71d7d9 Merge PR #440: centralize environment variable management with Pydantic schema + CI gate
EnvConfig Pydantic schema (60+ vars, 6 sub-models) + thread-safe lazy singleton accessor replacing scattered os.getenv across 50+ files; AST-based CI gate (tools/ci_env_var_gate.py, noqa: env-gate suppression) wired into CI. Conflict with main resolved in agent/src/providers/llm.py: kimi-coding UA override (848d139) now reads moonshot_user_agent via get_env_config().

Closes #438.
2026-07-10 11:22:41 +08:00
shadowinlife d593377668 fix(doc-reader): fail close when OCR engine is missing instead of silent degradation
Previously, when rapidocr_onnxruntime was not installed and a scanned/image
page was encountered, the tool silently returned empty text. Users had no
indication that content was lost.

Now:
- PDF pages with <50 chars of extractable text return an error with clear
  install instructions (pip install rapidocr_onnxruntime)
- Standalone image files return the same error when OCR is unavailable
- Text-based PDFs and text files are unaffected (no OCR needed)
- _ocr_available() caches the import check for performance

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 42/42
AI-Contributed/UT: 0/0
2026-07-10 10:58:43 +08:00
卫斯李 df92a1a0be test(api): cover the scheduled research routes (#452)
Route-level contracts for POST/GET/DELETE /scheduled-runs: create/validation/422, newest-first list + status filter + limit bounds, delete/404/unsafe-id 400. Store singleton redirected to a temp file so tests never touch the runtime root. Verified locally against main: 9 passed.
2026-07-10 10:43:28 +08:00
shadowinlife 5b8aaaf124 feat(strategy-dev-manager): add Strategy Development Manager skill with artifact store and decay monitoring
Add a new 'strategy-dev-manager' skill that orchestrates the full lifecycle
from academic paper to validated factor/strategy: ingest, extract, implement,
evaluate, and monitor with automated decay detection.

New modules:
- strategy_store/: StrategyStoreProtocol + InMemoryStore + SqliteStrategyStore
  (WAL mode, FK constraints, PRAGMA user_version migration)
- strategy_store/decay.py: DecayEvaluator state machine with configurable
  thresholds (IC ratio, IR, Sharpe-based transitions)
- tools/sdm_register_tool.py: Register extracted factors/strategies
- tools/sdm_status_tool.py: Query/update lifecycle (list/detail/disable/enable/decay_check)
- tools/sdm_decay_scan_tool.py: Batch decay monitoring with per-artifact scanning

New skill (agent/src/skills/strategy-dev-manager/):
- SKILL.md: 5-phase orchestration workflow
- examples.md: Paper-to-factor and paper-to-strategy examples
- templates/: Factor and strategy SignalEngine templates, decay report template
- references/: Decay thresholds, scheduled scan config, extraction guide, metrics

Tests: 60 tests (41 store/tool + 19 SQLite-specific), all passing.

Closes #455

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 3404/3404
AI-Contributed/UT: 753/753
2026-07-10 10:37:29 +08:00
Haozhe Wu f0213c14bc docs: add July 9 news 2026-07-09 13:48:52 +08:00
Haozhe Wu 848d1392cb fix: integrate startup and provider quick wins
Fix Docker/server startup when mounted router-like routes do not expose path.
Preserve the first CLI turn entered at the resume prompt, align loader fetch signatures, refresh Codex OAuth defaults, add Kimi for Coding and opencode-compatible provider mappings, and clean up Tushare reference fence typos.

Fixes #450.
Integrates #437, #448, #446, #435, #449, #444.
Reported-by: Penn-Live
Co-authored-by: shadowinlife <shadowinlife@gmail.com>
Co-authored-by: morluto <williamlin1327@gmail.com>
Co-authored-by: Carls <yxhuang@uibe.edu.cn>
Co-authored-by: Sankalp Singh <iamsankz@gmail.com>
Co-authored-by: flash1234pku <53250768+flash1234pku@users.noreply.github.com>
2026-07-09 13:35:45 +08:00
shadowinlife 8c2fd3e127 fix: complete env-var migration — CI gate exit 0, 9 schema fields, noqa mechanism
- Add 9 missing env vars to EnvConfig schema (QVERIS_API_KEY,
  VIBE_TRADING_API_URL, OPENAI_MODEL, FUTU_TRADE_PWD_MD5, etc.)
- Migrate 26 os.getenv calls to get_env_config() accessor across 17 files
- Add # noqa: env-gate inline suppression to ci_env_var_gate.py (21 lines)
- Refactor security.py _env_flag_enabled to direct schema access
- Wire CI gate into .github/workflows/test.yml
- Fix api_server.py import of deleted constants
- Restore _legacy.py timeout positive-value validation
- Add reset_env_config() to test_security_auth_api.py for singleton freshness

CI gate: exit 0 (was exit 1 with 54 violations)
Tests: 4953 passed, 4 pre-existing tushare failures, 0 regressions

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 188/188
AI-Contributed/UT: 39/39
2026-07-08 22:42:14 +08:00
shadowinlife 8234058562 refactor: centralize environment variable management with Pydantic schema + CI gate
- Add agent/src/config/env_schema.py: Pydantic models for ~50 env vars across 6 sub-models (LLMConfig, DataConfig, APIConfig, SwarmConfig, AgentTuningConfig, PathConfig)
- Add agent/src/config/accessor.py: thread-safe lazy singleton get_env_config() + unified _parse_bool() + reset_env_config() for test isolation
- Fix TIMEOUT_SECONDS CLI display default (2400 -> 120, matching runtime)
- Fix MAX_RETRIES CLI display default ('(not set)' -> 2, matching runtime)
- Fix VIBE_TRADING_API_KEY server-side alias (security.py now resolves it via get_env_config().api.api_auth_key)
- Fix VIBE_TRADING_CHANNELS_AUTO_START=on silently ignored (unified _parse_bool accepts 'on')
- Migrate 207 scattered os.getenv/os.environ.get calls across 50+ files to centralized accessor
- Convert 12 module-level import-time fixations to lazy accessor pattern (loop.py, swarm_tool.py, _backend.py, worker.py, security.py)
- Add reset_env_config() calls after settings_routes.py env mutations (13 mutation points)
- Replace print('[WARN]...') with logger.warning() in tushare/okx/yfinance loaders
- Add tools/ci_env_var_gate.py: AST-based CI gate preventing new os.getenv calls outside src/config/
- Add tools/test_ci_env_var_gate.py: 18 test cases for the CI gate
- Update tools/ci_grep_gates.sh to call the new env var gate
- Add agent/tests/test_env_schema.py: 81 test cases covering defaults, type coercion, singleton, thread safety, bool parser
- Update agent/.env.example with schema cross-references
- Update agent/tests/conftest.py with reset_env_config autouse fixture

Closes HKUDS/Vibe-Trading#438

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 1383/1383
AI-Contributed/UT: 735/735
2026-07-08 19:36:24 +08:00
Haozhe Wu ef0335286c docs: add July 8 news — fundamental factor layer Phase 1, QVeris optional premium data, maintainer day 2026-07-08 15:06:24 +08:00
Haozhe Wu 9c128a6544 feat(factors): fundamental factor layer Phase 1 — PIT-safe SEC fundamentals as daily panels
The translator layer: sparse point-in-time filing events become dense
daily panels shaped like price/volume, so existing factor primitives and
backtest engines consume fundamentals with zero changes.

- loaders/_fundamental_schema.py: 12 raw fields, SEC XBRL concept alias
  table (new-standard revenue concepts first), derived-field formulas
  (roe/roa/gross_profitability/asset_growth/accruals/leverage)
- loaders/fundamentals_loader.py: filed-date-anchored PIT ffill (never
  period_end), first-filed-wins restatement policy, duration-span frame
  selection (true quarters only — YTD and full-year frames excluded),
  fiscal Q4 synthesized as FY minus three quarters anchored on the 10-K
  filed date, TTM = rolling four-quarter sum
- factors/registry.py: PanelColumn opens to fund:-prefixed columns
  (price cols unchanged, unknown cols still rejected); decay_horizon
  bound raised to 512 for annual-report-scale factors
- backtest/runner.py: panel injection when a selected factor requires
  fund:* columns; lazy import, zero overhead otherwise, data-source
  failure degrades to NaN frames instead of crashing the run
- tools/get_fundamentals_tool.py: read-only auto-discovered facade
- factors/zoo/fundamental/: fund_roe, fund_earnings_yield,
  fund_gross_profitability, fund_asset_growth (registry now 460 alphas);
  cross-sectional zscore added to factors/base.py (purity-gate home)

Live-verified against SEC EDGAR: AAPL TTM revenue $385.7B and ROE 1.36
match independent hand calculation; the YTD double-count this caught is
pinned by a regression test. Full suite 4817 passed.
2026-07-08 15:00:41 +08:00
Haozhe Wu 7f5a6ea766 fix(cli): reject extra --swarm-run tokens and explain invalid vars JSON
--swarm-run was declared nargs="+" but the handler silently dropped
every token after the second, and a non-JSON vars argument died with a
bare "Invalid JSON". Extra tokens now fail fast naming the dropped
values, and JSON errors report the offending string, the parser error,
correct usage, and a shell-quoting hint.

Closes #428
2026-07-08 15:00:41 +08:00
Haozhe Wu 2c62367ed8 test(frontend): fix flaky QVeris settings save tests on slow CI
The base-URL input renders its default value before the config GET
resolves, so findByDisplayValue could match while the save button was
still loading-disabled — the click was swallowed and the PUT assertion
timed out on slow runners. Wait for the button to be enabled instead.
2026-07-08 14:33:24 +08:00
Haozhe Wu b37da118fb Merge pull request #424 from shadowinlife/refactor/api-server-complete-modular
refactor(api): extract shared infrastructure into focused modules

# Conflicts:
#	agent/api_server.py
2026-07-08 14:18:29 +08:00
Haozhe Wu 0320f050ce Merge pull request #429 from isaveall/fix/validation-json-missing-artifacts-dir
fix(backtest): create artifacts dir before writing validation.json (closes #427)
2026-07-08 14:16:44 +08:00
Haozhe Wu 035673b065 revert: back out IRR-AGL reliability/governance stack (#405, #416)
Reverts merge commits 0b867a7 (#405) and 474f5e4 (#416), and removes the
now-orphaned #406 artifact metadata store (src/reliability + tests, zero
references left outside its own tests).

Why: the stack wrapped the tool registry in every session path (even
governance mode=off) without exposing the registry surface AgentContext
reads, breaking all session-runtime chats within a day (#433, #431). It
also modified protected core files (src/agent/loop.py, src/session/
service.py), shipped 12 internal-convention docs/ files into the public
tree, and its enforcement layer already has reported bypass paths (#420).
Trading safety stays on the single mandate-gate design (#31, #144).

README news entries for 2026-07-07/07-06 updated accordingly.
2026-07-08 14:15:56 +08:00
Haozhe Wu be117a24b4 feat: QVeris paid data marketplace integration (pluggable, single-commit revertable)
Provider: qveris.ai capability marketplace (search/inspect/execute + usage/ledger).
All logic lives in new files; every touch to an existing file is a tagged
QVERIS-INTEGRATION line so 'git revert' of this commit removes the feature cleanly.

- agent tools: qveris_search / qveris_inspect (free) / qveris_execute
  (paid; preview-by-default, allow_paid gated by per-session credit budget)
- REST: GET/PUT /qveris/config + GET /qveris/status (masked key, signup url)
- CLI: vibe-trading qveris status|enable|disable|usage (+ /qveris slash)
- backtest loader: source="qveris" (explicit-only; structurally excluded
  from every auto fallback chain; granularity-aware capability selection;
  top-3 candidate fallback; provider-named series container parsing;
  truncated-result full-content download)
- frontend: Settings QVeris card (toggle/base URL/key/mode/budget/balance/
  recent usage/referral signup CTA), 5 locales aligned (21 keys)
- skill: qveris SKILL.md + references (rest-api, coverage map) + data-routing row
- README x5: free-vs-premium dual-track data section (QVERIS-START/END)

Live-verified end to end against the real API: CLI enable/status, REST config,
search/inspect/preview-refusal/paid execute (charged 1-2 credits, ledger
reconciled), loader fetched 9 real AAPL daily bars. Full backend suite
5025 passed with no regressions; frontend vitest + tsc clean.
2026-07-08 13:42:31 +08:00
isaveall 7c15396a79 fix(backtest): create artifacts dir before writing validation.json
When config["validation"] is set, run_backtest() writes
run_dir/artifacts/validation.json in step 7, but run_dir/artifacts is
only created by _write_artifacts() in step 8. If the directory does not
already exist (e.g. a swarm agent workspace), the write raises
FileNotFoundError and aborts the backtest.

Ensure the parent directory exists before the write. Adds a regression
test that runs a backtest with validation enabled against a run_dir
with no pre-created artifacts/ and asserts validation.json is written.

Fixes #427

Signed-off-by: isaveall <isaveall0@gmail.com>
2026-07-07 21:45:00 +08:00
shadowinlife 8bbadc4ece fix(api): resolve channel runtime singleton staleness across tests
The host-check logic in _get_channel_runtime/_get_session_service used
_host_attr(name, None) which returns None when the host module sets the
attribute to None (e.g. monkeypatch reset). The elif branch checking the
local singleton was unreachable because api_server always exists and
hasattr is True for re-exported symbols, so stale local instances from
prior tests were returned instead of re-initializing.

Replace _host_attr with direct sys.modules getattr so that an explicit
None on the host falls through to the initialization path, matching the
original api_server.py behavior before extraction.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 23/23
AI-Contributed/UT: 0/0
2026-07-07 17:59:54 +08:00
shadowinlife 39627475af refactor(api): extract shared compat layer, fix session service writeback
- Create src/api/_compat.py with shared host_attr/set_host_attr
- Remove duplicated _host_attr from security.py, helpers.py, state.py
- Fix _get_session_service missing _set_host_attr writeback to api_server
- Simplify host-check logic in state.py using _compat helpers
- Change _DEFAULT_CORS_ORIGINS from mutable list to immutable tuple
- Add middleware registration comment in api_server.py
- Expand test coverage: 7 → 33 tests (CORS, path validation, SPA routes,
  dotenv round-trip, compat layer, session service writeback)

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 104/104
AI-Contributed/UT: 199/199
2026-07-07 17:14:04 +08:00
shadowinlife 976a1c71f3 refactor(api): extract shared infrastructure into focused modules (#331)
Move auth/CORS/security helpers, Pydantic models, dotenv/SPA helpers,
and lazy-init service singletons out of api_server.py into four focused
modules under src/api/. api_server.py becomes a thin assembler that
creates the FastAPI app, mounts middleware, registers route modules,
and re-exports symbols for test compatibility.

- security.py: auth deps, CORS parsing, DNS-rebinding guard, loopback
  host validation, shell-tools gating (379 lines)
- models.py: Artifact, BacktestMetrics, RAGSelection, RunInfo,
  RunResponse Pydantic schemas (91 lines)
- helpers.py: path constants, dotenv I/O, SPA deep-link middleware,
  path-parameter validation (188 lines)
- state.py: lazy-init session service and channel runtime (124 lines)

api_server.py: 1,103 → 363 lines. Zero behavior change.
All 171 existing API tests pass unchanged.

Closes #331

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 1718/1718
AI-Contributed/UT: 57/57
2026-07-07 16:36:27 +08:00
Haozhe Wu a2b68e8e6b docs: add July 7 contributor batch news 2026-07-07 13:24:51 +08:00
Haozhe Wu 0b867a7444 Merge pull request #405 from Elfsa-Miranda/upstream-pr/00-rfc-tool-inventory
docs: add IRR-AGL reliability RFC and tool inventory
2026-07-07 13:10:57 +08:00
Haozhe Wu 474f5e4361 Merge pull request #416 from Elfsa-Miranda/upstream-pr/08-hardening-docs
docs: harden IRR-AGL migration, security, and regression coverage
2026-07-07 13:09:24 +08:00
Haozhe Wu 74d69e8498 Merge pull request #407 from sambazhu/feat/ai-berkshire-skills
Feat/ai berkshire skills
2026-07-07 13:07:35 +08:00
Haozhe Wu 824c4d8e47 Merge pull request #397 from mustafakamal88/fix/aware-utc-timestamps-395
Use timezone-aware UTC timestamps
2026-07-07 13:06:16 +08:00
Haozhe Wu 2157738d85 Merge pull request #417 from irfanallana-oss/fix/place-order-zero-size
fix(trading): treat zero quantity/notional as unset in trading_place_order
2026-07-07 13:06:12 +08:00
Haozhe Wu 0ec8f2400e Merge pull request #393 from kadaliao/codex/beginner-guide-tutorial
docs: Add beginner Vibe-Trading tutorial
2026-07-07 13:05:14 +08:00
Haozhe Wu c00de34676 Merge pull request #396 from kadaliao/codex/alpha-library-social-card
Add Alpha Library social card
2026-07-07 13:04:29 +08:00
Haozhe Wu 8324ed070a Merge pull request #413 from SyntaxSawdust/codex/im-channel-reply-timeout
fix(channels): make IM reply timeout configurable
2026-07-07 13:04:25 +08:00
mustafa88 c15437c441 Merge main into timezone-aware timestamp fix 2026-07-07 13:03:31 +08:00
sambazhu a6b82efba8 fix(frontend): add value investing locale parity 2026-07-07 12:58:22 +08:00
irfan allana e2f02a6e4f test(trading): cover zero unused order sizing fields 2026-07-07 12:58:22 +08:00
Miranda a974372a1a fix: remove IRR-AGL trailing EOF blanks 2026-07-07 12:53:50 +08:00
irfan allanaandClaude Opus 4.8 9f2be9dc61 fix(trading): treat zero quantity/notional as unset in trading_place_order
The agent's trading_place_order tool could never place a share-quantity
order. LLMs routinely populate both sizing fields, leaving the unused one
at 0. _num_or_none(0) returned 0.0, so both quantity and notional reached
the connector as non-None and place_order rejected the request with
"provide exactly one of quantity or notional".

A zero order size is never valid, so coerce a 0 quantity/notional to None,
preserving the "exactly one of quantity/notional" contract regardless of
which field the model over-fills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:33:41 -05:00
Miranda a9835776ad Merge upstream/main into upstream-pr/08-hardening-docs 2026-07-06 23:07:06 +08:00
Miranda bb2c2b538e fix: keep phase5 factor registry registration compatible 2026-07-06 21:58:21 +08:00
Dustin Persek f4da7e6523 fix(channels): configure IM reply timeout
Signed-off-by: Dustin Persek <dustin.persek@protonmail.com>
2026-07-06 08:57:32 -04:00
Miranda c906d2fe93 docs: harden IRR-AGL migration and regression coverage
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 19:37:06 +08:00
Haozhe Wu c19da874f3 docs: add July 6 README news 2026-07-06 17:31:49 +08:00
sambazhuandClaude 6c09d25c34 feat(skills): English skill bodies + macro constraints per upstream review
Responding to upstream review feedback:

1. All skill bodies converted to English (matching bundled skills like
   akshare). Some Chinese wording was ambiguous in an international project.
2. Added macro constraints (demographic clock / consumption trend /
   generational shift) per reviewer's point that a consumer-name pick
   should weigh long-term macro headwinds:
   - thesis-tracker: new red line on macro-trend reversal for
     consumer/property/education names.
   - value_investing_committee Li Lu agent: mandatory demographic-clock +
     consumption-trend + generational-shift section for consumer-dependent
     names; a consumer name cannot score high 10-year certainty without
     addressing demographics.

No structural changes; counts unchanged (85 skills, 70 tools, 30 presets).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 17:25:39 +08:00
Haozhe Wu 760a490b87 test(search): cover CN fallback without network
Keep web_search fallback tests deterministic by mocking CN fallback providers and covering the disabled fallback error path.

Clean trailing blank lines from the new reliability artifact files so diff checks stay green.
2026-07-06 17:10:56 +08:00
Miranda 60bc141f95 feat(reliability): add artifact metadata store
Add a local artifact metadata foundation with canonical hashing, secret redaction, lineage helpers, and SQLite-backed records.\n\nPreserve trace and run-card compatibility by making new artifact references optional.
2026-07-06 17:08:24 +08:00
sambazhu b3531409e4 feat(search): add CN fallback search backends
Add optional Alibaba Cloud IQS, Sogou, and cn.bing fallback paths for environments where ddgs engines are unavailable or slow.\n\nKeep the fallback configurable and preserve the existing web_search JSON envelope.
2026-07-06 17:08:15 +08:00
shadowinlife fa52c0069b refactor(api): extract remaining routes into modules
Move runs, scheduled research, swarm, sessions, goals, and live routes from api_server.py into focused src/api route modules.\n\nKeep compatibility re-exports for existing tests and preserve route behavior while reducing api_server.py to the app shell and shared wiring.
2026-07-06 17:07:47 +08:00
Dustin Persek c3f440ee55 fix(preflight): avoid following provider probe redirects
Treat provider preflight redirects as successful reachability responses by disabling redirect following on the stripped base URL probe.\n\nAdd regression coverage for redirect responses and request errors.
2026-07-06 17:07:37 +08:00
Miranda b054afe9de feat: add research cards and reliability panels
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 17:02:44 +08:00
Miranda b9eaa31656 feat: add deterministic agent eval harness
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 15:57:55 +08:00
sambazhuandClaude 5d9fd088ea feat(frontend): add Value Investing examples to welcome screen
Adds a 'Value Investing' category to WelcomeScreen with 4 one-click
examples surfacing the ai-berkshire toolkit:
- Value Investing Committee (run_swarm value_investing_committee, Tencent)
- Supply-Chain Bottleneck Hunter (bottleneck-hunter, AI infra)
- Investment Thesis Tracker (thesis-tracker, Moutai)
- Exact Valuation Check (financial_rigor PE/PB/ROE + three-scenario)

i18n: en + zh-CN (title + desc + prompt). dist is gitignored — CI builds it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:35:36 +08:00
Miranda 23004d5303 feat: add quant reliability scorecards
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 15:18:42 +08:00
sambazhuandClaude bab15351f1 feat(skills): add deep-company-series (P3)
An 8-part publication-grade deep-dive on a single company (~120k words):
cognitive reset / moat / profit engine / hidden assets / era variable /
financials / management / valuation+redlines. The core IP is the strict
fact-check checklist (5 pseudo-precision traps + 7 revision checks) and
the cross-article consistency scan.

Adapted from ai-berkshire's deep-company-series.md. Cross-article scan
reuses report_audit (extract numbers across 8 articles) + financial_rigor
cross_validate (flag >1% deviations) — closes the loop with P1's tools.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:02:09 +08:00
sambazhuandClaude fdc7e198d1 feat(skills): add data verification discipline + research bias self-check (P3)
P3 of the ai-berkshire fusion — small, broad-impact methodology additions:

- data-routing: add a 'Data Verification Discipline' section — cross-check
  material figures across >=2 sources, flag >1% deviations as caliber
  mismatches, use financial_rigor's cross_validate for exact comparison,
  mark unverified numbers. Pairs with the cross_validate tool added in P1.
- research-discipline (new skill, analysis): a 60-second self-bias checklist
  run at the start of any research task — leader-bias, English-bias,
  narrative-bias, confirmation-bias, recency-bias, with concrete corrections.
  Covers the reasoning layer (vs cross_validate=numbers, report_audit=output).

No code changes, no new deps. Existing data-routing tests still pass (4/4).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:46:41 +08:00
Miranda ab782c9189 feat: add research protocol registry and trial ledger
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:38:11 +08:00
Miranda e3afb93f4b test: add IRR-AGL readiness gate
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:35:15 +08:00
Miranda 6f23860dc9 docs: add IRR-AGL phase 0 inventory baseline
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:35:15 +08:00
Miranda b74a179c53 feat: add governed tool registry and policy decisions
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:34:24 +08:00
Miranda 4551fa2f08 test: stabilize local suite on Windows
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:32:01 +08:00
Miranda f3640e976a fix: preserve audited loader fetch compatibility
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:28:58 +08:00
Miranda 5dd1194be4 feat: add data audit and PIT observe mode
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:28:57 +08:00
Miranda dcb42d2e5d feat: add IRR-AGL artifact store and trace refs
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:18:35 +08:00
sambazhuandClaude 5c438c0d37 fix(swarm): register value_investing_committee in the routing table
The preset YAML was on disk and loadable (inspect_preset valid=True), but
run_swarm refused preset_name='value_investing_committee' as 'Unknown preset'
because _PRESET_NAMES is derived from _PRESET_KEYWORDS, which the new preset
was not added to. The LLM then fuzzy-matched to the similarly-named built-in
investment_committee and ran the wrong team.

Register value_investing_committee in _PRESET_KEYWORDS (value-investing /
Buffett / Munger / Duan Yongping / Li Lu keywords) and add its
{company}/{market} variables to _build_variables.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:15:04 +08:00
Miranda a265a0a174 test: add IRR-AGL readiness gate
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:07:13 +08:00
Miranda 1b674eaf1a docs: add IRR-AGL phase 0 inventory baseline
Signed-off-by: Miranda <tu07918382691@gmail.com>
2026-07-06 12:07:01 +08:00
sambazhuandClaude 93da172460 fix(tools): normalize three_scenario growth > 1 as percent
LLMs routinely pass '15%' as 15 instead of 0.15, which made
three_scenario_valuation compound at 1500% and return absurd target prices
(23.5 x 16^3 = 96256 instead of 35.74). The math was correct for the input
given, but the tool silently accepted an unreasonable growth rate.

Now |growth| > 1 is treated as a percent and divided by 100, with a
growth_normalized_from_percent flag and note in the result. Description
also clarified ('0.15 for 15%').

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 09:11:15 +08:00
sambazhuandClaude 366e82385b fix(tools): make financial_rigor + report_audit repeatable
loop.py dedups non-repeatable tools by name (a tool already in _called_ok
is refused on a second call). Both tools are read-only and idempotent but
were left at the default repeatable=False, so calling financial_rigor
twice in one session (e.g. verify_valuation then three_scenario) hit a
'cached result, cannot re-invoke' refusal. Set repeatable=True on both.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 08:55:26 +08:00
Haozhe Wu 2442fa2e1f docs: add July 5 README news 2026-07-05 23:38:42 +08:00
Haozhe Wu 1cb15d1f3c Merge remote-tracking branch 'origin/pr/401' 2026-07-05 23:26:15 +08:00
Haozhe Wu 5cc9c44baa Merge remote-tracking branch 'origin/pr/390' 2026-07-05 23:25:43 +08:00
Haozhe Wu 5c4e1ae1b3 Merge remote-tracking branch 'origin/pr/382' 2026-07-05 23:25:20 +08:00
Haozhe Wu cfe397c360 Merge remote-tracking branch 'origin/pr/399' 2026-07-05 23:25:12 +08:00
Miranda 7a1c7c9d42 fix(tests): bypass proxies for loopback MCP fixtures 2026-07-05 21:14:41 +08:00
Miranda 76725699b9 fix(tests): skip fork-dependent bench mock test on Windows 2026-07-05 21:04:43 +08:00
Miranda b84518066c fix(tests): make oauth token cache permission checks portable 2026-07-05 21:03:35 +08:00
Miranda 3be0738f1e fix(tests): isolate loader cache root on Windows 2026-07-05 21:02:17 +08:00
sambazhuandClaude ef74a22370 feat(skills): add value-investing toolkit (rigor + audit + 4 skills + committee preset)
Ports the ai-berkshire value-investing methodology into first-class
Vibe-Trading tools / skills / swarm preset, rewritten for the BaseTool +
SKILL.md + swarm model (no Claude Code slash-commands or TeamCreate).

Tools (auto-discovered BaseTool subclasses, pure stdlib):
- financial_rigor: exact-decimal verification — verify_market_cap,
  verify_valuation, cross_validate, benford, calc, three_scenario. The calc
  evaluator walks an AST in the Decimal domain (0.1 + 0.2 == 0.3 exactly),
  so there is no eval and no IEEE-754 drift.
- report_audit: markdown data-point extraction -> random sample -> PASS/FAIL
  verdict gate. A single-source failure now correctly FAILs the report
  (the source logic silently WARNed instead).

Skills (markdown methodology):
- bottleneck-hunter: supply-chain bottleneck arbitrage — decompose a
  super-trend to Layer 2/3 choke points with mandatory valuation gates.
- thesis-tracker: post-buy discipline — 5-sentence thesis, falsifiable
  assumptions, red lines, 1-10 health score.
- management-deep-dive: management integrity / ability / capital-allocation
  / governance with Duan Yongping's 3-question verdict.
- private-company-research: 6-lens pre-IPO framework (business / financial /
  competitive / risk / tech / alternative-data) with confidence labeling
  and signal-consistency cross-validation.

Swarm preset:
- value_investing_committee: four adversarial master perspectives
  (Buffett / Munger / Duan Yongping / Li Lu) plus a chair synthesizing
  consensus and contradictions into a verdict.

63 new tests; EXPECTED_PRESET_COUNT bumped 29 -> 30.

Inspired by https://github.com/xbtlin/ai-berkshire.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 13:39:34 +08:00
shadowinlife b1c6fcec23 fix(ashare-mootdx): replace bare except with except Exception
The batch_fetch_daily function in a_mootdx_fetcher.py used a bare
'except:' clause that caught KeyboardInterrupt and SystemExit in
addition to ordinary exceptions. This prevented users from interrupting
long-running A-share data pulls with Ctrl+C and silently swallowed
OOM/SIGTERM signals.

Replace with 'except Exception as e:' and log a warning so that:
- KeyboardInterrupt and SystemExit propagate to the caller
- fetch failures are visible in logs instead of being silent

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-07-05 13:05:06 +08:00
mustafa88 5084e03fd6 Use timezone-aware UTC timestamps 2026-07-04 21:16:11 +01:00
Kada Liao 63f825ca77 fix(wiki): add alpha library social card
Signed-off-by: Kada Liao <kadaliao@gmail.com>
2026-07-05 02:09:09 +08:00
Haozhe Wu 5f6e572219 docs: add July 4 README news 2026-07-04 14:06:43 +08:00
shadowinlife 43be0ca4d1 refactor(api): extract settings routes
Move LLM and data-source settings routes plus their exclusive helpers into src/api/settings_routes.py, preserving existing api_server re-exports for test monkeypatch compatibility.

Local verification: 63 focused settings/security tests passed; compileall, diff check, settings route registration, and monkeypatch target checks passed.
2026-07-04 13:11:06 +08:00
aeonframework 4d71bd60df fix(deps): raise vulnerable dependency floors
Raise Pillow and LangChain floors past disclosed advisories, and align the direct langgraph constraint with the newer LangChain 1.3 dependency graph so the package remains installable.

Local verification: Python 3.13 venv pip dry-run for editable install resolved; Pillow/langchain/langgraph/langchain-openai installed and imported; pip check passed.
2026-07-04 13:10:41 +08:00
Kada Liao 2e5ea4fc17 docs: add Chinese beginner tutorial
Add a Chinese beginner-facing wiki tutorial and link it from the tutorial index, keeping broker/trading guidance conservative around read-only, paper, mandate, kill switch, and fail-closed boundaries.

Local verification: diff check, HTML/resource/id validation, sensitive-claim scan, and local wiki HTTP fetches passed.
2026-07-04 13:07:09 +08:00
shadowinlife bae9d21828 refactor(api): extract channels routes
Move the IM channel HTTP routes and channel runtime lifecycle wrappers from api_server.py into src/api/channels_routes.py while preserving the existing registration path and test monkeypatch compatibility.

Local verification: 85 focused API/channel tests passed; compileall, diff check, and route duplicate checks passed.
2026-07-04 13:06:21 +08:00
Kada Liao 936480b1f1 docs: add beginner Vibe-Trading tutorial 2026-07-04 11:47:34 +08:00
Haozhe Wu 077ceae759 Merge pull request #392 from HKUDS/codex/20260703-readme-news
docs: add July 3 README news
2026-07-03 16:49:21 +08:00
Haozhe Wu c3af4a9dd2 docs: add July 3 README news 2026-07-03 16:42:04 +08:00
Haozhe Wu 180aa2d053 Merge pull request #391 from HKUDS/codex/20260703-pr-issue-audit
fix: align Robinhood MCP and env startup
2026-07-03 16:35:31 +08:00
Haozhe Wu 44f8cad928 fix: align Robinhood MCP and env startup 2026-07-03 16:28:24 +08:00
Haozhe Wu f96795c6ee Merge pull request #389 from hobostay/fix/ssrf-cgnat-qq-redirect
fix(security): block CGNAT/mesh ranges in SSRF guard; close QQ media redirect-to-internal
2026-07-03 16:21:23 +08:00
Haozhe Wu 6af4c72e4d Merge pull request #378 from shadowinlife/refactor/pr-1-system-routes
refactor(api) [Patch 2/9]: extract system routes to src/api/system_routes.py
2026-07-03 16:21:16 +08:00
aeonframework 7b9d519386 fix(deps): raise Pillow and langchain floors past disclosed CVEs
Pillow >=10.0.0 -> >=12.2.0 clears PYSEC-2026-457/GHSA-3f63-hfp8-52jq (arbitrary code execution), GHSA-44wm-f244-xhp3 (buffer overflow), GHSA-j7hp-h8jx-5ppr (libwebp OOB write), PYSEC-2023-175, PYSEC-2026-165/GHSA-r73j-pqj5-w3x7 (PDF-parse infinite-loop DoS), GHSA-wjx4-4jcj-g98j (font integer overflow).

langchain >=1.0.0,<2 -> >=1.3.9,<2 clears GHSA-gr75-jv2w-4656 (path traversal / sandbox escape, fixed 1.3.9); stays within the existing <2 cap.

Detected by osv-scanner. Manifest-only change (pyproject.toml + agent/requirements.txt).
2026-07-03 08:13:38 +00:00
hobostayandClaude 3ce2ee3ed1 fix(security): block CGNAT/mesh ranges in SSRF guard; close QQ media redirect-to-internal
Two SSRF gaps in the channel media-fetch path:

1. channels/utils._is_private only checked is_loopback | is_link_local |
   is_private. ipaddress.is_private is False for RFC 6598 shared address
   space (100.64.0.0/10 — the default Tailscale/mesh range), so those hosts
   passed validate_url_target and every channel (QQ, DingTalk, NapCat,
   Telegram, channelsui) would fetch them as media. Switch to
   `not addr.is_global or addr.is_multicast`, which blocks all
   non-globally-routable ranges uniformly and also closes an IPv4-multicast
   gap (is_global is True for multicast). Matches web_reader_tool._url_allowed.

2. qq._read_media_bytes validated media_ref once, then fetched with
   allow_redirects=True. media_ref is agent-controlled (from msg.media, which
   is routinely influenced by untrusted external content), so a public URL
   returning 302 -> http://169.254.169.254/... would be followed unchecked and
   the internal body uploaded as a media attachment. Set allow_redirects=False
   and fail closed on 3xx, mirroring napcat.py. (The separate inbound download
   at qq.py:618 fetches a trusted QQ CDN URL and is intentionally left as-is.)

Adds agent/tests/test_url_target_security.py covering both fixes. The central
guard (validate_url_target / _is_private) previously had no tests at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 10:50:26 +08:00
shadowinlife 199c59113b refactor(api): extract settings routes to src/api/settings_routes.py
Move 4 settings routes (LLM GET/PUT, data-sources GET/PUT) and
7 exclusive helpers from api_server.py into a self-contained
route module following the PR #375 pattern.

- settings_routes.py defines all Pydantic models locally (no shared modules)
- register_settings_routes() resolves auth deps via sys.modules fallback
- Late-access closures for monkeypatch targets (ENV_PATH, ENV_EXAMPLE_PATH)
- Re-exports: ENV_PATH, ENV_EXAMPLE_PATH, _baostock_supported,
  _baostock_installed, _load_llm_providers for test compatibility
- api_server.py: -294 lines (3449 -> 3155)

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: idealab/Qwen3.7-Max-DogFooding
AI-Contributed/Feature: 0/756
AI-Contributed/UT: 0/0
2026-07-03 08:35:58 +08:00
shadowinlife 88b5945ee2 refactor(api): extract channels routes to src/api/channels_routes.py
Move 4 channel routes (status, start, stop, pairing/command) and the
_start/_stop_channel_runtime lifecycle helpers from api_server.py into
a self-contained route module following PR #375 pattern.

- channels_routes.py defines ChannelPairingCommandRequest locally
- Lifecycle helpers access host state via sys.modules for monkeypatch compat
- _get_channel_runtime() stays in api_server.py (uses global on module state)
- Re-exports lifecycle functions for startup/shutdown hooks

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: idealab/Qwen3.7-Max-DogFooding
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 72/190
AI-Contributed/UT: 0/0
2026-07-03 08:24:50 +08:00
shadowinlife b0baa9c211 refactor(api): extract system routes to src/api/system_routes.py
Move 5 system routes (health, correlation, shutdown, skills, api-info)
and the _terminate_current_process helper from api_server.py into a
self-contained route module following the PR #375 pattern.

- system_routes.py defines HealthResponse locally (no shared modules)
- register_system_routes() resolves _security and
  _require_shutdown_authorization via sys.modules fallback
- Re-exports _terminate_current_process for test monkeypatch compat
- api_server.py: -92 lines (3552 → 3471)

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: idealab/Qwen3.7-Max-DogFooding
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 270/270
AI-Contributed/UT: 0/0
2026-07-03 08:07:24 +08:00
Haozhe Wu 01e211853f docs: add July 2 news 2026-07-02 16:48:01 +08:00
Haozhe Wu 88dfc97877 perf(factors): optimize rolling operators and bench path
Closes #339
Refs #342

Original performance implementation from #342 by @shadowinlife. Maintainer integration keeps the contribution and tightens the parallel execution and dependency edges before merge.

Validation:
- PYTHONPATH=/tmp/vibe-pr342-bn-only:agent pytest agent/tests/test_factor_operators.py agent/tests/test_bench_parallel.py agent/tests/test_equity_regression.py -q
- VIBE_TRADING_DISABLE_BOTTLENECK=1 PYTHONPATH=/tmp/vibe-pr342-bn-only:agent pytest agent/tests/test_factor_operators.py -q
- python -m compileall -q agent/backtest/engines/base.py agent/scripts/bench_performance.py agent/src/factors/_backend.py agent/src/factors/base.py agent/src/factors/bench_runner.py
- git diff --check
- GitHub Actions CI test passed
2026-07-02 16:13:47 +08:00
Haozhe Wu b574af8528 refactor(api): extract upload routes
Refs #331
Refs #358

Original route extraction from #358 by @shadowinlife. Maintainer integration narrows the first API modularization step to the upload and Shadow report routes so no unused duplicate API modules land in main.

Validation:
- PYTHONPATH=agent pytest agent/tests/test_upload_api.py agent/tests/test_upload_security.py agent/tests/test_security_auth_api.py agent/tests/test_settings_api.py agent/tests/test_spa_deep_link.py agent/tests/test_run_card.py -q
- python -m compileall -q agent/api_server.py agent/src/api/uploads_routes.py
- git diff --check
- GitHub Actions CI test passed
2026-07-02 16:12:28 +08:00
Haozhe Wu 2cd47cd166 fix(core): narrow backtest subprocess environment
Closes #332

Summary:
- Narrow generated-backtest subprocess environment inheritance to OS/Python basics, proxy/certificate settings, allowed run-root config, and read-only market-data settings.
- Avoid forwarding LLM provider keys, API auth tokens, shell-tool switches, broker trading secrets, and live/advisory toggles into generated strategy code.
- Document the generated backtest trust boundary in README and SECURITY.

Validation:
- PYTHONPATH=agent pytest agent/tests/test_runner_env.py agent/tests/test_run_card.py agent/tests/test_path_safety.py agent/tests/test_file_tool_sandbox_security.py -q
- python -m ruff check agent/src/core/runner.py agent/tests/test_runner_env.py
- python -m compileall -q agent/src/core/runner.py agent/tests/test_runner_env.py
- git diff --check
- GitHub Actions CI passed
2026-07-02 15:23:41 +08:00
shadowinlife 6ec6bd1314 fix(channels): add IM session reset commands
Closes #371.

- Add channel-agnostic /new, /reset, and /newsession handling in ChannelRuntime so IM users can start a fresh session without leaving the chat.
- Preserve existing session history while clearing only the channel session mapping.
- Document in-chat slash commands across README locales.
- Normalize pairing slash-command parsing so the documented case-insensitive command behavior matches runtime behavior.

Validation:
- PYTHONPATH=agent pytest agent/tests/test_channels_runtime.py -q
- python -m compileall -q agent/src/channels/runtime.py agent/src/channels/pairing/store.py agent/tests/test_channels_runtime.py
- git diff --check
- GitHub Actions CI: test
2026-07-02 14:45:09 +08:00
Haozhe Wu 4709f4c33c docs: add July 1 news 2026-07-01 16:21:19 +08:00
Haozhe Wu 87e675c344 fix(frontend): update echarts security patch 2026-07-01 15:49:20 +08:00
Matt Van Horn d8f88d45da fix(wiki): harden alpha-library CSP by externalizing inline scripts 2026-07-01 15:40:27 +08:00
Haozhe Wu e22e06ac89 fix(frontend): update vulnerable frontend dependencies 2026-07-01 15:40:07 +08:00
Haozhe Wu 041e1cf643 fix(frontend): reconcile channel proxy and zh-CN i18n 2026-07-01 15:26:04 +08:00
hoc.s 18d97e8bef fix(i18n): fix zh-CN language switching and LanguageSwitcher display
1. Remove nonExplicitSupportedLngs — it strips region codes from compound
   language keys (zh-CN → zh) causing isSupportedCode to reject them and
   fall back to English.

2. Add /channels to Vite proxy paths — Settings page calls /channels/status
   via Promise.all with getLLMSettings and getDataSourceSettings; without a
   proxy rule these requests return index.html causing JSON parse errors.

3. Fix LanguageSwitcher always showing 'English' — i18n.languages (plural)
   always includes the fallback 'en', and SUPPORTED_LANGUAGES has 'en' first
   so .find() always matched 'en'. Fixed by checking i18n.language (singular)
   first.

Signed-off-by: hoc.s <a615828a615828@outlook.com>
2026-07-01 15:23:34 +08:00
opj161 80c811eadd fix(frontend): make channel settings robust against API failures and proxy channel requests
Signed-off-by: opj161 <opj161@outlook.com>
2026-07-01 15:23:34 +08:00
Haozhe Wu 1b9a6700cb Merge pull request #349 from digger-yu/feat/cli-setup-dev
fix(cli): dev pre-checks frontend deps to surface clear error
2026-07-01 14:59:03 +08:00
Haozhe Wu fda61aa4c5 Merge pull request #351 from shadowinlife/fix/frontend-loopback-port-334
fix(docker): bind frontend dev port to loopback for consistency with backend
2026-07-01 14:58:57 +08:00
Haozhe Wu 3b78745b1f Merge pull request #338 from Robin1987China/safe-bind-default
fix(api): default bind to 127.0.0.1 and warn on non-loopback without API_AUTH_KEY
2026-07-01 14:58:50 +08:00
Robin1987China 3234a82e45 fix(api): default bind to 127.0.0.1 and warn on non-loopback without API_AUTH_KEY
Default the --host bind address to 127.0.0.1 instead of 0.0.0.0 to
match the secure-by-default posture already used in docker-compose.
Add a startup warning when binding to a non-loopback address without
API_AUTH_KEY configured — the existing loopback peer-IP and
DNS-rebinding checks still reject remote requests, but the warning
surfaces the misconfiguration at startup.

Also default the Typer `serve` command's --host to 127.0.0.1 so every
serve entry point agrees (the console script and `python -m cli` already
bound loopback; this covers the `python -m cli.main` path too).

Tests: _is_loopback_bind_host truth table (IPv4/IPv6/hostname/edge),
loopback default, and the warning firing only on a non-loopback bind
without a configured key.

Refs #333

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-06-30 19:50:20 +08:00
shadowinlife 2b9647a5be fix(docker): bind frontend dev port to loopback for consistency with backend
The frontend dev service published port 5899 on all interfaces
(0.0.0.0) while the backend was correctly bound to 127.0.0.1:8899.
On a shared/LAN network, this exposed the Vite dev server (source,
module graph, HMR websocket) to other hosts unnecessarily.

Add the 127.0.0.1 prefix to the frontend port mapping so both
services share the same loopback-only exposure discipline.

Closes #334

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 2/2
AI-Contributed/UT: 0/0
2026-06-30 17:05:15 +08:00
digger yu df6b760ea4 fix(cli): dev pre-checks frontend deps to surface clear error
`vibe-trading dev` previously assumed the user had already run
`vibe-trading setup`. When they hadn't, the Vite spawn failed with
npm's confusing "vite is not a command" message and the user had to
know to install dependencies separately.

Add a quick `frontend/node_modules/.bin/vite` (or vite.cmd) existence
check before spawning. If missing, fail fast with a clear panel that
points the user at `vibe-trading setup`.

Signed-off-by: digger yu <digger-yu@outlook.com>
2026-06-30 16:30:05 +08:00
Haozhe Wu 2d08b9550c docs: add IM channel README coverage (#343) 2026-06-30 15:09:46 +08:00
Haozhe Wu 101306e916 feat: wire IM channel runtime
Merge IM channel runtime wiring, controls, dependency gates, tests, and legacy-name cleanup.
2026-06-30 14:31:26 +08:00
Haozhe Wu 6f6b1794a3 docs: add 2026-06-29 news to all 5 READMEs (live advisory + Trading 212 + fixes) 2026-06-29 17:15:49 +08:00
Haozhe Wu 4ad497dfc4 Merge pull request #321 from mvanhorn/fix/309-trading212-readonly-connector
feat: read-only Trading 212 broker connector
2026-06-29 16:55:33 +08:00
Matt Van Horn eb1c23db48 feat: read-only Trading 212 broker connector
Fixes #309
2026-06-29 16:44:19 +08:00
Haozhe Wu 2416a82373 Merge branch 'fix/gemini-thought-signature-replay-local' 2026-06-29 16:12:30 +08:00
Haozhe Wu 59a1dc1853 Merge branch 'patch1' 2026-06-29 16:12:23 +08:00
Haozhe Wu ddb44e0be2 Merge branch 'feat/advisory-interface' 2026-06-29 16:12:18 +08:00
mvanhorn 4ac49e8582 fix: preserve Gemini thought_signature across AgentLoop dict-history replay 2026-06-29 16:09:40 +08:00
digger yu b17ad71761 fix(i18n): polish language switcher semantics 2026-06-29 16:02:41 +08:00
shadowinlife 6ed89f6a94 test(live): assert advisory reviews stay observational 2026-06-29 15:59:59 +08:00
Haozhe Wu b4430b3b40 fix(tools): route US statements to SEC EDGAR 2026-06-29 15:51:47 +08:00
Haozhe Wu 399b5b4e23 fix(wiki): harden alpha library hydration 2026-06-29 15:51:47 +08:00
Haozhe Wu 7ec94c6d2a Merge pull request #329 from hannibal-lee/fix/pandas-3-windows-crash
Fix pandas version constraint
2026-06-29 15:51:23 +08:00
hannibal-lee d46db51750 Fix pandas version constraint 2026-06-29 13:58:57 +08:00
shadowinlife e5ca46aa6a fix(live): advisory provider registry, public enforcement aliases, exception sanitization
Address review findings from PR #328:

- Add module-level provider registry (register/clear/get_advisory_providers)
  replacing the hardcoded AdvisoryOrchestrator([]) dead-code path
- Add public aliases in enforcement.py for 3 private helpers used by advisory
- Replace monkeypatch __init__ in test #9 with registry-based injection
- Sanitize exception messages: str(exc) → type(exc).__name__ in audit dicts
- Add exc_info=True to logger.warning for full traceback in logs only
- Add logger.info when advisory enabled but no providers registered
- Rename account_drawdown → utilization_ratio (semantic accuracy)

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 85/85
AI-Contributed/UT: 57/57
2026-06-29 12:02:38 +08:00
shadowinlife 866d4e5683 test(live): add advisory interface and gate integration tests (#317)
9 test cases covering:
- Verdict enum members (4 values matching invinoveritas contract)
- AdvisoryContext frozen dataclass
- AdvisoryResult auto-timestamp
- MockAdvisory default approve, configurable verdict, raise_on_review
- AdvisoryOrchestrator fail-open on provider exception
- Gate advisory disabled by default (no advisory in gate_decision)
- Gate advisory enabled with mock provider (verdict embedded in audit)

All tests pass, ruff clean, no network dependencies.


Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-06-29 11:36:59 +08:00
shadowinlife 20ea6d6f5a feat(live): wire advisory layer into order gate (opt-in, observational) (#317)
Add advisory review hook to LiveOrderGuardTool._allow() behind an opt-in
environment variable (VIBE_TRADING_ENABLE_ADVISORY). Advisory verdicts are
embedded in gate_decision['advisory'] for audit visibility but never block
or alter order execution.

- _advisory_review() builds AdvisoryContext from intent/positions/balance
- Fail-open: any exception → REVIEW_UNAVAILABLE, order proceeds
- Default off: env var unset = no advisory field in gate_decision
- _allow() accepts positions/balance kwargs threaded from execute()


Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-06-29 11:36:53 +08:00
shadowinlife 2c92714340 feat(live): add MockAdvisory for advisory interface testing (#317)
Configurable in-memory advisory provider for test suites. Supports verdict
injection, optional delay, forced failure, and call-history recording —
everything needed to exercise the orchestrator and gate integration without
any network dependency.


Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-06-29 11:36:47 +08:00
shadowinlife 1bcb8ee336 feat(live): add PreTradeAdvisoryInterface and advisory data models (#317)
Add broker-agnostic advisory interface for external risk assessment services
to provide observational pre-trade opinions. Purely additive — never blocks
or alters order execution.

- Verdict enum: APPROVE, APPROVE_WITH_CONCERNS, REJECT, REVIEW_UNAVAILABLE
- AdvisoryContext: frozen dataclass with 8 normalized fields
- AdvisoryResult: frozen dataclass with auto-filled UTC timestamp
- PreTradeAdvisoryInterface: abstract base with provider_id + review()
- AdvisoryOrchestrator: sequential provider execution, fail-open aggregation
- AggregatedVerdict: worst-case verdict with flattened concerns


Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-06-29 11:36:41 +08:00
digger yu ae9aee9282 feat(i18n): add ja/ko/ar locales, RTL layout, LanguageSwitcher, and i18n tests
Add Japanese, Korean, and Arabic locales to match the README set,
replace the two-option zh/en toggle with a full language dropdown,
and add comprehensive i18n parity tests.

i18n core
- New ja/ko/ar.json (634 keys each, 100% parity with en.json).
- SUPPORTED_LANGUAGES registry + isRtl()/applyDocumentDirection()
  helpers; navigator as secondary detector with Node.js 21+ guard;
  nonExplicitSupportedLngs for regional variant matching.
- New i18next.d.ts for strict t() key type-checking at compile time.

Components
- LanguageSwitcher dropdown in Layout.tsx: position: fixed + JS-measured
  coords (resists overflow/clipping/RTL direction), full ARIA, touchstart
  + Escape dismiss, i18n.languages array for regional code matching.
- WelcomeScreen: fix double-prefix bug — CATEGORIES/CAPABILITY keys now
  carry full i18n paths with `as const`.
- AlphaZoo: 10 dynamic t() calls use `as any` + defaultValue fallback.
- APP_VERSION constant removed; version string sourced from t('app.version').

RTL & theme
- Logical properties throughout (border-e, ps/pe, border-s-2) +
  rtl:flex-row-reverse on main layout.
- --popover CSS variable defined (light + dark); bg-popover was producing
  no color before, making every popover transparent.
- [dir="rtl"] .rtl\:flip-x rule for mirroring directional icons.

Tests (i18n.test.ts — 231 total, all passing)
- Key parity: 5 locales × 2 directions (missing + extra) + sanity check.
- Interpolation variable parity across all locales.
- isRtl: base codes, regional variants (ar-EG/ar-SA), unknown codes.
- SUPPORTED_LANGUAGES registry integrity.

zh-CN.json
- Translation refinements ("A股回测"→"多市场回测", description alignment
  with en.json); key ordering aligned to en.json structure.

Signed-off-by: digger yu <digger-yu@outlook.com>
2026-06-29 09:28:59 +08:00
Haozhe Wu 5bde656fbf docs: update June 28 news 2026-06-28 22:18:03 +08:00
Haozhe Wu e1feee31af fix(tools): clean file tool sandbox imports 2026-06-28 17:02:16 +08:00
Haozhe Wu 8633bc51dd Merge pull request #299 from skloxo/contrib/fix-file-tools-tilde 2026-06-28 17:01:23 +08:00
Haozhe Wu b5246ca999 fix(frontend): guard runtime status polling
Fixes #322
2026-06-28 16:39:51 +08:00
Haozhe Wu cb1aa92f7c Merge pull request #292 from digger-yu/patch1
feat(cli): add `setup` and `dev` commands for cross-platform frontend…
2026-06-28 16:36:33 +08:00
Haozhe Wu 1a3cee4012 fix(mcp): sanitize OAuth cache keys
Fixes #313
2026-06-28 11:31:11 +08:00
Haozhe Wu d1aab2be76 fix(cli): align legacy OpenAI default model 2026-06-28 03:35:54 +08:00
Haozhe Wu 53135139f3 Merge remote-tracking branch 'refs/remotes/pr/319' 2026-06-28 03:35:09 +08:00
Haozhe Wu efff920eee Merge remote-tracking branch 'refs/remotes/pr/320' 2026-06-28 03:35:03 +08:00
Matt Van Horn 9a9e4da5b2 fix: clearer Robinhood agent.json validation and safe seed config surfacing
Fixes #312
2026-06-27 09:44:36 -07:00
Matt Van Horn 454bceed6c fix: resolve default OpenAI model to an available id (gpt-5.5-instant unavailable)
Fixes #311
2026-06-27 09:39:00 -07:00
Haozhe Wu 3f7a3c8b4d docs: update June 27 news 2026-06-27 21:40:11 +08:00
shadowinlife 022d042c6a fix(providers): tolerate content-filtered LLM responses
Skip individual content-filtered LLM responses instead of aborting event-driven analysis or swarm workers, surface high filter-rate warnings in run cards, and recognize Gemini safety finish reasons alongside OpenAI content_filter.

Closes #307.

Thanks @shadowinlife.
2026-06-27 21:39:13 +08:00
卫斯李 f5900c5f33 refactor(shadow-account): centralize price feature contract
Centralize Shadow Account price feature names in models.PRICE_FEATURES so extractor and codegen cannot drift, and keep price-context bounds at four decimals to avoid losing small return thresholds.

Includes regression coverage for feature-name sharing and prior_5d_return precision.

Thanks @Robin1987China.
2026-06-27 21:39:02 +08:00
Haozhe Wu 0cf7d691f8 docs(news): add 2026-06-26 update 2026-06-26 22:52:04 +08:00
Haozhe Wu 93efeb76e0 refactor(loaders): drop unused _ETF_PREFIXES import after #315 2026-06-26 22:52:04 +08:00
shadowinlife 28f9a26a1e fix(loaders): route tushare ETF/index/HK to correct API endpoints (#315)
Tushare loader now routes ETF/LOF to fund_daily(), index to index_daily(), HK to hk_daily() instead of always calling daily() (empty for non-stocks). Adds per-symbol empty warnings + runner partial-fetch warning. Closes #310. Thanks @shadowinlife.
2026-06-26 22:42:07 +08:00
卫斯李 632a6778f2 feat(shadow-account): carry price-condition bounds into extracted rules (#314)
Promoted entry_rsi14 / prior_5d_return bounds now flow into extracted ShadowRules and the generated SignalEngine for real conditional entry. Follows #302. Thanks @Robin1987China.
2026-06-26 22:41:57 +08:00
skloxo ab50a0afca refactor(tools): isolate read/write allowed roots, deduplicate path resolution, and add comprehensive security tests 2026-06-26 17:33:41 +08:00
skloxo 049a5afa82 Merge remote-tracking branch 'upstream/main' into contrib/fix-file-tools-tilde 2026-06-25 21:31:47 +08:00
Haozhe Wu 1792715145 docs(news): add 2026-06-24 and 2026-06-25 updates 2026-06-25 19:57:29 +08:00
skloxo 955a1d02d2 feat(i18n): expand reactive zh-CN localization
Moves more main-panel UI copy through reactive English/zh-CN translations.
2026-06-25 19:53:42 +08:00
MarkfuGod df731256f4 fix(agent): sync data-source count and gate microcompact
Closes #282. Derives the data-source count dynamically and avoids microcompaction below the token threshold.
2026-06-25 19:53:39 +08:00
gyx09212214-prog ec7e65015f fix(validation): write strict JSON outputs
Normalizes non-finite validation payload values before writing validation.json or stdout.
2026-06-25 19:53:36 +08:00
卫斯李 1cadda4199 feat(shadow-account): add as-of price-context features
Adds PIT-safe entry RSI and prior return features for Shadow Account strategy extraction.
2026-06-25 19:53:32 +08:00
Hinotobi a351a258ae fix(api): allow authenticated remote same-origin UI requests
Follow-up to #293. Allows authenticated remote same-origin browser requests while preserving cross-site rejection.
2026-06-25 19:53:29 +08:00
muku314115 987630e7f8 adding support for india 2026-06-24 13:22:54 +05:30
skloxo 3241aa736c chore: update uv.lock to fix zigzag build Cython dependency issue 2026-06-24 10:20:15 +08:00
skloxo 91764e74eb fix: safe_path tilde expansion at source + widen file roots to /
Root cause: safe_path() did (workdir / p).resolve() which treats ~ as a
literal directory name. This was masked before VIBE_TRADING_ALLOWED_FILE_ROOTS
was added because tools only accessed run_dir + skills/ (relative paths).

Fix: expand tildes in safe_path() itself so ALL callers benefit. Revert
per-tool workarounds (read/write/edit) in favor of the centralized fix.

Also:
- Set VIBE_TRADING_ALLOWED_FILE_ROOTS=/ for full filesystem access
- Set VIBE_TRADING_ALLOWED_RUN_ROOTS=/ (same)
- Clean revert of 629c2cf per-tool patches, keeping ALLOWED_FILE_ROOTS support
2026-06-24 10:19:34 +08:00
skloxo 5561a548d7 fix: tilde expansion in file tools — safe_path doesn't expand ~, causing paths like ~/.vibe-trading/ to resolve to root/~/.vibe-trading/ (nonsense). Now all three tools (read/write/edit) expand tildes before path validation.
Also set VIBE_TRADING_ALLOWED_FILE_ROOTS=/home/skloxo in vibe-trading-api.service
so the API agent has full filesystem access like the user requested.
2026-06-24 10:19:32 +08:00
skloxo 360a0696c6 fix: expand read_file/write_file/edit_file to access configured extra file roots
The file tools were sandboxed to run_dir only. Now they also check
VIBE_TRADING_ALLOWED_FILE_ROOTS (from path_utils._allowed_file_roots)
so the agent can read/write ~/.vibe-trading/scripts/, db/, etc.
2026-06-24 10:19:29 +08:00
digger yu 88bfc6d419 feat(cli): add cross-platform setup and dev commands
Add ``vibe-trading setup`` and ``vibe-trading dev`` to fix the
cross-platform frontend build / dev workflow.

The Windows npx tsc footgun (npm pulls the abandoned ``tsc@2.0.4``
package from the registry) is solved by always invoking tsc via
``npm exec --package=typescript tsc -b`` on Windows. POSIX uses the
standard ``npm run build`` since npm prepends ``./node_modules/.bin``
to PATH for local scripts.

``dev`` launches the backend from ``AGENT_DIR`` (so ``python -m
cli._legacy serve`` resolves the in-repo ``cli`` package) and the
Vite dev server with the port from ``vite.config.ts`` (5899, not the
5173 we used to hardcode). Both children receive SIGINT/SIGTERM and
are force-killed after a 5 s grace period.

On Windows, ``subprocess.Popen`` does not consult ``PATHEXT``, so the
npm path is taken from ``shutil.which`` (which returns the ``.cmd``
form) and used directly — without this, ``Popen(["npm", ...])``
raises ``FileNotFoundError`` even when ``which`` succeeds.

argparse subparsers are registered in ``_legacy.py``; typer
commands ``setup`` / ``dev`` are registered in ``main.py``'s
``_build_typer_app()``. ``init`` behavior is unchanged.

Tests: ``agent/tests/test_cli_setup_dev.py`` covers platform-aware
build commands, the node/npm-missing path, the dev 5899-vs-5173
regression, and the AGENT_DIR-cwd regression. The existing
``cmd_live_authorize`` flow (OAuth handshake) is untouched.

Signed-off-by: digger yu <digger-yu@outlook.com>
2026-06-24 08:59:59 +08:00
Haozhe Wu 45df314d2f docs(news): add 2026-06-23 entry across all 5 READMEs (#298)
Local API CSRF hardening (#293). Keeps 3 visible news entries; 2026-06-20 moves under the fold.
2026-06-23 20:28:58 +08:00
Haozhe Wu 5155016c4b docs(readme): sync Roadmap shipped status (Research Autopilot / Data Bridge / Research Delivery) (#297)
Mark Research Autopilot (Phase 1-3, #260/#267), the local Data Bridge loader (#252), and the scheduled-research scheduler/executor (#272/#278/#288) as shipped in the Roadmap status column across all 5 READMEs. Feature descriptions left intact so SQL connectors and multi-channel outbound delivery still read as pending. Closes #294.
2026-06-23 19:13:14 +08:00
Hinotobi dafdb036fa fix(api): reject cross-site unsafe browser requests before loopback auth (#293)
Run the existing cross-site guard for unsafe methods (non GET/HEAD/OPTIONS) before honoring loopback dev-mode trust, so a malicious page cannot drive local POST/PUT/DELETE routes via blind cross-origin requests. Adds upload-API regressions for cross-site rejection and the local non-browser path.
2026-06-23 18:49:41 +08:00
Haozhe Wu 57f73fe583 docs(news): add 2026-06-22 entry across all 5 READMEs (#291)
Live-authorize OAuth fix (#281, closes #259), Alpha Zoo headline
double-count fix (#287, closes #286), and scheduled-research usage
docs (#288). Rolls the 0.1.10 (06-19) entry into the Earlier-news
fold to keep three entries visible.
2026-06-22 23:24:33 +08:00
Haozhe Wu 9b127f453b docs(readme): sync current counts across all 5 READMEs (#289)
Update the live capability counts to match the code, verified against
the registries:
- Alpha Zoo 452 -> 456 (registry.list() == 456); academic zoo 6 -> 10
  (Jegadeesh / George-Hwang / Amihud / Harvey-Siddique added in #277)
- Finance skills 77 -> 79
- MCP tools 36 -> 54 (mcp_server has 54 @mcp.tool fns); the exposed-tool
  list is completed with the 18 read-only data tools that were missing
- agent tools tree comment 31 -> 68 (build_registry().tool_names == 68)

Only current-state claims are touched. Dated News entries (the 0.1.8 and
v0.1.9 release notes), the "Shipped 0.1.8" roadmap row, and contributor
credits are point-in-time records and are deliberately left unchanged.

Supersedes #285.
2026-06-22 22:54:10 +08:00
卫斯李 1c897e3642 fix(cli,mcp): keep live authorize alive through the OAuth flow (#281)
The connector authorize OAuth handshake timed out before the token was
persisted (#259). Two root causes:

1. The OAuth flow is driven lazily by the list_tools discovery request,
   which is bounded by the per-call tool_timeout (default 30s), not
   init_timeout. The prior fix widened only init_timeout, so a Robinhood
   face-scan sign-in (>30s) still tripped the handshake.
2. The 30s timeout was retried, and the retry opened a fresh client
   context that started a SECOND OAuth callback server on a new port,
   orphaning the sign-in the user had just completed.

Fix:
- cmd_live_authorize now raises BOTH tool_timeout and init_timeout to a
  configurable authorize deadline (VIBE_LIVE_AUTHORIZE_TIMEOUT_SECONDS,
  default 300s), raise-only so a larger user value is preserved.
- The authorize handshake runs a single non-retried list_tools attempt
  via build_mcp_tool_wrappers(..., max_list_tools_attempts=1), so a
  transient failure cannot spawn a duplicate OAuth flow.
- _run_with_retry takes a configurable attempts cap; runtime/registry
  callers keep the default 2 (one transient retry) unchanged.

Closes #259

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
2026-06-22 22:33:42 +08:00
Haozhe Wu 02c65b818e docs(readme): document Scheduled Research usage across all 5 READMEs (#288)
The scheduled-research feature (#254/#278) shipped without user-facing
docs, so users couldn't tell how to drive it. Add a "Scheduled research"
subsection to the API Server section plus the three /scheduled-runs
endpoint rows in the endpoint table, in en/zh/ja/ko/ar: the
VIBE_TRADING_ENABLE_SCHEDULER flag, the interval-ms / 5-field-cron
schedule format, and create/list/cancel curl examples.
2026-06-22 22:22:03 +08:00
Haozhe Wu 86b98214ec fix(frontend): stop Alpha Zoo headline rendering the count twice (#286) (#287)
The AlphaZoo headline rendered a standalone `{total}` and then the
translated `prebuiltAlpha` string, which already interpolates
`{{count}}` — so the page showed the number twice (e.g. "456 456
pre-built quant alphas across 4 zoos"). It also carried a hardcoded,
now-stale `452` fallback.

Render only the translated string with the live (filter-aware) total,
and show a count-less loading variant while alphas load. New
`alphaZoo.prebuiltAlphaLoading` key added to both en and zh-CN.
2026-06-22 22:12:55 +08:00
Haozhe Wu dc8a5eb060 docs(news): add 2026-06-21 entry across all 5 READMEs 2026-06-21 19:24:27 +08:00
shadowinlife 4a64f0e2b9 feat(prompt): add layered post-backtest attribution guidance (#280)
Adds a 4-layer post-backtest attribution framework to the agent system prompt (trade attribution, beta regression, regime analysis, Monte Carlo permutation test) with strategy routing (At-risk / Sub-optimal / Healthy) and self-check rules. Prompt-only change to the Backtest task routing — no engine or tool code. Attribution is gated by data availability and explicitly marked secondary to strategy correctness.

Verified against live artifact schemas (trades.csv pnl!=0 exit rows with holding_days/return_pct, validation.json monte_carlo section, config.validation wiring) and prompt .format() brace integrity; 11 new tests pass.

Closes #279
2026-06-21 19:10:20 +08:00
LemonCANDY42 8f7dc0aa3d feat(reports): add backtest report library page (#224)
Adds a dedicated /reports Run Library page (product shape agreed in #216): lists report-worthy runs with run id, status, created time, truncated prompt, symbols, date range, return, Sharpe, and links to Run Detail + Compare. Search over run id / prompt / symbol / status; v1 status + created-date filters; newest-first sort with oldest/return/Sharpe options. Sidebar + page copy localized in en + zh-CN. listRuns gains an optional limit aligned with the existing GET /runs?limit contract.

Verified open-box: npm run build clean, full frontend suite 209/209 passing.

Closes #216
2026-06-21 19:07:08 +08:00
Matt Van Horn 974ad59a68 feat(scheduled-research): add executor that fires due jobs on schedule (#278)
Adds the background executor that polls the scheduled-research store and dispatches due jobs (interval-ms + cron) through the existing session runtime. Gated behind VIBE_TRADING_ENABLE_SCHEDULER (default off), so existing deployments are unchanged. Resets stale RUNNING jobs on startup, re-reads each record before completion writes to survive delete/replace races, rejects impossible cron schedules without blocking the loop, and dispatches with a config copy so saved jobs are never mutated.

Completes the scheduled-research feature begun in #272 (data model + store + CRUD).

Closes #254
2026-06-21 19:06:46 +08:00
Haozhe Wu eeb11bf303 docs(news): add 2026-06-20 entry across all 5 READMEs
Autopilot Phase 3 (loop closure) + loader OHLC integrity guard + 4
academic alphas. Keeps the 3-latest-visible convention (2026-06-17 moved
into the Earlier-news details).
2026-06-20 18:30:05 +08:00
Matt Van HornandMatt Van Horn f3bdc3b9ad feat: add scheduled research job data model and CRUD API (#272)
* feat: add scheduled research job data model and CRUD API

Implements the first slice of issue #254 (scheduled execution of agent
prompts and backtests): a durable ScheduledResearchJob data model and
ScheduledResearchJobStore with crash-safe atomic persistence (write-temp
+ fsync + replace + fsync-dir), mirroring src/live/runtime/jobstore.py.
Wires three new API endpoints to api_server.py (POST /scheduled-runs,
GET /scheduled-runs, DELETE /scheduled-runs/{job_id}). Execution wiring
is intentionally deferred to a follow-up PR once the schedule types and
output channels are confirmed. 24 unit tests included.

Refs #254

* fix(scheduled-research): root job store under the user runtime dir

_default_store_path() wrote scheduled_research_jobs.json into the repo
working tree (agent/data/), which is not gitignored — running the API
would leave job state in the checkout. Root it under get_runtime_root()
(~/.vibe-trading/scheduled_research/) instead, the same root the live
runtime, swarm config, and persistent memory resolve. Also range-check
cron fields (minute 0-59 / hour 0-23 / dom 1-31 / month 1-12 / dow 0-6)
so an out-of-range expression like '99 * * * *' is rejected.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-20 18:00:39 +08:00
Lanre Shittu 9addc95a4b feat(loaders): validate OHLC invariants at the loader boundary (#274)
* feat(loaders): validate OHLC invariants at the loader boundary

Loaders only dropped NaN rows, so structurally invalid bars (high<low, a
non-positive price, or a high/low that fails to bracket open/close) passed
straight into the backtest and surfaced downstream as NaN/inf metrics that
break the strict allow_nan=False JSON serializers.

Add a shared validate_ohlc() helper in loaders/base.py and call it after the
existing dropna in the yfinance and local loaders. The default drops offending
rows and logs a count, matching the loaders' existing silent-clean convention;
"warn" and "raise" strategies are available for stricter callers.

Signed-off-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com>

* feat(loaders): apply OHLC validation centrally for every source

Wire validate_ohlc into the runner's single fetch-convergence point so
auto, single-source, runtime-fallback, and any future loader are guarded
uniformly — not just the two loaders wired at source. Adds
_sanitize_data_map(data_map) in runner.py, applied right after the data
map is assembled and before the empty-data check, plus a test that a dirty
bar from an unwired loader is dropped before it reaches the backtest.

---------

Signed-off-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com>
2026-06-20 18:00:35 +08:00
卫斯李andRobin1987China f87f94db88 test(market-data): cover the shared loader-resolution layer (#276)
src/market_data.py is the source-detection + normalization layer shared
by the MCP server and the agent get_market_data tool (added with the #270
global data layer), but shipped without dedicated tests. Adds 24
network-free tests using an injected stub loader:

- detect_source: A-share/US/HK/crypto symbol routing + tushare fallback.
- cap_rows: under-limit passthrough, max_rows=0 (no cap), negative ->
  default, and stride sampling with the final bar always pinned.
- _json_safe: non-finite floats -> None, Timestamp isoformat, numpy
  scalar unwrap, plain passthrough.
- fetch_market_data: explicit-source normalization, auto-grouping by
  detected source, loader-exception fallthrough to _unresolved, and
  partially-resolved symbols listed in _unresolved.
- fetch_market_data_json: strict-JSON output and NaN sanitization under
  allow_nan=False.

Test-only; no production changes.

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
2026-06-20 17:55:36 +08:00
卫斯李andRobin1987China 2e7761a554 feat(autopilot): scaffold signal engine + auto-link backtest metrics (Phase 3) (#267)
Two new tools close the hypothesis -> backtest -> evidence loop that
Phase 1+2 left half-wired:

- scaffold_signal_engine: writes a contract-correct code/signal_engine.py
  stub (no-arg SignalEngine, generate(self, data_map) -> dict of pd.Series)
  with a runnable flat-signal default and the hypothesis signal_definition
  embedded as a docstring. Eliminates the runner's most common pre-flight
  validation failure. Refuses to overwrite an existing engine unless
  overwrite=true.
- link_autopilot_backtest: reads run_card.json metrics from a run dir and
  links them to the hypothesis via link_backtest in one step, instead of
  making the agent hand-extract the metrics dict.

Both resolve run_dir through safe_run_dir, inheriting the allowed-run-root
boundary (fail-closed on path traversal). Adds 12 tests, including a check
that the generated stub satisfies the backtest runner contract by importing
it and running generate().

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
2026-06-20 17:55:33 +08:00
卫斯李andRobin1987China 33527a7f77 feat(factors): add 4 canonical academic alphas (#277)
Expands the thin academic zoo (6 -> 10) with widely-cited, OHLCV-only
factors, each following the existing academic template (__alpha_meta__ +
cross-sectional z-score compute, src.factors.base primitives only):

- academic_strev: Jegadeesh (1990) one-month reversal — inverse 21d return.
- academic_high52w: George & Hwang (2004) 52-week-high momentum —
  close / ts_max(close, 252).
- academic_illiq: Amihud (2002) illiquidity — 21d mean of |return| per
  dollar volume.
- academic_retskew: Harvey & Siddique (2000) skewness premium — inverse
  60d return skewness.

All four auto-register via the registry AST scan and pass the
alpha_purity and lookahead gates (no future peeking, whitelisted imports
only). Verified: no inf, fully-valid latest row, factors suite green.

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
2026-06-20 17:55:29 +08:00
卫斯李andRobin1987China 94f03c9fc2 test(tools): add coverage for trade journal analyzer + parsers (#268)
trade_journal_tool.py and trade_journal_parsers.py shipped with no
dedicated tests despite carrying deterministic, user-facing numeric
logic. Adds 48 tests:

- parsers: A-share symbol qualification (.SH/.SZ/.BJ by prefix), buy/sell
  token normalization (CN + EN), market inference, format detection
  (tonghuashun/eastmoney/futu/generic/unknown), and end-to-end parse_file
  on CSV fixtures.
- tool: FIFO roundtrip pairing (exact PnL, proportional fee allocation,
  partial-fill splitting, unmatched-sell handling), profile win-rate/PnL,
  disposition-effect diagnostic, filter expressions, and the
  analyze_trade_journal dispatch + error paths (missing file, bad
  extension, profile/behavior/strategy modes).

Test-only; no production changes.

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
2026-06-20 17:55:25 +08:00
卫斯李andRobin1987China f855be3103 test(tools): add coverage for chart pattern recognition (#269)
pattern_tool.py (370 LOC) shipped with no dedicated tests despite
holding deterministic numeric detection logic that feeds strategy
design and post-backtest analysis. Adds 21 tests:

- find_peaks_valleys: exact peak/valley indices, too-short guard, NaN
  center handling.
- trend_line_slope: exact slope on a linear series, NaN prefix, flat=0.
- candlestick_patterns: doji (neutral), hammer (bullish), bullish and
  bearish engulfing.
- support_resistance: peak/valley clustering separation, flat-series shape.
- head_and_shoulders: <3-peak guard + a constructed positive case flagged
  at the head.
- double_top_bottom: exact double-top and double-bottom detection.
- triangle / broadening: output shape/dtype + flat-series zero guards.
- run_pattern: path-safety rejection, missing-OHLCV error, invalid pattern
  name, and an end-to-end run over an ohlcv CSV fixture.

Test-only; no production changes.

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
2026-06-20 17:55:21 +08:00
Lanre Shittu 12bdca5c1c fix(loaders): honor interval in local loader by resampling
The local loader accepted an ``interval`` argument but passed it only to the
cache key, so requesting e.g. 4H against hourly data silently returned the
file's native bars.

Resample to the requested interval using the same OHLCV aggregation as the
yfinance loader (open=first, high=max, low=min, close=last, volume=sum). A
finer-than-source interval cannot be fabricated from a file, so the source
bars are returned unchanged with a warning; requests already matching the
source granularity are passed through. The end-date filter is now inclusive
of the whole end day so intraday bars on that day survive for sub-daily
intervals.

Signed-off-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com>
2026-06-19 20:20:03 +01:00
Haozhe Wu 01d31f589f docs: add in-place-upgrade note for the 0.1.10 LangChain 1.x bump
0.1.10 moved to LangChain 1.x. Upgrading in place with `pip install -U` over a pre-0.1.10 install can leave orphaned dist-info and break imports (e.g. langgraph). Add a short "Upgrading from an older version?" note to all five READMEs (en/zh/ja/ko/ar) pointing users to recreate the venv or `--force-reinstall`. Docs-only; a fresh install is unaffected.
2026-06-19 23:19:18 +08:00
Haozhe Wu 2a5557edb6 chore(release): 0.1.10 — version sync + wheel packaging + Docker weasyprint libs (#271)
* chore(release): 0.1.10 — version sync, wheel packaging, Docker weasyprint libs

Release-prep for 0.1.10 (bundles the global data layer + everything since 0.1.9):

- version 0.1.9 -> 0.1.10 synced across all surfaces: pyproject, frontend
  package.json/lock, sidebar footer + i18n, SKILL.md, Dockerfile LABEL. REST
  API + MCP server now derive the version from cli._version (were hardcoded
  "5.0.0" / unset).
- pyproject package-data ships skill example scripts (skills/**/*.py) so the
  wheel + sdist include them; fastmcp floor -> 2.14.0 (first release where the
  MCP version= kwarg applies).
- Dockerfile installs weasyprint native libs (Pango/HarfBuzz/Fontconfig/Cairo/
  gdk-pixbuf + DejaVu) so PDF reports render in the image; .dockerignore *.md
  -> /*.md (root-anchored, skill markdown still ships).
- README news (5 locales): 06-19 reframed as the v0.1.10 release; 06-19/06-18
  trimmed.

Verified: clean-room pip install imports all new loaders/tools; wheel + sdist
carry the eastmoney/sec-edgar skills incl. UTF-8 references + scripts; 4167
backend tests pass (weasyprint PDF path covered on CI).

* docs(release): credit v0.1.10 cycle contributors across READMEs

Refresh the Contributors section in all 5 READMEs from the v0.1.9 cycle to
the v0.1.10 cycle (PRs #162–#260): @Hinotoi-agent, @mvanhorn, @gyx09212214-prog,
@BillDin, @Robin1987China, @LemonCANDY42, @zwrong, @forge-builder, @skloxo,
@LeeCQiang, @KaiLuettmann, @ngoanpv, @ShahNewazKhan, @sambazhu, @bhlt, @octo-patch.
2026-06-19 20:54:20 +08:00
Haozhe Wu adfbc82303 feat(data): global data layer — 8 new sources + 18 read-only data tools (#270)
- 8 loaders (eastmoney/sina/stooq/yahoo free + finnhub/alphavantage/tiingo/fmp
  optional-key); registry now 18 sources, fallback chains re-ordered by IP-ban
  risk behind a shared throttled HTTP gate
- 18 read-only data tools (fund flow, dragon-tiger, northbound, margin, block
  trades, shareholder count, lockup, sector, research, news, SEC filings,
  financial statements, options chain, stock profile, screener, search, FRED
  macro, iwencai), exposed over MCP
- 3 shared clients (eastmoney/yahoo/sec_edgar); consolidated data-routing skill
  + per-source eastmoney/sec-edgar skills; read_file skill-ref resolution fix
- docs: 5 READMEs (Data Sources & Smart Fallback section + counts + 06-19 news),
  .env.example, SKILL.md

1200 tests green; live e2e LLM-routing + data smoke verified.
2026-06-19 18:19:12 +08:00
Haozhe Wu ca9ded74fd docs: remove exposed scam discord invite code 2026-06-19 00:08:09 +08:00
Haozhe Wu 84bbf871c5 docs: add June 18 news (Research Autopilot Phase 1 + local Data Bridge loader + Discord security notice) 2026-06-18 23:54:02 +08:00
Haozhe Wu 0d0ef439bb chore: finish proposal_id hardening + document MCP initTimeout (#264)
Completes work the 2026-06-18 self-merged batch left half-applied:
- Tighten the mandate proposal_id relay/preview parsers in api_server.py
  and cli/_legacy.py to the strict mp_[0-9a-f]{32} format already enforced
  by commit.py (#256), so the relay and the commit gate agree on format.
- Document the MCP initTimeout config key (shipped in #263) in README and
  wire its base-default passthrough in config/loader.py.
- Add the registered pytest.mark.unit marker to test_mandate_commit_security.py.
2026-06-18 23:36:32 +08:00
Haozhe Wu e7a95106da fix(autopilot,loaders): repair zero-review runtime defects in #260 + #252 (#266)
#260 Research Autopilot shipped two reproduced runtime-breaking bugs:
- run_research_autopilot never received the host session_id (it read only
  kwargs, which the LLM cannot know), so every call errored 'session_id is
  required'. Inject default_session_id via build_registry like the goal tools.
- generate_backtest_config returned a run_dir under ~/.vibe-trading/runs,
  which safe_run_dir rejected, breaking the advertised write_file -> backtest
  handoff. Add ~/.vibe-trading/runs to the allowed run roots.
Also validate the derived backtest source against VALID_SOURCES (fall back to
'auto' with a warning) and document the goal-replace semantics.

#252 local loader silently emptied tz-aware CSV/Parquet inputs (a tz-naive vs
tz-aware Timestamp comparison raised TypeError that was swallowed). Normalize
the timezone in _normalize_columns.

Adds registry-level + end-to-end tests for both autopilot tools and a tz
regression test for the loader.
2026-06-18 23:36:27 +08:00
Haozhe Wu 83a33dfef1 docs(security): document official Discord + wallet-impersonation warning
Adds an 'Official channels & impersonation' section pointing to the only
official Discord (HKUDS server) and warning that any wallet 'verification'
prompt is a scam, after the 2026-06-18 impostor-Discord incident.
2026-06-18 22:33:34 +08:00
Haozhe Wu b20af90b8d docs: point community Discord to the official HKUDS server
Re-add the Discord badge (5 READMEs) and an issue-template contact removed
in 8346def, now pointing at the org-controlled HKUDS community server
(discord.gg/6TdQnT5xcF, guild 1296348098003734629, ~1.9k members) — the same
server linked from the HKUDS org profile and LightRAG — instead of the
compromised invite. Keeps GitHub Discussions as a second contact.
2026-06-18 22:18:36 +08:00
Haozhe Wu 8346def2aa fix(security): remove compromised Discord invite from README + issue template
The discord.gg/2vDYc2w5 invite (in all 5 READMEs and the issue-template
contact links) resolves to a "Vibe-Trading" Discord server we no longer
control. It runs a fake Collab.Land wallet "verification" via
collab.layers-land.com — a throwaway domain registered 2026-06-18 — that
phishes users' crypto wallets. Remove every public pointer to it and replace
the issue-template community link with GitHub Discussions.
2026-06-18 22:13:12 +08:00
Haozhe Wu 69545dbabd fix(live): extend Robinhood OAuth init timeout (#263) 2026-06-18 21:42:28 +08:00
83ec532564 feat(autopilot): add goal-hypothesis bridge for Research Autopilot Phase 1 (#260)
* feat(autopilot): add goal-hypothesis bridge for Research Autopilot Phase 1

New `run_research_autopilot` agent tool that connects the Hypothesis
Registry to the Research Goal runtime. Given a hypothesis_id, the tool
reads the hypothesis, creates a research goal with the thesis as
objective and backtest-relevant criteria, and returns a goal snapshot
with next-step hints.

Auto-registered via BaseTool subclass discovery.

Signed-off-by: Robin1987China <fanrenzhige@163.com>

* feat(autopilot): add generate_backtest_config tool for Phase 2

New `generate_backtest_config` tool auto-generates backtest config.json
from hypothesis metadata. Maps universe names (CSI 300, S&P 500, etc.)
to standard index codes and uses hypothesis.data_sources for the source
field. Writes config.json to a run directory and returns the path so the
agent can proceed to create signal_engine.py and call backtest.

Signed-off-by: Robin1987China <fanrenzhige@163.com>

* fix: harden research autopilot config generation

---------

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-06-18 21:39:49 +08:00
Haozhe Wu d6bf209303 fix(provider): parse DeepSeek DSML tool calls
Fixes #261.
2026-06-18 21:38:57 +08:00
cf32a6dfaa feat(loaders): add local data loader for CSV/Parquet/DuckDB files (#252)
* feat(loaders): add local data loader for CSV/Parquet/DuckDB files

- New 'local' loader reads OHLCV from user-configured CSV/Parquet/DuckDB sources
- Config file at ~/.vibe-trading/data-bridge/config.yaml maps symbols to files
- Symbols prefixed with 'local:' auto-route to the local data source
- Integrated into loader registry with fallback chain support

Signed-off-by: Robin1987China <fanrenzhige@163.com>

* fix: allow duckdb local loader without path

---------

Signed-off-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Robin1987China <fanrenzhige@163.com>
Co-authored-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-06-18 21:37:39 +08:00
HinotobiandHaozhe Wu 43d33a036f [security] fix(live): contain mandate proposal identifiers (#256)
* fix: contain live mandate proposal identifiers

* test: use valid mandate proposal id in consent helper

---------

Co-authored-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-06-18 21:34:49 +08:00
Hinotobi 078ce6fcd2 fix(security): contain MCP swarm run IDs (#258)
Signed-off-by: hinotoi-agent <paperlantern.agent@gmail.com>
2026-06-18 21:30:30 +08:00
Hinotobi 27c74932b1 fix(memory): validate persistent memory types (#257)
Signed-off-by: hinotoi-agent <paperlantern.agent@gmail.com>
2026-06-18 21:30:23 +08:00
Haozhe Wu 1387b213d6 docs: update June 16 and 17 news 2026-06-17 01:30:35 +08:00
Haozhe Wu 7aa6b1d960 test: cover assistant-prefill handoff paths (#251) 2026-06-17 01:19:52 +08:00
卫斯李andwesley d0ff3599c1 fix(loop,providers): prevent assistant prefill errors + configurable Kimi User-Agent (#248)
- Replace assistant-role handoff messages with user-role in agent loop
  to avoid "assistant message prefill" rejection on Anthropic Claude Opus 4.8+
- Allow MOONSHOT_USER_AGENT env var to override Kimi/Moonshot User-Agent header
  for compatibility with Kimi Coding API endpoint

Closes #246, Closes #204

Signed-off-by: wesley <wesley87@wesley87deMacBook-Pro.local>
Co-authored-by: wesley <wesley87@wesley87deMacBook-Pro.local>
2026-06-17 01:17:24 +08:00
Matt Van HornandMatt Van Horn 43ffd041c0 feat(providers): add glm/zhipu provider alias and model-name inference (#247)
Add a first-class `glm` alias of the existing `zhipu` provider so a model
name like `glm-4.6`/`glm-5.1` resolves to the Zhipu capability record and
base URL instead of silently falling back to the OpenAI slot.

- capabilities.py: share one `_ZHIPU_CAPABILITIES` record between `zhipu`
  and the new `glm` alias (mirrors kimi/moonshot), and infer `zhipu` for
  model ids starting with `glm`.
- llm_providers.json: register a `glm` entry aliasing the Zhipu env names
  and default model so onboarding/diagnostics surface it.
- tests: cover the alias, model-name inference, env-name resolution, the
  unchanged openai fallback, and the new registry default.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-17 01:17:20 +08:00
Haozhe Wu ecbbd72eff fix: make harmonic backend optional (#250) 2026-06-17 01:11:51 +08:00
Hinotobi 97c67c9bb5 [security] fix(api): require auth for settings writes (#245)
Require a valid bearer token (not just loopback peer IP) for settings WRITE endpoints (/settings/llm, /settings/data-sources) when API_AUTH_KEY is configured, closing a DNS-rebinding / loopback-trust bypass while keeping zero-config local dev writes.

Also reconciles the DNS-rebound security tests across the #241-#245 sprint: now that #242's rebound-host middleware 403s loopback requests with an attacker-controlled Host before they reach the route, the end-to-end rebound tests for /swarm/runs and /sessions/messages (#243) and /settings/llm (#245) assert the request is rejected at the host-gate layer (403) and never reaches the shell-tool / settings-write decision. Full suite: 3217 passed.
2026-06-16 00:51:58 +08:00
Hinotobi 0568527cd6 [security] fix(api): require explicit opt-in for agent shell tools (#243)
* fix(api): require explicit opt-in for swarm shell tools

* test: cover session shell tool opt-in boundary
2026-06-16 00:37:50 +08:00
Hinotobi e5ee704e2e fix(api): reject untrusted loopback hosts (#242) 2026-06-16 00:37:18 +08:00
Hinotobi 73d3e1153b fix: require explicit auth for local shutdown (#241)
Signed-off-by: hinotoi-agent <paperlantern.agent@gmail.com>
2026-06-16 00:36:27 +08:00
sambazhu 205e4c1843 fix(frontend): sync completed chat attempts (#236) 2026-06-16 00:36:07 +08:00
gyx09212214-prog 9ba1f7bf7b fix(loaders): tolerate malformed RSSHub timeout env vars (#240)
Signed-off-by: gyx09212214-prog <gyx09212214@gmail.com>
2026-06-16 00:36:03 +08:00
gyx09212214-prog f4ce8b44d4 test(search): cover ddgs retry fallback behavior (#239) 2026-06-16 00:35:58 +08:00
gyx09212214-prog d8c55f3112 fix(run-card): write strict JSON for non-finite metrics (#238)
Signed-off-by: gyx09212214-prog <gyx09212214@gmail.com>
2026-06-16 00:35:52 +08:00
Haozhe Wu f63849e55e docs: news for 06-15 — web_search multi-engine resilience + Web UI run-continuity (#229) 2026-06-15 18:38:37 +08:00
Haozhe Wu f0ba648b1f fix(agent): make Stop responsive mid-stream and between tools (#229) (#235)
Symptom 2 of #229: clicking Stop during a run did nothing, or only took
effect after a long delay. The cancel signal (_cancelled bool) was polled
only at the top of the ReAct iteration loop, so a Stop pressed while the
agent was mid-LLM-stream or executing tools was ignored until the entire
current iteration — the whole streamed answer plus all its tool calls —
finished and the next iteration check ran.

Add cooperative cancel checkpoints at the granularity the user actually
waits on:

- Switch _cancelled to a threading.Event (_cancel_event) so the flag is
  safe to poll from the streaming thread.
- Thread a should_cancel predicate into ChatLLM.stream_chat; the per-chunk
  loop breaks as soon as it is set, so a live stream stops at the next
  token instead of running to completion.
- After the stream returns, check the flag before processing tool calls:
  a cancel during streaming ends the run immediately and discards the
  partial turn (no tools run).
- Check the flag at the top of _process_tool_calls and between tool batches
  in _batch_execute, so a multi-tool turn stops after the current tool
  rather than launching the rest.

Limitation (documented in the PR): a connection that hangs before emitting
any chunk leaves the worker blocked in a socket read, which Python cannot
interrupt cooperatively from another thread; that case stays bounded by the
existing client timeout (TIMEOUT_SECONDS). Cancel now lands promptly in all
the normal "working" states (tokens streaming, tools running).

Tests: chat-level should_cancel breaks the chunk loop (and absence is a
no-op); loop-level cancel mid-stream ends the run as cancelled and skips
tool execution. Full suite: 3199 passed, 2 skipped.
2026-06-15 17:41:43 +08:00
Haozhe Wu 9b9e9f50a0 fix(web): reconnect SSE when returning to a running session (#229) (#234)
Symptom 1 of #229: navigating away from the chat page and back often made
the agent "stop working". The page run is server-side and the frontend only
subscribes via SSE; the unmount cleanup tore down that stream, but the
navigation effect only (re)subscribed when the URL session differed from the
store session. Returning to the SAME session hit neither branch, so SSE was
never reopened — in-flight events stopped arriving and the run looked frozen
until the 90s safety timeout flipped it to idle.

The backend already buffers events per session and replays them on connect
(GET /sessions/{id}/events?replay=active), and setupSSE already requests it;
the only defect was the missing re-subscribe on same-session remount.

Add that branch: re-hydrate like a reload — reset the transient streaming
view first (so replay=active rebuilds the in-flight turn from the ring buffer
instead of duplicating deltas onto the preserved text), refresh committed
history (covers an attempt that finished while away), then re-subscribe.

Symptom 2 (Stop ineffective during a hung/long turn) is cooperative-cancel
granularity in the agent core and is tracked separately.

Verified: tsc clean, frontend build passes, existing store/useSSE/Runtime
tests green (42).
2026-06-15 17:40:56 +08:00
Haozhe Wu 541c4e5d8c ci: bump first-party actions to Node 24 (checkout/setup-python/setup-node v6) (#233)
GitHub forces Node 20 actions onto the Node 24 runtime starting 2026-06-16
and removes Node 20 from runners on 2026-09-16. Bump the three first-party
actions to their latest Node-24-native majors across all workflows:

- actions/checkout v4 -> v6
- actions/setup-python v5 -> v6
- actions/setup-node v4 -> v6

Inputs are unchanged (node-version "20" is the frontend JS runtime, not the
action runtime). Third-party cloudflare/wrangler-action is out of scope.
2026-06-15 17:08:04 +08:00
Haozhe Wu 04480e8a03 fix(tools): make web_search resilient via multi-engine fallback (#232)
web_search relied on ddgs's default 'auto' backend, which leads with
DuckDuckGo — heavily rate-limited from cloud/shared IPs. When DDG
threw, the whole tool returned ❌ (issue #231) even though Google/
Bing/Brave/Mojeek/Yahoo were reachable.

- Pass an explicit ordered backend list so ddgs falls through a
  throttled engine to the next; override via VIBE_TRADING_SEARCH_BACKENDS
- Retry transient failures with linear backoff (3 attempts)
- Treat "No results found" as an ok+empty envelope, not an error
- On persistent failure, return an actionable message (retry / set
  backend env / use read_url) instead of a bare exception string
- Report the backend list used in the ok envelope

Tests: new tests/test_web_search_tool.py (6 cases, mocked ddgs) cover
multi-backend pass-through, env override, retry-then-succeed,
no-results, persistent-failure message, and max_results cap.
2026-06-15 17:01:32 +08:00
bhlt 5399a078e8 fix(baostock): support baostock-native code format (sh.601398) (#230)
The baostock loader previously only accepted tushare-style codes (601398.SH)
and silently rejected baostock-native codes (sh.601398) — the format baostock
uses in its own docs. _is_a_share() now accepts both (case-insensitive), and
_fetch_one() passes native codes through unchanged while still converting
tushare-style suffixes. Adds 10 unit tests.

Thanks to @bhlt for the fix.
2026-06-15 16:38:49 +08:00
Haozhe Wu 59d98a741b docs: news for 06-14 — per-run token usage + progressive Run Detail charts 2026-06-14 23:03:30 +08:00
gyx09212214-prog 9f5b8afe7b fix(loaders): tolerate malformed crypto timeout env vars (#227)
Malformed values like CCXT_TIMEOUT_MS=abc / OKX_TIMEOUT_S=abc raised ValueError at import time and could block startup. Add shared positive_env_int/float helpers in loaders/base.py; invalid/blank/non-positive timeout/budget values now warn and fall back to defaults. Valid values still honored.

Closes #227.
2026-06-14 22:25:29 +08:00
gyx09212214-prog 9e7c1a8772 fix(loaders): include requested yfinance end date (#226)
yfinance download(end=...) is exclusive while the loader API treats end_date as inclusive, so the final trading day was dropped. Pass end_date + 1 day to the yfinance call (bulk + single-symbol fallback); cache lookups/writes keep the original requested range.

Closes #226.
2026-06-14 22:25:26 +08:00
LemonCANDY42 88dd54c7c3 perf(run-detail): add opt-in chart payloads (#225)
Add opt-in chart payload modes for the Run Detail endpoint and UI. Default /runs/{id} stays byte-for-byte unchanged; ?chart_payload=summary returns metadata + chart_symbols (no bars/markers); ?chart_symbol=SYM returns one symbol's bars+markers. Run Detail UI renders summary first, then progressively loads selected symbols with loading/progress + cancel. New copy localized en + zh-CN.

Refs #215.
2026-06-14 22:14:50 +08:00
LemonCANDY42 8678c289ce feat(agent): persist AgentLoop usage artifacts (#223)
Persist provider-reported AgentLoop usage as a run-scoped artifact (<run_dir>/llm_usage.json) and surface it additively on /runs/{id}. Provider-reported tokens only — no prompt/content capture, no price estimation. Behind the existing usage guard so providers without usage_metadata write nothing. Live llm_usage SSE event keys unchanged.

Refs #214.
2026-06-14 21:52:11 +08:00
Haozhe WuandLanre Shittu ccfe50fff2 fix(loaders): land RSSHub event/sentiment provider with explicit feed config
Builds on @Shizoqua's verified provider core (#201) and resolves the three
open-box blockers from review:

- No built-in feed catalogue: the shipped DEFAULT_FEEDS routes (/stock/notice,
  /stock/news) don't exist in RSSHub, so a default run scored every bar 0.0.
  Feeds are now declared explicitly in config (event_feeds = list of
  {name, route_template, event_type, code_style}); DEFAULT_FEEDS is empty, so
  there is nothing to silently rot as RSSHub routes evolve.
- Per-symbol code formatting: a feed declares a code_style
  (raw / bare / exchange_prefix), so dotted backtest codes (600519.SH) are
  rewritten to the form a route needs (e.g. SH600519 for xueqiu-style routes).
- No silent all-zero scores: each fetch failure is logged per feed/code, and
  query_events raises EventProviderError when every fetch failed (configured
  but fully unreachable) instead of returning an all-zero event_score that
  reads as "sentiment considered, no signal". A reachable feed that returned
  no items stays a legitimate empty result.

PIT discipline, defusedxml hardening, the pluggable scorer, and the
engine/runner wiring are otherwise unchanged. Adds 12 tests for the code
styles, config parsing/validation, and the loud-failure path.

Thanks @Shizoqua (#201) for the provider core.

Co-authored-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com>
2026-06-14 06:50:02 +08:00
Haozhe Wuandskloxo f470947c81 feat(loaders,i18n): add baostock/tencent A-share sources + zh-CN frontend i18n
A-share data sources (free, no-auth, bypass eastmoney CDN IP blocks):
- baostock loader (TCP protocol) + tencent loader (HTTP), both wrapped with
  cached_loader_fetch like the mootdx/akshare loaders so they honor the opt-in
  local cache
- registered into the a_share fallback chain (tushare -> mootdx -> baostock ->
  tencent -> akshare); auto-detect default source stays tushare
- pyproject [ashare] extra for the optional baostock dependency

Frontend i18n (English default, sidebar toggle to Chinese, persisted):
- i18next + react-i18next + browser-languagedetector; first visit defaults to
  English (localStorage-only detection, no navigator sniff), the toggle persists
  the choice across reloads
- en / zh-CN locales at full key parity, including the Runtime page
- i18n initialized in the test setup so useTranslation resolves real strings;
  restored the exported METRIC_LABELS so formatters consumers/tests keep working

Thanks @skloxo (#217) for the i18n layer and the A-share data sources.

Co-authored-by: skloxo <skloxo@users.noreply.github.com>
2026-06-14 06:50:02 +08:00
LemonCANDY42 b215e99805 feat(frontend): add read-only runtime status dashboard (#210)
Adds a dedicated /runtime page + sidebar entry rendering read-only /live/status: global halt, broker auth presence, runner liveness, active mandate limits/expiry, and derived runtime risk state. Fail-closed unavailable + empty-profile states. No write controls (no order/mandate/authorize/start/stop).

Verified locally: tsc --noEmit clean, Runtime.test.tsx 3/3 pass, vite build OK.
2026-06-14 05:12:04 +08:00
Haozhe Wu 4207392433 docs: news for 06-13 — resume past session by ID from the CLI (#218) 2026-06-14 00:12:03 +08:00
Haozhe Wu b2573b4689 Merge pull request #218 from zwrong/pr/trace-offload
feat(cli): show session-id on exit, add `resume <session-id>` subcommand
2026-06-14 00:01:51 +08:00
zwrong 8a02b7c87f refactor(cli): extract _build_session_history, add resume-by-id tests
Simplify / Brooks-Lint follow-up:

- Extract _build_session_history() helper to eliminate the duplicated
  get_messages -> role/content filter -> _HISTORY_RETAINED_TURNS slice
  pattern from both _maybe_resume_last_session and the resume path.
- Use _extract_max_iter instead of hardcoded max_iter=50 for the resume
  dispatch so future default changes propagate consistently.
- Add TestResumeByIdDispatch (3 tests) covering the vibe-trading resume
  <session-id> CLI dispatch path — previously uncovered.

Signed-off-by: zwrong <1003912034@qq.com>
2026-06-13 17:25:56 +08:00
zwrong 2317947c4e feat(cli): add vibe-trading resume <session-id> and exit session-id display
Exit interactive CLI shows the session ID so users can copy-paste
`vibe-trading resume <id>` to resume later. The resume subcommand
loads a specific session by ID and enters the interactive loop with
full history — messages are persisted through the existing
_run_one_turn / _append_message path.

Signed-off-by: zwrong <1003912034@qq.com>
2026-06-13 17:25:33 +08:00
Haozhe Wu 8b0199d4b5 Merge pull request #206 from zwrong/pr/trace-offload
feat(agent): complete trace.jsonl overhaul — zero truncation, offload, message entries, iter consistency
2026-06-13 01:22:00 +08:00
Haozhe Wuandzwrong 4f70258d40 fix(trace): rebase trace offload onto current main
Co-authored-by: zwrong <1003912034@qq.com>
2026-06-13 01:21:24 +08:00
Haozhe Wu 4c4565a50f fix(web): clear partial stream text on provider retry 2026-06-13 01:02:00 +08:00
Haozhe Wu 65660e4087 docs: news for 06-12 — provider reliability overhaul (capability layer, provider doctor, streaming liveness) 2026-06-12 23:41:25 +08:00
Haozhe Wu fa2ad4b664 fix(providers): capability-gated payloads, explicit stream errors, liveness + provider doctor
A cluster of provider reports traced to one root: every OpenAI-compatible
provider shared a single shim that applied DeepSeek/Kimi/Gemini quirks
globally and silently swallowed stream failures.

- Provider capability layer (capabilities.py): reasoning capture/replay,
  Gemini thought signatures, Kimi User-Agent (version from package
  metadata), OpenRouter reasoning body — gated per provider, no
  cross-contamination
- Optional native DeepSeek adapter via VIBE_TRADING_DEEPSEEK_ADAPTER
  (auto/native/openai-compatible) + 'deepseek' extra (langchain-deepseek)
- ChatLLM.stream_chat: silent fallback to chat() removed; failures raise
  ProviderStreamError with redacted provider/model context; one retry for
  transient errors in AgentLoop + swarm worker (deterministic 4xx fail fast)
- Empty model response diagnosed as empty_model_response instead of
  'reached max iterations'
- reasoning_delta SSE event (throttled to 1/s, first chunk immediate) +
  frontend 'Reasoning…' indicator; reasoningActive reset on safety timeout
- SSE heartbeats carry no replay id; replay falls back to full buffer on
  unknown last-event-id; read-only tools get a hard timeout
  (VIBE_TRADING_TOOL_TIMEOUT_SECONDS) instead of hiding behind heartbeats
- 'vibe-trading provider doctor' prints redacted provider/model/package/
  proxy diagnostics; preflight includes the same hint
- kimi-k2.x temperature=1 requirement auto-applied; Kimi path verified
  end-to-end against the live API (kimi-k2.6 tool calls + strict
  multi-turn reasoning replay)

Refs #208 #203 #195 #204
2026-06-12 23:41:19 +08:00
zwrong 38582c564d fix(trace): all entries carry iter right after type, resume before start/message
Three interrelated fixes for trace.jsonl field consistency:

1. Field order — every entry now has `iter` immediately after `type`.
   This was already the convention for tool_call, tool_result, thinking,
   answer, and cancelled. Fixed start, message (user+assistant), and end
   to follow the same pattern.

2. Iteration values — start and message (user) previously used
   `self._run_iteration` (which is 0 before the loop begins), so the
   first turn showed iter=0 while the first loop iteration showed iter=1.
   Changed to `self._run_iteration + 1` so they share the same iter as
   the first loop iteration. end now carries `iter` in addition to
   `iterations` (the per-run count), so it participates in iteration
   grouping like every other entry.

3. Resume ordering — the iteration-counter restore logic (reading max
   iter from an existing trace to continue a session) was placed AFTER
   start/message were written, so resumed sessions had iter=0 on those
   entries. Moved the resume block before the write calls.

Tests added (TestTraceFieldConsistency):
- test_tool_result_iter_second — write_tool_result() dict order
- test_generic_write_preserves_type_then_iter — generic write order
- test_all_entry_types_carry_iter — 10 entry types all have iter at
  position 1; catches future regressions

Closes the long-standing gap where start/message/end could not be
attributed to a specific turn in multi-turn sessions.

Signed-off-by: zwrong <1003912034@qq.com>
2026-06-12 13:44:19 +08:00
zwrong c4dec47576 feat(agent): complete trace.jsonl — zero truncation, message entries, tool result offload
- TraceWriter: add write_tool_result() with 50K offload threshold + 500-char preview
- TraceWriter: read() auto-resolves offloaded results from tool-results/*.txt
- TraceWriter: add find_trace_dir() — sessions/ first, runs/ fallback
- loop.py: sessions/<id>/trace.jsonl when session_id valid, runs/ fallback for -p
- loop.py: remove all [:N] truncation from trace entries (prompt, thinking, answer, args, summary)
- loop.py: add message type entries (user/assistant) for full conversation replay
- loop.py: use write_tool_result() for proper inline/offload routing
- loop.py: auto_compact transcript now uses trace.dir_path
- _legacy.py: find_trace_dir() in _build_history_from_trace, cmd_show, cmd_trace, cmd_continue
- _legacy.py: cmd_trace display supports new fields (result_path, result_preview, result_size)
- tests: 18 new TraceWriter tests covering inline, offload, boundary, mixed, unicode, find_trace_dir

Signed-off-by: zwrong <1003912034@qq.com>
2026-06-12 13:44:15 +08:00
Haozhe Wu c390aa8462 docs: news for 06-10/06-11 — swarm market-data hardening, Docker Ollama + persistence
Two entries across all 5 READMEs (3 visible + earlier rotated into
details): 06-11 swarm loader-backed get_market_data rollout (#198/#199/
#200, bare-ticker grounding, 48 tools) bundled with Docker state
persistence (#197); 06-10 host-side Ollama reachability (#196).
2026-06-11 13:34:12 +08:00
Haozhe Wu 2ba6ae2f0c fix(docker): persist user agent state across container rebuilds
All user-level agent state lives under ~/.vibe-trading in the container
(persistent memory, sessions.db search index, user-created skills,
shadow accounts, hypothesis registry, broker connector config), but
docker-compose only mounted volumes for web sessions and backtest runs.
Every `docker compose up --build` therefore recreated the container and
silently wiped the user's memory store (#197). Swarm run history and
uploads were lost the same way.

- Mount vibe-home -> /home/vibe/.vibe-trading, plus vibe-swarm-runs and
  vibe-uploads.
- Pre-create /home/vibe/.vibe-trading owned by the service user in the
  Dockerfile so the named volume inherits writable permissions (a named
  volume mounted onto a missing path is created root-owned and the
  non-root user cannot write).
- README: document what persists across updates.

Verified end-to-end: memory written via PersistentMemory in one
container is intact in a fresh container on the same volume; the
counterfactual run without the volume reproduces the wipe.

Fixes #197.
2026-06-11 13:17:04 +08:00
b6fd9b1fd4 fix(docker): reach host Ollama via host.docker.internal (#196)
* fix(docker): reach host Ollama via host.docker.internal

Inside the container, OLLAMA_BASE_URL=http://localhost:11434 resolves to
the container itself, so the agent's preflight check and AgentLoop fail
with "Connection refused" even when Ollama is running on the host.

Override OLLAMA_BASE_URL to http://host.docker.internal:11434 in the
compose service (leaving agent/.env correct for bare-metal runs) and add
an extra_hosts host-gateway mapping so the same file also works on Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(docker): parameterize OLLAMA_BASE_URL and document host networking

Address PR review feedback:
- Make OLLAMA_BASE_URL overridable via Compose variable substitution
  (${OLLAMA_BASE_URL:-http://host.docker.internal:11434}) so it can be
  changed without editing the compose file when Ollama runs elsewhere.
- Note the Docker Engine >= 20.10 / Compose v2 requirement for the
  host-gateway mapping, both inline and in the README Docker section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Shah Newaz Khan <shah.khan@lexful.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:55:48 +08:00
Haozhe Wu dfd71b4bc2 feat(swarm): ground bare US tickers mentioned in swarm prompts
Grounding previously only matched loader-suffixed symbols (NVDA.US),
so an investment_committee run on "long or short on NVDA" got no
pre-fetched ground-truth prices and workers fell back to ad-hoc
scripts (#198, fix direction 5).

Promote bare all-caps tokens to <TOKEN>.US under guards:
- 2-5 uppercase letters only, dotted compounds rejected on both sides
  (FOO.USDA promotes neither side; sentence-ending "NVDA." still works)
- stopword list for finance/English acronyms (ETF, CEO, GDP, USD,
  bare crypto symbols, index providers like MSCI, ...)
- suffixed matches are blanked before the bare scan, so BTC-USDT can
  never leak a bogus BTC.US
- promotions sort after explicit symbols, so explicit symbols win the
  max-symbols cap; the per-symbol fetch remains the final validator

Closes the last open item of #198.
2026-06-11 12:22:51 +08:00
Haozhe Wu d7d1654f32 fix(swarm): expose get_market_data across all market-data presets
PR #199 added the loader-backed get_market_data tool to
investment_committee and equity_research_team. Extend it to the 16
remaining workers (across 11 presets) whose skills include tushare,
yfinance, or okx-market, so every OHLCV-capable worker gets the
loader-first path and the worker prompt policy.

Add a regression test locking the convention: any preset worker with a
market-data skill must whitelist get_market_data.

Part of #198.
2026-06-11 12:14:15 +08:00
Chengcheng DingandChengcheng Ding d1ed1019c0 fix(swarm): preserve preset context for continuations (#200)
Signed-off-by: Chengcheng Ding <billdin1999@gmail.com>
Co-authored-by: Chengcheng Ding <billdin1999@gmail.com>
2026-06-11 12:11:37 +08:00
Chengcheng DingandChengcheng Ding 7436986246 fix(swarm): expose loader-backed market data tool (#199)
Signed-off-by: Chengcheng Ding <billdin1999@gmail.com>
Co-authored-by: Chengcheng Ding <billdin1999@gmail.com>
2026-06-11 12:10:52 +08:00
Haozhe Wu a80b4431bf fix(web): arm SSE safety timeout so a no-event turn can't hang forever
The streaming safety timeout guarded on lastEventRef.current being truthy, but
it starts at 0 and only real SSE events (not the keep-alive heartbeat) update
it. A turn whose very first event never arrives — e.g. the LLM provider hangs
before emitting a token — left it at 0, so the guard short-circuited and the UI
stayed on "Agent is working…" indefinitely. Arm the clock on entering the
streaming state so the timeout fires after sse_timeout and resets to idle with a
notice. Turns issue #195's hang into a recoverable timeout.
2026-06-09 20:33:24 +08:00
Haozhe Wu f735e4ca51 fix(backtest): make mootdx & futu first-class config sources
Two whitelists/maps had drifted as mootdx and futu loaders were added:

1. BacktestConfigSchema._VALID_SOURCES rejected an explicit
   source="mootdx"/"futu" even though both are registered loaders (and in
   FALLBACK_CHAINS), while src/tools/backtest_tool.py already allowed them.
   Fix: one canonical VALID_SOURCES in backtest.loaders.registry, consumed by
   both backtest.runner and src.tools.backtest_tool so they cannot diverge.

2. metrics annualisation maps (_TRADING_DAYS / _BARS_PER_DAY) lacked mootdx and
   futu, so intraday backtests on them fell back to bars_per_day=1 and
   mis-annualised vol/Sharpe. Fix: add both as equity sources (252 days, 240-min
   session, mirroring tushare/akshare).

Regression tests: mootdx/futu accepted by the config schema, every registered
loader covered by VALID_SOURCES, and intraday bars_per_year correct for both.
2026-06-09 19:17:21 +08:00
Matt Van HornandMatt Van Horn 05e492a99a docs: add custom data loader guide (#194)
Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-09 19:06:58 +08:00
Haozhe Wu bbd8466ded fix(web): surface auth-required reason on remote 403 (#191) (#192)
Chat sends from a non-loopback client (another machine, VM host, LAN
device) without API_AUTH_KEY returned 403 on every sensitive endpoint,
but the UI only showed a generic "Failed to send message, please retry."
The send path now surfaces AUTH_REQUIRED_MESSAGE so users know to set
API_AUTH_KEY (and enter it once in Settings) or browse via localhost.

Also document the localhost-vs-LAN rule in the web-UI setup and add a
2026-06-09 news entry across all 5 locale READMEs.
2026-06-09 11:10:02 +08:00
Haozhe Wu 6b37c040c1 revert: drop GHCR pre-built image path (#187)
The Path E "pull a pre-built image, no clone" deploy path was open-box
broken: the GHCR package defaults to private, so anonymous `docker pull`
fails, and the path was never verified end-to-end against the main repo
(the contributor tested a self-built image, not ghcr.io/hkuds/vibe-trading).

Removes the publish workflow, docker-compose.prod.yml, the Dockerfile
VERSION arg, and the README Path E section/table row/tree note. The
2026-06-08 news entry is rewritten to cover only the Gemini 3.x
dict-path fix (#184). Also drops the now-orphan edge-build paths-ignore.

If a buildless deploy path is wanted later, do it properly: make the
package public, verify an anonymous pull works, then document it.
2026-06-08 10:45:25 +08:00
Haozhe Wu 4389246350 docs(readme): add 2026-06-07 + 2026-06-08 news in 5 locales
06-07 live swarm status in chat (#188) + preset routing (#189); 06-08 one-pull GHCR image (#187) + Gemini 3.x dict-path fix (#184). Older 06-05/06-04 folded into Earlier news to keep 3 visible.
2026-06-08 10:29:02 +08:00
Haozhe Wu 7dd9e58870 ci(docker): skip GHCR edge build for doc-only main pushes
Release builds still always fire via the release trigger; only the edge-on-main-push build is skipped when a commit touches nothing but .md / docs/ / wiki/.
2026-06-08 10:29:02 +08:00
Chengcheng DingandChengcheng Ding 0f0fcb6280 fix(swarm): honor explicit preset names (#189)
Signed-off-by: Chengcheng Ding <billdin1999@gmail.com>
Co-authored-by: Chengcheng Ding <billdin1999@gmail.com>
2026-06-08 10:13:12 +08:00
Chengcheng DingandChengcheng Ding f194e51f4d feat(web): show swarm agent status in chat (#188)
Signed-off-by: Chengcheng Ding <billdin1999@gmail.com>
Co-authored-by: Chengcheng Ding <billdin1999@gmail.com>
2026-06-08 10:13:07 +08:00
Kai LüttmannandCopilot d77f6b3379 feat: publish pre-built image to GHCR on release (closes #183) (#1) (#187)
* feat: publish pre-built image to GHCR on release (#183)

* docs: remove Watchtower references from README

* Add workflow_dispatch trigger to Docker publish workflow

* Allow Docker publish on additional branch

* Update Docker publish workflow triggers

Removed manual trigger and restricted push branches.

---------
authored-by: Kai Lüttmann <33122946+KaiLuettmann@users.noreply.github.com>

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-06-08 10:12:59 +08:00
Pham Van Ngoan 0e4ffc8c66 fix(llm): preserve Gemini thought_signature through the AgentLoop dict path (#184)
#176 added the Gemini thought_signature round-trip but only covered the
in-memory AIMessage path its unit tests exercise. The AgentLoop replays
conversation history as OpenAI-format dicts, stamping the signature into
tool_calls[i].extra_content.google.thought_signature
(loop.py _attach_tool_call_thought_signatures).

LangChain's _convert_dict_to_message discards extra_content during the early
_convert_input step of invoke()/stream() -- before _get_request_payload
re-injects -- so _signature_maps reads an empty additional_kwargs and the
outbound request goes out unsigned. Gemini then rejects the next tool-using
turn with HTTP 400 "Function call is missing a thought_signature in
functionCall parts". This broke every tool-using loop turn on gemini-3.x
(single or parallel), e.g. the parallel load_skill case.

Override ChatOpenAIWithReasoning._convert_input -- the single chokepoint both
invoke and stream call while input is still raw dicts -- to lift signatures
back onto the converted AIMessage in the same
additional_kwargs["tool_call_thought_signatures"] shape the in-memory path
uses, so the existing _signature_maps / _inject_tool_call_thought_signatures
machinery handles both paths identically. Idempotent via an
isinstance(raw, dict) guard. No parallel-specific handling needed: Gemini
signs only the first of N parallel calls and that signature covers the block.

Add regression tests in TestChatOpenAIWithReasoningOutboundPayload that drive
the real dict path (the gap #176's tests missed): red without the fix
(KeyError: 'extra_content'), green with it.
2026-06-08 10:12:56 +08:00
Haozhe Wu b31e9b4af1 docs(readme): add 2026-06-06 alpha compare news in 5 locales 2026-06-07 01:08:47 +08:00
Haozhe Wu b110bdc3b2 feat(factors): add alpha compare across CLI, REST, Web UI and agent tool
Head-to-head comparison of hand-picked Alpha Zoo alphas, sharing one core
(compare_runner.compare_alphas) across four surfaces:

- CLI: `vibe-trading alpha compare <id1> <id2> ... --sort ir`
- REST: POST /alpha/compare + SSE progress/result stream
- Web UI: Compare view in Alpha Zoo (catalogue multi-select -> ranked table)
- Agent: read-only `alpha_compare` tool (now 47 tools)

Adds a backward-compatible `only=` subset filter to run_bench so comparing
a few alphas no longer benches the whole zoo. Ranks by IC mean/std, IR,
IC-positive ratio or sample count, each with its gap to the leader.

39 new backend tests; frontend tsc + 197 vitest + build green.
2026-06-07 01:08:42 +08:00
Haozhe Wu 9b9e83fe6c docs(readme): add 2026-06-05 news (Dhan/Shoonya, Gemini, docstrings, tests) in 5 locales 2026-06-05 21:52:59 +08:00
Haozhe Wu 36c5a4457c fix(deps): patch 7 Dependabot alerts (react-router XSS/DoS, vitest RCE)
Bump react-router-dom ^7.1.0 -> ^7.15.0 (resolves to 7.17.0), clearing 6 react-router advisories (XSS, open redirect, DoS, turbo-stream arbitrary constructor; patched across 7.13.2-7.15.0).

Bump vitest + @vitest/coverage-v8 ^3.2.6 -> ^4.1.0 (resolves to 4.1.8), clearing the critical Vitest UI-server arbitrary file read/exec (patched in 4.1.0) and aligning the coverage peer dependency on the v4 line. Frontend suite (197 tests) green on vitest 4; production build passes.
2026-06-05 20:56:40 +08:00
sambazhu d17faacefc test: add frontend test suite + backend security/coverage tests
Cherry-picked the test-infra commit from #175 (sambazhu): vitest setup + 19 frontend test files (197 tests), backend auth/path-traversal/CORS tests, yfinance crypto fallback (BTC-USDT->BTC-USD), and CI coverage + frontend-test steps. Dropped the PR's separate i18n commit to stay consistent with #163.

Fix: align @vitest/coverage-v8 to ^3.2.6 (was ^4.1.8, incompatible with vitest ^3.2.6 - blocked npm install and the new npm ci CI step).
2026-06-05 20:41:14 +08:00
leeadmin 44f14b67f8 docs(factors): add Chinese docstrings to factor zoo
Cherry-picked the factor-module Chinese docstrings from #180 (alpha101 / gtja191 / qlib158 / academic, 452 files, purely additive). Dropped the i18n parts — react-i18next deps + 29 swarm-preset name_zh/description_zh (currently unused) — to stay consistent with the #163 decision to not bundle i18n yet.
2026-06-05 20:25:36 +08:00
Matt Van HornandMatt Van Horn 51b207f0ba fix: round-trip Gemini thoughtSignature through OpenAI-compat tool calls (#176)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-05 20:15:42 +08:00
Panditvirchandra77-lgtmandHaozhe Wu dda7a5739b feat(trading): add Dhan + Shoonya Indian broker connectors (paper-capped)
Two broker_sdk connectors for the Indian equity and F&O markets (NSE/BSE):
Dhan (dhanhq) and Shoonya / Finvasia (NorenRestApiPy). Read access plus
locally simulated paper-account order placement.

Both brokers expose no sandbox and no runtime paper/live discriminator — a
single token / TOTP login reaches the same real account — so, following the
Longbridge precedent, the order path is structurally capped at paper:

- place_order / cancel_order refuse any non-paper config at the first line, so
  a flipped `profile` override can never reach a live order;
- no live order-placing profile is registered (paper-sdk read-only,
  paper-trade local-sim, live-sdk-readonly read-only — three profiles each,
  mirroring Longbridge);
- DHAN/SHOONYA tool-class maps wired into the live classifier registry;
- order ops pinned WRITE; secrets redacted; pyotp made an optional dependency;
  Shoonya sessions cached per user_id to avoid cross-account reuse.

Tests cover profile registration, the non-paper fail-closed guard, paper
simulation, WRITE-pinned order ops, secret redaction, and a regression guard
that these no-discriminator brokers never expose a *-live-trade profile.

Maintainer follow-up to PR #174: the original submission shipped live order
placement with no structural paper/live discriminator (and an invalid asset
class that crashed the live path); this revision caps both connectors to
paper + read-only to keep the paper/live boundary structural, never an
agent-flippable config flag.

Co-authored-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-06-05 15:45:11 +08:00
Haozhe Wu c2257fe6f3 fix(wiki): make footer counter all-time cumulative, not a 30-day window
Sum the full visits history instead of windowing to 30 days, so the agent/human counts grow monotonically since launch (data is never GC'd).
2026-06-05 14:54:43 +08:00
Haozhe Wu ee28390a2c fix(wiki): cache-bust main.js so browsers load the traffic-counter JS immediately
main.js is browser-cached up to 4h; the fresh HTML still pointed at the unversioned /main.js, so returning visitors kept running the pre-counter JS and the footer never populated. Versioned query forces an immediate fetch of the new module.
2026-06-05 14:47:39 +08:00
Haozhe Wu cb82c6262c fix(wiki): align live-trading copy with shipped Robinhood Agentic support; hide footer counter until data loads
The wiki still claimed 'no live trading / does not execute live trades', which contradicts the shipped opt-in bounded Robinhood Agentic channel (and the README). Aligns home, docs, and alpha-library copy with the README's framing. Also fixes a CSS specificity bug where .footer-traffic{display:flex} overrode the [hidden] attribute, showing placeholder dashes before /api/stats data arrived.
2026-06-05 14:39:55 +08:00
Haozhe Wu e2a42aa4ab fix(wiki): cache PyPI installs in D1, drop redundant GitHub call from /api/stats
pypistats/GitHub calls from the Worker intermittently rate-limit, blanking the footer. PyPI now reads through a D1 last-good cache so it never goes empty; GitHub stars are dropped here since main.js already loads them client-side.
2026-06-05 14:14:07 +08:00
Haozhe Wu 6fac55d795 fix(ci): deploy wiki in config mode so Pages Functions compile
pages deploy <dir> from repo root uploads functions/ as static files and never compiles them, so /api/stats (the agent-vs-human counter) was never live. Run wrangler from wiki/ (workingDirectory) with the wrangler.toml present so functions/ is detected and compiled.
2026-06-05 14:10:04 +08:00
Haozhe Wu 25b6ee5d72 feat(wiki): add agent-vs-human traffic counter to vibetrading.wiki
First-party, anonymous analytics for the public wiki — no client-side telemetry in the pip package. Pages Function middleware classifies each page request (AI agent / bot / human) by User-Agent server-side and counts it into D1; /api/stats serves the footer's aggregate counts alongside public PyPI install and GitHub star numbers.
2026-06-05 14:03:17 +08:00
Haozhe Wu 1cd4ba19df docs(readme): add 2026-06-04 opt-in data cache news entry across 5 locales 2026-06-04 23:40:06 +08:00
Matt Van Horn ff56134b91 feat(loaders): opt-in local data cache for all backtest data sources (#177)
Add an opt-in local market-data cache (VIBE_TRADING_DATA_CACHE) for the backtest
loaders. When enabled, every data source — tushare, okx, ccxt, akshare, mootdx,
yfinance, futu — caches settled historical bars under ~/.vibe-trading/cache
(user home, never the repo tree) so repeated and long-horizon / cross-market
backtests skip the network and avoid provider rate limits.

- Generic loader_cache_get / loader_cache_put / cached_loader_fetch helpers in
  the loader base; batch/connection loaders (yfinance, futu) skip the bulk
  download / FutuOpenD connection entirely on a full cache hit.
- Staleness guard: ranges ending today or in the future are never cached, since
  the key is content-addressed on end_date, not fetch time.
- Cached frames round-trip byte-identical to freshly fetched ones (columns name
  and per-level index dtype preserved across the duckdb parquet round-trip).
- pid + uuid tmp filenames for concurrency-safe atomic writes; cache version 2.
- Documented in .env.example; staleness, real-duckdb, and batch-loader tests.

Closes #171.

Signed-off-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-06-04 23:14:32 +08:00
Roger 1a5f096496 docs: add AI contributor guide (#173)
Add AGENT_CONTRIBUTOR_GUIDE.md for AI/automation-assisted contributors: safe
local checks, high-risk broker/MCP/credential surfaces, targeted test hints,
documentation and security rules, PR expectations, and rollback notes. Linked
from CONTRIBUTING.md so agent-assisted contributors see it before changing code.

Adds a guard test (agent/tests/test_agent_guide_paths.py) that parses the guide
for in-repo paths and asserts each still exists, so the guide turns a renamed
test/entry-point into a red CI run instead of silently rotting. pytest --ignore
targets (gitignored e2e suites) and .env do-not-write references are excluded by
design.

Signed-off-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-06-04 18:42:26 +08:00
Haozhe Wu 2c2c072319 docs: add trendshift badge to readmes 2026-06-04 10:43:21 +08:00
Haozhe Wu d1309b4f73 docs(readme): add 2026-06-03 community-triage news entry across 5 locales
Adds the 2026-06-03 News entry (trace call_id correlation #168, dead-doc
reference cleanup #166, langchain-community install-warning clarification
#167, Gemini thoughtSignature scoped to help-wanted #170) to the English,
Chinese, Japanese, Korean, and Arabic READMEs, and collapses the 2026-05-31
entry into the "Earlier news" section to keep three latest entries visible.
2026-06-03 23:39:18 +08:00
Haozhe Wuandzwrong 65a14c632e feat(loop): add call_id to trace entries for tool-call correlation
Adds the tool call_id to the tool_call and tool_result trace records so a
result can be correlated back to its originating call when replaying a trace.
Keeps the existing [:200] truncation on serialized args to avoid bloating
trace files with large tool inputs.

Equivalent to #168 (arg truncation preserved).

Co-authored-by: zwrong <1003912034@qq.com>
2026-06-03 22:59:25 +08:00
Haozhe Wu b5efee66ab docs: remove dead docs/live-trading/SPEC.md references from source comments
The live-trading source files referenced docs/live-trading/SPEC.md in
docstrings, comments, and tests, but docs/ is gitignored (internal planning),
so external contributors hit a dead path when they grep / ls-tree for it.
Genericize the references to "the live-trading SPEC"; docs/ stays internal.

Closes #166
2026-06-03 22:59:21 +08:00
Haozhe Wu 0df32f2eb3 feat(live): direct-SDK bounded-autonomy order gate + HK/CN universe
Function-based mandate gate for direct-SDK live orders mirroring the MCP gate:
mandate load + schema/expiry check, kill-switch halt, quantity→notional pricing,
fail-closed pre-trade checks via check_mandate, per-broker audit, and a daily
count consumed only on a confirmed placement. Shared daily counter (order_guard
now delegates to it). AssetClass gains HK_EQUITY/CN_EQUITY and OrderIntent gains
an explicit asset_class so the mandate gate buckets HK/A-share correctly. The 6
connector classification maps are wired into the live registry for fail-closed
WRITE coverage. Covered by two adversarial audit rounds; full suite 2979 passed.
2026-06-02 12:52:14 +08:00
Haozhe Wu 8563d5e78b feat(trading): add 6 SDK broker connectors with paper+live order placement
Tiger/Longbridge/Alpaca/OKX/Binance/Futu connectors under a new broker_sdk
transport — read-only account/positions/orders/quote/history + paper-account
order placement, plus bounded mandate-gated order placement for five of them.
Longbridge is paper + read-only only (its API exposes no runtime paper/live
discriminator). Per-broker structural paper/live guards: Tiger 17-digit account,
Alpaca/Binance host separation, OKX flag + uid pin, Futu trd_env via get_acc_list.
New trading_place_order/trading_cancel_order tools; generic period/limit wired
through the tool and CLI. News updated across all 5 READMEs.
2026-06-02 12:51:50 +08:00
Haozhe Wu ede8a8b33b fix(ui): clarify dev-jargon labels, drop drift-prone chip counts, add Compare loading state
Addresses UX feedback from #163: rename systems jargon leaking into chrome
(ev -> evidence, Run bench -> Run benchmark, Decay -> Decay (days), Compact
context -> Summarize conversation), add a hint for Reasoning effort, remove
hardcoded skill/preset/tool counts that go stale under frequent releases, and
show a skeleton on the Compare route while run data loads.
2026-06-02 10:37:20 +08:00
Roger de2a180ebd docs: clarify OpenClaw MCP smoke test (#165)
Closes #164
2026-06-02 10:03:56 +08:00
Octopus bcc3ae6405 feat: upgrade MiniMax default model to M3 (#162)
Add MiniMax-M3 as the new default model and retain MiniMax-M2.7 as
the legacy alternative (incl. -highspeed). Update llm_providers.json
registry, cli onboard provider choices, .env.example example, and the
provider-default regression tests.

Signed-off-by: Octopus <liyuan851277048@icloud.com>
Signed-off-by: octo-patch <liyuan851277048@icloud.com>
2026-06-02 00:52:45 +08:00
Haozhe Wu 15c7a9b577 docs(release): credit v0.1.9 cycle contributors across READMEs
Refresh the Contributors section in all 5 READMEs from the v0.1.8 cycle
list to the v0.1.9 cycle (PRs #124–#160): @toanalien, @ai7eam-dev,
@shadowinlife, @DoubleSky123, @ArthurXi, @omcdecor-cyber, @Soli22de,
@ruok808, @faizack, @fightZy, @lcwSeven, @Teerapat-Vatpitak.
2026-06-01 23:07:06 +08:00
Haozhe Wu 5a06371a94 chore(release): 0.1.9
Roll-up release since 0.1.8 — connector-first broker profiles (IBKR +
Robinhood Agentic Trading), Research Goal runtime, swarm reconcile/MCP
keepalive + worker MCP tools + strict alpha-bench + retry_run, the
agent/cli/ package refactor, the mootdx A-share loader, a robustness pass,
and the session/version fixes (#150 #156 #157 #158 + UI indicator).

Version → 0.1.9 across pyproject, SKILL.md, frontend APP_VERSION, and the
wiki docs version selector. MCP tool count corrected to 36 (adds retry_run)
across all 5 READMEs, with the ar/ko tool lists rebuilt from a stale 22 and
the ar skill count fixed 75→77. CHANGELOG [0.1.9] + 0.1.9 news entry added
to all 5 READMEs. Home backtest card data-source count corrected 3→7.
2026-06-01 23:00:43 +08:00
Haozhe Wu df70288dee fix(cli): derive version from pyproject, drop hardcoded fallback (#156)
The shipped 0.1.8 wheel reported 0.1.7 because a hardcoded constant was
not bumped on release. The metadata path already fixed installed packages;
this removes the remaining hardcoded fallback so an un-installed checkout
reads the version straight from pyproject.toml — making pyproject the one
and only place the version is written, with no constant left to drift.

- _version: tomllib-based pyproject fallback (>=3.11 guaranteed), "unknown"
  sentinel only if the tree is moved away from its pyproject.toml
- tests: pyproject fallback matches declared version, resolved-not-unknown,
  and a guard against reintroducing any hardcoded version literal
2026-06-01 22:39:57 +08:00
Haozhe Wuandai7eam-dev d2f853233f feat(swarm): retry failed/stale/cancelled runs (HTTP + MCP)
Completes the swarm-retry capability extracted from #159 and makes it
usable end-to-end instead of dead API surface.

- api_server: POST /swarm/runs/{id}/retry — reconciles a stale "running"
  run first, refuses a genuinely active run (409), else re-launches a new
  run with the original preset + user_vars
- mcp_server: retry_run(run_id) tool so the list_runs -> spot-failure ->
  retry loop works from OpenClaw/Claude Desktop (the frontend has no swarm
  dashboard; MCP + HTTP are the real consumers)
- api.ts: retrySwarmRun client method (parity with cancelSwarmRun)
- tests: missing-run / running-refused / happy-path relaunch, MCP well-known
  tool name, and HTTP traversal rejection for the new endpoint
- docs: bump current MCP tool count 35 -> 36 across the 5 READMEs (ar/ko
  prose still carries a stale 22-tool list — tracked separately)

Co-authored-by: ai7eam-dev <ai.7eam@gmail.com>
2026-06-01 22:39:57 +08:00
Haozhe Wuandai7eam-dev a0da6c7727 fix(ui): reliable session running-status indicator
Scoped extraction of the UI running-status indicator from #159 (the
remaining commits there overlapped with #157/#158 or touched the
protected providers/ module).

- useSSE: add attempt.created/started, message.received, session.created,
  tool_heartbeat, llm_usage to knownTypes so a mid-stream reconnect/replay
  still surfaces the working state
- agent store: track streamingSessionId, preserved across switchSession so
  the sidebar spinner persists when navigating away from a running session
- Agent.tsx: handle attempt.created/started (re-affirm streaming on reconnect
  and page reload), keep streaming alive on tool_heartbeat, add a persistent
  pulse bar, reword "Thinking…" to "Agent is working…"
- Layout: replace the dead-code (status === "failed") dot with a spinning
  Loader2 for the actively streaming session
- index.css: pulse-slide keyframe for the running bar

Co-authored-by: ai7eam-dev <ai.7eam@gmail.com>
2026-06-01 22:39:57 +08:00
a8144e9acf feat(frontend): make SSE idle timeout configurable via VIBE_TRADING_SSE_TIMEOUT (#157)
CI / test (push) Waiting to run
Wiki / validate (push) Waiting to run
Deploy Wiki / Deploy to Cloudflare Pages (push) Canceled after 0s
The frontend safety timeout was hardcoded at 90 s, causing false
"Execution timed out" toasts for users running slow local models
(e.g. Ollama on limited VRAM or CPU).

- Backend: expose `sse_timeout_seconds` in `LLMSettingsResponse`,
  reading from `VIBE_TRADING_SSE_TIMEOUT` env var (default 90 s)
- Frontend: fetch the value from `/settings/llm` on mount and use it
  instead of the hardcoded 90 000 ms constant
- `.env.example`: document the new variable in the Agent Tuning section

Co-authored-by: Sky <985369746@qq.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:13:15 +08:00
ai7eam-dev f9bea768f4 fix(correlation): normalize timestamps for cross-market asset alignment (#158)
Cross-market correlation (e.g. BTC-USDT vs MSTR) failed with
'No overlapping return data between assets' because different loaders
produce DatetimeIndex values at different UTC offsets:

- OKX/CCXT (crypto): timestamps at UTC midnight (00:00:00)
- yfinance (US equity): timestamps at EDT midnight (04:00:00 UTC)
  After tz_localize(None) the time component is preserved,
  so 04:00 != 00:00 and the inner join produces zero rows.

Fix: call .index.normalize() on each close series before computing
returns, collapsing all timestamps to midnight regardless of source.

Also improve the error message to show actual date ranges per asset
when no overlap is found, making future debugging easier.
2026-06-01 22:13:09 +08:00
Thiên Toán 2a893e1a26 fix(ui): always show Full Report link when runId exists in session history (#150)
* fix(ui): always show Full Report link when runId exists in session history

Previously, when loading a session from history (e.g. in a new browser),
the run_complete card was silently dropped if api.getRun() failed or
returned non-report-worthy data. This caused the "Full Report" link to
disappear for remote/cross-browser users while remaining visible in the
original browser's in-memory cache.

Now the card always renders when runId is present in message metadata,
with metrics and equity curve as optional enrichments. Also improves the
RunDetail error page with diagnostic hints and a back button.

* fix(ui): cast equity to number to satisfy TypeScript strict typing

* fix(ui): distinguish non-report runs from fetch failures in run card logic

Address PR review: not every run_id is a backtest — plain chat turns also
get a run_id. Now only show the Full Report card when getRun() confirms
report-worthiness OR when the fetch fails (auth/network). Plain chat turns
where getRun() succeeds but isReportWorthyRun is false correctly skip the
card, avoiding spurious links.
2026-06-01 22:12:50 +08:00
Haozhe Wu 1e3bd7a8c0 chore(trading): clean connector docs and on-ramp mapping
Refresh multilingual MCP tool counts and centralize broker-to-connector on-ramp profile resolution so the frontend no longer duplicates broker profile fallbacks.
2026-06-01 00:27:38 +08:00
Haozhe Wu 52f5059159 feat(trading): add connector-first broker profiles
Add unified trading connector profiles and tools for IBKR and Robinhood. Gate live runner behavior by connector capability, preserve IBKR local read-only config, and update CLI, MCP, web UI, tests, skill docs, and multilingual README news.
2026-06-01 00:17:28 +08:00
Haozhe Wu 4e009c7769 docs: news 2026-05-30 — backtest/agent-loop/session robustness pass 2026-05-30 21:45:17 +08:00
Haozhe Wu 168122311a fix(backtest): emit clean JSON for signal-engine source errors
Follow-up to #149. Source-level AST validation (circular self-import,
unsafe imports, decorators, top-level statements) raises ValueError from
_load_module_from_file, but the call site was unguarded so it surfaced
as a raw Python traceback -- contradicting #149's stated goal of
actionable JSON errors for the circular-import case. Wrap the call and
emit a clean JSON envelope, unifying all source-validation errors with
the runner's JSON error contract.
2026-05-30 11:46:07 +08:00
Haozhe Wu 7c242b934c fix(loop): guard wrap-up nudge to mid-iterations only
Follow-up to #148. The wrap-up nudge fired at iteration == wrap_up_at
unconditionally, which on tiny budgets (max_iter<=2 -> wrap_up_at==1)
injected a '[SYSTEM] wrap up' user message on the very first iteration,
displacing the active research-goal context as the most recent user
message and breaking test_agent_goal_context. It also conflicted with
goal-continuation by telling the agent to stop early.

Now the nudge only fires for 1 < iteration < max_iterations: never on
the first iteration, and never on the last (the forced text-only path
already guarantees a final answer there).
2026-05-30 11:46:07 +08:00
Thiên Toán 002e042d44 fix(loop): graceful exit on max iterations with wrap-up nudge + forced text-only (#148)
AgentLoop could exhaust all 50 iterations when the LLM kept calling tools,
resulting in status="failed" with no useful output. Mirror the swarm worker's
proven approach: inject a wrap-up nudge at 80% of iteration budget and drop
tool definitions on the last iteration to force a text response.
2026-05-30 11:43:37 +08:00
Thiên Toán a7522d29bb fix(session): handle corrupted JSONL lines and harden message writes (#147)
Truncated writes (from process crash mid-append) produce invalid JSON
lines that crash the GET /sessions/{id}/messages endpoint with a 500.

Write-side: flush + fsync after each append so the full line is
durable before the request continues — prevents data loss for
expensive AI API responses.

Read-side: catch JSONDecodeError per line, log the first 200 chars
for manual recovery, and continue loading the remaining messages
instead of failing the entire endpoint.
2026-05-30 11:43:33 +08:00
ArthurXiandwuyi 86bb254334 Fix IME Enter submission handling (#146)
Co-authored-by: wuyi <wuyi@Wu-Yi-MacBook-Air.local>
2026-05-30 11:18:55 +08:00
Thiên Toán 7dc888eeae fix(backtest): pre-flight validation for LLM-generated signal engines (#149)
Add interface checks before instantiation to catch common LLM mistakes
early with actionable JSON errors instead of raw tracebacks:
- Circular self-import detection (from signal_engine import SignalEngine)
- Required __init__ args check (all params must have defaults)
- Missing generate() method check
- Return-type validation (must be Dict[str, pd.Series], not list)
2026-05-30 11:18:52 +08:00
Haozhe Wu 108bfae960 feat(live): bounded-autonomy live trading channel (Robinhood Agentic Trading)
Add an opt-in, read-only-by-default live execution channel via Robinhood
Agentic Trading (remote MCP, OAuth). The agent trades autonomously only inside
a user-committed mandate (symbol allowlist, order size, exposure, leverage,
instrument, daily cap):

- Mandate model + read-only protected store; commit is the ONLY writer
  (no set_mandate tool reachable from the agent loop)
- Pre-trade enforcement gate (fail-closed) + order-intent extractor + quote pricing
- 3-tier read/write tool classification (default-deny) + OAuth token cache
- Filesystem kill switch (LLM-independent) + preemptive flatten on trip
- Mandate auto-expiry, checked proactively every runtime tick
- Persistent autonomous runtime: scheduler, durable job store, liveness,
  market/interval triggers, crash-recovery reconciliation
- Consent UX (propose/commit/adjust) across CLI and Web UI
- Compliance-grade audit ledger with credential/PII redaction

Read-only/fail-closed by default; protected agent core (src/agent, src/session)
untouched. Paper/mock-tested only; live verification is the community's
(experimental, use at your own risk). Updates all 5 README languages with the
bounded-autonomy disclaimer.
2026-05-29 18:00:08 +08:00
Haozhe Wu f0c799d5b4 docs: news 2026-05-28 — swarm DAG gating + strict alpha bench + worker MCP 2026-05-28 16:40:05 +08:00
Soli22de ec5d8b18d8 feat(factors): strict_bench mode with mandatory random control (#143)
bench_runner.run_bench() labels an alpha "alive" when its raw IC mean
beats 0.02, IC-positive ratio beats 0.55, and the IC self-t-stat clears
2. That gate accepts factors whose IC is driven by shared cross-sectional
beta (market/size/sector leakage) — the test is benchmarked against zero,
not against a same-universe random control, so a row-shuffled version of
the same factor typically clears the gate too.

Adds an opt-in `run_bench_strict()` companion (existing `run_bench()`
untouched and back-compat) that:

1. Builds N same-universe random controls per alpha by row-shuffling the
   factor values within each row — preserves the per-date cross-sectional
   distribution (mean, std, fat tails) while destroying the
   signal-to-instrument mapping. Fairer null than Gaussian noise for IC.
2. Computes a per-date paired alpha series = signal_IC - random_IC and
   evaluates its t-stat instead of the IC self-t-stat.
3. Optionally splits train/test on a user-supplied date and requires the
   test-period alpha_t to also clear the threshold before labelling an
   alpha `confirmed_alive` (otherwise `train_only` for benign decay,
   `reversed_strict` for OOS sign-flip).
4. Makes `random_control: bool` keyword-only with no default — passing
   nothing raises TypeError. Mirrors the rail from Soli22de/Bili_Stock's
   foundation Backtest constructor, which was hardened after a 9-month
   audit found every accidental no-random-control run inflated alpha by
   3-8 pp.

Categories:
  confirmed_alive  alpha_t_full >= thr AND (no OOS OR alpha_t_test >= thr)
  train_only       alpha_t_full >= thr AND alpha_t_test in noise band
  reversed_strict  alpha_t_full <= -thr  OR  OOS sign-flip
  noise            alpha_t_full in (-thr, thr)  OR  ic_count < min_ic_count

Default threshold 2.0 stays numerically comparable with the existing
`categorise()` gate. Pass `StrictThresholds(alpha_t_threshold=3.5)` to
apply the Harvey-Liu-Zhu (2016) multiple-testing correction when running
the full 455-alpha zoo (the Vibe-Trading default zoo size).

Legacy bucket aliases (`alive` / `reversed` / `dead` / `by_theme`) are
preserved on the wire payload so existing dashboards keep rendering
without code changes:
  alive    = confirmed_alive
  reversed = reversed_strict
  dead     = noise + train_only

Tests: 33 in test_bench_strict.py (helpers, categorisation rules,
keyword-only rail, OOS boundary semantics, inf-as-NaN, legacy aliases,
schema-complete error envelope, exception-safe on_progress, planted-
signal integration for both confirmed_alive and reversed_strict).
Full agent/tests/factors/ suite: 1002 passed, 1 skipped — zero
regression in the existing factor tests.
2026-05-28 16:28:57 +08:00
shadowinlife 7acaf74e10 feat(swarm): operator-configured external MCP tools in workers (#142)
Lets swarm workers call tools from operator-approved external MCP
servers in addition to the 31 local Vibe-Trading tools. Useful for
internal knowledge bases, compliance services, or any extra MCP server
the operator wants to attach.

Trust model is preserved at the schema level: MCP server URLs, commands,
env, and allowlists are resolved from the operator's config file
(VIBE_TRADING_SWARM_AGENT_CONFIG / ~/.vibe-trading/swarm-agent.json /
~/.vibe-trading/agent.json), never from `variables` or any other
caller-supplied input. The `run_swarm` MCP entry point cannot inject
server config.

Per-worker registries are built by intersecting each agent's `tools:`
whitelist with the resolved local + remote tool pool. Whitelist entries
the operator hasn't surfaced are dropped with an operator-facing
warning rather than failing the worker. Discovery is narrowed to the
servers actually referenced by the whitelist, so local-only workers
incur no MCP spawn cost.

MCP server-name collisions (e.g. `foo-bar` / `foo_bar`) keep their hash
suffixes after pruning — `_prune_agent_config_for_swarm_tools` resolves
segments against the full config first, then passes the preserved
mapping into `build_registry` so the worker whitelist still matches.

Adds `server` + `remote_tool` metadata to `tool_call` / `tool_result`
events for audit clarity, with nested redaction of sensitive argument
and result values.

Wired into MCP server, API server, CLI live runner, and in-process
SwarmTool. 27 new tests across M1-M5 (config plumbing, registry
assembly, boot wiring, e2e, trust model); 288 broader swarm/mcp/tool
tests pass.
2026-05-28 16:21:32 +08:00
Haozhe Wu 46e49626b9 fix(swarm): wire task_blocked into CLI live panel + carry agent_id
Follow-up to #145. The new task_blocked event was emitted without
agent_id, so the CLI live panel (cli/_legacy.py) could not attach it
to the per-agent row — blocked agents stayed visually "pending" until
the run finalized. Three small fixes:

- runtime.py: pass agent_id=task.agent_id on the task_blocked emit
- cli/_legacy.py: handle task_blocked in the live event loop
- models.py: extend SwarmEvent docstring to mention task_blocked
- test_swarm_dag_gating.py: assert agent_id is present on the event
2026-05-28 15:32:04 +08:00
omcdecor-cyber 5a6780be2b fix(swarm): block downstream tasks when upstream fails (#145)
Bug
---
A failed task in layer N did NOT prevent layer N+1 tasks that declare it
as a dependency from being dispatched. The orchestration loop in
_execute_run set all_succeeded=False on failure but never gated the next
layer. The downstream worker's upstream-summary loop in _execute_layer
silently skipped the missing key (the `if source_task_id in task_summaries`
check), so the worker ran with no upstream context.

This is most visible in the investment_committee preset, where
portfolio_manager depends_on=["task-risk"]: a failed risk_officer
let PM produce a "decision" with no risk input. Reproducer attached as
tests/test_swarm_dag_gating.py shows the same pattern in 3 tasks.

The layer-boundary _sync_run_tasks_snapshot added in #132 surfaces the
failure to polling clients sooner, but it does not gate dispatch — PM
still runs the moment the layer with the failed risk task finishes.

Fix
---
_execute_layer: before dispatch, walk task.depends_on and load each
  upstream from TaskStore. If any upstream has status != completed
  (failed / blocked / cancelled / missing), mark this task
  TaskStatus.blocked, record blocked_by + reason, emit task_blocked,
  and skip executor.submit. Same-layer peers with no shared upstream
  are unaffected (the loop continues).

_execute_run: tasks blocked in _execute_layer are absent from
  layer_results. After the result loop, any layer_task_id missing from
  layer_results was blocked -> set all_succeeded = False so the run is
  marked RunStatus.failed at finalization.

Tests
-----
3 new regression tests in tests/test_swarm_dag_gating.py (TDD red-green
verified before-and-after on this PR's diff):
  - test_failed_upstream_blocks_downstream
  - test_blocked_downstream_emits_task_blocked_event
  - test_run_marked_failed_when_downstream_blocked
Full swarm test suite: 115/115 pass (112 existing + 3 new).
2026-05-28 15:30:52 +08:00
Haozhe Wu bc63784752 docs: bump skill count 75→77 and recommend mootdx as no-token A-share fallback
- 5 READMEs had "75 skills" / "75 个 skills" / similar wording stale
  in 7+ places each; actual `agent/src/skills/` count is 77 after
  today's mootdx skill. Bumped every mention to 77.
- The "no-API-key" tip recommended AKShare as the free A-share
  fallback, but mootdx is now the more reliable option (TCP-direct,
  no IP throttle). Reworded the tip in all 5 languages: mootdx
  preferred, AKShare as broader-coverage backup.
2026-05-27 20:06:41 +08:00
Haozhe Wu 6b1b180f66 docs: news 2026-05-27 — mootdx loader + CCXT proxy + output polish
Add the 2026-05-27 entry to all 5 READMEs and rotate the
2026-05-24 Research Goal runtime entry into Earlier news so the
top-3 stays at 5-27 / 5-26 / 5-25 per the news convention.
2026-05-27 20:00:01 +08:00
Haozhe Wu d542038d36 fix(render): drop markdown HR separators and bump valid_sources
User report (#139): example output shows ugly full-width horizontal
lines instead of clean tables. Root cause is the LLM emitting `---`
between sections, which Rich (CLI) and react-markdown (web) both
render as a full-width separator. Fixed at three layers:

1. System prompt (context.py): explicit rule to use markdown pipe
   tables for multi-row data and `##` headings instead of `---`
   horizontal rules. Also corrected stale "5 data sources" -> "7"
   to reflect mootdx + futu.
2. CLI render_answer (transcript.py): strip standalone `---` /
   `***` / `___` lines as defense-in-depth so historical answers
   and the occasional rule-breaking output render cleanly.
3. Frontend MessageBubble.tsx: add `prose-hr:hidden` so any `<hr>`
   that slips through doesn't draw a line.

Also fixed backtest_tool valid_sources whitelist: `source="mootdx"`
and `source="futu"` were rejected by the validator even though both
loaders were registered. Added both to the allowed set.

Tests: +1 transcript regression covering the HR strip; existing
2385 tests stay green.
2026-05-27 19:53:46 +08:00
Haozhe Wu e4e2a9d9c2 feat(loaders): add mootdx A-share OHLCV loader
Mootdx talks the native 通达信 binary protocol over TCP, bypassing
akshare's East Money HTTP scrape that periodically throttles. No API
token, no per-IP rate limit.

Scope: A-share OHLCV.
- Daily 1D/1W/1M via get_k_data (native date range).
- Intraday 1m/5m/15m/30m/1H via bars() with 25-page walk-back
  pagination (~10y daily, ~5y 1H, ~3mo 1m of lookback).
- SH / SZ supported. 北交所 (BJ) returns empty upstream, so the
  loader detects BJ symbols and skips with a clear warning rather
  than swallowing the failure.
- Mootdx extended market (futures/options) is upstream-broken in
  v0.11.7 — falls through to tushare/akshare for those markets.

Fallback chain becomes a_share: [tushare, mootdx, akshare] so
mootdx serves as the no-token, no-scrape middle tier between
tushare (auth-gated) and akshare (HTTP scrape).

Optional dependency: pip install mootdx && pip install 'httpx>=0.28.1'.
Mootdx pins httpx<0.26 in setup.py but only uses forward-compatible
httpx APIs; the second install restores the modern httpx that the
rest of the project (MCP server, fastmcp) needs.

Closes #107.
2026-05-27 19:43:24 +08:00
ruok808 f607f7a930 feat(loaders): pass proxy env to CCXT (#126)
Read HTTP_PROXY/HTTPS_PROXY/ALL_PROXY (and lowercase variants) and pass them to the CCXT exchange constructor so Binance/OKX public data works from restricted networks. Additive only; no behavior change when no proxy env is set.
2026-05-27 18:42:53 +08:00
Haozhe Wu 3bd356c1f6 fix(deps): bump langgraph for CVE-2026-28277 2026-05-26 00:55:58 +08:00
Haozhe Wu 83801bad46 docs: refresh multilingual news 2026-05-26 00:49:02 +08:00
Haozhe Wu acab70c556 fix(goal): close research goal lifecycle 2026-05-26 00:43:28 +08:00
Haozhe Wu 6b8241a356 fix(wiki): route docs rewrites to docs app 2026-05-25 22:43:33 +08:00
噎 cceb4def1e Fix agent session history loading 2026-05-25 00:53:59 +08:00
liucaiwen 62869fc26a fix(api): accept short universe names in alpha list endpoint
Normalize csi300/sp500/btc-usdt to canonical names before validation
in GET /alpha/list, matching the behavior of the bench form endpoint.

Signed-off-by: liucaiwen <liucaiwenseven@163.com>
2026-05-25 00:53:55 +08:00
Haozhe Wu 8b2273c6de fix(ui): streamline composer goal workflow
Move Research Goal entry into the chat composer: the + menu now switches the
normal input into goal mode, sends create the session goal directly, and the
active goal stays above the input as an inline chip with expandable criteria
and evidence details.

Stabilize the local dev launcher used for browser smoke tests by wiring custom
backend ports into VITE_API_URL, launching long-lived process groups, cleaning
up child processes reliably, and reporting actual frontend/backend status.
2026-05-25 00:41:31 +08:00
Haozhe Wu 2a18d33c23 feat(goal): add research goal runtime
Add the session-scoped Research Goal layer across backend, CLI, API/MCP,
SSE, and Web UI. Goals now persist claims, acceptance criteria, evidence,
budgets, and completion policy; agent tools and /goal can create goals and
attach evidence; REST/MCP expose snapshots and evidence writes; SSE keeps
chat clients fresh.

Also carry the 2026-05-24 transplant cleanup: replay-all/session-id/upload
wiring, report-worthy Full Report gating, English-only Web UI copy, and audit
fixes for verified evidence, live-trading risk tiers, CLI session linkage,
goal ledger cleanup, replay-all hookup, and frontend snapshot races.
2026-05-24 23:55:00 +08:00
Haozhe Wuandykykj 74614f0e7c feat(cli): refresh interactive terminal UI
Refresh the interactive terminal transcript, activity rail, and prompt visuals. Document the 2026-05-23 CLI update in README and keep generated CLI screenshots out of git.

Co-authored-by: ykykj <140159248+ykykj@users.noreply.github.com>
2026-05-24 00:17:45 +08:00
Haozhe Wu 1404eda72b docs(readme): add 2026-05-22 swarm news 2026-05-22 23:45:14 +08:00
Haozhe Wu cac3f0b5ed fix(swarm): unified reconcile + MCP keepalive + heartbeat coverage (#132)
Symptoms reported in #132 (WorkBuddy + DeepSeek MCP):
- MCP run_swarm blocked 30 min then errored, losing run_id
- get_swarm_status / get_run_result showed stale snapshot while tasks/*.json
  had real progress
- Host crash left runs "running" forever with no recovery path
- list_swarm_runs / SSE rendered the same stale snapshot

Architecture:
- store.reconcile_run(write=True): single read-side entry point. Hydrates
  live task files, recovers terminal-but-not-finalized state (host crashed
  between last layer sync and finalize), and reaps stale running runs whose
  events.jsonl has gone silent past compute_stale_threshold.
- Threshold is per-run: max(60, min(heartbeat_interval*10, retry_ceiling))
  — fast detection when heartbeats work, retry-budget-clamped when not.
- HeartbeatTimer wraps registry.execute, llm.stream_chat, AND grounding
  fetch so no normal execution phase can produce >heartbeat_interval of
  silence. Slow first-token / reasoning-mode / pure tool-call responses
  no longer trip the reaper.
- run_swarm emits ctx.report_progress every poll with elapsed seconds, so
  MCP transport stays alive via progress notifications; first frame is
  fixed-format ``swarm_started run_id=<id>`` so transport-drop clients can
  still recover the handle.

Endpoints updated to use reconcile_run:
- MCP: get_swarm_status, get_run_result, list_runs, run_swarm polling
- API: /swarm/runs, /swarm/runs/{id}, /swarm/runs/{id}/events SSE
- SwarmTool: polling + budget-out path (no longer cancels on timeout)

Other fixes:
- SWARM_HEARTBEAT_INTERVAL_S parses with ValueError fallback in worker
  (was crashing import on bad config)
- reaper derives run terminal status from task statuses (no more
  hardcoded "failed" on a run whose tasks all completed)
- serialize_task surfaces started_at / completed_at / depends_on / blocked_by
- reap_stale_runs() MCP tool for explicit user-triggered cleanup

Tests: 22 new regressions in tests/test_swarm_status_hydration.py covering
hydrate, reconcile, terminal recovery, stale reap, MCP keepalive cadence,
auto-recovery on read, threshold math, env var robustness, heartbeat
wiring around LLM + tool execution.

Verification:
- targeted suite: 22 passed
- full swarm/mcp suite: 169 passed, 4 skipped, 0 failed
- compileall + import smoke clean
2026-05-22 11:56:29 +08:00
Haozhe Wu e9c0000f5f chore(providers): refresh default model ids 2026-05-22 10:04:26 +08:00
Haozhe Wu 1edf27e040 ci: switch syntax-check from cli.py to cli/ package
The CLI was split into a package in b5a44a1; the workflow still
ran ``python -m py_compile cli.py`` against the now-renamed file
and failed with "No such file or directory: 'cli.py'".

Use ``python -m compileall -q cli`` so every module under the
package (``main.py``, ``_legacy.py``, ``commands/``, ``components/``,
``utils/``, ``_version.py``, ``__main__.py``) is checked together.
2026-05-22 00:04:00 +08:00
Haozhe Wu b5a44a1e56 refactor(cli): split agent/cli.py into agent/cli/ package
Restructures the 3216-line agent/cli.py into a focused package
while preserving every existing surface verbatim.

Legacy compatibility:
- _legacy.py shim keeps every subcommand (serve, run, hypothesis,
  alpha, memory, provider, swarm, sessions, ...) reachable through
  the same CLI flags and console-script entry.
- Public symbols (cmd_*, _INIT_ENV_PATH, Confirm, AGENT_DIR, ...)
  re-exported through cli.<name>; `from cli import *` exposes 76
  symbols (was 1).
- Package-level cli.RUNS_DIR / cli.SESSIONS_DIR monkeypatches now
  propagate into _legacy before any cmd_* runs.

New scaffolding:
- agent/cli/main.py interactive REPL front door
- agent/cli/commands/ slash router + handlers (15 commands)
- agent/cli/components/ reusable Rich UI building blocks
- agent/cli/{theme,intro,onboard,input,completer,stream}.py
- agent/cli/__main__.py restores `python -m cli`
- agent/cli/_version.py reads importlib.metadata.version() so
  --version output and the banner stay aligned with pyproject.toml

Production wiring:
- New FastAPI middleware in api_server.py serves
  frontend/dist/index.html on browser navigation to /runs/{id}
  and /correlation, regex-narrowed so /runs/{id}/code and
  /runs/{id}/pine API routes stay reachable.
- frontend/vite.config.ts dev proxy mirrors the same narrowing.
- scripts/dev backend starts via package-safe
  `python -c 'import cli; cli.main(...)'`.

Chat-gate narrowed: chat --help / chat -h / chat --max-iter <bad> /
chat extra correctly reach legacy argparse instead of being
swallowed by the new REPL.

Tests: 2271 passed, 43 skipped (focused CLI/SPA test files:
test_cli_interactive.py 67 + test_spa_deep_link.py 13 + existing
CLI memory/init/hypothesis 48 = 128).
2026-05-22 00:00:47 +08:00
Haozhe Wu 34a30fb58d docs(readme): News — Hypothesis Registry CLI (2026-05-20)
Adds a 2026-05-20 entry to all 5 READMEs (EN/ZH/JA/KO/AR) covering the
`vibe-trading hypothesis {list,show,invalidate}` subcommands shipped in
dda06ae. Pushes the 2026-05-17 Alpha Zoo v1 entry into the collapsed
"Earlier news" block to preserve the 3-latest-visible convention.
2026-05-20 22:32:14 +08:00
Haozhe Wu dda06ae351 feat(cli): hypothesis registry — list / show / invalidate
Adds `vibe-trading hypothesis {list,show,invalidate}` subcommands that wrap
the existing HypothesisRegistry storage. List prints a Rich table (or JSON
with --json), supports --status filter and --limit. Show prints a Rich
detail panel including linked run cards. Invalidate flips status to
rejected and optionally records an invalidation_notes message; existing
notes are preserved when --note is omitted.

Closes the roadmap P2 backlog item "Add CLI workflows for listing,
editing, and reviewing hypotheses" on the CLI side; Web workflow waits
on the v0.2.0 IA landing.

22 new tests cover wiring, list/show/invalidate paths, JSON output,
status filter, limit, missing-id errors, and note persistence.
2026-05-20 21:36:22 +08:00
Haozhe Wu 008c0839cd docs(readme): add AI-Trader to ecosystem 2026-05-20 20:40:33 +08:00
Haozhe Wu a1e83aded7 feat: live tool feedback + graceful CLI cancel
Tool calls now emit a 3s heartbeat plus structured per-stage progress
via emit_progress(). run_backtest, read_document, read_url opted in;
all other tools get heartbeat-only feedback automatically.

CLI Rich Live dashboard: Unicode spinner, ASCII progress bar, ETA,
per-tool rows (up to 3 parallel) keyed by tool name. Color discipline
restored (yellow reserved for cancelled/warning; running stays cyan).
0.25s throttle on tool_progress; no-rich path coalesces dots and
progress lines with per-tool gating.

Frontend: new ToolProgressIndicator with rAF coalescing, ARIA
role=status + hidden <progress>, ProgressRing SVG when determinate,
parallel-tool stacking with overflow + truncation. Extracted reusable
primitives ProgressBar.tsx and lib/tools.ts (shared tool-name i18n).

CLI Ctrl+C: first press calls agent.cancel() for graceful exit
(current step finishes, trace closes cleanly); second within 2s
force-quits. Backend POST /sessions/{id}/cancel was already wired.

Also fixes 2 pre-existing baseline test failures:
test_fundamental_filter_example (path resolved via __file__) and
test_openai_codex (pytest.importorskip for optional dep).

Tests: 2174 -> 2176 pass.
2026-05-19 16:19:42 +08:00
Haozhe Wu 0c39711432 docs(readme): add one-line star CTA below star history (5 locales) 2026-05-19 13:35:45 +08:00
Faijan KhanandCursor c1f3898056 fix(providers): normalize remote Ollama base URLs without /v1 (#129)
Append /v1 automatically when LANGCHAIN_PROVIDER=ollama so remote hosts
like http://host:11434 work with ChatOpenAI without manual path suffixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 12:55:59 +08:00
Haozhe Wu 8a7a658260 fix(cli): vibe-trading init writes to ~/.vibe-trading/.env
Help text and docstring promised ~/.vibe-trading/.env but the
implementation wrote to <AGENT_DIR>/.env, which lives inside
site-packages for pip-installed users and gets wiped on upgrade.

Align the implementation with the documented behavior, mkdir -p
the parent, and chmod 600 the file since it holds API keys.
2026-05-18 16:10:36 +08:00
Haozhe Wu 37fb6e2f4e fix(frontend): show Thinking… placeholder between Send and first SSE event (#128)
Closes #127

Before this change, the live streaming area in Agent.tsx only rendered
when streamingText was non-empty or toolCalls had at least one entry.
The gap between handleSubmit setting status="streaming" and the first
SSE event arriving left the chat area visually blank, making the UI
look unresponsive on slow networks or long agent "thinking" time.

Add a Thinking… placeholder that renders exactly during that gap
(status === "streaming" && !streamingText && toolCalls.length === 0)
and is implicitly hidden once the first SSE event populates either
streaming state.
2026-05-18 15:29:49 +08:00
Haozhe Wu 8a6c578a76 chore: cleanup pass — 3 latent bug fixes + 913 LOC net delete
Bug fixes
- CompositeEngine no longer misroutes bare Chinese-futures codes like
  RB2410 to GlobalFuturesEngine. _is_china_futures moved into a shared
  _market_hooks module with a case-normalized product table and a
  non-CN exchange guard. 9 new regression cases (RB2410 / rb2410 /
  RB2410.SHFE / CL.NYMEX / M2412.CBOT / CF2412.ICE / AU2412.COMEX /
  FG2412.EUREX / bare CN-collision still routes Chinese).
- Session FTS5 indexes now persist timestamps so cross-session search
  can sort by date. Same path also fixes a re-upsert that was
  wall-clocking every session's started_at on title-rename.
- Vite dev-mode proxy gained the missing /alpha entry; AlphaZoo page
  resolves on npm run dev.

Cleanup
- Deleted swarm/api_models.py + swarm/mailbox.py + SwarmDashboard.tsx
  (dead modules / orphaned UI after swarm-via-agent flow).
- Deleted SessionService.resume_attempt + get_attempts + get_attempt
  + SessionStore.list_attempts + Attempt.mark_waiting_user chain
  (half-baked human-in-the-loop from initial commit).
- Deleted ChatLLM.achat (async sibling never called) and
  _extract_balanced_json (only-tested helper).
- Deleted american_exercise_value in options_portfolio.py
  (driver inlines bs_price logic).
- Deleted _compute_ic_series_reference in factor_analysis_core.py
  (kept-as-reference, no callers).
- Collapsed MEMORY_TYPES duplication between cli.py and
  memory.persistent; restored set-equality invariant assert.
- Deleted runSwarm (~197 LOC), swarmDash state, swarmCancelRef
  write-only ref (orphaned by new agent-driven swarm flow).
- Dropped 76 unused 'vw = vwap(...)' boilerplate lines across
  gtja191 alphas + their now-unused vwap imports.
- Cleared 11 F401 unused-import sites in tools/, agent/, providers/.

Test infrastructure
- test_e2e_harness_v2.py (real-LLM e2e) gated behind
  VIBE_TRADING_RUN_LIVE_E2E so CI no longer flips shape based on
  env-key presence. CI workflow now has explicit --ignore.
- test_auto_compact_with_long_history: monkeypatch TOKEN_THRESHOLD so
  the test deterministically triggers compact instead of relying on
  unrelated LLM behaviour.
- test_compact_tool_explicit: assertion corrected — compact tool is
  special-cased in loop._process_tool_calls and does NOT emit
  tool_call events. Assert on the 'compact' event instead.
- TestSwarmE2E: per-test @pytest.mark.timeout overrides matching the
  internal 300s poll deadlines; cancel test now polls for terminal
  state instead of fixed sleep(3).

Tooling
- ruff per-file-ignores for factors/zoo/** (3783 → 0 F401 noise);
  F841 stays active in zoo to catch real formula bugs.
- frontend tsconfig: noUnusedLocals + noUnusedParameters as
  regression guards.
- highlight.js hoisted to direct dependency (was transitive via
  rehype-highlight).
- Vite proxy: dropped unused /run, /skills, /health, /api, /system;
  added /alpha.

Annotations
- direction parameter renamed to _direction in 6 calc_commission
  signatures (reserved for future borrow-fee asymmetry).
- _MARGIN_PER_CONTRACT in global_futures.py annotated as reference
  data.
- Backtest engines' _calc_pct_change variants cross-reference each
  other in docstrings.

Docs
- 2026-05-18 news entry added to README.md / README_zh.md /
  README_ja.md / README_ko.md / README_ar.md; 2026-05-15 entry
  rolled into the Earlier news <details>.
- Contributors heading: v0.1.7 cycle → v0.1.8 cycle in all 5
  READMEs.

126 files changed, +363 / -1276.
2026-05-18 15:16:24 +08:00
Teerapat Vatpitak 5684d5fe09 fix(providers): log resolved dotenv source
Emit a redacted dotenv resolution diagnostic and redact logged base URLs to avoid leaking credentials.
2026-05-17 23:36:33 +08:00
shadowinlife 51148d71ee feat(agent): support remote MCP transports
Support SSE and streamable HTTP MCP servers, harden remote tool retries, and move registry construction off the async session loop.
2026-05-17 23:33:15 +08:00
Haozhe Wu d2c94396aa chore: move tweet draft out of public wiki
wiki/research-lab/posts/alpha-191-in-2026.tweet.md was an internal
marketing draft (pre-flight checklist + draft tweet text) that should
not have been committed to a public path. Moved to docs/alpha-zoo/
(gitignored) and removed from main.

Note: the file remains in git history at commit 137689c. If history
scrub is required, a force-push rewrite is needed (separate decision).
2026-05-17 20:23:44 +08:00
Haozhe Wu 07e1adcbba chore(0.1.8): version, CHANGELOG, packaging, News across 5 READMEs, SKILL.md
Release prep for Alpha Zoo v1. Wraps up the 0.1.8 main-branch
content so PyPI + ClawHub publish can run from a clean checkout.

Version
- pyproject.toml: 0.1.7 → 0.1.8
- wiki/docs/content.js: DOCS_DEFAULT_VERSION 0.1.7 → 0.1.8 + 0.1.7
  retained in the version dropdown
- agent/SKILL.md frontmatter: 0.1.7 → 0.1.8

Packaging
- pyproject.toml [tool.setuptools.package-data] adds the new
  src.factors zoo asset globs (zoo/**/*.yaml, zoo/**/*.md,
  zoo/**/NOTICE) alongside the existing src and backtest entries
- MANIFEST.in adds `recursive-include agent/src/factors *.yaml *.md`
  and `include NOTICE`

CHANGELOG.md (first changelog file in the repo)
- Full 0.1.8 entry covering the Alpha Zoo (4 zoos × 452 alphas),
  Web UI + 4 REST routes, CLI subcommand suite, safety floor
  (AST purity gate + lookahead test + pytest-socket + grep gates),
  community governance (CONTRIBUTING + DCO + NOTICE + per-zoo
  LICENSE.md), and the IC/IR core extraction.
- Known limitations subsection: btc-usdt is single-asset; cross-
  sectional IC needs ≥2 instruments; crypto-majors basket planned
  for 0.2.
- [Unreleased] anchor at top for the next cycle.

agent/SKILL.md (ClawHub package descriptor)
- Frontmatter description gains Alpha Zoo + 75 skills (was 74)
- New `### Alpha Zoo (452 pre-built alphas)` section with the
  4-zoo breakdown + CLI / REST / signal-engine entry points
- Skill count 74 → 75 wherever it appears
- Trademarked-name string intentionally absent (CI gate b enforces)

5 READMEs (EN/ZH/JA/KO/AR) per `docs/alpha-zoo/readme-update-spec.md`
- Roadmap: Alpha Zoo `Planned` → `Shipped 0.1.8` with expanded
  description (452 alphas, 4 zoo sources, CLI + agent + Web UI)
- Skill count 74 → 75 (Tool category 10 → 11, `alpha-zoo` added)
- Agent tools count 29 → 31 (alpha_zoo_tool, alpha_bench_tool)
- Project structure tree: new `factors/` block (base / registry /
  bench_runner / zoo), new `api/` block (alpha_routes), new
  top-level `tools/` block (ci_grep_gates.sh). Frontend pages list
  expanded (AlphaZoo, Correlation, Settings)
- Quick Example: second snippet showing `alpha bench` one-liner
- New "What You Can Do" row for the bench-a-pre-built-alpha-zoo path
- New `<details>` block under Detailed Capabilities listing the
  4 zoos + counts + sources + license stance
- Examples → Strategy & Backtesting: 3 snippets (bench, list+show,
  multi-factor compose via ZooSignalEngine.from_zoo)
- API Server route table: 4 `/alpha/*` rows (list, get, bench,
  bench stream SSE)
- CLI Reference: alpha subcommand introduced in the top block;
  single-run examples block extended with 3 alpha commands

`### Key Features` 4-card grid (#7) and Demo section (#13) are
intentionally NOT touched here — they need layout redesign + new
GIFs/screenshots; design notes in docs/alpha-zoo/readme-redesign-7-13.md
will be implemented in a follow-up PR.

969 tests pass; CI grep gates green (no yaml.load misuse, no
trademarked-name leak, no per-stock-code data in wiki); TypeScript
clean on the frontend.
2026-05-17 20:19:04 +08:00
Haozhe Wu 137689c5c1 feat(wiki): Alpha Library auto-render + "Alpha 191 in 2026" blog
wiki/scripts/build_alpha_library.py (475L)
- Reads manifest.json produced by `vibe-trading alpha export-manifest`
- Emits per-alpha pages (content/<zoo>/<id>.html) and per-zoo
  overview tables (content/<zoo>/index.html) — 452 + 4 = 456 pages
- Jinja2 with select_autoescape(["html","xml"]) mandatory; no |safe
  on user-derived content; strict CSP on every generated page
  (default-src 'self'; style-src 'self' 'unsafe-inline';
   script-src 'none'; img-src 'self' data:)
- Emits content/index.json for the landing page to hydrate per-zoo
  counts via fetch()
- Friendly exit when manifest.json missing
- End-of-main self-test compares manifest formula content against
  rendered HTML to verify the escape pipeline; prints
  "html escape ok" or "html escape FAIL"

wiki/alpha-library/index.html (rewritten from 78-line shell to 192L)
- Hero + 4 zoo cards (Kakushadze 101 Formulaic Alphas, qlib158,
  gtja191, academic) + stats grid + 3-line CLI quickstart + GitHub
  footer
- Per-zoo counts hydrated at runtime from content/index.json
- Loose CSP (script-src 'self' 'unsafe-inline') only on this landing
  page to keep the site theme-init inline script and the inline
  fetch() — generated /content/** pages stay on script-src 'none'

wiki/research-lab/index.html
- Post grid replacing the empty hero stub; links to the new blog
- One-line lede explaining the section

wiki/research-lab/posts/alpha-191-in-2026.html (~675L, ~2400 words)
- 6-section deep-dive: TL;DR / Background / Method / Findings /
  Caveats / Reproduce
- Numbers from a real bench run on CSI 300 / 2018-2025: 10 alive,
  15 reversed, 165 dead (of 190 tested, 1 skipped). Top 5 by IR:
  gtja191_171 / _111 / _163 / _002 / _054. Theme survival rates:
  microstructure 22%, reversal 11%, volatility 8%, volume 5%,
  momentum 2%, liquidity 0%
- Survivorship-bias caveat for cross-zoo readers (the current
  constituent list of any index is a forward-biased proxy for the
  point-in-time constituents during the window)
- Strict CSP (script-src 'none'); no MathJax/KaTeX — formula_latex
  rendered as <code class="formula">; aggregate stats only (no
  per-stock per-date values, Tushare ToS)

wiki/research-lab/posts/alpha-191-in-2026.tweet.md
- HKUDS-style draft tweet (Chao Huang voice): emoji-decorated
  project name, real numbers (10 / 15 / 165), 8-year OOS qualifier,
  GitHub + pip links, ≤2 hashtags. READY to post once the release
  goes live.

.gitignore adds wiki/alpha-library/manifest.json and
wiki/alpha-library/content/ as generated, never-checked-in artifacts.
2026-05-17 20:04:24 +08:00
Haozhe Wu d77c64e2ce feat(alpha-zoo): bench runner + CLI + Web UI + 4 REST routes
End-to-end product surfaces over the factor framework: a CLI for
power users, an agent-tool layer for natural-language workflows,
a REST + SSE API for browser clients, and the Web UI itself.

CLI (vibe-trading alpha <subcommand>)
- list / show / bench / compare / export-manifest with security
  guards (path-traversal, alpha_id allowlist before any FS read)
- agent/cli.py wired (7-line glue), all logic in cli_handlers.py
- alpha bench: requires --zoo (or --yes); drives bench_runner with
  an on_progress callback feeding a Rich Progress bar; prints ETA
  banner; emits stderr summary "✓ Bench complete. N tested, M
  skipped. Report: <path>"; returns exit 1 on status != ok
- alpha list: --limit N / --json flags; TTY next-step hints
- alpha show: --brief; Syntax(word_wrap=True) so source survives
  narrow terminals
- TUSHARE_TOKEN errors append a 3-step fix banner
- ConstantInputWarning silenced at module level

Agent tools (auto-discovered via BaseTool.__subclasses__)
- AlphaZooTool: list_alphas / get_alpha / health
- AlphaBenchTool: orchestrator with Jinja2 autoescape HTML report +
  strict CSP meta. Module-level run_alpha_bench() reused by CLI

Universe loader (agent/src/tools/alpha_bench_tool.py)
- csi300 → Tushare index_weight + pro.daily, in-house panel build
  because the bundled tushare loader drops `amount`. Parallel fetch
  via ThreadPoolExecutor(max_workers=4); cache at ~/.vibe-trading/
  cache/<universe>_<start>_<end>.pkl with sha256 sidecar integrity
  guard
- sp500 → Wikipedia constituent list (UA-spoofed; bare pd.read_html
  returns 403) → yfinance loader; surfaces survivorship_bias=True
  in panel._meta because the constituent set is point-in-NOW
- btc-usdt → bundled okx loader; fails fast (ValueError) on single-
  asset cross-sectional IC degeneracy

REST API (agent/src/api/alpha_routes.py + agent/api_server.py +6 lines)
- GET /alpha/list?zoo=&theme=&universe=&limit= — paginated catalogue
- GET /alpha/{alpha_id} — meta + source code via Registry.get_source
- POST /alpha/bench — kicks off asyncio.create_task running the
  bench in asyncio.to_thread; in-memory job state with threading.Lock
  + 1-hour TTL + asyncio.Semaphore(2) concurrency cap (429 on
  saturation)
- GET /alpha/bench/{job_id}/stream — Server-Sent Events emitting
  progress / result / done / error events. Heartbeat ": ping\n\n"
  every 15s so reverse proxies don't close idle streams. Unexpected
  exceptions are sanitised to "internal error; see server logs"

Web UI (frontend/src/pages/AlphaZoo.tsx ~950 LOC, 3 sub-views)
- Browse: 4 gradient zoo cards (qlib158 / Kakushadze 101 / gtja191 /
  academic) + filter bar (zoo, theme, universe, id substring) +
  paginated table (50 rows + Load more)
- Detail: formula_latex in <code> block (no MathJax under strict
  CSP); metadata table; collapsible <details> source code; "Run
  bench" CTA prefilled
- Bench: form → SSE-streamed progress bar + currently-computing
  alpha id → Alive/Reversed/Dead/Skipped stat cards + Top-5-by-IR +
  Most-Reversed tables + by-theme echarts bar chart
- New /alpha-zoo route triplet wired in router.tsx; Layout.tsx
  nav entry with the Layers icon
- Raw EventSource pattern (useSSE hard-codes agent-stream event
  types and would drop our progress/result/done/error)
- a11y: htmlFor/id pairs on every label, aria-hidden on decorative
  lucide icons, sr-only table caption, CategoryBadge component
- BTC universe fail-fast surfaces as inline form error with hint
  to try sp500/csi300

Registry singleton via get_default_registry() so API requests don't
each pay the 452-module AST scan. compute_ic_series vectorised via
pandas corrwith(method="spearman"); ~5-10x speedup, numerically
identical to the prior scipy loop within 1.1e-16.

VWAP 千元 fix: empirical Tushare probe against 000001.SZ confirmed
daily.amount is in 千元 (not raw CNY). base.vwap(EQUITY_CN) now does
safe_div(amount * 1000.0, volume * 100.0 + 1.0) — previously
~1/1000th of true vwap. Affects factors mixing vwap with price
additively (alpha101_093 etc.).

969 tests pass; registry health 452 loaded / 0 failed; CI grep
gates green; TypeScript clean.
2026-05-17 20:04:02 +08:00
Haozhe Wu 94c2a8ffb2 feat(factors): port 452 alphas across 4 zoos
Implements four production-ready alpha catalogues, each in its own
agent/src/factors/zoo/<zoo>/ subdirectory with a per-zoo LICENSE.md
declaring provenance and treating the formulas as mathematical
content (paper prose, tables, and figures are not reproduced):

qlib158 — 154 alphas
  Port of Microsoft Qlib's Alpha158 handler.py feature definitions
  (github.com/microsoft/qlib @ d5379c5, Apache-2.0). Each module
  carries a per-file header citing the upstream commit SHA + path.
  Includes the upstream NOTICE per Apache-2.0 §4(d). 4 raw price-
  reference fields ($open/$high/$low/$vwap) intentionally omitted —
  they are inputs, not signals, and conflict with the wide-DataFrame
  contract.

alpha101 — 101 alphas
  Implementation of Kakushadze (2015) "101 Formulaic Alphas"
  (arXiv:1601.00991), written from the paper appendix. 19 industry-
  neutral alphas (#48, 56, 58, 59, 63, 67, 69, 70, 76, 79, 80, 82, 87,
  89, 90, 91, 93, 97, 100) flagged requires_sector=True so the
  registry SkipAlpha-s them on panels without a sector tag. Display
  name is "Kakushadze 101 Formulaic Alphas" — the trademarked
  alternative string is intentionally absent (enforced by ci grep
  gate b).

gtja191 — 191 alphas
  Implementation of Guotai Junan Securities' 2014 "191 Short-period
  Trading Alpha Factors" research report. Ambiguous formulas
  (MAX/MIN set vs ts, sign convention, SMA(x,n,m) → x.ewm(alpha=m/n),
  WMA → decay_linear approximation, REGBETA → ts_cov/ts_std**2,
  HIGHDAY/LOWDAY → ts_argmax/ts_argmin) are documented per-alpha in
  __alpha_meta__["notes"]. Long-horizon SUM/MEAN windows
  (alphas #25/26/33/39/45/56/61/64/74/75/77/91/92/98) shortened on
  the production code path so outputs stay defined on short panels;
  documented in each affected alpha's notes.

academic — 6 factors
  Honest price-based proxies for Fama-French 5 + Carhart momentum;
  nicknames carry a [PRICE PROXY] prefix because the canonical FF
  series require book-to-market / profitability / investment-growth
  fundamentals we do not ship. Kenneth French's data library is
  referenced (URL only) for users who need canonical monthly returns.
  Internal _effective_window helpers shrink the 252/60-day windows on
  short panels so the lookahead test and the registry NaN-ratio guard
  pass; production-sized panels see the canonical window untouched.

Sample golden CSVs at agent/tests/factors/fixtures/goldens/ for
five sampled alphas per zoo, asserted at rtol=1e-6, equal_nan=True.
ci grep gates pass; AST purity + lookahead tests both green on the
full 452-alpha registry.
2026-05-17 20:03:28 +08:00
Haozhe Wu 1676eaa328 feat(factors): Alpha Zoo framework — registry, operators, safety gates
New module agent/src/factors/ providing the foundation for the Alpha
Zoo:

- base.py: 19 cross-sectional + time-series operators on wide
  pd.DataFrame panels (rank/scale/ts_*/delta/decay_linear/signed_power/
  safe_div + market-aware vwap). NaN propagates strictly; delta(d>=1)
  enforced at the operator layer; no negative shifts allowed.
- registry.py: AST-extracts __alpha_meta__ from each zoo .py module
  without importing it; pydantic AlphaMeta (extra="forbid",
  frozen=True). Lazy importlib.import_module on compute(); filesystem-
  loader fallback for plugin/test trees. Sanity gates reject +/-inf,
  NaN ratio > 95%, and shape mismatches.
- factor_analysis_core.py: IC/IR + layered backtest math extracted
  from agent/src/tools/factor_analysis_tool.py so it's reusable from
  CLI, bench tool, and API.
- bench_runner.py: per-alpha compute → IC vs forward return →
  alive/reversed/dead categorisation (mean IC > 0.02, pos_ratio >=
  0.55, |t-stat| > 2). Shared by CLI driver and API worker.

Multi-factor skill gains a panel-style ZooSignalEngine.from_zoo()
factory (cross-sectional standardisation + weighting + optional
top-N / bottom-N long-short conversion). The legacy per-symbol
example_signal_engine.py is left untouched. SKILL.md edits in
multi-factor and factor-research cross-reference the new zoo.

Safety floor:
- test_alpha_purity.py: AST allowlist scan of every zoo/**/*.py
  module. Allowed imports: pandas, numpy, scipy.*, src.factors.base,
  __future__, typing, math, dataclasses. Banned names: os/sys/
  subprocess/socket/urllib/requests/httpx/pathlib/Path/open/eval/exec/
  compile/__import__, plus dunder-access via getattr(_, "__*"),
  breakpoint, input, help, memoryview, globals, locals, vars, and
  the __class__/__bases__/__mro__/__globals__/__builtins__ chain.
  Module-level statements limited to docstring + imports + functions
  + ALPHA_ID/__alpha_meta__ assignments with literal RHS.
- test_lookahead.py: 300-row synthetic panel; corrupt rows >= 270;
  assert factor at probe row 260 unchanged within 1e-9.
- test_base.py + test_registry.py + conftest.py with opt-in
  pytest-socket integration that hard-fails outbound network during
  the factors test suite.
- tools/ci_grep_gates.sh: rejects yaml.load( without safe_load,
  any trademarked-name leak in shipped artifacts, and any per-stock-
  code data leak in wiki/**/*.{json,csv,html}.

Governance:
- CONTRIBUTING.md with the Developer Certificate of Origin trailer
  for community PRs and the alpha PR reviewer checklist.
- NOTICE at the repo root with Apache-2.0 attribution for the
  upstream qlib NOTICE and the formulas-as-mathematical-content
  stance for Kakushadze / GTJA / Fama-French sources.
2026-05-17 20:03:02 +08:00
Haozhe Wu 6d7d365892 refactor(loaders): extract bounded retry/budget pattern into base.py
ccxt_loader and okx each carried their own copy of the same 25-line
retry+budget loop introduced in #121. Hoist the pattern into two
helpers — check_budget() and retry_with_budget() — so any future loader
(yfinance, akshare, tushare) inherits the same guarantees: per-call
transient retries, a wall-clock budget, non-transient exceptions left
unwrapped, original exception preserved as __cause__.

ccxt_loader / okx now declare only what is loader-specific (the
exception class for transient errors and the budget env var); the
scheduling, sleep math, and TimeoutError wrapping live in base.py.

Adds 11 helper-level tests + the 11 pre-existing integration tests
still pass against the refactored loaders.

Follow-up to #121.
2026-05-17 00:13:28 +08:00
Haozhe Wu fd9e5fc689 refactor(swarm): _is_error_result via JSON parse with substring fallback
The substring head-match (a) false-positived on nested status fields and
(b) false-negated when the error envelope sat past the 160-char head.
Parse the result as JSON and check only the top-level status field; fall
back to the original substring scan when the payload is truncated /
unparseable so the classifier never raises on the worker hot path.

Adds 7 unit tests pinning the new behavior — nested-status non-match,
past-head match, truncated fallback, non-error statuses (degenerate /
warning) correctly classified as non-error.

Follow-up to #119.
2026-05-17 00:13:19 +08:00
Haozhe Wu 2f9fa22a56 perf(redaction): cache _internal_roots() to avoid recomputation per call
_internal_roots resolves 8 candidate paths + builds 3 separator variants
+ sorts on every redact_internal_paths call. On the worker hot path this
runs once per task error / event / file-tool failure. Result is process-
stable (paths don't change without a restart), so @cache it.

Follow-up to #119.
2026-05-17 00:13:10 +08:00
Teerapat VatpitakandHaozhe Wu 39aaa62008 fix(swarm): surface errors, output contract, Windows-safe store, redact paths (#119)
* fix(swarm): surface task error at read boundaries

run_swarm / get_swarm_status / get_run_result and the in-process
run_swarm tool each hand-maintained a field allowlist that omitted
SwarmTask.error. A misconfigured provider returned status="failed"
with no diagnosable reason, though the error was captured on disk
the whole time.

Add src/swarm/serialization.py as the single source of truth for the
per-task projection (now includes error + iterations) plus a run-level
error summary; route all three boundaries through it so the allowlist
cannot silently drift again. Additive JSON change; the only shape
normalization is _format_result missing-summary "" -> null.

(cherry picked from commit 95f2717ac32bd02829f650bc971e3cd6dd7fffa6)

* fix(swarm): enforce worker output contract

A worker that emitted only its plan ("Phase 1 — Plan"), mock data,
unparsed tool markup, or (for a data agent) made no tool call and
wrote no report was returned status="completed". runtime.py also
folded timeout/token_limit into completed. The run reported success
with a stub as final_report (P01); degraded workers were invisible
(P03).

Add WorkerStatus.incomplete and a hybrid output contract in worker.py:
content-sanity for every agent, tool-evidence only for data agents so
tool-less synthesis/editor roles are not false-rejected. Fail-fast to
incomplete (no corrective retry — left for a follow-up). runtime.py
only maps a true completed to TaskStatus.completed; anything else
fails the run, so final_report can no longer be a non-deliverable.

(cherry picked from commit 774b26824509678c953cd0200ef7a56c03f23201)

* fix(swarm): thread shell tools + unify runs root

The MCP run_swarm wrapper called start_run() without
include_shell_tools, so stdio swarm workers silently lost bash and
could not run the scripts their own execution rules mandate (P03-B).
build_filtered_registry also dropped a requested-but-unavailable
tool with no log, hiding the contradiction.

Pass the server's shell-tool policy into start_run and warn on a
dropped tool. Add swarm_runs_root() as the single source of truth
for the run store location, shared by mcp_server and the path_utils
run-dir allow-list so the two can no longer drift (P03-A: an
installed-wheel layout had put every worker run_dir outside it).

(cherry picked from commit 2975fda09b5ad44d15580d9330890f72f43c00ce)

* fix(swarm): make store atomic write Windows-safe

SwarmStore._atomic_write did tmp.replace(target) with no retry. On
Windows os.replace raises PermissionError WinError 5/32 when the
target is concurrently open by load_run on the poll path, crashing
the worker thread and leaving the run stuck pending (P13). POSIX
os.replace is atomic and never raises these.

Add a WinError-scoped, bounded retry around the rename and a matching
transient read/parse retry in load_run (the other side of the same
race). Non-transient errors re-raise immediately; off-Windows the
loop runs once, so POSIX behavior is unchanged. Pre-existing, not
introduced by the P01/P03/P04 fixes — surfaced by E2E dogfooding.

(cherry picked from commit 46a779cf436353591e7840879f8c32f763dde826)

* style(swarm): drop unused imports (ruff F401)

Remove an unused `from pathlib import Path` in the PR03 test added by
2975fda, and the pre-existing unused `from src.swarm.mailbox import
Mailbox` in runtime.py (file already in this branch's diff). Net ruff
delta for PR1-PR4 is now -1. The remaining mcp_server.py:41 E402 is
pre-existing on upstream and structural — left out of scope.

(cherry picked from commit e96f56af1ead9b5b7058c90ec0d4df738b45e473)

* fix: redact internal absolute paths in errors

Unknown preset / workspace-escape / missing run-or-task dir errors
embedded the full install path (OS username, .venvs/site-packages
topology) — CWE-209/497, reachable via MCP/API/agent/CLI (P10).

Drop the absolute path while keeping the actionable bits: preset
name + Available list, the workspace-root boundary, and the logical
run/task id (basename only). Two test_path_safety assertions that
matched the old leaking message are updated to the redacted wording
(behavior unchanged — still raises and rejects). Verified-clean
sites (caller's own input / env-var / names list) left untouched.

(cherry picked from commit 69ea45295410cd25ea3079c25b7e3c4c73390b80)

* fix(security): redact internal paths in errors

Add agent/src/tools/redaction.py: anchored prefix redaction (no
regex, idempotent, None-safe) that hides only known-internal root
prefixes while keeping the relative tail for diagnosability.

Wire it through the swarm error read boundaries (serialization,
runtime, store) and the file tools' final broad except so a leaked
absolute path cannot reach a user-facing string (CWE-209/497, P10).
ValueError branches and the protected providers/llm.py are left
untouched.

(cherry picked from commit 21dc9cdfacf17cccd31cacfbe34481f62f1390af)

* fix(security): redact swarm event error payloads

---------

Co-authored-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-05-16 23:50:37 +08:00
Teerapat Vatpitak 220abb10a1 fix(mcp): unresolved symbols, finite options validation, row cap (#120)
* fix(mcp): surface unresolved market-data symbols

A typo / wrong-suffix / delisted / no-data code vanished from
get_market_data's result (the dict only held winners), so it was
indistinguishable from "no data", and a loader exception lost every
already-resolved symbol (P05).

Report any requested code that produced nothing under a reserved
`_unresolved` key (additive — omitted when all codes resolve, so the
all-resolved payload is byte-identical to before) and wrap the
per-group loader.fetch so a blow-up is contained, not opaque. Richer
per-symbol status objects are a deliberate breaking follow-up.

(cherry picked from commit eba2044e982c5d467c808bad05386de3d856d635)

* fix(tools): reject invalid analyze_options inputs

Invalid inputs (volatility<=0, spot/strike<=0, negative expiry,
bad option_type) and T=0 all returned status:"ok" with silently
wrong / zeroed Greeks, and NaN could leak into the JSON (P06).

Validate at the boundary and reject invalid inputs with an error
envelope; flag the legitimate T=0 expiry as status:"degenerate"
with a warning (intrinsic value still returned); serialize with
allow_nan=False. The normal path is numerically unchanged. The
Greeks-convention rework (delta=0.5 at expiry) is a larger
follow-up, out of scope here.

(cherry picked from commit 823dc2382d73b97919807cf14a3644073886247d)

* fix(mcp): cap get_market_data row output

Every row of every symbol was emitted, so the canonical "1 symbol,
1 year, daily" request (~251 rows) breached the MCP token budget and
had to spool to a file, defeating an inline data tool (P07).

Add a max_rows cap (default 250): a symbol exceeding it returns a
head+tail window plus truncation metadata (rows/returned/truncated/
hint); symbols within the cap are unchanged (plain list, so small
queries are byte-identical); max_rows=0 restores the unbounded legacy
behavior. Columnar encoding and envelope de-duplication are broader
follow-ups, out of scope here.

(cherry picked from commit 0e426b1d0523e8c8144c699f89cd4847199e6448)

* fix(options): reject nan/inf + log dropped loader

options_pricing previously accepted NaN/Inf numeric inputs and
silently produced unreliable Greeks. Reject any non-finite spot,
strike, expiry_days, volatility, or risk_free_rate at the
validation boundary before pricing, and treat a missing result
key as non-finite so a dropped field is flagged degenerate
instead of defaulting to a confident 0.0.

mcp_server.get_market_data: the contained loader-failure path
(P05) now logs the swallowed exception with logger.exception
instead of vanishing silently; the _unresolved fallthrough is
unchanged.

* fix(mcp): stride row cap, reject neg max_rows

_cap_rows previously returned a head+tail window with a synthetic
{_gap: N} sentinel, hiding the middle of the series and emitting a
fake non-bar record. Replace with an even-stride downsample (every
step-th bar, last bar pinned) so the capped series spans the full
date range with only real bars.

A negative max_rows now enforces the default cap instead of being
treated as <=0 (which silently disabled the cap and returned an
unbounded payload). max_rows=0 still means unbounded; the in-cap
plain-list path is unchanged.
2026-05-16 23:47:32 +08:00
Teerapat Vatpitak 14ba958513 fix(loaders): bound ccxt + okx fetch (timeout, retry, budget) (#121)
* fix(loaders): bound ccxt fetch timeout + retry

ccxt_loader set no request timeout and paginated fetch_ohlcv in an
unbounded loop with no retry budget, so a transient disconnect hung
get_market_data for 10+ minutes (P12-b — it stalled a crypto E2E).

Set an explicit per-call timeout on the exchange, retry the transient
ccxt.NetworkError family with bounded backoff, and enforce a hard
wall-clock budget that raises a clear TimeoutError instead of
hanging. Non-network errors (e.g. bad symbol) are not retried; the
happy path still issues one call per page (no behavior change).
okx.py has the same shape — noted as a follow-up, out of scope here.

(cherry picked from commit ef934fbd70209d74a110e81757dbe322a2b717ab)

* fix(loaders): bound okx fetch retry + budget

okx.py set a per-request timeout but had no retry and no wall-clock
budget, so a transient blip dropped the whole symbol via the outer
silent skip, and a slow keyless tier could stall roughly
max_pages * timeout (P12-b parity with the ccxt loader).

Retry the transient requests.RequestException family with bounded
backoff and enforce a hard wall-clock budget that raises a clear
TimeoutError; non-network errors are not retried; the happy path
still issues exactly one request per page (no behavior change).

(cherry picked from commit e5030bf3edc3e44cc27003b3ac23e9e4fefc3c97)
2026-05-16 23:47:10 +08:00
Haozhe Wu 0e42cbe4c8 docs: sync readme roadmap status 2026-05-16 17:23:22 +08:00
Haozhe Wu 9769e5b543 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	agent/src/tools/web_reader_tool.py
2026-05-16 17:10:58 +08:00
Haozhe Wu 101eb9b614 feat: add security scanner and hypothesis registry 2026-05-16 17:10:06 +08:00
Teerapat Vatpitak bdb9dfca55 fix(tools): disclose read_url Jina dependency + cache opt-out (#122)
- SKILL.md now explicitly notes that read_url forwards the full URL to the third-party Jina Reader (r.jina.ai), and warns against passing credentials or internal URLs.
- New optional no_cache=True parameter sends x-no-cache to bypass Jina's cache when freshness matters.
- Responses that contain Jina's cached-snapshot marker now surface a cached: true flag.
- Error paths keep the upstream HTTP body and exception text in the returned error string (and also log them via logger.warning) so operators can diagnose remote failures without grepping logs.
2026-05-16 14:51:27 +08:00
Haozhe Wu b1acd830d2 fix(loop): populate reason field on AgentLoop cancelled / max-iter exits (#116)
Reporter saw 'Execution failed: unknown' when AgentLoop hit
max_iterations. The run's state.json recorded a useful reason but
loop.run()'s returned dict omitted it on the cancelled and failed
branches, so SessionService.mark_failed got 'unknown'.

Populate reason on both branches with actionable phrasing and add
iterations + max_iterations to the result dict.

Fixes #114
2026-05-16 01:56:11 +08:00
Haozhe Wu 8f49fe4902 feat(swarm): universal data-citation discipline in worker prompt (#115)
Lift the anti-fabrication rule from grounding.py into build_worker_prompt
as a universal Data Citation Discipline section that renders
unconditionally. Free-form prompts without explicit symbols previously
escaped the rule; aggregator / synthesizer agents with no data tools
had no defense against inventing numbers upstream omitted. Applies to
all 29 swarm presets via the central prompt builder.

Fixes #106
2026-05-16 01:49:53 +08:00
shadowinlifeandshadowinlife e346fa1adf feat(agent): add MCP client integration (stdio, v1) (#83)
Adds a stdio MCP client so the built-in agent can load external MCP tools alongside local tools.

- Pydantic `AgentConfig` + loader (JSON/YAML, ~/.vibe-trading discovery) with safe fallback
- `MCPServerAdapter` wraps FastMCP stdio client: per-tool timeout, single transient retry, schema normalization, sync-from-async bridge
- Per-server failure isolation in `build_registry` — one broken server never blocks local tools or other MCP servers
- Sanitization gate: `mcpServers` in session-level overrides is stripped by default (RCE prevention); opt-in via `ALLOW_SESSION_MCP_SERVERS=1`
- Server-name collision resolver with operator-facing warnings propagated through CLI + SessionService SSE
- v1 scope: stdio only, serial execution, tools only, no Swarm propagation
- 58 new tests (16 adapter + 17 config + 11 registry + 6 stdio subprocess + 6 regression + 2 sanitize/merge invariant)

Co-authored-by: shadowinlife <shadowinlife@users.noreply.github.com>
2026-05-16 01:17:47 +08:00
Haozhe Wu 55d81a6c53 docs: update May 15 README news 2026-05-16 00:18:27 +08:00
Haozhe Wu db21715cf9 feat(run-card): surface trust layer card in web UI 2026-05-15 11:51:18 +08:00
Teerapat Vatpitak dce3eb94c2 feat(memory): harden PersistentMemory.add() for control bytes, length, empty names (#112)
* feat(memory): harden PersistentMemory.add() for control bytes, length, empty names

Bundles three follow-up fixes for the same module, requested by
@warren618 in the issue triage of #108, #109, and #110.

#109 (highest priority of the three) — `add()` previously wrote the
full body to disk while `_scan_entries` clipped reads at
`MAX_ENTRY_CHARS`, so any content past the limit was "saved but
invisible" from inside Vibe-Trading. Enforce the limit at write time
and append a visible ``[truncated at N chars]`` marker so anyone
inspecting the file sees the clip explicitly.

#110 — Empty / whitespace-only names now raise ``ValueError``
("memory name must not be empty or whitespace-only") instead of
collapsing to the same ``{type}_.md`` filename. For names whose
sanitized slug normalizes to just underscores (emoji-only,
punctuation-only, etc.), append a 6-char SHA-256 prefix so distinct
inputs produce distinct files. The same input still produces the
same filename, so the "edit memory" workflow is unaffected.

#108 — Strip C0 (``U+0000-U+001F`` except ``\t`` ``\n``) and C1
(``U+0080-U+009F``) control bytes from agent-supplied content before
persisting. These cannot legitimately appear in finance notes and
would otherwise replay back through `memory show` as ANSI sequences
into the user's terminal.

Implementation:

- New module-level helpers `_sanitize_body` and `_truncate_body`
  (small, pure, regression-tested in isolation).
- New `_CONTROL_CHAR_RE` regex constant + `_TRUNCATION_MARKER`
  template, named for readable error/diff output.
- `add()` now strips control bytes before truncation so the marker is
  computed against user-visible content length.

Tests:

- 5 unit tests for `_sanitize_body` (ANSI escape, NUL/BEL, tab/newline
  preservation, C1 range, empty input).
- 4 unit tests for `_truncate_body` (short passthrough, at-limit,
  over-limit with marker, custom limit).
- 3 tests for empty/whitespace/tab-only name rejection.
- 3 tests for hash-suffix slug disambiguation (two emoji names produce
  distinct files; same name is deterministic; punctuation-only also
  gets a hash).
- 2 integration tests for `add()` end-to-end (control byte stripped on
  disk; long body truncated with marker on disk).

All 108 existing memory/CLI/remember-tool tests still pass.

Closes #108, #109, #110.

* fix(memory): reserve truncation marker space inside MAX_ENTRY_CHARS limit

E2E showed that appending the marker AFTER `body[:MAX_ENTRY_CHARS]`
makes the marker live past byte 8000 — _scan_entries clips reads at
MAX_ENTRY_CHARS, so consumers that inspect `entry.body` (rich.print,
recall scoring) never see the marker, defeating the "explicit
truncation" goal of #109.

Reserve the marker space *inside* the limit instead: head =
content[:limit - len(marker)] + marker. Total on-disk body stays ≤
MAX_ENTRY_CHARS, the marker survives the read-side clip, and
programmatic readers see the marker exactly the same as `cat` does.

Trade-off: when content exceeds the limit, ~28 chars of head content
is dropped to fit the marker. That is intentional — surfacing the
truncation event is more valuable than retaining 28 more bytes of
content that nobody could see was clipped.

Tests updated to assert the new invariant (`len(out) <= MAX`,
`out.endswith("chars]\n")`).
2026-05-15 11:28:02 +08:00
Haozhe Wu 1190ee9735 docs: update README news for wiki launch 2026-05-14 22:14:41 +08:00
Haozhe Wu eba0f1f8a1 docs: add wiki section shells 2026-05-14 22:02:32 +08:00
Haozhe Wu 298ada79b7 ci: deploy wiki to Cloudflare Pages 2026-05-14 21:26:15 +08:00
Haozhe Wu 7b27f6e3e2 Add static Vibe-Trading wiki
Add a static Cloudflare Pages wiki under wiki/, including the landing page, docs app, redirects/headers, maintainer README, and wiki validation workflow.
2026-05-14 19:31:31 +08:00
Teerapat Vatpitak 02a16a6422 fix(memory): extend tokenizer + slug regex to Thai/Arabic/Hebrew/Cyrillic (#104)
The previous CJK tokenizer ranges (#87, #95) only matched ``一-鿿``
and ``㐀-䶿``, so memory entries with Thai, Arabic, Hebrew, or
Cyrillic titles:

- Tokenized to the empty set, making recall always miss (e.g.
  ``find_relevant("ถัวเฉลี่ย")`` returned nothing even when the body
  contained the word).
- Had their slug characters stripped to ``_``, so two distinct Thai
  titles of equal length silently overwrote each other on disk.

The new ``_NON_LATIN_SCRIPT_RANGES`` constant covers CJK + Thai +
Arabic + Hebrew + Cyrillic and is reused by:

- ``_TOKEN_RE`` — single alternation pattern, one ``re.findall`` per
  ``_tokenize`` call (one text scan instead of two; precompiled at
  module level so it doesn't go through ``re.compile`` cache lookup on
  each invocation).
- ``_SLUG_DISALLOWED_RE`` — negation pattern used by ``add()``.

Arabic and Hebrew are deliberately narrowed to the basic letter blocks
(U+0620-U+064A, U+05D0-U+05EA) to keep bidi-control codepoints like
U+061C ARABIC LETTER MARK and combining marks out of on-disk slugs,
where they would render as invisible-but-distinct filenames.

Tests cover tokenization for each script, slug preservation
(parametrized across the four new scripts), and a Thai collision-
distinction regression.

Out of scope: ``agent/src/session/search.py`` has the same CJK-only
range in its FTS sanitizer; worth a follow-up PR to consume the same
constant.
2026-05-14 18:46:40 +08:00
Haozhe Wu 6b6683a026 docs: sync localized READMEs and feature assets 2026-05-14 02:48:18 +08:00
Haozhe Wu 45a9f525e4 docs: refresh README positioning and feature art 2026-05-14 02:21:48 +08:00
Haozhe Wu 75ecbce1fc docs: update README news for memory CLI 2026-05-14 00:46:09 +08:00
Haozhe Wu 4604d4c67a Merge pull request #102 from Teerapat-Vatpitak/feat/memory-introspection-cli
feat(cli): add memory introspection commands
2026-05-14 00:22:14 +08:00
Teerapat-Vatpitak 16102825b4 fix(memory): coerce frontmatter values to str in _scan_entries
The frontmatter parser returns lists for ``[a, b]`` syntax and bools
for ``true``/``false``, but MemoryEntry annotates ``title``,
``description``, and ``memory_type`` as ``str``. The mismatch was latent
until the new memory CLI tried to render those fields through
``rich.markup.escape``, which raised ``TypeError: expected string or
bytes-like object, got 'list'`` for any description that looked like a
YAML list (e.g. ``description: [red]inject[/red]``).

Add a small ``_coerce_str`` helper and route the three frontmatter
fields through it in ``_scan_entries`` so the dataclass type contract is
truthful at the storage boundary. Other consumers (recall scoring,
``remember_tool``) also benefit transparently.

Tests:

- Unit coverage of ``_coerce_str`` for str/None/list/bool inputs
- Storage-layer regression: a markdown file with a bracketed description
  parses to a string-typed description
- CLI regression: ``cmd_memory_list`` no longer crashes on entries with
  a bracketed description
2026-05-13 16:38:55 +07:00
Teerapat-Vatpitak 2d80b6248c style(cli): revert unintended formatter spillover on unrelated functions
The previous two commits inadvertently captured whole-file output from a
local pre-tool-edit formatter, reformatting ~30 functions
(`_print_status_bar`, `_format_tool_call_args`, `_build_benchmark_table`,
`_print_result`, `_build_welcome_panel`, ...) that have nothing to do
with the memory introspection feature.

Restore those functions to upstream `main` while keeping only the
intentional changes for this PR:

- `from datetime import datetime` hoisted to top imports (so the new
  CLI commands can use it without a per-call local import)
- `from rich.markup import escape as rich_escape` for safely rendering
  user-supplied memory titles/descriptions
- New `memory` subparser block in `_build_parser`
- New `cmd_memory_list / show / search / forget` plus the local
  `_MEMORY_TYPES` constant and `_MEMORY_TYPE_STYLES` dict
- Dispatch for `args.command == "memory"` in `main()`
- Drop the now-redundant local `from datetime import datetime` inside
  `cmd_trace`

Net diff for agent/cli.py shrinks from +296/-53 to +170/-1.
2026-05-13 16:27:40 +07:00
Teerapat-Vatpitak 0e0a584d85 perf(cli): avoid loading agent runtime on cli startup
Hoisting PersistentMemory to top-level cli.py made every CLI invocation
import `src.agent.frontmatter`, which transitively triggers
`src/agent/__init__.py` and eagerly loads AgentLoop, SkillsLoader (74
skills), and ToolRegistry. `vibe-trading --version` measured 7.6s
because of this chain.

- Move `from src.memory.persistent import PersistentMemory` back into
  each `cmd_memory_*` so only memory commands pay the import cost
- Mirror MEMORY_TYPES as a local `_MEMORY_TYPES` tuple inside cli.py so
  argparse `choices` and `_MEMORY_TYPE_STYLES` no longer require the
  module-level import. The module-load assertion still guards against
  drift between the local tuple and the style dict

`vibe-trading --version` drops from 7.6s to 0.66s (11.5x). Memory
commands keep the same latency since they need the module anyway.
2026-05-13 15:02:24 +07:00
Teerapat-Vatpitak 32a96fb5b2 feat(cli): add memory introspection commands
Add `vibe-trading memory list/show/search/forget` so users can inspect
and manage persistent memory without opening the file store directly.
Two recent memory bugs (#87 underscore tokenization, #95 CJK slug
collisions) were hard to spot without observability into what is
actually saved and recalled.

Also expand the PersistentMemory public surface to keep CLI lookup
logic out of storage internals:

- MEMORY_TYPES tuple as the single source of truth for type names,
  shared by argparse choices and the CLI style table
- list_entries() / find() / remove_entry() back the new commands;
  find() resolves by title first, then by on-disk filename stem so
  users can paste either form from the index
- remove_entry(entry) skips the re-scan that remove(title) does

Existing remove(title) is unchanged; remember_tool stays green.
2026-05-13 13:59:04 +07:00
Haozhe Wu b41141fa8f docs: update May 13 README news 2026-05-13 01:40:03 +08:00
Haozhe Wu 0678fd195a Merge commit 'refs/pr/93' 2026-05-13 01:15:28 +08:00
Haozhe Wu fb7efe76f6 Merge commit 'refs/pr/84' 2026-05-13 01:15:28 +08:00
Haozhe Wu 9e70dca97c fix(swarm): thread grounding data safely 2026-05-13 01:13:44 +08:00
Haozhe Wu e7d72be574 fix(swarm): redact worker event previews 2026-05-13 01:13:44 +08:00
Haozhe Wu 3981eb53f8 feat: add trust layer run cards 2026-05-12 18:44:19 +08:00
Peng QianandClaude Opus 4.6 ca26b0660a fix: resolve report.md into worker summary and sanitize event payloads
Address PR review feedback (HKUDS#84):

1. Read report.md back into WorkerResult.summary:
   - Add `_resolve_summary()` that checks for report.md in artifact dir
   - Applied at all 5 exit points (timeout, token_limit, llm_failure,
     normal_completion, iteration_limit)
   - Ensures API/UI shows the full report, not just the brief closing text

2. Sanitize event logging:
   - Truncate tool arguments to 200 chars per value
   - Rename "result" to "result_preview" and reduce from 5000 to 500 chars
   - Avoids persisting/streaming large or sensitive tool payloads

3. Add regression tests for report → summary handoff:
   - Test report.md content is returned when present
   - Test fallback behavior when report.md missing or empty
   - Test error handling for unreadable paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 19:15:12 +08:00
Peng QianandClaude Opus 4.6 5d90f3317f fix: add swarm runs to allowed paths and increase all preset timeouts
- Add `agent_root / ".swarm" / "runs"` to `_default_run_roots()` in
  path_utils.py. Without this, all `write_file` / `edit_file` / `read_file`
  calls from swarm workers fail with "run_dir is outside allowed run roots".
- Increase `timeout_seconds` from 600 to 1800 for all 29 swarm presets.
  The aggregator agent in commodity_research_team timed out at 615s, and
  complex analysis tasks routinely exceed 600s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 19:15:12 +08:00
Peng QianandClaude Opus 4.6 37e287c6c7 fix: persist swarm worker reports and improve summary extraction
Swarm workers frequently produced no usable output because:
- The Execution Rules prompt asked agents to write summaries "directly in
  your response," but the worker only captured `last_assistant_content`
  (often a mid-process message like "let me fix the script").
- On timeout/iteration limit, all in-memory messages were discarded.
- Event logging recorded only tool names and elapsed time, not I/O.

Changes to worker.py:
- Force agents to write final reports via `write_file report.md` (Phase 3),
  decoupling report persistence from the in-memory message lifecycle.
- Add `_best_summary()` to extract the longest assistant message (>100
  chars) instead of relying on the last fragment.
- Add `_persist_messages()` to save full conversation to messages.json on
  timeout/iteration-limit for post-mortem debugging.
- Include tool arguments in tool_call events and result content (up to 5000
  chars) in tool_result events for better observability.

Changes to equity_research_team.yaml:
- Increase timeout_seconds from 600 to 900 for all four agents, as the
  aggregator previously timed out at 615s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 19:15:12 +08:00
Haozhe Wu 3daa8dfae8 docs: update May 11 README news 2026-05-11 12:15:42 +08:00
ykj@hku 7320ac8d01 Improve CLI run UI and preflight startup check
Refresh the CLI run experience and add a startup preflight check for common environment issues.
2026-05-11 12:09:32 +08:00
Teerapat Vatpitak 96dbdb8050 feat(swarm): use real provider tokens for SwarmRun totals
Prefer provider-reported token usage for swarm runs while keeping the existing estimate fallback.
2026-05-11 12:09:21 +08:00
d 🔹 ac955c5692 fix(memory): preserve CJK chars in slug to avoid silent filename collisions
Preserve CJK characters in persistent memory slug generation and add regression coverage.
2026-05-11 12:09:11 +08:00
Teerapat Vatpitak 5731868a20 fix(swarm): route grounding fetch through _detect_market(code)
Empirical end-to-end verification on a real run revealed the dispatch
in fetch_grounding_data was wrong:

    loader_cls = resolve_loader(code)   # resolve_loader takes a market key
    loader     = loader_cls()           # resolve_loader already returns
                                        # a ready instance, not a class

So a real call with code="NVDA.US" produced
"No available data source for market 'NVDA.US'. Tried: []." — the
fallback chain table is keyed by market type ("us_equity",
"hk_equity", "crypto", ...), not by raw symbol shape.

The unit tests passed only because they monkey-patched resolve_loader
to a `lambda code: lambda: _StubLoader(...)` that happened to satisfy
both the wrong call site and the wrong return-type expectation. A
useful reminder that mock-shaped tests can hide real interface bugs.

Fix
---
- Import the same `_detect_market` runner.py uses for source routing
  and feed its output to `resolve_loader`. The private import is a
  reasonable pragmatic choice — there is no public equivalent today,
  and replicating the regex table in two places would invite drift.
- Drop the `loader_cls()` instantiation step; `resolve_loader` already
  returns a ready loader instance.

Test
----
Updated `test_fetch_returns_normalized_bars` to assert the call is
routed through `"us_equity"` (captured via a stub `resolve_loader`).
This pins the contract: a regression that passes the raw code as the
market key, or rewrites the helper so dispatch goes elsewhere, will
fire here instead of waiting for someone to actually run a swarm.

Verified with the local pipeline:
    NVDA.US latest close = $215.20 on 2026-05-08  (7 bars, yfinance)
i.e. the same number this PR was supposed to make available to the
worker prompt, instead of the empty block the original commit
silently produced.

`pytest agent/tests/test_swarm_grounding.py -v` -> 12/12 pass.
Full sweep at the same baseline (877 passed, 3 pre-existing Windows-
specific failures untouched, separately addressed in #88).
2026-05-10 20:19:29 +07:00
Teerapat Vatpitak b1d363b33c feat(swarm): pre-fetch market data and ground worker prompts in real prices
Symptom
-------
Workers in fundamental_research_team and similar presets routinely quote
prices from their model's training cutoff. A run targeting NVDA.US in
2026-05 produced a research report anchored on $145.89 while the actual
yfinance close that day was $215.20. Subsequent recommendations
("Hold/Accumulate, target $148-160") were therefore wrong by 30%+ before
any analyst even saw them. issue #42-class "weird outputs" land here too
when a worker silently substitutes a remembered number for a missing
data call.

Why prompt rules alone don't fix it
-----------------------------------
worker.py:158-171 already prints generic "use tools when needed"
guidance. LLMs nevertheless prefer the cheap path of recalling a number
over the expensive path of issuing a tool call — especially under the
20-tool-call hard limit imposed two paragraphs later. Adding a stronger
"never quote prices" rule helps but does not eliminate the failure mode.

Fix
---
Make the data unavoidable. SwarmRuntime.start_run now:

  1. Scans every value in user_vars for suffixed symbols
     (NVDA.US / 700.HK / 600519.SH / BTC-USDT / 000001.SZ / 430090.BJ).
  2. Pre-fetches the last 30 days of OHLCV via the existing loader
     registry (resolve_loader handles per-market routing). Failures
     are non-fatal — the run still proceeds, just without grounding.
  3. Pins the result on a new SwarmRun.grounding_data field and
     persists it via the existing run.json path.

SwarmRuntime._execute_run renders the data into a markdown "Ground
Truth" block once per run and threads it through
_run_worker_with_retries -> run_worker -> build_worker_prompt, which
splices it ahead of Execution Rules. The block contains the recent
window range, last close, the last 5 daily closes, and an explicit
instruction to prefer these prices over training-data prices and to
call get_market_data for any range outside the window.

Backward compatibility
----------------------
* SwarmRun.grounding_data defaults to None; existing run.json files
  parse unchanged (Pydantic deserialises the missing key to the
  default). The legacy regression case is covered by
  test_swarm_run_metadata.py once that lands; this PR's tests cover the
  new module and prompt plumbing in isolation.
* build_worker_prompt and run_worker each gain a grounding_block= kw
  with default "" so callers outside the swarm runtime keep working.
* Empty grounding (no symbols detected, or every fetch failed) skips
  the entire section — no empty heading rendered.

What's not in scope
-------------------
* Bare tickers without a suffix (just "NVDA") are intentionally not
  matched. The false-positive cost on common English words is too high
  and every shipped preset already supplies suffixed symbols.
* The block is a one-shot snapshot at run start. Long-running swarms
  may see stale data after many minutes — still strictly better than
  training-data prices from a year ago. A refresh hook can land later.

Tests
-----
agent/tests/test_swarm_grounding.py covers symbol extraction (all four
loader-supported suffix shapes, ordering, non-string values, word
boundaries), fetch behaviour with a stubbed loader (normalised bars,
empty-frame skip, empty-input no-op), markdown rendering (range / last
close / explicit instruction text), and end-to-end prompt plumbing
(grounding block lands before Execution Rules; absent block omits the
section). All 12 pass; full pytest sweep against main shows the same
pre-existing 3 failures (test_backtest_runner_security x2,
test_loop_helpers normalize) as main and 875 passed (= baseline 863 +
the new 12 grounding tests).
2026-05-10 20:19:29 +07:00
Haozhe Wu fbe37e0f63 docs: update May 10 README news 2026-05-10 20:36:15 +08:00
Haozhe Wu 1e0a7a0dd8 Merge pull request #92 from Teerapat-Vatpitak/feat/swarm-record-provider-model
feat(swarm): record provider/model on SwarmRun for cost audit + debug
2026-05-10 20:24:34 +08:00
Haozhe Wu bb977f2614 Merge pull request #91 from Teerapat-Vatpitak/fix/runner-validate-run-dir
fix(backtest): validate run_dir in CLI entry against allowed run roots
2026-05-10 20:24:24 +08:00
Haozhe Wu 209334d609 Merge pull request #90 from Teerapat-Vatpitak/fix/api-server-bare-except
fix(api_server): replace bare except: with specific exception types
2026-05-10 20:24:17 +08:00
Haozhe Wu f55ef1ffb9 Merge pull request #88 from Teerapat-Vatpitak/fix/windows-test-compat
test: make path-handling tests pass on Windows runners
2026-05-10 20:24:10 +08:00
Teerapat Vatpitak ba7cd19564 feat(swarm): record provider/model on SwarmRun for cost audit + debug
run.json currently has no field describing which LLM provider/model the
swarm was actually launched against — SwarmAgentSpec.model_name carries
per-agent overrides but defaults to None ("use the global config"), and
that resolved config is never captured anywhere. Cost auditing,
side-by-side provider experiments, and post-hoc bug triage all need this
information; today it has to be reconstructed from .env timestamps.

This adds two optional fields to SwarmRun:

- provider: lowercase form of LANGCHAIN_PROVIDER, the same env var
  src/providers/llm.py:136,195 reads when constructing ChatLLM.
- model: LANGCHAIN_MODEL_NAME (raw, since model strings are
  case-sensitive — "gpt-4o" vs "openai/gpt-5").

Both default to None and are populated in SwarmRuntime.start_run before
the run is persisted (runtime.py:start_run), so every new run.json from
this point forward records the active provider/model. Per-agent
overrides remain visible on SwarmAgentSpec.model_name; this field is the
run-level default.

Backward compat: existing run.json files lack the keys entirely. Pydantic
deserializes missing optional fields to their default (None), so
SwarmStore.get_run / list_runs continue to work on legacy files. A
parametrized regression test covers this in
agent/tests/test_swarm_run_metadata.py along with construction,
serialization round-trip, and acceptance of multiple provider names.
2026-05-10 15:49:32 +07:00
Teerapat Vatpitak 80e177228f fix(backtest): validate run_dir in CLI entry against allowed run roots
backtest.runner.main() previously accepted any Path it was handed and
went straight to reading config.json + importing code/signal_engine.py
from that location. The MCP backtest tool already routes through
safe_run_dir (src/tools/backtest_tool.py:23), but
"python -m backtest.runner <path>" did not — meaning a contributor or
agent that can drop config.json + code/signal_engine.py anywhere on
disk could have it loaded and executed by the CLI path.

The AST scrubber at runner.py:217-239 blocks executable top-level
statements, but any function or method body still runs on instantiation
or method call (BaseEngine.run_backtest -> SignalEngine().__init__ ->
.generate(...)), so the AST guard is not a substitute for a path
boundary.

Reuse the existing safe_run_dir() (src/tools/path_utils.py:190) at the
top of main() so the CLI honours the same VIBE_TRADING_ALLOWED_RUN_ROOTS
whitelist as the MCP path. Errors are surfaced through the existing
{"error": "..."} JSON-on-stdout + sys.exit(1) convention this entry
point already uses.

Existing test_path_safety.py exercises safe_run_dir directly and still
passes; behaviour for run dirs under the default roots
(agent/runs, $cwd/runs, ~/.vibe-trading/shadow_runs) and any path listed
in VIBE_TRADING_ALLOWED_RUN_ROOTS is unchanged.
2026-05-10 15:49:31 +07:00
Teerapat Vatpitak 51d0188cc5 fix(api_server): replace bare except: with specific exception types
Three bare except: clauses in api_server.py silently caught BaseException
(KeyboardInterrupt, SystemExit, GeneratorExit) in addition to ordinary
errors. That makes the API server unresponsive to Ctrl+C inside these
blocks and hides real bugs (NameError, AttributeError) as silent passes.

The blocks themselves are best-effort metadata extraction from per-run
artifact files, where falling through with the default values is the
right behavior — but only for the specific I/O / parse failures these
blocks can actually produce. The new clauses spell those out:

- lines 1015, 1022 (req.json / planner_output.json prompt extraction):
    except (json.JSONDecodeError, OSError)
- line 1042 (metrics.csv numeric parse):
    except (OSError, ValueError)

Behavior is unchanged for the failures the blocks are designed to swallow;
KeyboardInterrupt, SystemExit, and programming errors now propagate as
they should.
2026-05-10 15:49:31 +07:00
Teerapat Vatpitak 5eb8390921 test: make path-handling tests pass on Windows runners
Three tests fail when the suite is run on Windows even though the
underlying behaviour is correct on both platforms — the failures are
test-side platform assumptions, not application bugs. Linux CI is
unaffected; this is purely a quality-of-life fix for contributors who
run pytest on Windows.

1. test_backtest_runner_security.py — rce path interpolation
   ----------------------------------------------------------
   Both ``test_signal_engine_rejects_top_level_execution`` and
   ``test_signal_engine_rejects_class_level_execution`` write a
   ``signal_engine.py`` whose source contains the pytest tmp_path
   formatted into an ``os.system('touch ...')`` call. On Windows,
   tmp_path looks like ``C:\Users\teera\AppData\Local\Temp\...``;
   interpolated into a single-quoted Python string literal, the leading
   ``\U`` is interpreted as a (truncated) unicode escape and ``ast.parse``
   raises ``SyntaxError`` before the security scrubber under test ever
   runs. Switching to ``Path.as_posix()`` produces forward-slash paths
   that survive Python source escaping intact while pointing at the same
   filesystem location on every OS.

2. test_loop_helpers.py — absolute-path preservation
   --------------------------------------------------
   ``test_preserves_absolute_run_dir`` passed the literal
   ``/var/tmp/custom_run`` and expected it back unchanged.
   ``_normalize_tool_run_dir`` calls ``Path.is_absolute()``, which on
   Windows returns False for a bare drive-less path — so the function
   correctly treats the input as relative and joins it with
   ``memory_run_dir``. The test was wrong, not the function: it asserted
   POSIX semantics on a cross-platform helper. Resolving the input
   through ``os.path.abspath`` before handing it to the function
   produces the platform's actual absolute form (``/var/tmp/custom_run``
   on POSIX, ``C:\var\tmp\custom_run`` on Windows) and the assertion now
   holds on both.

Verified locally: the targeted three now pass; the full pytest sweep
goes from "3 failed, 863 passed" on main to "866 passed, 0 failed".
No production code is touched.
2026-05-10 15:47:09 +07:00
Hitesh PandClaude Opus 4.7 09dd59e9aa fix(memory): treat underscores as word boundaries in _tokenize (#87)
Memory titles saved through RememberTool are slugified to snake_case
(e.g. "mcp_wiring_test"), so the previous regex `[a-zA-Z0-9_]{3,}`
collapsed them into a single token. A natural-language recall query
like "mcp wiring" produces tokens {"mcp", "wiring"} and never
intersects {"mcp_wiring_test"} — recall returned 0 results despite
the memory existing on disk.

Drop `_` from the character class so underscores act as token
boundaries. Verbatim queries still work because the same string
splits into the same tokens on both sides of the comparison.

Adds a regression test for the snake_case case.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:43:20 +08:00
Teerapat Vatpitak 5eef5f3bd3 test(mcp_server): add smoke test for tools/call deadlock regression (#86)
Spawns vibe-trading-mcp via subprocess and walks through the JSON-RPC
happy path: initialize + tools/list + tools/call analyze_options. The
critical assertion is that tools/call does not hang — without the
registry pre-warm in mcp_server.main() (PR #85), the lazy registry build
runs inside FastMCP's worker thread and deadlocks on the first call.

The test also verifies (a) the tool catalogue has at least 20 entries
including analyze_options/get_market_data/list_skills, and (b) the
analyze_options result has a positive price and a delta in [0, 1] —
covering tool-registration regressions and Black-Scholes formula breaks
in addition to the deadlock.

analyze_options is chosen over network-dependent tools (get_market_data,
web_search) so the test stays deterministic and runs offline in CI.

Verified locally with differential validation:
- with pre-warm: PASS in 3.91s
- pre-warm commented out: FAIL with 15s timeout on tools/call
- pre-warm restored: PASS in 3.81s
2026-05-10 15:35:06 +08:00
Haozhe Wu 9fe6698d17 docs: update May 9 README news 2026-05-09 22:00:58 +08:00
Haozhe Wu 79efe1d6a8 Merge pull request #85 from Teerapat-Vatpitak/fix/mcp-server-tools-call-deadlock
fix(mcp_server): pre-warm registry in main thread to avoid tools/call deadlock
2026-05-09 15:13:48 +08:00
Teerapat Vatpitak dd594080c3 fix(mcp_server): pre-warm registry in main thread to avoid tools/call deadlock
_get_registry() lazy-imports src.tools.build_registry on first call. Every
caller is a FastMCP tool handler, so the first tools/call triggers the lazy
init inside FastMCP's asyncio worker thread, where the import chain (notably
src.tools.shell.*) deadlocks. initialize and tools/list succeed because
they don't touch the registry.

Pre-warming _get_registry() in main() — after _include_shell_tools is set
and before mcp.run() — performs the import on the main thread and lets
worker threads only ever read the cached singleton. _get_registry() is
idempotent (if _registry is None guard), so the 14 existing call sites are
unaffected.

Verified deterministically: removing the line reintroduces the hang within
15s; adding it back fixes tools/call within 0.01s. End-to-end through
Claude Code's MCP client returns in <2s.
2026-05-09 12:36:43 +07:00
Haozhe Wu 5f411ddaf7 Merge pull request #80 from SJoon99/fix/validate-path-params
fix(api): validate run_id and session_id path parameters
2026-05-09 09:52:11 +08:00
Haozhe Wu b9a82655bd fix(api): reject newline path parameters 2026-05-09 09:51:50 +08:00
Haozhe Wu bd722334d3 Merge pull request #82 from voidborne-d/fix/frontend-proxy-vite-api-url
fix(frontend): honor VITE_API_URL env for vite dev proxy target
2026-05-08 22:30:24 +08:00
voidborne-d 977fddfbff fix(frontend): honor VITE_API_URL env for vite dev proxy target
vite.config.ts hardcoded http://localhost:8899 across 14 proxy entries,
ignoring the VITE_API_URL env that docker-compose.yml passes to the
frontend service (http://vibe-trading:8899). Inside the frontend
container, localhost resolves to the frontend's own network namespace,
so all proxied requests (sessions, runs, skills, etc.) ECONNREFUSED.

Read VITE_API_URL via loadEnv with the original localhost:8899 default,
and DRY the per-route target into a single PROXY_PATHS list driven by
Object.fromEntries.

Closes #78
2026-05-08 21:21:59 +08:00
SJoon99 e2535cc7c3 fix(api): validate run_id and session_id path parameters
Endpoints under /runs/{run_id}, /swarm/runs/{run_id} and /sessions/{session_id}
constructed filesystem paths as `RUNS_DIR / run_id / ...` without validating
the path-parameter value. An authenticated caller could submit values like
`..` or `foo/../bar` and reach paths outside the intended directory.

The same file already validates `{shadow_id}` with `_SHADOW_ID_RE`. This
change applies the same hardening to `run_id` and `session_id` via a shared
`_validate_path_param()` helper that enforces a safe character class
(`^[A-Za-z0-9_-]{1,128}$`). The class accepts every ID format produced by the
codebase today (regular run_id from `state.create_run_dir`, swarm
`swarm-{ts}-{short_uuid}`, session_id from `uuid.uuid4().hex[:12]`) and
rejects path separators, dots, NUL, whitespace, and oversize input.

13 endpoints are guarded:
  - GET /runs/{run_id}/code, /pine, /
  - GET|DELETE|PATCH /sessions/{session_id}
  - POST /sessions/{session_id}/messages, /cancel
  - GET /sessions/{session_id}/messages, /events
  - GET /swarm/runs/{run_id}, /events
  - POST /swarm/runs/{run_id}/cancel

Tests added in `test_security_auth_api.py`:
  - parametrized validator unit tests (5 valid IDs / 9 invalid inputs)
  - per-endpoint integration tests using FastAPI TestClient
2026-05-08 07:50:36 +00:00
Haozhe Wu 52b624d3c8 docs: update news for Tushare statement filters 2026-05-08 10:53:35 +08:00
Haozhe Wu 4115960a42 Merge pull request #77 from HKUDS/fix/fundamental-fields-hard-fail
fix: fail fast on Tushare fundamental enrichment errors
2026-05-08 10:41:11 +08:00
Haozhe Wu 4fd82a0952 fix: fail fast on Tushare fundamental enrichment errors 2026-05-08 10:38:06 +08:00
Haozhe Wu 4ae673b6d1 Merge pull request #76 from mrbob-git/codex/vibe-trading-issue-62-prefilter
feat: wire Tushare fundamentals into filters
2026-05-08 10:33:56 +08:00
MrBob 94d0589638 feat: wire tushare fundamentals into filters 2026-05-07 15:16:07 -03:00
Haozhe Wu 0f4e3d7110 docs: sync May 7 translated news 2026-05-07 13:40:17 +08:00
Haozhe Wu d1273171a6 docs: update May 7 news 2026-05-07 13:38:09 +08:00
Andrew Barnes 947d945827 add Tushare fundamental provider contract
Adds a point-in-time Tushare fundamental provider and tests the project TUSHARE_TOKEN path.
2026-05-07 13:27:49 +08:00
Haozhe Wu 41fa4ad0b0 docs: fold older README news entry 2026-05-06 20:31:06 +08:00
Haozhe Wu 2fc8c90d9f docs: update README for 0.1.7 release 2026-05-06 20:21:13 +08:00
Haozhe Wu 946d9ae3b9 chore(release): prepare 0.1.7 2026-05-06 20:08:12 +08:00
Haozhe Wu f917b71960 chore: add local dev workflow 2026-05-06 19:04:43 +08:00
SiMinus ecf1e51915 feat: add swarm preset inspection command (#73) 2026-05-06 19:03:34 +08:00
Haozhe Wu 052277bb56 Harden supplemental security boundaries 2026-05-05 23:30:58 +08:00
Haozhe Wu 7e167ea238 docs: collapse older readme news 2026-05-04 12:15:27 +08:00
Haozhe Wu 688d388551 docs: refresh readme news for cli ux 2026-05-04 12:13:17 +08:00
Haozhe Wu 012a499f74 fix(tests): align CI path expectations 2026-05-04 11:45:55 +08:00
Haozhe Wu 14c97d7c19 Merge pull request #69 from RuifengFu/feat/cli-ux-improvements
feat(cli): add live streaming status indicator and arrow-key navigation to interactive mode
2026-05-04 11:30:58 +08:00
Haozhe Wu 9454d4a27a Harden API and tool security defaults 2026-05-03 22:42:20 +08:00
RuifengFu 5c3d5f98a2 feat(cli): add live status bar and arrow key navigation
Two improvements to the CLI interactive mode:

1. Live status bar: displays provider/model, session duration (real-time
   tick), last run elapsed time, and cumulative tool call stats. Uses
   prompt_toolkit bottom_toolbar with 1s refresh for live updates; falls
   back to static Rich print when prompt_toolkit is unavailable.

2. Arrow key navigation: replaces Rich Prompt.ask() with prompt_toolkit
   PromptSession, enabling up/down history browsing and left/right cursor
   editing. Falls back to Prompt.ask() when prompt_toolkit is not installed
   or stdin is not a tty.

Co-authored-by: RuifengFu <frf2000@tju.edu.cn>
2026-05-02 23:46:47 +08:00
Haozhe Wu fd767f5c29 docs: refresh readme news and roadmap 2026-05-02 18:00:54 +08:00
Haozhe Wu fd7384e472 feat(skills): add dividend analysis skill 2026-05-02 17:51:48 +08:00
Haozhe Wu 3d812ae5ec fix(frontend): proxy correlation endpoint 2026-05-01 19:07:30 +08:00
Haozhe Wu 5bafac3de8 Merge pull request #66 from MB-Ndhlovu/feat/correlation-heatmap
Feat/correlation heatmap
2026-05-01 19:05:50 +08:00
Haozhe Wu 319255c54b docs: sync multilingual news updates 2026-05-01 01:04:13 +08:00
MB-Ndhlovu c3174bacb9 feat: add correlation heatmap dashboard (#64)
Adds the cross-asset correlation endpoint, matrix computation engine, React correlation page, ECharts heatmap, and regression tests for market inference and rolling-window behavior.
2026-05-01 00:44:21 +08:00
ykj@hku 1cbbaab88e feat: add OpenAI Codex OAuth provider option (#65)
Adds the openai-codex OAuth provider, login command, Settings metadata, supported default model, and focused adapter regression tests.
2026-05-01 00:39:15 +08:00
Malibongwe Ndhlovu 72e4488a45 fix correlation PR review blockers 2026-04-30 13:22:11 +00:00
Haozhe Wu 246d5a0dc3 fix(skill): harden ashare penalty relevance filtering 2026-04-30 17:53:16 +08:00
shadowinlife 55972c3e9b feat(skill): add ashare pre-ST filter
Adds the ashare-pre-st-filter skill, Sina regulatory penalty scraper, and regression tests. Follow-up hardening will refine penalty-record relevance filtering.
2026-04-30 17:43:00 +08:00
Malibongwe Ndhlovu b18f22bc94 feat: cross-asset correlation heatmap dashboard
Backend:
- agent/backtest/correlation.py: correlation engine (Pearson/Spearman,
  multi-market loader support, auto market inference from ticker)
- agent/api_server.py: +GET /correlation endpoint
- agent/tests/test_correlation.py: 9 tests (infer_market + core matrix)

Frontend:
- frontend/src/pages/Correlation.tsx: new /correlation page
- frontend/src/components/charts/CorrelationMatrix.tsx: ECharts heatmap
- frontend/src/lib/echarts.ts: register HeatmapChart + VisualMapComponent
- frontend/src/router.tsx: +/correlation route
- frontend/src/components/layout/Layout.tsx: +nav entry
- frontend/src/lib/i18n.tsx: +i18n keys

# Conflicts:
#	frontend/src/components/layout/Layout.tsx
#	frontend/src/router.tsx
2026-04-30 08:00:35 +00:00
Malibongwe Ndhlovu 6512c7ba2c fix(cli): remove broken rich.box import
table.box attribute doesn't exist in this rich version.
table uses default ASCII border instead.
2026-04-30 07:58:30 +00:00
Malibongwe Ndhlovu 27cc7e74a8 fix(cli): remove broken rich.box import 2026-04-30 07:58:30 +00:00
Haozhe Wu cb87c3a37d docs: sync README updates for settings UI and validation CLI 2026-04-30 03:39:17 +08:00
BigNounce f1f3e00c97 fix: validate backtest run_dir CLI input (#60)
Validate the backtest validation CLI run_dir argument and add regression coverage for missing, blank, malformed, missing-path, and non-directory inputs.
2026-04-30 03:26:22 +08:00
TaoMu 6af0a86e2f feat: add model and data source settings UI (#57)
Adds a Settings page for LLM provider configuration and data source credentials, with local/auth protection for settings reads and writes.
2026-04-30 03:22:16 +08:00
1460 changed files with 237663 additions and 8111 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "Vibe-Trading",
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
}
},
"forwardPorts": [
8899,
5899
],
"portsAttributes": {
"8899": {
"label": "Vibe-Trading API",
"onAutoForward": "notify",
"visibility": "private"
},
"5899": {
"label": "Vibe-Trading Frontend",
"onAutoForward": "notify",
"visibility": "private"
}
},
"postCreateCommand": "python -m pip install --upgrade pip && python -m pip install -e . && cd frontend && npm install",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python"
}
}
}
}
+4 -1
View File
@@ -43,8 +43,11 @@ build/
dist/
# Project docs
# NOTE: `*` never crosses `/` in Docker ignore matching, so `/*.md` (anchored)
# excludes only ROOT-level markdown. Nested skill docs (agent/src/skills/**/*.md)
# and agent/SKILL.md are intentionally NOT excluded — they ship in the image.
CLAUDE.md
CONTRIBUTING.md
*.md
/*.md
!README.md
!LICENSE
+4 -1
View File
@@ -1,7 +1,10 @@
blank_issues_enabled: true
contact_links:
- name: Discord Community
url: https://discord.gg/2vDYc2w5
url: https://discord.gg/6TdQnT5xcF
about: Join the HKUDS community Discord
- name: GitHub Discussions
url: https://github.com/HKUDS/Vibe-Trading/discussions
about: Ask questions, share strategies, get help
- name: Documentation
url: https://github.com/HKUDS/Vibe-Trading#-quick-started
+44
View File
@@ -0,0 +1,44 @@
version: 2
updates:
# Root Python project (pyproject.toml).
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 4
groups:
# Minor/patch bumps are low-risk and reviewed as a single batch; majors
# are excluded so they still arrive individually and get real scrutiny
# (a batched PR mixing 10 patch bumps with 1 breaking major is worse
# than either alone — you can't reject just the major).
pip-minor-patch:
applies-to: version-updates
update-types: ["minor", "patch"]
ignore:
# pandas 3.x has real breaking changes (Copy-on-Write always-on, removed
# deprecated APIs) we haven't vetted; the <3.0.0 ceiling is deliberate,
# not an oversight. Revisit as a dedicated migration, not a dep bump.
- dependency-name: "pandas"
update-types: ["version-update:semver-major"]
# Frontend npm dependencies.
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 4
groups:
npm-minor-patch:
applies-to: version-updates
update-types: ["minor", "patch"]
# GitHub Actions workflow pins.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 4
groups:
actions-minor-patch:
applies-to: version-updates
update-types: ["minor", "patch"]
+19 -5
View File
@@ -12,9 +12,9 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@v5
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.11"
cache: pip
@@ -22,10 +22,16 @@ jobs:
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Repository safety gates
run: bash tools/ci_grep_gates.sh
- name: Test environment variable gate
run: pytest tools/test_ci_env_var_gate.py -q
- name: Syntax check
run: |
cd agent
python -m py_compile cli.py
python -m compileall -q cli
python -m py_compile api_server.py
python -m py_compile mcp_server.py
python -m py_compile src/agent/loop.py
@@ -33,9 +39,12 @@ jobs:
python -m py_compile backtest/runner.py
- name: Run tests
run: pytest --ignore=agent/tests/e2e_backtest --tb=short -q
# test_e2e_harness_v2.py is gated by VIBE_TRADING_RUN_LIVE_E2E
# (real-LLM only); list --ignore here so CI's intent is explicit and
# the suite never silently switches state on env presence.
run: pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --cov=agent --cov-report=term-missing --cov-report=xml --tb=short -q
- uses: actions/setup-node@v4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
cache: npm
@@ -46,3 +55,8 @@ jobs:
cd frontend
npm ci
npm run build
- name: Frontend tests
run: |
cd frontend
npx vitest run --reporter=verbose
+38
View File
@@ -0,0 +1,38 @@
name: Deploy Wiki
on:
push:
branches:
- main
paths:
- "wiki/**"
- ".github/workflows/wiki-deploy.yml"
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
name: Deploy to Cloudflare Pages
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Deploy wiki
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Deploy from inside wiki/ (config mode) so wrangler reads
# wiki/wrangler.toml and compiles wiki/functions/ as Pages Functions.
# Deploying the dir positionally (`pages deploy wiki`) uploads the
# functions as static files and never compiles them.
workingDirectory: wiki
wranglerVersion: "4.98.0"
command: pages deploy --project-name=vibetrading-wiki --branch=main
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+58
View File
@@ -0,0 +1,58 @@
name: Wiki
on:
push:
branches: [main]
paths:
- "wiki/**"
- ".github/workflows/wiki.yml"
pull_request:
branches: [main]
paths:
- "wiki/**"
- ".github/workflows/wiki.yml"
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.11"
- name: Check JavaScript syntax
run: |
node --check wiki/main.js
node --check wiki/theme.js
node --check wiki/docs/content.js
node --check wiki/docs/main.js
- name: Check locale JSON
run: python -m json.tool wiki/locales/en.json >/dev/null
- name: Check social card metadata
run: python wiki/scripts/check_social_cards.py
- name: Check static site files
run: |
test -f wiki/index.html
test -f wiki/home/index.html
test -f wiki/docs/index.html
test -f wiki/tutorials/index.html
test -f wiki/alpha-library/index.html
test -f wiki/research-lab/index.html
test -f wiki/_redirects
test -f wiki/_headers
test -f wiki/assets/icon.png
test -f wiki/assets/alpha-library-og.png
blocked="localhost\\|nano"
blocked="${blocked}bot"
! grep -R "$blocked" wiki --exclude=README.md
+34
View File
@@ -4,6 +4,16 @@ __pycache__/
.pytest_cache/
*.pyc
# Coverage reports
.coverage
coverage.xml
# Node dependencies (root level)
node_modules/
# Backtest output artifacts
results/
.env
agent/.env
@@ -21,6 +31,17 @@ agent/tests/*_result.json
.ruff_cache/
frontend/dist/
frontend/tsconfig.tsbuildinfo
wiki/node_modules/
wiki/dist/
wiki/.astro/
wiki/.vite/
wiki/.wrangler/
.wrangler/
wiki/.cache/
.vibe-dev/
.vibe-record/
.cache/
.remotion/
# DuckDB data cache
data/*.duckdb
@@ -31,6 +52,10 @@ data/backup/
# Internal docs (plans, specs)
docs/
!wiki/docs/
!wiki/docs/**
docs/screenshots/agent-run.png
docs/screenshots/boot.png
data/minutes/
data/overnight*.log
data/*.log
@@ -45,6 +70,9 @@ build/
# Local demo videos (too large for git; link to external host instead)
assets/*.mp4
# Local demo / promo production workspace
demo/
# Manual E2E smoke tests — require live API keys (OpenRouter / yfinance),
# would break CI if committed. Kept local; run explicitly when validating
# a release.
@@ -56,3 +84,9 @@ NOTES.md
# Agent tool aliases (auto-generated mirror of CLAUDE.md)
AGENTS.md
.DS_Store
# Alpha Zoo wiki artifacts (generated, not source)
wiki/alpha-library/manifest.json
wiki/alpha-library/content/
+139
View File
@@ -0,0 +1,139 @@
# Agent Contributor Guide
This guide is for AI-assisted and automation-assisted contributors working on
Vibe-Trading. It does not replace `CONTRIBUTING.md`, `SECURITY.md`, or the pull
request template. It makes the repo's agent-facing safety and verification
expectations explicit.
## Repository Shape
- Backend and package code live under `agent/`.
- Frontend code lives under `frontend/`.
- Public wiki content lives under `wiki/` and has separate GitHub Actions checks.
- MCP entry point: `vibe-trading-mcp` / `agent/mcp_server.py`.
- CLI entry point: `vibe-trading` / `agent/cli/`.
- Broker connector, mandate, order gate, halt, and audit-ledger logic are safety
critical even when a change appears small.
- Community commits must include the DCO `Signed-off-by:` trailer; see
`CONTRIBUTING.md`.
Do not copy local `.env` files, token caches, broker exports, run artifacts,
private notebooks, generated reports, or local agent memory into this repository
unless they are explicitly sanitized fixtures.
## Safe Local Checks
These commands are normally safe for local validation:
```bash
git status --short --branch
git diff --check
python -m compileall -q agent/cli
python -m py_compile agent/api_server.py agent/mcp_server.py
pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --tb=short -q
pytest agent/tests/test_sdk_order_gate.py agent/tests/test_mandate_enforcement.py -q
cd frontend && npm ci && npm run build
```
Use the narrowest test command that matches the changed files when a full suite
is too expensive, and state what was not run.
## High-Risk Surfaces
Get explicit maintainer or operator approval before running commands that:
- place, cancel, approve, flatten, or otherwise affect broker orders,
- authorize broker, OAuth, MCP, exchange, payment, wallet, or cloud accounts,
- write real credentials into `agent/.env`, `~/.vibe-trading/`, token caches, or
external services,
- start externally reachable API, MCP, SSE, webhook, or dashboard servers,
- deploy the wiki, publish packages, trigger releases, or change CI secrets,
- rewrite history, force-push shared branches, delete backups, or remove
persistent run or memory data.
Never run live trading, payment, wallet, contract, or broker-write flows as part
of routine PR validation.
## Targeted Test Hints
For general Python changes:
```bash
pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --tb=short -q
```
For live/order safety changes:
```bash
pytest agent/tests/test_sdk_order_gate.py \
agent/tests/test_mandate_enforcement.py \
agent/tests/test_killswitch_blocks_orders.py \
agent/tests/test_readonly_default.py -q
```
For factor-zoo changes:
```bash
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
```
For frontend changes:
```bash
cd frontend && npm ci && npm run build
```
## Documentation Rules
- Update `README.md` for user-facing CLI, Web, MCP, connector, provider, or
safety-boundary changes.
- Update `CONTRIBUTING.md` when contributor workflow, DCO expectations, style
rules, or test expectations change.
- Update `SECURITY.md` only for vulnerability reporting policy changes.
- Keep wiki edits under `wiki/`; avoid mixing generated assets or local drafts
into public docs.
- If a change affects live/order behavior, document the safety boundary and the
rollback or halt path.
## Security Rules
- Do not commit API keys, access tokens, OAuth caches, session cookies, private
keys, seed phrases, broker credentials, `.env` files with real values, or
private trading exports.
- Treat secrets as access, not text. If one appears, redact it, stop repeating
it, and recommend rotation.
- API or Web deployments beyond loopback must use `API_AUTH_KEY`.
- External MCP servers are operator-trust surfaces. Do not allow caller-provided
MCP command, URL, environment, or allowlist injection unless the code path
explicitly documents and tests that opt-in.
- Broker connector writes must remain mandate-gated, kill-switch-aware,
fail-closed, and audit-logged.
- Prefer sanitized fixtures over real financial data.
## Pull Request Expectations
Every PR should explain:
- the goal,
- the affected areas,
- what is deliberately out of scope,
- the test plan,
- any live, broker, MCP, network, secret, or deployment risk,
- rollback or close path.
Every community commit must be signed:
```bash
git commit -s -m "docs(agent): add contributor guide"
```
Do not add AI-assistant attribution trailers; follow `CONTRIBUTING.md`.
## Rollback
- Documentation-only changes can usually be reverted with `git revert <sha>`.
- Code changes touching order gates, mandate enforcement, OAuth, providers, MCP,
settings, or external network behavior need a targeted regression test before
merge and a clear revert path in the PR.
- If a safety regression is found after merge, prefer a small revert or
fail-closed hotfix over broad refactors.
+506
View File
@@ -0,0 +1,506 @@
# Changelog
All notable changes to Vibe-Trading are documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.1.12] — 2026-07-22
### Added
- **User swarm-presets directory**: preset YAMLs dropped into
`~/.vibe-trading/swarm/presets/` are discovered alongside the bundled
roster (same-name files override it — the same rule as user skills) and
survive `pip install -U`. `list_presets()` entries now carry a
`source: "user" | "bundled"` field; explicitly named user presets run
through `run_swarm(preset_name=...)`, while keyword auto-routing stays
limited to the curated table. Preset names are validated to a single path
segment before any filesystem lookup.
- **Security hardening**: all 10 findings from the 2026-07-10 external audit
closed (#476, tracking discussion #468) — Docker multi-stage rebuild with
digest-pinned base images, AST-hardened backtest sandbox (blocks
network/subprocess/eval/os.environ/unsafe-open reachable from generated
code, including inside nested function bodies), short-lived single-use SSE
auth tickets replacing a long-lived key in the URL/logs, hardened Compose
(`read_only`, dropped capabilities, `no-new-privileges`, resource limits),
auth + rate limiting on `/correlation`, security headers (CSP
Report-Only, `X-Content-Type-Options`, `Permissions-Policy`), `/live` +
`/ready` health split, hash-locked dependencies wired into the Docker
build, GitHub Actions pinned by commit SHA, and an HMAC-authenticated
factor cache.
- Opt-in **TAP mode** for Alpaca (#377, thanks @0xZKnw) — routes all broker
egress through a self-hosted TAP proxy so the agent process never holds
the raw API key, with writes blocking on human approval.
- Realized portfolio turnover (`avg_turnover` / `total_turnover`) surfaced
in backtest metrics for every optimizer (#478, thanks @Robin1987China).
- **Frazzini-Pedersen betting-against-beta** academic factor (#480, thanks
@YogeshModi24) — Alpha Zoo: 460 → **461**.
- **MetaTrader 5 connector + data source** (Exness-style MT5 brokers,
Windows-only `pip install "vibe-trading-ai[mt5]"`). Broker connectors:
11 → **12** — full read surface plus order placement against a locally
running terminal, with a bidirectional identity guard (paper profile ⇔
demo `trade_mode`, login pinned, contest rejected), connector-level
`max_order_volume`/`max_order_notional_usd` guards on demo AND live, and
hedging-safe position close by ticket. The live mandate gate gains
`forex`/`cfd` instrument vocabulary (schema v1 unchanged) and a lot-aware
`quantity_notional_usd` sizing hook so USD caps bind on lot-sized orders
(0.1 lot EURUSD ≈ $10,800, never 0.1 × quote). Market-data sources:
20 → **21** — the `mt5` loader heads the forex fallback chain (broker-exact
symbols with Exness suffix discovery, 1m–1D bars), `get_market_data` learns
forex/metal symbol routing (`EUR/USD`, `XAUUSD.FX` previously fell through
to tushare), and akshare's forex path accepts the canonical slash form so
degradation off-Windows keeps working.
- **Strategy Development Manager** skill (#457, thanks @shadowinlife, closes
#455) — `sdm_register` / `sdm_status` / `sdm_decay_scan` turn academic
papers and broker research into registered factors/strategies with a
persistent SQLite artifact store (`UNIQUE(name, universe)`) and automated
IC/Sharpe decay monitoring driving an active → monitoring → decayed →
disabled lifecycle. Pluggable OCR for `read_document` (local RapidOCR by
default; cloud Qwen-VL is explicit opt-in only via
`VIBE_TRADING_OCR_ENGINE=qwen-vl`, never auto-selected). Skills: 86 → **87**.
- **Requesty** as an OpenAI-compatible LLM gateway provider (#474, thanks
@Thibaultjaigu) — same `provider/model` naming and capability shape as
OpenRouter, wired through CLI onboarding, provider menu, and Settings.
- Binance USD-M perpetual routing, slice 1 of #462 (#470, thanks @honginp) —
explicit `BTC-USDT-PERP` symbol contract with execution/mark price
separation, fail-closed when the two aren't timestamp-synchronized.
- **Correlation regime timeline** (#756, thanks @ebujinovch, closes #719) — a
new additive `GET /correlation/regime` endpoint plus an opt-in "Regime
timeline" strip on the Correlation tab: rolling pairwise correlations reduce
to an edge-density series, causally smoothed, and run through a two-threshold
hysteresis state machine that marks FUSED episodes ("when did the market fuse
into one bloc?"). Descriptive risk context, not a trading signal; shares
`/correlation`'s auth + rate-limit budget. Backed by the **correlation-regime
skill** (#557, thanks @ebujinovch).
- **Three new LLM providers** — SiliconFlow CN + Global (#565, thanks @UNHNQ),
iFlytek Spark (#537, thanks @FenjuFu), and a **native Anthropic Messages API**
adapter (#695, thanks @jelech; `pip install "vibe-trading-ai[anthropic]"`).
MiniMax now exposes its regional API endpoints (#731, thanks @octo-patch).
- **Historical USD-M funding settlements** for Binance perpetuals (#716, thanks
@honginp); maintenance brackets are supplied as a validated, versioned
artifact rather than a live authenticated fetch (#757, thanks @honginp), so a
plain `-PERP` backtest stays zero-credential.
- **Pluggable OCR** for `read_document` with optional LLM-vision extraction and
a configurable text-density threshold (#548, thanks @shadowinlife) — local
RapidOCR by default; cloud engines are explicit opt-in, never auto-selected.
- New academic factor `academic_corr_rewire` (#705, thanks @ebujinovch) and the
fundamental zoo wired into the `_VALID_ZOOS` whitelist (#707, thanks
@sambazhu). Binance crypto fallback loader (#643) and bounded OKX history
fetches with rate-limit handling (#644, thanks @tyj147454413-cmd).
- QVeris premium-track hardening — session budget applied to backtest data
calls (#685) and atomic credit accounting (#686, thanks @xkam7ar).
### Changed
- Correlation tab accepts bare tickers like `AAPL,SPY` and walks the full
loader fallback chain instead of failing with `Fetched: []` (#472, thanks
@yxhuang, closes #471).
- `local` loader honors the requested interval via OHLCV resampling instead
of silently returning daily bars (#467, thanks @Shizoqua).
- Provider credentials are resolved through one centralized path, fixing a
gateway misroute (#563, thanks @shadowinlife, closes #549/#553). When no
`*_BASE_URL` is set, the backend now falls back to the provider catalog's
canonical `default_base_url` (the same default Web Settings already used), so
a CLI / manual-`.env` user reaches the right endpoint instead of defaulting
to `api.openai.com`.
- Signal alignment is vectorized for an ~80× speedup on wide panels (#698,
thanks @shadowinlife); swarm workers cache MCP tool-discovery specs to avoid
redundant RPC round-trips (#704, thanks @shadowinlife).
### Fixed
- Explicit `source: local` backtests now route US/HK equities to the
global-equity engine instead of the crypto default, and explicit benchmarks
are fetched through the configured source's loader — `local` fails closed
(no yfinance fallback) so offline runs stay offline (#550).
- Loading `.env` now invalidates an `EnvConfig` singleton cached during early
CLI imports, so the welcome panel, `/settings`, and dotenv diagnostic report
the configured provider and model consistently (#541).
- FastMCP transport imports work across both module layouts (#469, thanks
@roberttidball).
- Portfolio optimizers no longer include the decision bar's close-to-close
return in weights executed at that bar's open (#487, thanks @YZY0108).
- Backtest turnover metrics now use actual filled and rounded position sizes;
targets rejected by market rules no longer inflate reported turnover.
- End-of-backtest liquidations now apply exit slippage and include their
commission in the final reported equity.
- Open-price rebalances no longer use the decision bar's close for sizing or
depend on whether a replacement symbol sorts before the position it closes.
- Preflight (`vibe-trading run`) no longer resolves provider/model against a
stale `EnvConfig` snapshot cached before dotenv loads (#479, thanks
@ananaymital, closes #477).
- Switching providers no longer leaves a stale `OPENAI_BASE_URL` from a
previous configuration silently overriding the newly-resolved endpoint
(#484, thanks @Bortlesboat, closes #482).
- **Strict-JSON / finite-number hardening** across the backtest + tools stack
(thanks @santhreal): risk ratios stay finite when equity crosses zero mid-path
(#765) or annualizes an explosive path (#739/#740); scalar backtest metrics
(#766), factor IC std (#767), and pattern trend-slope (#764) emit strict
RFC-8259 JSON (`null`, never bare `NaN`/`Infinity`); Black-Scholes helpers
treat non-positive spot/strike as intrinsic (#744).
- **Loader / data correctness** — yahoo `1m` stays minute bars instead of being
uppercased to monthly (#761, @santhreal); the composite engine falls back to
the first available sub-engine for unknown symbols (#734, thanks @Marnie0415);
mootdx history that doesn't reach the requested start is rejected (#692, thanks
@xkam7ar).
- **Session / journal robustness** — one corrupt `session.json` (#762) or a
schema-bad `messages.jsonl` line (#763) no longer aborts listing/reading;
Excel float-stringified A-share codes (#770), unicode-dash PDF page ranges
(#769), and `export KEY=` dotenv lines (#768) all parse correctly (@santhreal).
- **Native `zai` provider on glm-5.1** (#758) — endpoints that stream zero
chunks fall back to a non-streaming invoke instead of raising, and an HTML
error page surfaces an actionable base-URL hint.
- Partial market-data results are completed through the loader fallback chain
instead of silently shrinking the universe (#689, closes #681).
- Cancellation is honored before the first AgentLoop iteration (#641, thanks
@xkam7ar, closes #638); streaming output no longer triggers an `insertBefore`
DOM race in the frontend (#717, thanks @Marnie0415); codex stream HTTP
failures are classified for correct retry (#663, thanks @tyj147454413-cmd).
- Robinhood connector `account_number` wiring and remote-MCP display shape
(#726, thanks @nareshkps).
- Broad reliability fixes across packaging, web, scheduler, swarm, and CLI
(#584, thanks @xkam7ar).
## [0.1.11] — 2026-07-11
### Added
- **Indian equity (NSE/BSE) as a first-class market** (#305, thanks
@muku314115). A dedicated `IndiaEquityEngine` — T+1 delivery, no overnight
shorts (opt-in intraday), configurable circuit bands, 1-share lots, and a
config-driven STT / stamp-duty / exchange / SEBI / GST cost stack — with
`.NS`/`.BO` symbol routing (`yahoo → yfinance → india_broker → local`), an
opt-in read-only Shoonya/Dhan `india_broker` data bridge, and 255
alpha101/qlib158 factors opted into the new `equity_in` universe. Backtest
engines: 7 → **8**; market-data sources: 19 → **20**.
- **Fundamental factor layer, Phase 1.** PIT-safe SEC fundamentals flow into
dense daily `fund:*` factor panels — filed-date anchoring, first-filed
restatement policy, true-quarter `(start, end)` frame selection with Q4
synthesis (so YTD/annual frames can't contaminate TTM), and rolling TTM —
plus a `get_fundamentals` tool and 4 quality/value factors in a new
`fundamental` zoo family. Alpha Zoo: 456 → **460** across **5** families.
- **Research Autopilot Phase 3 — the loop closes** (#267, thanks
@Robin1987China). `scaffold_signal_engine` writes a contract-correct signal
engine from a hypothesis and `link_autopilot_backtest` runs it, completing
hypothesis → signal-engine → backtest end to end.
- **4 canonical academic alphas** (#277, thanks @Robin1987China) — Jegadeesh
short-term reversal, George–Hwang 52-week high, Amihud illiquidity, and
Harvey–Siddique co-skewness join the academic family (452 → 456), with a
**central OHLC-invariant guard** at the runner fetch boundary dropping
malformed bars from every loader (#274, thanks @Shizoqua).
- **Scheduled research runs end to end** (#278 closing #254, thanks
@mvanhorn). A default-off background executor
(`VIBE_TRADING_ENABLE_SCHEDULER`) fires due interval/cron jobs through the
session runtime, on top of a crash-safe atomic job store, 3 auth-gated
`/scheduled-runs` REST routes, a Reports library, and post-backtest
attribution. Route test coverage followed in #452 (thanks @Robin1987China).
- **IM channel runtime — research delivery over 16 adapters.** The agent
session runtime now attaches to 16 built-in message adapters (WebSocket,
Telegram, Slack, Discord, Matrix, WhatsApp, Signal, QQ/NapCat,
WeChat/WeCom, Feishu, DingTalk, email, MS Teams, MoChat), dependency-gated
with install hints, configurable via `AgentConfig.channels`, and surfaced in
REST (`/channels/*`), CLI (`vibe-trading channels ...`), and Web Settings —
in all 5 UI locales.
- **QVeris optional premium data track.** The 19 free sources stay the
default; an explicit-only QVeris mode (Settings → QVeris or
`vibe-trading data mode paid`) unlocks 63+ providers behind 3 key-gated
tools (`qveris_search` / `qveris_inspect` / `qveris_execute`) with
preview-by-default and a session budget gate. Never enters auto-fallback.
- **Trading 212 read-only connector** (#321, thanks @mvanhorn) — 11 brokers
total. Trading 212 exposes no runtime paper/live discriminator, so the
connector is fully read-only: `place_order`/`cancel_order` hard-refuse
every order, paper included. Live order guards also gained an opt-in,
broker-agnostic `PreTradeAdvisoryInterface` that records advisory reviews
without bypassing the mandate gate.
- **Turnover-aware portfolio optimizer** (#466, thanks @Robin1987China) —
fifth optimizer: mean-variance utility with an L1 penalty on weight changes
versus the previous rebalance (SLSQP, long-only simplex), so the portfolio
only trades when expected improvement outweighs churn. Optimizers: 4 → **5**.
- **`analyze_image` vision tool** (#464, thanks @fei-moss) — send a local
chart / K-line screenshot / app screenshot to the session model as a
multimodal message and get a semantic read (complements `read_document`'s
OCR). Path-validated against the allowed file roots; requires a
vision-capable model. Tools: 71 → **72** free-mode (75 with QVeris).
- **Value-investing toolkit** (thanks @sambazhu): financial-rigor +
report-audit tools, 4 skills, and a `value_investing_committee` swarm
preset. Swarm presets: 29 → **30**.
- **CN-friendly search fallbacks** — `web_search` gains China-reachable
backends in the ordered no-key engine chain.
- **Provider roster additions**: Kimi for Coding as a distinct provider
(#435, thanks @yxhuang), opencode provider mappings (#444, thanks
@imsankz), Codex OAuth default model bumped to `openai-codex/gpt-5.4`
(#446, thanks @morluto).
- **SKILL.md manifest guard test** (#461, thanks @asahikiko) — the packaged
skill's capability counts (skills / presets / zoo / sources / MCP tools /
engines) are now asserted against source, so distribution paperwork can't
silently drift again.
### Changed
- **`api_server.py` modularization completed** — 1,103 → 371 lines (#424
closing #331, thanks @shadowinlife), after route slices for channels,
settings, and the remaining route groups, plus a shared compat layer with
session-service writeback fixes.
- **Centralized environment variable management** (#440 closing #438, thanks
@shadowinlife) — every env var flows through a single Pydantic `EnvConfig`
schema, enforced by an AST-based CI gate that rejects raw
`os.getenv`/`os.environ` outside `agent/src/config/`.
- **Factor engine acceleration** — hot rolling operators use
`bottleneck`/NumPy fast paths, and alpha-bench parallelism stops resending
large panel payloads to workers.
- **Robinhood Agentic MCP refresh** — current MCP tool names across generic
reads, live-runner plumbing, default read-only seeds, and mandate-gate
tests; interactive OAuth holds the handshake open through multi-minute
broker sign-ins (`VIBE_LIVE_AUTHORIZE_TIMEOUT_SECONDS`).
- **Loader `fetch()` signatures** now match the loader protocol across
OKX / Tushare / yfinance (#437, thanks @shadowinlife).
- **Timezone-aware UTC timestamps** across session, goal, channel, and API
paths (#397, thanks @mustafakamal88).
- **Inbound IM media now lands under `~/.vibe-trading/uploads/<channel>/`**
(fixes #465, thanks @fei-moss for the report) — inside the default allowed
file roots, so the agent can read what users send over IM channels with
zero configuration. The Matrix E2E store moves to the runtime dir (legacy
path honored) so credentials never enter the readable root.
### Fixed
- **Docker/server startup crash** when FastAPI route iteration hit an
included-router entry without `path` (#450, thanks @Penn-Live).
- **GLM thinking models on the zhipu provider** no longer lose their
reasoning stream (#458).
- **Trading mandate UX guards** — a second-confirmation dialog before
committing a real mandate, unified error toasts, and clarified inputs
(#453, thanks @wison1717-maker).
- **`trading_place_order` treats zero quantity/notional as unset** instead of
passing a zero-size order to the broker (#417, thanks @irfanallana-oss).
- **Longbridge Decimal values serialize as floats** across quotes, bars,
balances, positions, orders, and executions (#459, thanks @fanfpy).
- **NapCat private messages now trigger pairing codes** (#463, thanks
@fei-moss).
- **Backtest validation artifacts**: `validation.json` no longer requires a
pre-existing artifacts dir (#429, thanks @isaveall), and nested
`NaN`/`Infinity` values are normalized before writing, so strict JSON
parsers don't choke.
- **CLI**: `resume` preserves the first user message (#448 closing #447,
thanks @morluto); `--swarm-run` rejects extra tokens with a clear error
(#428, thanks @isaveall); the interactive CLI prints the session-id on
exit with a copy-paste resume hint.
- **Shadow Account**: extracted rules carry RSI / prior-return entry bounds
computed from PIT-safe context fetched through the loader registry, so
generated engines enter on real conditions (#302/#314/#316, thanks
@Robin1987China); tushare ETF/index/HK symbol routing fixed along the way.
- **Content-filter resilience** — event-driven and swarm runs skip individual
LLM content-moderation hits and warn when filter rates are high; Gemini
safety finish-reasons recognized.
- **IM channel reply timeout is configurable** (#413, thanks @dpersek).
- **Provider preflight no longer follows redirects** (#404 closing #402,
thanks @dpersek).
- **Windows baseline green** — `vibe-trading setup`/`dev` handle Windows
TypeScript builds, correct cwd, the Vite 5899 port, and child-process
shutdown; mootdx batch pulls let `KeyboardInterrupt`/`SystemExit`
propagate.
- **Security hardening** — loopback API CSRF protection (cross-site POSTs
can no longer drive side effects on the local API), SSRF guards on
interactive fetch paths, tightened API/Docker/frontend dev defaults, and
cleared frontend dependency/CSP alerts.
- **Reverted the IRR-AGL reliability/governance stack** (#405/#416) after it
broke session chats on day 1 (#433 — thanks @yxhuang for the precise
diagnosis); the evidence-bound research-pipeline direction continues in
reviewable slices on #442.
## [0.1.10] — 2026-06-19
Roll-up release; see the
[v0.1.10 release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.10)
for the full narrative.
### Added
- **Global data layer** — market-data sources 10 → 18 (direct-API Eastmoney /
Sina / Stooq / Yahoo + key-gated Finnhub / Alpha Vantage / Tiingo / FMP)
with ban-risk-ordered fallback chains behind a shared throttled HTTP gate,
plus **18 read-only data tools** (fund flow, dragon-tiger, northbound,
margin, block trades, shareholder count, lockup, sector, research, news,
SEC filings, financial statements, options chains, institutional holdings,
screening, symbol search, FRED macro, iwencai) — all MCP-exposed.
- **10 broker SDK connectors** — Tiger / Longbridge / Alpaca / OKX / Binance /
Futu / Dhan / Shoonya join IBKR (local read-only) and Robinhood (Agentic
MCP); direct-SDK live orders pass the fail-closed bounded-autonomy gate;
brokers without a runtime paper/live discriminator are structurally capped
at paper + read-only.
- **Alpha Zoo `alpha compare`** across CLI, REST, Web UI, and agent tool.
- **Research Autopilot Phase 1** (`run_research_autopilot`,
`generate_backtest_config`) and the local Data Bridge loader.
- **Opt-in local data cache** (`VIBE_TRADING_DATA_CACHE`) for settled bars
under `~/.vibe-trading/cache/`.
- Per-run token usage (`llm_usage.json`) + progressive Run Detail charts;
CLI `resume <session-id>`.
### Changed
- **Provider reliability overhaul** — DeepSeek hang fixes, Kimi access,
streaming liveness watchdog, Gemini 3.x multi-turn tool-calling fix.
- Swarm workers pull market data through the loader layer; live swarm status
cards stream in the chat timeline.
- Baseline install slimmed (`pyharmonics`/`ta` behind
`vibe-trading-ai[harmonic]`).
### Fixed
- Community security-hardening wave (#241–#258): Settings write auth, shell
tools opt-in, LAN-access 403 clarity, Docker-to-host Ollama URL rewrite,
web_search multi-engine fallback, and more.
## [0.1.9] — 2026-06-01
### Added
- **Connector-first broker profiles (IBKR + Robinhood).** Trading access now
starts from a selectable connector profile rather than separate broker/live
entry points; `vibe-trading connector list/use/check/account/positions/orders/quote/history`
and the MCP `trading_*` tools share the selected profile, with paper/live as
a property under the connector. IBKR is usable immediately as a local
read-only TWS / IB Gateway profile; the official IBKR remote MCP path is
seeded as an OAuth `mcp.read` probe until stable read tool names ship.
Robinhood Agentic Trading is a bounded connector behind OAuth, a committed
mandate, an order guard, an audit ledger, and an instant halt switch.
- **Research Goal runtime.** Long-running, research-only goals with auditable
checklist criteria, budgets, and a `/goal` CLI command, plus REST + MCP
endpoints (`start_research_goal`, `get_research_goal`, `add_goal_evidence`,
`update_research_goal_status`) and a Web `GoalDrawer`.
- **Swarm `retry_run`.** Re-launch a failed/stale/cancelled run with the
original preset + variables; exposed as both `POST /swarm/runs/{id}/retry`
and an MCP `retry_run` tool (the `list_runs → retry` loop). 36 MCP tools now.
- **Operator-configured external MCP tools in swarm workers** (#142) and
**remote MCP transports** for the built-in agent.
- **`mootdx` A-share OHLCV loader** — native 通达信 TCP, no token, sits between
tushare and akshare in the fallback chain. CCXT loader now reads proxy env
for restricted networks (#126).
- **Hypothesis Registry CLI** — `list / show / invalidate`.
- **Strict alpha-bench mode** with a mandatory random control (#143).
### Changed
- **CLI split into the `agent/cli/` package** (from a 3216-LOC single file),
with a refreshed interactive terminal UI (figlet banner + activity rail) and
a single `cli/_version.py` version source.
- Swarm status reconciles from live task files on every read; `run_swarm`
sends MCP progress heartbeats, and the stale-run reaper uses per-run
thresholds (#132).
- Refreshed provider default model ids; bumped `langgraph` for CVE-2026-28277.
### Fixed
- **`--version` no longer drifts (#156).** The version derives from package
metadata, falling back to reading `pyproject.toml` directly — no hardcoded
constant left to forget on release.
- **Session running-status indicator** now survives reconnect / page reload /
sidebar navigation; **swarm DAG** blocks downstream tasks when an upstream
task fails (#145).
- **Robustness pass:** pre-flight validation for LLM-generated signal engines
with clean JSON errors (#149), graceful agent-loop exit at the iteration
budget instead of an output-less `failed` (#148), `flush + fsync` session
message writes that skip corrupted JSONL lines on read (#147), and IME Enter
handling in the Web composer (#146).
- **Full Report** link now always renders when a `runId` exists, even cross-browser
(#150); SSE idle timeout is configurable via `VIBE_TRADING_SSE_TIMEOUT` (#157);
cross-market correlation normalizes timestamps so crypto-vs-equity pairs align (#158).
## [0.1.8] — 2026-05-17
### Added — Alpha Zoo (450+ pre-built quant alphas)
- `agent/src/factors/` — base operators (`rank`, `scale`, `ts_*`, `delta`,
`decay_linear`, `signed_power`, `safe_div`, market-aware `vwap`) and a
registry that AST-extracts metadata from each alpha module without
importing it. Lookahead is enforced at the operator level
(`delta(d>=1)`), and registry sanity checks reject `+/-inf` and
outputs that are more than 95 % NaN.
- 4 zoos shipping 452 alphas total:
- **qlib158** (154 alphas) — port of Microsoft Qlib's `Alpha158`
feature handler under Apache-2.0, with pinned commit SHA per file.
- **alpha101** (101 alphas) — implementation of Kakushadze (2015)
*"101 Formulaic Alphas"* (arXiv:1601.00991), written from the paper
appendix; the relevant trademarked string is intentionally absent.
- **gtja191** (191 alphas) — implementation of Guotai Junan's 2014
*"191 Short-period Trading Alpha Factors"* research report.
- **academic** (6 factors) — Fama-French 5 + Carhart momentum, shipped
as honest price-based proxies (not the canonical FF series).
- `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI
subcommand. `show` and `export-manifest` enforce path-traversal guards.
- New agent tools: `AlphaZooTool` (browse) and `AlphaBenchTool`
(orchestrator with Jinja2 autoescape + strict CSP HTML report).
- `ZooSignalEngine.from_zoo(...)` — composite multi-factor signal engine
with cross-sectional standardisation, weighting, and optional top-N /
bottom-N long-short conversion.
- `wiki/scripts/build_alpha_library.py` — Alpha Library renderer.
Reads `manifest.json` produced by `vibe-trading alpha export-manifest`
and emits 452 per-alpha HTML pages plus 4 per-zoo overviews, each with
`script-src 'none'` CSP. The landing page hydrates per-zoo counts
from `content/index.json`.
- New blog post: *"Which of the 191 GTJA alphas still work in 2026?"*
with aggregate IC statistics, theme breakdown, and the top alphas
that survive eight years of out-of-sample data.
### Added — Web UI for Alpha Zoo
- New page at `/alpha-zoo` in the Vite + React frontend with three
views: browse (4 zoo cards + filter bar + paginated table), detail
(formula, metadata, collapsible source code), and bench-runner
(form → SSE-streamed progress + Alive/Reversed/Dead stat cards +
Top-5-by-IR table + by-theme breakdown chart). "Alpha Zoo" nav
entry added to the layout.
- Four new REST routes in the FastAPI server:
- `GET /alpha/list` — filterable alpha catalogue
- `GET /alpha/{alpha_id}` — meta + source code
- `POST /alpha/bench` — kicks off a background bench job and
returns a `job_id`
- `GET /alpha/bench/{job_id}/stream` — Server-Sent Events with
`progress`, `result`, `done`, and `error` event types. In-memory
job state with a 1-hour TTL; no Redis/Celery dependency.
- Bench math is refactored into `agent/src/factors/bench_runner.py`
so the CLI driver (`agent/scripts/w4a_run_benches.py`) and the new
API worker share a single implementation.
### Added — Safety floor
- `agent/tests/factors/test_alpha_purity.py` — AST allowlist scan over
every `zoo/**/*.py` module (whitelist: pandas, numpy, scipy.\*,
`src.factors.base`, `__future__`, `typing`, `math`, `dataclasses`;
banned: `os`, `sys`, `subprocess`, `socket`, `urllib`, `requests`,
`httpx`, `pathlib`, `Path`, `open`, `eval`, `exec`, `compile`,
`__import__`, and `getattr(_, "__*")`).
- `agent/tests/factors/test_lookahead.py` — sentinel future-row
injection on a 300-row synthetic panel; corrupting rows after the
probe must leave the probe value unchanged within 1e-9.
- `tools/ci_grep_gates.sh` — CI gate that rejects `yaml.load(` without
`safe_load`, any trademarked-name leak in shipped artifacts, and any
per-stock-code data leak in `wiki/**/*.{json,csv,html}`.
- `agent/tests/factors/conftest.py` — opt-in `pytest-socket` integration
that hard-fails any test attempting outbound network during the
factors test suite.
### Added — Community governance
- `CONTRIBUTING.md` — Developer Certificate of Origin sign-off
requirement and a contributor checklist for new alpha PRs (purity,
lookahead, `__alpha_meta__` shape, LaTeX-matches-code, per-zoo
LICENSE.md, DCO).
- `NOTICE` (repo root) — Apache-2.0 attribution for Qlib and a
declaration that the bundled formulas from Kakushadze, GTJA, and the
academic baselines are mathematical content (paper prose, tables, and
figures are not reproduced here).
- Per-zoo `LICENSE.md` for each of `qlib158/`, `alpha101/`, `gtja191/`,
and `academic/`, plus an upstream `NOTICE` for `qlib158/`.
### Changed
- `agent/src/tools/factor_analysis_tool.py` extracted its IC/IR and
layered-backtest helpers to `agent/src/factors/factor_analysis_core.py`
so the new `alpha_bench_tool` reuses the same maths. Public tool
signature is unchanged; `_compute_ic_series` and `_compute_group_equity`
remain importable as backward-compatible aliases.
- `agent/cli.py` grew by 7 lines to register the `alpha` subcommand;
all handler logic lives in `agent/src/factors/cli_handlers.py`.
- Packaging: `pyproject.toml` now ships `zoo/**/*.yaml`, `zoo/**/*.md`,
and `zoo/**/NOTICE` as package data; `MANIFEST.in` recursively
includes `agent/src/factors`.
### Known limitations
- The `btc-usdt` universe is single-asset; cross-sectional IC requires
≥2 instruments, so the bundled `alpha101_btc` bench run returns
alive/reversed/dead = 0/0/0 by construction. Use a multi-symbol crypto
basket (e.g. BTC + ETH + SOL + the top-N perpetuals) for meaningful
cross-sectional results; a curated `crypto-majors` universe is planned
for 0.2.
### Internal
- `wiki/alpha-library/manifest.json` and `wiki/alpha-library/content/`
are generated artifacts and gitignored. Run
`vibe-trading alpha export-manifest --out wiki/alpha-library/manifest.json
--force` followed by `python wiki/scripts/build_alpha_library.py` to
regenerate the static site.
+136 -140
View File
@@ -1,169 +1,165 @@
# Contributing to Vibe-Trading
Thanks for your interest in contributing! This guide will help you get started.
Vibe-Trading is a natural-language finance research AI agent (FastAPI + ReAct
agent backend, Vite+React frontend, vectorized daily and options backtesting).
This guide covers contribution governance: the Developer Certificate of Origin
(DCO) sign-off requirement, the reviewer checklist for Alpha Zoo factor
contributions, and the quickstart for adding a new alpha.
## Quick Links
For general project setup (`pip install -e ".[dev]"`, dev servers,
`pytest --ignore=agent/tests/e2e_backtest`), see the README. For bug reports
and feature requests, use the GitHub issue templates.
- [Discord](https://discord.gg/2vDYc2w5) — ask questions, discuss ideas
- [Issues](https://github.com/HKUDS/Vibe-Trading/issues) — bug reports & feature requests
- [Good First Issues](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — great starting points
For AI-assisted or automation-assisted contributions, also see
[`AGENT_CONTRIBUTOR_GUIDE.md`](AGENT_CONTRIBUTOR_GUIDE.md). It summarizes safe
local checks, higher-risk broker/MCP/credential surfaces, and the expected PR
risk notes for agent-authored changes.
## Development Setup
## Developer Certificate of Origin (DCO)
Every commit in a community pull request MUST carry a `Signed-off-by:`
trailer. We do not require a CLA — the DCO is a lightweight per-commit
attestation that you wrote the code or have the right to submit it under
the project's MIT license. Maintainer-direct commits to `main` are not
subject to the trailer requirement (the maintainer's authorship is
already attested by the commit's author field), but community PRs are.
Sign your commits with `-s`:
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\Activate.ps1 # Windows
pip install -e ".[dev]"
cp agent/.env.example agent/.env
# Edit agent/.env — set your LLM provider
git commit -s -m "feat(factors): add gtja191 alpha 042"
```
### Run tests
This appends a trailer like:
```bash
pytest --ignore=agent/tests/e2e_backtest --tb=short -q
```
Signed-off-by: Your Name <you@example.com>
```
### Start dev servers
PRs without a `Signed-off-by:` on every commit will be asked to rebase and
resign. To fix an unsigned series, run
`git rebase --signoff <base-branch>` and force-push the branch.
```bash
# Terminal 1: API server
vibe-trading serve --port 8899
### DCO 1.1 (full text)
# Terminal 2: Frontend
cd frontend && npm install && npm run dev
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
1 Letterman Drive
Suite D4700
San Francisco, CA, 94129
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
## Project Structure
## Alpha PR Reviewer Checklist
| Directory | What lives here | Open to contribute? |
|-----------|----------------|:-------------------:|
| `agent/src/skills/` | 69 finance skill definitions (SKILL.md) | Yes |
| `agent/src/tools/` | 21 agent tools | Yes |
| `agent/backtest/` | Backtest engines, loaders, optimizers | Yes |
| `agent/src/swarm/presets/` | 29 swarm preset YAMLs | Yes |
| `frontend/` | React 19 + Vite web UI | Yes |
| `agent/src/agent/` | ReAct agent core (loop, context, skills) | Ask first |
| `agent/src/session/` | Session management | Ask first |
| `agent/src/providers/` | LLM provider abstraction | Ask first |
This checklist applies to any PR adding or modifying files under
`agent/src/factors/zoo/**/*.py`. Reviewers MUST verify every box before
merging. Authors are strongly encouraged to self-check first.
> **Core modules** (`agent/`, `session/`, `providers/`) are protected — please open an issue to discuss before modifying.
- [ ] **Purity gate**: file passes `pytest agent/tests/factors/test_alpha_purity.py`.
The AST scan rejects any imports outside the allowlist
(`pandas`, `numpy`, `scipy.*`, `src.factors.base`, `__future__`, `typing`,
`math`, `dataclasses`) and any reference to forbidden names: `os`,
`subprocess`, `socket`, `urllib`, `requests`, `httpx`, `pathlib`, `Path`,
`eval`, `exec`, `compile`, `__import__`, bare `open`, or `getattr` with a
second argument beginning with `"__"`.
- [ ] **Lookahead gate**: file passes `pytest agent/tests/factors/test_lookahead.py`.
No negative shifts, no forward leakage. `delta(df, d)` must have `d >= 1`.
- [ ] **`__alpha_meta__` present** with all required pydantic-validated fields:
`id`, `theme`, `formula_latex`, `columns_required`, `universe`, `frequency`,
`decay_horizon`, `min_warmup_bars`. Optional: `nickname`, `extras_required`,
`requires_sector`, `notes`.
- [ ] **`compute(panel)` contract**: returns a DataFrame with the same shape
as `panel["close"]`, NaN preserved at warmup / missing-data positions,
no `+/-inf` values.
- [ ] **LaTeX matches code**: the `formula_latex` in `__alpha_meta__` and the
formula in the module docstring describe what `compute()` actually does.
- [ ] **Per-zoo `LICENSE.md` updated** to cite the source paper / report and
to state that formulas are mathematical facts, not subject to copyright,
and that prose / tables / figures from papers and reports are not
reproduced in this repo. Do NOT frame the bundled formulas using US
affirmative-defense terminology — that is a litigation posture, not a
license grant. State the mathematical-facts rationale instead.
- [ ] **Apache-2 attribution**: if the alpha is adapted from an Apache-2.0
upstream (e.g. Microsoft Qlib), the file MUST carry a header of the form
`# Adapted from <repo>@<commit-sha>:<path> (Apache-2.0). Copyright (c) <holder>.`
- [ ] **DCO**: every commit in the PR carries `Signed-off-by:`.
## How to Contribute
## Adding a New Alpha (Quickstart)
### Reporting Bugs
Use the [Bug Report](https://github.com/HKUDS/Vibe-Trading/issues/new?template=bug_report.yml) template. Include:
- Steps to reproduce
- Expected vs actual behavior
- Your environment (OS, Python version, provider)
- Error logs if available
### Suggesting Features
Use the [Feature Request](https://github.com/HKUDS/Vibe-Trading/issues/new?template=feature_request.yml) template.
### Submitting Code
1. **Fork & branch** — create a feature branch from `main`
1. Pick the target zoo directory under `agent/src/factors/zoo/` (e.g.
`gtja191/`, `alpha101/`, `qlib158/`, `academic/`).
2. Create `<alpha_id_short>.py` in that directory. Define `__alpha_meta__`
(must satisfy the pydantic `AlphaMeta` schema in
`agent/src/factors/registry.py`) and a pure `compute(panel)` function
that imports only from `src.factors.base` plus the allowlisted stdlib /
numpy / pandas / scipy.
3. Run the purity and lookahead gates locally:
```bash
git checkout -b feat/my-feature
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
```
2. **Make changes** — follow the code standards below
3. **Test** — add or update tests, ensure `pytest` passes
4. (Optional but recommended) Run a quick bench:
```bash
pytest --ignore=agent/tests/e2e_backtest --tb=short -q
vibe-trading alpha bench --zoo <zoo_id> --universe csi300 --period 2020-2025
```
5. Open a PR. Every commit must include `Signed-off-by:` (use
`git commit -s`). Reviewers will walk the checklist above.
4. **Commit** — use [Conventional Commits](https://www.conventionalcommits.org/)
```
feat: add RSI divergence skill
fix: handle empty DataFrame in backtest loader
docs: update CLI reference table
test: add crypto engine edge cases
```
## Code Style
5. **Pull Request** — open a PR against `main` with:
- Summary of changes
- Related issue number (e.g., `Closes #42`)
- Test plan
- Format with `black`; lint with `ruff` (config in `pyproject.toml`).
- Install both tools with the development extra: `pip install -e ".[dev]"`.
- Run them on the Python files you changed, for example:
```bash
black --check agent/src/example.py agent/tests/test_example.py
ruff check agent/src/example.py agent/tests/test_example.py
```
The repository does not yet enforce a whole-tree Black or Ruff check, so
avoid mixing unrelated formatting cleanup into a focused pull request.
- Type-annotate all public function and method signatures.
- Google-style docstrings (`Args:` / `Returns:` / `Raises:`).
- Keep files under 400 lines where practical, 800 hard cap.
- No hardcoded paths, secrets, or URLs — config via `.env`, YAML, or
module-level constants.
- Delete unused code rather than commenting it out.
## Code Standards
## Attribution
- **Python**: Google-style docstrings, type hints encouraged
- **No hardcoding**: config via `.env`, YAML, or constants
- **Delete unused code** — don't comment-preserve
- **OKX pairs**: use `BTC-USDT` format (hyphen, uppercase)
- **UI text**: English. LLM output follows user language
- **File size**: aim for < 400 lines, max 800
Do NOT add `Co-Authored-By:` trailers or AI-assistant attribution lines to
commit messages or PR descriptions. The DCO sign-off is the only required
trailer; keep commit metadata clean.
## Contribution Roadmap
### Easy: New Skill
Each skill is a single `SKILL.md` file in `agent/src/skills/<category>/<skill-name>/`. Categories: `data-source`, `strategy`, `analysis`, `asset-class`, `crypto`, `flow`, `tool`. Look at existing skills for the format.
### Easy: New Swarm Preset
A YAML file in `agent/src/swarm/presets/` defining agents, roles, and DAG workflow. See existing presets.
### Medium: New Data Source Loader
1. Create `agent/backtest/loaders/<source>.py`
2. Implement the `DataLoader` Protocol (see `agent/backtest/loaders/base.py`)
3. Register in `agent/backtest/loaders/registry.py`
4. Add tests in `agent/tests/`
### Medium: Portfolio Optimizer Constraints
Current optimizers (`agent/backtest/optimizers/`) support basic weight optimization. We need:
- **Leverage constraints** — max gross exposure limits
- **Sector/group caps** — weight limits per sector or asset group
- **Turnover penalties** — transaction cost-aware rebalancing
- Extend `BaseOptimizer` interface, keep backward compatibility
### Medium: Correlation Heatmap Dashboard
Add a cross-asset correlation matrix component to the frontend:
- Time-window slider (30d / 60d / 90d / 1Y)
- Hierarchical clustering for sector grouping
- ECharts heatmap or custom canvas renderer
- Wire to a new API endpoint that computes rolling correlations
### Hard: Intraday Backtest Engine
Current engines run on daily bars. We need sub-daily execution:
- Support 1m / 5m / 15m / 30m / 1H / 4H intervals
- Handle market session boundaries (pre-market, after-hours, overnight gaps)
- Intraday margin / buying power tracking
- Integrate with minute-level data loaders (OKX klines, yfinance intraday)
- Start from `BaseEngine` — see `agent/backtest/engines/base.py` for the abstract interface
### Hard: Options Volatility Surface & Greeks Visualization
Build 3D implied-vol surface and Greeks charts in the frontend:
- **Vol surface**: strike vs. expiry vs. implied vol (3D mesh or contour)
- **Greeks dashboard**: delta, gamma, theta, vega curves across strikes
- **Skew chart**: 25-delta risk reversal over time
- Backend: extend `options_pricing_tool.py` to return surface data
- Frontend: ECharts GL for 3D, or custom WebGL renderer
### Hard: Monte Carlo & Stress Testing
Add scenario simulation and tail-risk analysis:
- **Monte Carlo**: generate N return paths from fitted distribution, compute VaR/CVaR confidence intervals
- **Historical stress tests**: replay specific crisis periods (2008, 2020 COVID, 2022 crypto winter)
- **Regime switching**: HMM-based regime detection (bull/bear/sideways), conditional risk metrics
- Output: distribution plots, drawdown probability curves, worst-case tables
- Can be a new tool in `agent/src/tools/` or an extension to the backtest engine
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
By contributing, you agree that your contributions are licensed under the
project's MIT license (see `LICENSE`).
+76 -15
View File
@@ -1,7 +1,8 @@
# ============================================================================
# Stage 1: Build frontend
# ============================================================================
FROM node:20-slim AS frontend-build
FROM node:20-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 AS frontend-build
# node:20-slim digest resolved 2026-07-13
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
@@ -10,37 +11,97 @@ COPY frontend/ ./
RUN npm run build
# ============================================================================
# Stage 2: Python runtime
# Stage 2: Python builder — compiles wheels + builds a self-contained venv.
# build-essential lives ONLY here; it never reaches the runtime image.
# ============================================================================
FROM python:3.11-slim AS runtime
FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 AS builder
# python:3.11-slim digest resolved 2026-07-13
WORKDIR /app
# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Python deps (install before copying code for layer caching)
COPY agent/requirements.txt agent/requirements.txt
RUN pip install --no-cache-dir -r agent/requirements.txt
# Isolated venv we can copy wholesale into the runtime stage.
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Copy project
WORKDIR /app
# Python deps first for layer caching. Installed from the hash-pinned lock
# (agent/requirements.txt is the human-edited source; regenerate the lock
# with the command documented at the top of requirements-lock.txt whenever
# agent/requirements.txt changes).
COPY agent/requirements.txt agent/requirements.txt
COPY requirements-lock.txt requirements-lock.txt
RUN pip install --no-cache-dir --require-hashes -r requirements-lock.txt
# Copy project + install the CLI entrypoint (editable — the runtime stage
# re-creates the same /app/agent source tree the .pth file points at).
COPY pyproject.toml LICENSE README.md ./
COPY agent/ agent/
RUN pip install --no-cache-dir -e .
# Copy built frontend
# ============================================================================
# Stage 3: Runtime — carries the prebuilt venv only, no compilers/dev headers.
# ============================================================================
FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 AS runtime
# python:3.11-slim digest resolved 2026-07-13
LABEL org.opencontainers.image.title="Vibe-Trading" \
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \
org.opencontainers.image.version="0.1.12" \
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Runtime-only native libs. NO build-essential here — these are weasyprint's
# shared libraries (Pango/HarfBuzz/Fontconfig/Cairo/gdk-pixbuf) per its official
# Debian install list; without them the lazy `from weasyprint import HTML` in
# reporter.py fails and PDF rendering silently downgrades to HTML-only.
# fonts-dejavu-core gives non-blank PDFs.
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libfontconfig1 \
libgdk-pixbuf-2.0-0 \
libcairo2 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Bring in the prebuilt venv from the builder stage.
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY --from=builder /opt/venv /opt/venv
# Re-materialize the source tree the editable install references, plus the
# built frontend static assets.
COPY pyproject.toml LICENSE README.md ./
COPY agent/ agent/
COPY --from=frontend-build /app/frontend/dist frontend/dist
# Install CLI entrypoint
RUN pip install --no-cache-dir -e .
# Runtime should not run as root. `vibe` owns the writable app-data dirs so
# named volumes inherit usable permissions. `vibe-sandbox` is an unprivileged
# system account (no home, no shell) that runner.py drops into via
# subprocess.run(user="vibe-sandbox") to execute LLM-generated code with the
# least privilege — created here by fixed contract, not otherwise used.
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
&& useradd --system --no-create-home --shell /usr/sbin/nologin --uid 10001 vibe-sandbox \
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs /home/vibe/.vibe-trading \
&& chown -R vibe:vibe /app /home/vibe/.vibe-trading
USER vibe
# Default port
EXPOSE 8899
# Health check
# Health check — hits /live (liveness probe; /health remains a legacy alias).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')" || exit 1
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/live')" || exit 1
# Run API server (serves frontend/dist as static files)
CMD ["vibe-trading", "serve", "--host", "0.0.0.0", "--port", "8899"]
+4
View File
@@ -1,4 +1,5 @@
include LICENSE
include NOTICE
include README.md
include README_zh.md
include README_ja.md
@@ -11,6 +12,9 @@ include docker-compose.yml
# Developer entry-point template
include agent/.env.example
# Alpha Zoo factor metadata and per-zoo LICENSE / NOTICE files
recursive-include agent/src/factors *.yaml *.md
# Tests shipped so dev installs can run `pytest` against the package.
# Files prefixed with `_` are local debug scripts kept untracked — keep
# them out of the sdist even if present in the working tree.
+22
View File
@@ -0,0 +1,22 @@
Vibe-Trading
Copyright 2026 HKUDS contributors.
This product includes software developed at HKUDS (https://github.com/HKUDS).
This product bundles Microsoft Qlib feature definitions under the Apache 2.0
license. See agent/src/factors/zoo/qlib158/NOTICE and LICENSE.md for the
upstream NOTICE and attribution.
Mathematical formulas from the following sources are reimplemented in this
repository as factual mathematical content (formulas are not copyrightable):
- Kakushadze, Z. (2015) "101 Formulaic Alphas" (arXiv:1601.00991)
See agent/src/factors/zoo/alpha101/LICENSE.md
- Guotai Junan Securities (2014) "191 Short-period Trading Alpha Factors"
See agent/src/factors/zoo/gtja191/LICENSE.md
- Fama-French five-factor model, Carhart momentum, Hou-Xue-Zhang q-factor
See agent/src/factors/zoo/academic/LICENSE.md
Prose, tables, and figures from the source papers and reports are NOT
reproduced in this repository. Only the mathematical formulas — which are
factual content and not subject to copyright — are reimplemented here.
+953 -127
View File
File diff suppressed because it is too large. Load diff
+891 -259
View File
File diff suppressed because it is too large. Load diff
+912 -280
View File
File diff suppressed because it is too large. Load diff
+900 -266
View File
File diff suppressed because it is too large. Load diff
+954 -294
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -20,6 +20,27 @@ We will acknowledge your report within **5 business days** and work with you to
This policy applies to the [HKUDS/Vibe-Trading](https://github.com/HKUDS/Vibe-Trading) repository.
## Generated backtest code
Backtest runs may execute generated Python strategy code locally. Treat generated strategies as local code you review before running. The runner validates run directories and uses a narrow subprocess environment: it preserves OS/Python basics, proxy/certificate settings, allowed run-root configuration, and read-only market-data credentials needed by loaders, but it does not forward LLM provider keys, API server bearer tokens, shell-tool opt-ins, broker trading secrets, or live/advisory toggles by default.
The backtest subprocess is still network-capable so loaders can fetch public or user-authorized market data. Do not run untrusted generated strategies in an environment that exposes sensitive files, secret-bearing proxy variables, or network services you would not trust local code to access.
## Official channels & impersonation
Vibe-Trading is an open-source finance **research** tool. We will **never** ask you to
"verify", connect, or sign with a crypto wallet to join our community, claim an airdrop, or
unlock features — any such prompt is a scam.
- Our only official Discord is **https://discord.gg/6TdQnT5xcF** (the HKUDS community server,
also linked from the README). Treat any other "Vibe-Trading" Discord as an impostor.
- If a Discord or website asks you to connect/sign a wallet for "verification", do not do it.
If you already did, move your funds to a fresh wallet and revoke approvals (e.g. via
[revoke.cash](https://revoke.cash)).
See the pinned security announcement in [Discussions](https://github.com/HKUDS/Vibe-Trading/discussions)
for the 2026-06-18 impostor-Discord incident.
## Disclosure
- Please do not publicly disclose the vulnerability until we have released a fix.
+154 -19
View File
@@ -1,60 +1,103 @@
# NOTE: All defaults are defined in agent/src/config/env_schema.py (EnvConfig).
# ============================================================================
# LLM Provider — uncomment ONE provider block below
# LLM Provider (LLMConfig) — uncomment ONE provider block below
# ============================================================================
# --- OpenRouter (recommended, multi-model gateway) ---
LANGCHAIN_PROVIDER=openrouter
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
OPENROUTER_API_KEY=sk-or-v1-your-key-here
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v4-pro
OPENROUTER_API_KEY=sk-or-...here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# --- Requesty (OpenAI-compatible multi-model gateway) ---
# Same provider/model naming as OpenRouter. Get a key at https://app.requesty.ai/api-keys
# EU endpoint: https://router.eu.requesty.ai/v1
# LANGCHAIN_PROVIDER=requesty
# LANGCHAIN_MODEL_NAME=openai/gpt-4o-mini
# REQUESTY_API_KEY=xxx
# REQUESTY_BASE_URL=https://router.requesty.ai/v1
# --- OpenAI ---
# LANGCHAIN_PROVIDER=openai
# LANGCHAIN_MODEL_NAME=gpt-4o
# LANGCHAIN_MODEL_NAME=gpt-5.5-instant
# OPENAI_API_KEY=sk-xxx
# OPENAI_BASE_URL=https://api.openai.com/v1
# --- Anthropic Messages API / compatible proxy ---
# LANGCHAIN_PROVIDER=anthropic
# LANGCHAIN_MODEL_NAME=claude-sonnet-4-6
# ANTHROPIC_API_KEY=sk-ant-xxx
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# ANTHROPIC_MAX_TOKENS=16384 # Recommended for proxy model names unknown to LangChain
# --- OpenAI Codex (ChatGPT OAuth; not the OpenAI API key path) ---
# Login first: vibe-trading provider login openai-codex
# Requires a ChatGPT account with Codex access. OAuth tokens are stored by oauth-cli-kit.
# LANGCHAIN_PROVIDER=openai-codex
# LANGCHAIN_MODEL_NAME=openai-codex/gpt-5.4
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
# --- DeepSeek ---
# LANGCHAIN_PROVIDER=deepseek
# LANGCHAIN_MODEL_NAME=deepseek-chat
# LANGCHAIN_MODEL_NAME=deepseek-v4-pro
# DEEPSEEK_API_KEY=sk-xxx
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# DeepSeek adapter selection: auto (default; use langchain-deepseek if installed),
# native (require langchain-deepseek, error if missing), or openai-compatible
# (force the legacy ChatOpenAI path). Install the adapter with:
# pip install "vibe-trading-ai[deepseek]"
# VIBE_TRADING_DEEPSEEK_ADAPTER=auto
# --- NVIDIA NIM (hosted API catalog) ---
# LANGCHAIN_PROVIDER=nvidia
# LANGCHAIN_MODEL_NAME=nvidia/nemotron-3-ultra-550b-a55b
# NVIDIA_API_KEY=nvapi-xxx
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# --- Gemini ---
# LANGCHAIN_PROVIDER=gemini
# LANGCHAIN_MODEL_NAME=gemini-2.5-flash
# LANGCHAIN_MODEL_NAME=gemini-3.5-flash
# GEMINI_API_KEY=xxx
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
# --- Groq ---
# LANGCHAIN_PROVIDER=groq
# LANGCHAIN_MODEL_NAME=llama-3.3-70b-versatile
# LANGCHAIN_MODEL_NAME=meta-llama/llama-4-maverick-17b-128e-instruct
# GROQ_API_KEY=gsk_xxx
# GROQ_BASE_URL=https://api.groq.com/openai/v1
# --- DashScope / Qwen ---
# LANGCHAIN_PROVIDER=dashscope
# LANGCHAIN_MODEL_NAME=qwen-plus
# LANGCHAIN_MODEL_NAME=qwen-plus-latest
# DASHSCOPE_API_KEY=sk-xxx
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# --- Zhipu ---
# LANGCHAIN_PROVIDER=zhipu
# LANGCHAIN_MODEL_NAME=glm-4-plus
# LANGCHAIN_MODEL_NAME=glm-5.1
# ZHIPU_API_KEY=xxx
# ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# --- Moonshot / Kimi ---
# --- Moonshot / Kimi (open platform, pay-as-you-go) ---
# LANGCHAIN_PROVIDER=moonshot
# LANGCHAIN_MODEL_NAME=kimi-k2.5
# LANGCHAIN_MODEL_NAME=kimi-k2.6
# MOONSHOT_API_KEY=sk-xxx
# MOONSHOT_BASE_URL=https://api.moonshot.ai/v1
# --- Kimi for Coding (subscription plan; NOT the open platform above) ---
# Create the key in the Kimi Code console (kimi.com/code/docs). A coding-plan
# key is rejected by api.moonshot.ai and vice-versa. temperature is forced to 1.
# LANGCHAIN_PROVIDER=kimi-coding
# LANGCHAIN_MODEL_NAME=kimi-for-coding
# KIMI_CODING_API_KEY=sk-kimi-xxx
# KIMI_CODING_BASE_URL=https://api.kimi.com/coding/v1
# --- MiniMax ---
# LANGCHAIN_PROVIDER=minimax
# LANGCHAIN_MODEL_NAME=MiniMax-M2.7 # or MiniMax-M2.7-highspeed (faster)
# LANGCHAIN_MODEL_NAME=MiniMax-M3 # or MiniMax-M2.7 / MiniMax-M2.7-highspeed
# MINIMAX_API_KEY=xxx
# MINIMAX_BASE_URL=https://api.minimax.io/v1
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # Global
# MINIMAX_BASE_URL=https://api.minimaxi.com/v1 # China
# Note: MiniMax requires temperature > 0. Set LANGCHAIN_TEMPERATURE=1.0 (default when using MiniMax)
# --- Xiaomi MIMO ---
@@ -63,6 +106,13 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# MIMO_API_KEY=xxx
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1
# --- iFlytek Spark ---
# APIPassword from the iFlytek console (https://console.xfyun.cn), sent as a Bearer token.
# LANGCHAIN_PROVIDER=spark
# LANGCHAIN_MODEL_NAME=4.0Ultra # or generalv3.5 / pro-128k / lite
# SPARK_API_KEY=xxx
# SPARK_BASE_URL=https://spark-api-open.xf-yun.com/v1
# --- Z.ai (for Coding Plan) ---
# LANGCHAIN_PROVIDER=zai
# LANGCHAIN_MODEL_NAME=glm-5.1
@@ -72,7 +122,7 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# --- Ollama (local) ---
# LANGCHAIN_PROVIDER=ollama
# LANGCHAIN_MODEL_NAME=qwen2.5:32b
# OLLAMA_BASE_URL=http://localhost:11434/v1
# OLLAMA_BASE_URL=http://localhost:11434
# LLM parameters
LANGCHAIN_TEMPERATURE=0.0
@@ -80,12 +130,12 @@ TIMEOUT_SECONDS=120
MAX_RETRIES=2
# Enable reasoning on relays that require opt-in (e.g. OpenRouter thinking models).
# Forwarded as extra_body.reasoning.effort = low / medium / high.
# Forwarded as extra_body.reasoning.effort = low / medium / high / max.
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
# LANGCHAIN_REASONING_EFFORT=medium
# ============================================================================
# Data Sources
# Data Sources (DataConfig)
# ============================================================================
# A-shares: Tushare Pro token (https://tushare.pro)
TUSHARE_TOKEN=your-tushare-token
@@ -97,19 +147,75 @@ TUSHARE_TOKEN=your-tushare-token
# Download FutuOpenD: https://www.futunn.com/download/openAPI
# FUTU_HOST=127.0.0.1
# FUTU_PORT=11111
# US / HK equities via LongPort OpenAPI (optional, requires LongPort developer account)
# Sign up: https://open.longbridge.com
# LONGBRIDGE_APP_KEY=your-app-key
# LONGBRIDGE_APP_SECRET=your-app-secret
# LONGBRIDGE_ACCESS_TOKEN=your-access-token
# Install the optional SDK before using Longbridge:
# pip install "vibe-trading-ai[longbridge]"
# To force Longbridge for a backtest, set "source": "longbridge" in config.json.
# In an Agent conversation, ask: "Use Longbridge to fetch QQQ.US historical data."
# Free direct-API sources need NO key and auto-join the fallback chain: Eastmoney / Sina / Stooq / Yahoo.
# Optional API-key data sources (enabled only when the key is set; silently skipped otherwise)
# FINNHUB_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
# ALPHAVANTAGE_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
# TIINGO_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
# FMP_API_KEY=xxx # optional US-equity OHLCV fallback provider (selectable get_market_data source)
# FRED_API_KEY=xxx # macro series (get_macro_series tool)
# VIBE_TRADING_IWENCAI_KEY=xxx # A-share natural-language research search (iwencai_search tool)
# VIBE_TRADING_SEC_UA="Your Name your@email.com" # SEC EDGAR compliant contact User-Agent (optional; a built-in default UA also works)
# Optional per-source request-spacing overrides for batch jobs — VIBE_TRADING_{EASTMONEY,SINA,STOOQ,YAHOO,SEC,FINNHUB,ALPHAVANTAGE,TIINGO,FMP,FRED,IWENCAI,THS}_MIN_INTERVAL (seconds).
# OCR engine for scanned PDF pages and images (auto by default).
# auto — local engines only (privacy: never sends pages to cloud)
# rapid — force RapidOCR (local, ONNX; pip install rapidocr_onnxruntime)
# llm-vision — force LLM vision OCR (cloud; any OpenAI-compatible vision model)
# none — disable OCR entirely
# VIBE_TRADING_OCR_ENGINE=auto
# Override the model used for LLM vision OCR (defaults to LANGCHAIN_MODEL_NAME).
# Example: qwen3.7-plus, gpt-5.5, gemini-3.5-flash, claude-sonnet-4.6
# VIBE_TRADING_OCR_LLM_MODEL=
# Opt-in local market-data cache for backtest loaders (off by default).
# When enabled, every data source caches settled historical bars under
# ~/.vibe-trading/cache/loaders/ so repeated/long-horizon backtests skip the
# network. Only fully-elapsed days are cached (ranges ending today are always
# re-fetched). Clear it any time with: rm -rf ~/.vibe-trading/cache
# VIBE_TRADING_DATA_CACHE=1
# Opt-in background scheduler for saved research jobs (off by default).
# When enabled, the API server runs a background poller that fires due
# interval/cron scheduled-research jobs through the session runtime.
# VIBE_TRADING_ENABLE_SCHEDULER=1
# ============================================================================
# API Server (optional)
# API Server (APIConfig, optional)
# ============================================================================
# Bearer token for API auth (empty = no auth, dev mode)
# Bearer token for API auth.
# Empty dev mode is loopback-only. Set this before exposing port 8899 beyond localhost.
# API_AUTH_KEY=
# CORS allowed origins (comma-separated)
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
# Enable session runtime
# ENABLE_SESSION_RUNTIME=true
# Docker compose sets this together with a 127.0.0.1 port bind so browser
# requests from the host machine still count as local dev-mode access.
# Do not enable this when publishing port 8899 on 0.0.0.0 without API_AUTH_KEY.
# VIBE_TRADING_TRUST_DOCKER_LOOPBACK=0
# Advanced local-only opt-in: expose shell execution tools to the agent.
# VIBE_TRADING_ENABLE_SHELL_TOOLS=0
# Optional import roots for read_document/analyze_trade_journal.
# Use comma-separated absolute paths. Defaults include agent/uploads, agent/runs,
# ./uploads, ./data, ~/.vibe-trading/uploads, and ~/.vibe-trading/imports.
# VIBE_TRADING_ALLOWED_FILE_ROOTS=
# Optional run roots for generated-code tools such as write_file/backtest.
# Defaults include agent/runs, ./runs, and ~/.vibe-trading/shadow_runs.
# VIBE_TRADING_ALLOWED_RUN_ROOTS=
# ============================================================================
# Agent Tuning (optional, defaults are sensible)
# Agent Tuning (AgentTuningConfig, optional, defaults are sensible)
# ============================================================================
# SWARM_WORKER_TIMEOUT=300
# SWARM_WORKER_MAX_ITER=50
@@ -118,3 +224,32 @@ TUSHARE_TOKEN=your-tushare-token
# SUBAGENT_TIMEOUT=300
# SUBAGENT_MAX_ITER=25
# TOKEN_THRESHOLD=40000
# Hard timeout (seconds) for read-only tool execution; write tools only warn.
# Default 1800; set 0 to disable.
# VIBE_TRADING_TOOL_TIMEOUT_SECONDS=1800
# Frontend SSE idle timeout in seconds before showing "Execution timed out".
# Increase when using slow local models (e.g. Ollama on CPU/limited VRAM).
# VIBE_TRADING_SSE_TIMEOUT=90
# Content filter warning threshold (default: 0.05 = 5%)
# When the ratio of LLM responses blocked by content moderation exceeds this
# threshold, the run card will warn you to switch providers.
# CONTENT_FILTER_WARNING_THRESHOLD=0.05
# ============================================================================
# TAP Mode — credential isolation for broker orders (optional, OFF by default)
# ============================================================================
# Route Alpaca order placement through the TAP proxy (https://tap.human.tech)
# instead of the local broker SDK: the agent process holds no Alpaca key, and
# every order is held for HUMAN APPROVAL before it reaches the broker. Leave
# these unset for the default (direct broker SDK) behaviour.
#
# Setup: in the TAP dashboard create a multi-secret credential named `alpaca`
# with fields `key_id` + `secret_key`, allowed host `paper-api.alpaca.markets`,
# assigned to your agent. See the README "TAP Mode" section for details.
#
# TAP_PROXY_URL=https://proxy.tap.human.tech
# TAP_AGENT_KEY=your-tap-agent-key
# Credential name configured in TAP (default: alpaca)
# TAP_ALPACA_CREDENTIAL=alpaca
# Seconds to wait for a human approval decision (default: 300)
# TAP_APPROVAL_TIMEOUT=300
+205 -18
View File
@@ -1,7 +1,7 @@
---
name: vibe-trading
version: 0.1.6
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, options pricing, 72 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 6 data sources (tushare, yfinance, okx, akshare, ccxt, futu).
version: 0.1.12
description: Professional finance research toolkit — backtesting (8 engines + benchmark comparison panel), factor analysis, Alpha Zoo (462 pre-built alphas across qlib158/alpha101/gtja191/academic/fundamental), options pricing, 88 finance skills, 30 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 23 market-data sources (tushare, yfinance, okx, binance, akshare, baostock, tencent, mootdx, ccxt, futu, mt5, local, eastmoney, sina, stooq, yahoo, india_broker, qveris, longbridge, plus optional-key finnhub/alphavantage/tiingo/fmp).
dependencies:
python: ">=3.11"
pip:
@@ -14,7 +14,7 @@ env:
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other tools work without it."
required: false
- name: LANGCHAIN_MODEL_NAME
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v3.2). Only needed if using run_swarm."
description: "LLM model name for run_swarm (e.g. deepseek/deepseek-v4-pro). Only needed if using run_swarm."
required: false
mcp:
command: vibe-trading-mcp
@@ -23,7 +23,7 @@ mcp:
# Vibe-Trading
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 72 specialized skills, and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
Professional finance research toolkit with AI-powered backtesting (8 engines), multi-agent teams, 88 specialized skills, the **Alpha Zoo** (462 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic / fundamental with one-line CLI benchmarking), and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
## Setup
@@ -53,12 +53,13 @@ Add to your agent's MCP config:
### API Key Requirements
**21 of 22 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, and all 72 skills are ready to use for HK/US equities and crypto.
Core research MCP tools work with zero API keys for HK/US/crypto. After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, the Alpha Zoo (462 pre-built alphas), and all 88 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
| Feature | Key needed | When |
|---------|-----------|------|
| HK/US equities & crypto | None | Always free (yfinance + OKX) |
| China A-share data | `TUSHARE_TOKEN` | Only if you query A-share symbols |
| HK/US equities & crypto | None | Always free (yfinance / stooq / yahoo + OKX) |
| China A-share data | None | Free via akshare / baostock / tencent / sina / eastmoney / mootdx fallback (`TUSHARE_TOKEN` optional for premium quality) |
| Premium US fundamentals/quotes | `FINNHUB_API_KEY` / `ALPHAVANTAGE_API_KEY` / `TIINGO_API_KEY` / `FMP_API_KEY` | Only for optional-key providers (graceful fallback to free sources) |
| Multi-agent swarm (`run_swarm`) | `OPENAI_API_KEY` + `LANGCHAIN_MODEL_NAME` | Swarm spawns internal LLM workers |
## What You Can Do
@@ -73,12 +74,18 @@ Feed a CSV broker export (同花顺 / 东财 / 富途 / generic), and the agent
5. `scan_shadow_signals` — list today's symbols that match your shadow's entry cadence (research only).
### Backtesting
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 6 data sources:
- **HK/US equities** via yfinance (free, no API key)
Create and run quantitative strategies across 8 engines (ChinaA, GlobalEquity, IndiaEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 23 market-data sources (auto-detect + ordered fallback):
- **HK/US equities** via yfinance / stooq / yahoo (free, no API key); optionally via **Longbridge** historical OHLCV (`longbridge`, requires the optional SDK and `LONGBRIDGE_APP_KEY` / `LONGBRIDGE_APP_SECRET` / `LONGBRIDGE_ACCESS_TOKEN`). To force it for a run, set `"source": "longbridge"` in `config.json`.
- **India equities (NSE/BSE)** via yahoo / yfinance using `<SYMBOL>.NS` (NSE, e.g. `RELIANCE.NS`) or `<SCRIP>.BO` (BSE, e.g. `500325.BO`) — free, no API key. The `IndiaEquityEngine` models T+1 delivery, no overnight shorts (set `allow_short` for intraday), configurable circuit bands, 1-share lots, and the STT/stamp-duty/exchange/GST cost stack. Optionally back-fill from your live broker via the `india_broker` source (Shoonya/Dhan; requires broker login).
- **Cryptocurrency** via OKX or CCXT/100+ exchanges (free, no API key)
- **China A-shares** via Tushare (token) or AKShare (free fallback)
- **China A-shares** via AKShare / baostock / tencent / sina / eastmoney / mootdx (free, no API key) — `TUSHARE_TOKEN` optional for premium quality
- **Futures, forex, macro** via AKShare (free, no API key)
- **HK & A-share equities** via Futu (broker login required, optional)
- **Local CSV/parquet bars** via the `local` loader (offline, no network)
- **Premium cross-market data** via QVeris (optional API key)
- **Premium US data** via optional-key finnhub / alphavantage / tiingo / fmp (graceful fallback to free sources)
Factors: the Alpha101 and QLib158 zoos are tagged for the `equity_in` universe, so they compute on NSE/BSE bars (the GTJA191 zoo stays China-only). Live/paper India trading uses the Shoonya / Dhan connectors (paper + read-only live; live order placement is structurally disabled because those brokers expose no paper/live switch).
Example workflow:
1. Use `list_skills()` to discover strategy patterns
@@ -87,7 +94,7 @@ Example workflow:
4. Use `backtest()` to run and get metrics (Sharpe, return, drawdown, etc.)
### Multi-Agent Swarm Teams
29 pre-built agent teams for complex research:
30 pre-built agent teams for complex research:
- **Investment Committee**: bull/bear debate → risk review → PM decision
- **Global Equities Desk**: A-share + HK/US + crypto → global strategist
- **Crypto Trading Desk**: funding/basis + liquidation + flow → risk manager
@@ -95,11 +102,21 @@ Example workflow:
- **Macro/Rates/FX Desk**: rates + FX + commodities → macro PM
- **Quant Strategy Desk**: screening → factor research → backtest → risk audit
- **Risk Committee**: drawdown, tail risk, regime analysis
- And 22 more specialized teams
- And 23 more specialized teams
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
### Finance Skills (72)
### Alpha Zoo (462 pre-built alphas)
One-line cross-sectional IC / IR / alive-reversed-dead categorisation across five bundled zoos:
- **qlib158** (154 alphas) — Microsoft Qlib's `Alpha158` feature handler, Apache-2.0 with pinned commit SHA.
- **alpha101** (101 alphas) — Kakushadze (2015) "101 Formulaic Alphas" (arXiv:1601.00991), written from the paper appendix.
- **gtja191** (191 alphas) — Guotai Junan 2014 "191 Short-period Trading Alpha Factors" research report.
- **academic** (12 factors) — Fama-French 5 + Carhart momentum + Jegadeesh reversal + George-Hwang 52-week-high + Amihud illiquidity + Harvey-Siddique skew + Frazzini-Pedersen betting-against-beta (price-based proxies) + a correlation-rewiring stability score (from the in-repo correlation-regime skill).
- **fundamental** (4 factors) — PIT-safe earnings yield, ROE, gross profitability, and asset growth from daily fundamental panels.
Each alpha ships with `__alpha_meta__` (formula LaTeX + theme + universe + warmup + columns required), guarded by an AST purity gate + 300-row lookahead sentinel test. Use the `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI, the `/alpha/*` REST routes (browser at `/alpha-zoo`), or compose multi-factor signals via `ZooSignalEngine.from_zoo(...)`.
### Finance Skills (88)
Comprehensive knowledge base covering:
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC, harmonic, chanlun)
- Quantitative methods (factor research, ML strategy, pair trading, multi-factor)
@@ -112,17 +129,39 @@ Comprehensive knowledge base covering:
Use `load_skill(name)` to access full methodology docs with code templates.
## Available MCP Tools (22)
## Available MCP Tools (54)
| Tool | Description | API Key |
|------|-------------|---------|
| `list_skills` | List all 72 finance skills | None |
| `list_skills` | List all 88 finance skills | None |
| `load_skill` | Load full skill documentation | None |
| `start_research_goal` | Create an auditable research goal | None |
| `get_research_goal` | Read the current research goal | None |
| `add_goal_evidence` | Attach evidence to a research goal | None |
| `update_research_goal_status` | Update goal lifecycle status | None |
| `backtest` | Run vectorized backtest engine | None* |
| `factor_analysis` | IC/IR analysis + layered backtest | None* |
| `analyze_options` | Black-Scholes price + Greeks | None |
| `pattern_recognition` | Detect chart patterns (H&S, double top, etc.) | None |
| `get_market_data` | Fetch OHLCV data across 6 sources (auto-detect + fallback) | None* |
| `get_market_data` | Fetch OHLCV data (auto-detect + ordered fallback across 23 sources) | None* |
| `get_fund_flow` | Capital fund-flow (main/retail net inflow) | None* |
| `get_dragon_tiger` | Dragon-tiger list (龙虎榜) top buyer/seller seats | None* |
| `get_northbound_flow` | Northbound (Stock Connect) net flow | None* |
| `get_margin_trading` | Margin trading & short-selling balances | None* |
| `get_block_trades` | Block-trade (大宗交易) records | None* |
| `get_shareholder_count` | Shareholder-count history per symbol | None* |
| `get_lockup_expiry` | Restricted-share lockup release schedule | None* |
| `get_sector_info` | Sector / industry constituents & performance | None* |
| `get_research_reports` | Sell-side analyst research reports | None* |
| `get_stock_news` | Market & company news headlines | None* |
| `get_sec_filings` | SEC EDGAR filings (10-K/10-Q/8-K, etc.) | None |
| `get_financial_statements` | Income / balance / cash-flow statements | None* |
| `get_options_chain` | Options chain (strikes, IV, OI, Greeks) | None* |
| `get_stock_profile` | Valuation, analyst estimates & institutional holdings (US/HK) | None |
| `screen_market` | Market screener with fundamental/technical filters | None* |
| `search_symbol` | Symbol / ticker search across markets | None |
| `get_macro_series` | FRED macroeconomic series | FRED_API_KEY |
| `iwencai_search` | A-share natural-language research search | IWENCAI_KEY |
| `web_search` | Search the web via DuckDuckGo | None |
| `read_url` | Fetch web page as Markdown | None |
| `read_document` | Extract text from PDF/DOCX/XLSX/PPTX/images | None |
@@ -138,8 +177,18 @@ Use `load_skill(name)` to access full methodology docs with code templates.
| `get_swarm_status` | Poll swarm run status without blocking | None |
| `get_run_result` | Get final report and task summaries | None |
| `list_runs` | List recent swarm runs with metadata | None |
| `reap_stale_runs` | Finalize stale swarm runs | None |
| `retry_run` | Re-run a failed/stale swarm run | LLM key |
| `trading_connections` | List selectable connector profiles | None |
| `trading_select_connection` | Select the default connector profile | None |
| `trading_check` | Check connector readiness | Connector app/OAuth |
| `trading_account` | Read account summary from selected connector | Connector app/OAuth |
| `trading_positions` | Read positions from selected connector | Connector app/OAuth |
| `trading_orders` | Read open orders from selected connector | Connector app/OAuth |
| `trading_quote` | Read a quote snapshot from selected connector | Connector app/OAuth |
| `trading_history` | Read historical bars from selected connector | Connector app/OAuth |
<sub>*A-share symbols require `TUSHARE_TOKEN`. HK/US/crypto are free.</sub>
<sub>*A-share symbols require `TUSHARE_TOKEN`. HK/US/crypto are free. Trading connector rows use the selected connector profile, e.g. IBKR local TWS/Gateway or Robinhood MCP OAuth.</sub>
## Quick Start
@@ -147,7 +196,145 @@ Use `load_skill(name)` to access full methodology docs with code templates.
pip install vibe-trading-ai
```
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `analyze_trade_journal`, `extract_shadow_strategy`, `web_search`, and all 72 skills immediately.
That's it — no API keys needed for HK/US/crypto markets. Start using `backtest`, `get_market_data`, `analyze_options`, `analyze_trade_journal`, `extract_shadow_strategy`, `web_search`, the **Alpha Zoo** (`vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`), and all 88 skills immediately.
## Loading Tools from External MCP Servers
The built-in agent can load tools from your own external MCP servers in addition to its local toolset.
> **Note:** This is the *MCP client* path — the opposite of the MCP plugin listed above. The plugin above makes Vibe-Trading's tools available to your agents. This section lets Vibe-Trading's own agent call tools from *your* servers.
### Setup
Create `~/.vibe-trading/agent.json`:
```json
{
"mcpServers": {
"my-server": {
"command": "uvx",
"args": ["my-mcp-server"],
"toolTimeout": 30,
"enabledTools": ["*"]
}
}
}
```
Ordinary external MCP tools appear automatically in every `vibe-trading run` / `vibe-trading chat` call. They are injected after local tools under stable names: `mcp_<server>_<tool>`. Live-broker MCP servers are consumed through the connector-scoped `trading_*` tools instead of exposing raw `mcp_<broker>_*` tools to the agent.
### Official IBKR MCP read-only probe
Add Interactive Brokers' official MCP endpoint as a read-only external server:
```json
{
"mcpServers": {
"ibkr": {
"type": "streamableHttp",
"url": "https://api.ibkr.com/v1/api/mcp",
"auth": {
"type": "oauth",
"scopes": ["mcp.read"],
"clientName": "Vibe-Trading",
"cacheDir": "~/.vibe-trading/live/ibkr/oauth"
},
"enabledTools": ["*"]
}
}
}
```
Authorize it with `vibe-trading connector authorize ibkr-live-official-mcp-readonly`. The wildcard is accepted
only for this `mcp.read` probe. Generic `trading_account` and `trading_positions`
calls stay disabled until IBKR publishes stable read tool names that Vibe-Trading
can map safely; `mcp.write` requires an explicit tool allowlist and live
order-guard handling. If IBKR issues a pre-registered OAuth client, add
`clientId` and `clientSecret` inside `auth`.
### Trading connector profiles
The public trading surface is connector-first. Choose a connector profile, then
paper/live is just an attribute under that connector.
```bash
pip install "vibe-trading-ai[ibkr]"
vibe-trading connector list
vibe-trading connector use ibkr-paper-local
vibe-trading connector configure ibkr-paper-local --yes
vibe-trading connector check
vibe-trading connector account
vibe-trading connector positions
vibe-trading connector orders
vibe-trading connector quote AAPL
vibe-trading connector history AAPL --duration "30 D" --bar-size "1 day"
```
Default ports are TWS paper `7497`, IB Gateway paper `4002`, TWS live-readonly
`7496`, and IB Gateway live-readonly `4001`.
### Config fields
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `type` | stdio: no, HTTP: yes | inferred only for stdio | Transport type. Use `sse` or `streamableHttp` for URL-based servers. |
| `command` | stdio: yes | — | Executable to launch |
| `args` | no | `[]` | Command arguments |
| `env` | no | `{}` | Extra env vars for the subprocess |
| `url` | HTTP: yes | — | Remote SSE / streamable HTTP endpoint URL |
| `headers` | no | `{}` | Extra HTTP headers for SSE / streamable HTTP servers |
| `toolTimeout` | no | `30` | Seconds before a tool call is cancelled |
| `enabledTools` | no | `["*"]` | Allowlist of remote tool names. `["*"]` enables all |
For URL-based transports, `type` is required. The agent no longer guesses between SSE and streamable HTTP from the URL suffix.
### Per-session override (API)
> **Security — disabled by default.** `mcpServers` defines subprocess `command`/`args`/`env` and is therefore restricted to operator-level trust. API callers **cannot** inject MCP server definitions through `POST /sessions` unless the server operator explicitly opts in.
To enable session-level MCP injection, set the environment variable on the server before starting the agent:
```bash
export ALLOW_SESSION_MCP_SERVERS=1
```
With the opt-in active, pass `mcpServers` inside `session.config` to extend or replace the global config for that session only:
```json
{
"config": {
"mcpServers": {
"research": {
"command": "uvx",
"args": ["research-mcp"],
"enabledTools": ["search"]
}
}
}
}
```
Without `ALLOW_SESSION_MCP_SERVERS=1`, any `mcpServers` key in `session.config` is silently stripped before config loading. The global operator config on disk (`~/.vibe-trading/agent.json`) is always respected regardless of this flag.
### v1 limits
- **Transport:** stdio, SSE, and streamable HTTP.
- **Execution:** serial only. MCP tools never enter the parallel readonly path.
- **Surfaces:** tools only. Resources and prompts are not exposed.
- **Swarm:** MCP tools are excluded from Swarm worker registries in v1.
- **Hot reload:** not supported. Restart the process to pick up config changes.
### Failure handling
| Case | Behavior |
|------|----------|
| Missing config file | falls back to empty config — no MCP servers loaded |
| Invalid config file | logs a warning and falls back to empty config |
| Server fails to start | that server is skipped; local tools and other servers still load |
| Tool call times out | returns a normalized error payload instead of raising |
| Two server names collide after sanitization | deterministic hash suffix appended; operator warning emitted |
## Examples
+265 -974
View File
File diff suppressed because it is too large. Load diff
+47 -6
View File
@@ -7,7 +7,7 @@ data given a set of strategy codes and a data source.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from typing import Any, Optional
import pandas as pd
@@ -42,6 +42,7 @@ def resolve_benchmark(
end_date: str,
interval: str = "1D",
explicit: Optional[str] = None,
loader: Optional[Any] = None,
) -> Optional[BenchmarkResult]:
"""Resolve the appropriate benchmark ticker and fetch its return series.
@@ -52,6 +53,10 @@ def resolve_benchmark(
end_date: Backtest end date.
interval: Bar interval (1m / 5m / 15m / 30m / 1H / 4H / 1D).
explicit: Override ticker (e.g. "SPY" passed via config).
loader: Loader of the configured data source. When given, the
benchmark is fetched through it first, falling back to
yfinance if it yields no data — except ``local``,
which fails closed to keep offline runs offline.
Returns:
BenchmarkResult with return series and total return, or None if no
@@ -61,8 +66,19 @@ def resolve_benchmark(
if ticker is None:
return None
offline = source == "local"
if offline and getattr(loader, "name", None) != source:
# The runtime fallback chain in fetch_data_map() may have swapped in a
# network loader while config["source"] still says local — never fetch
# the benchmark through it. Fail closed instead.
loader = None
try:
bench_df = _fetch_benchmark(ticker, start_date, end_date, interval)
bench_df = _fetch_benchmark(
ticker, start_date, end_date, interval,
loader=loader,
allow_fallback=not offline,
)
except Exception:
return None
@@ -134,11 +150,36 @@ def _fetch_benchmark(
start_date: str,
end_date: str,
interval: str,
loader: Optional[Any] = None,
allow_fallback: bool = True,
) -> pd.DataFrame:
"""Fetch benchmark OHLCV data via yfinance (single symbol, no auth)."""
loader = YfinanceLoader()
result = loader.fetch([ticker], start_date, end_date, interval=interval)
"""Fetch benchmark OHLCV data.
Tries the configured source's loader first (when given). Falls back to
yfinance (single symbol, no auth) when no loader is given or it yields
no data — unless ``allow_fallback`` is False (offline sources fail
closed instead of making a network request).
"""
if loader is not None:
try:
df = _extract_frame(
loader.fetch([ticker], start_date, end_date, interval=interval),
ticker,
)
except Exception:
df = pd.DataFrame()
if not df.empty:
return df
if not allow_fallback:
return pd.DataFrame()
result = YfinanceLoader().fetch([ticker], start_date, end_date, interval=interval)
return _extract_frame(result, ticker)
def _extract_frame(result: Any, ticker: str) -> pd.DataFrame:
"""Normalise a loader fetch result to a single DataFrame."""
if isinstance(result, dict):
df = result.get(ticker)
elif isinstance(result, pd.DataFrame):
@@ -149,4 +190,4 @@ def _fetch_benchmark(
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
return pd.DataFrame()
return df
return df
+293
View File
@@ -0,0 +1,293 @@
"""Cross-asset correlation matrix computation.
Computes pairwise Pearson or Spearman correlation of daily returns
over a configurable lookback window. Used by the /correlation API endpoint.
"""
from __future__ import annotations
import logging
from typing import Dict, Literal
import pandas as pd
import numpy as np
from scipy.stats import spearmanr
logger = logging.getLogger(__name__)
def infer_market(code: str) -> str:
"""Infer market key from a ticker symbol.
Resolution order:
1. Crypto pair spellings (``BTC-USDT``, ``ETH/USD`` …).
2. Explicit exchange suffix — always authoritative (``.HK``, ``.SH``/
``.SZ``/``.BJ``, ``.US``). Bare HK and A-share codes are both purely
numeric, so the suffix is the only reliable disambiguator.
3. Bare numeric codes by digit length: A-share codes are exactly 6 digits
(600000, 000001, 300750, 688981, 830799); HK codes are at most 5
(700, 0700, 9988, 3690). Prefix alone cannot tell them apart — both
markets use leading 0 and 3.
4. Anything else (alphabetic tickers) is a US equity.
"""
code_upper = code.strip().upper()
crypto_suffixes = ("USDT", "BTC", "ETH", "BNB", "SOL", "ADA", "DOGE")
if any(code_upper.endswith(s) for s in crypto_suffixes) or "/" in code:
return "crypto"
if code_upper.endswith(".HK"):
return "hk_equity"
if code_upper.endswith((".SH", ".SZ", ".BJ")):
return "a_share"
if code_upper.endswith(".US"):
return "us_equity"
if code_upper.isdigit():
if len(code_upper) == 6:
return "a_share"
if len(code_upper) <= 5:
return "hk_equity"
return "us_equity"
def _normalize_symbol(code: str, market: str) -> str:
"""Convert a user-supplied code to the project's canonical loader symbol.
The data loaders key US/HK/A-share instruments by an exchange-suffixed
symbol (``AAPL.US``, ``0700.HK``, ``600000.SH``); a bare ticker such as
``AAPL`` or ``600000`` matches no loader and fetches nothing. Crypto pairs
(``BTC-USDT``) are already canonical, and any code that already carries a
``.`` suffix is left untouched.
Args:
code: The raw code as typed by the user (e.g. ``AAPL``, ``600000``).
market: The market key from :func:`infer_market`.
Returns:
The canonical symbol the market's loaders expect.
"""
cleaned = code.strip()
# Crypto pairs and anything already exchange-qualified pass through as-is.
if market == "crypto" or "." in cleaned:
return cleaned
upper = cleaned.upper()
if market == "us_equity":
return f"{upper}.US"
if market == "hk_equity":
return f"{upper}.HK"
if market == "a_share":
# 6xxxxx -> Shanghai; 4xxxxx / 8xxxxx -> Beijing; else (0/3) Shenzhen.
if upper[:1] == "6":
return f"{upper}.SH"
if upper[:1] in ("4", "8"):
return f"{upper}.BJ"
return f"{upper}.SZ"
return cleaned
def _close_series(code: str, df: pd.DataFrame) -> pd.Series:
"""Extract the close-price series from a loader frame, date-indexed and sorted.
Supports ``trade_date`` as the index name, as a column, or a plain
DatetimeIndex — the shapes real loaders return.
"""
if df.empty:
raise ValueError(f"Price series for '{code}' is empty")
if "close" not in df.columns and "close" not in df.index.names:
raise ValueError(f"No 'close' column in price series for '{code}'")
# Support trade_date as index name, column, or a plain DatetimeIndex.
if "trade_date" in df.columns:
ts = df.set_index("trade_date")["close"]
elif "close" in df.columns and (
"trade_date" in df.index.names
or isinstance(df.index, pd.DatetimeIndex)
):
ts = df["close"]
else:
raise ValueError(
f"No trade_date index/column for price series '{code}'"
)
return ts.sort_index()
def _rolling_correlation_matrix(
price_series: Dict[str, pd.DataFrame],
window: int,
method: Literal["pearson", "spearman"],
) -> tuple[list[str], list[list[float]]]:
"""Compute correlation matrix for multiple price series.
Args:
price_series: Mapping of asset code -> DataFrame with a ``close`` column.
window: Rolling window size in days.
method: "pearson" or "spearman".
Returns:
(labels, matrix) where labels is the sorted list of codes and matrix
is a symmetric NxN matrix of correlation coefficients.
"""
if not price_series:
return [], []
codes = sorted(price_series.keys())
# Build a aligned returns DataFrame (row index = date)
returns_frames = []
closes = {}
for code, df in price_series.items():
closes[code] = _close_series(code, df)
for code in codes:
ts = closes[code]
# Normalize to date-only (midnight) so that cross-market assets
# (e.g. crypto via OKX/CCXT at UTC midnight vs US equity via
# yfinance at EDT midnight = 04:00 UTC) align correctly.
ts.index = ts.index.normalize()
rets = ts.pct_change().dropna()
rets.name = code
returns_frames.append(rets)
# Align all series to a common index (inner join)
aligned = pd.concat(returns_frames, axis=1).dropna()
if aligned.empty:
ranges = {
code: f"{closes[code].index.min()} .. {closes[code].index.max()}"
for code in codes
if len(closes[code]) > 0
}
raise ValueError(
f"No overlapping return data between assets. "
f"Date ranges: {ranges}"
)
# Apply the trailing window — only use the last `window` rows of aligned data
if len(aligned) > window:
aligned = aligned.iloc[-window:]
n = len(aligned)
if n < 2:
raise ValueError("Not enough data points to compute correlation")
labels = codes
n_assets = len(labels)
matrix = [[1.0] * n_assets for _ in range(n_assets)]
for i in range(n_assets):
for j in range(i + 1, n_assets):
xi = aligned.iloc[:, i].values
xj = aligned.iloc[:, j].values
if method == "spearman":
corr, _ = spearmanr(xi, xj)
else:
corr = np.corrcoef(xi, xj)[0, 1]
if np.isnan(corr):
corr = 0.0
matrix[i][j] = round(corr, 4)
matrix[j][i] = round(corr, 4)
return labels, matrix
def _fetch_price_series(
codes: list[str],
start_date: str,
end_date: str,
) -> Dict[str, pd.DataFrame]:
"""Fetch daily price frames for each code via the loader fallback chains.
Args:
codes: Asset codes as supplied by the caller (used as result keys).
start_date: Fetch range start, ``YYYY-MM-DD``.
end_date: Fetch range end, ``YYYY-MM-DD``.
Returns:
Mapping of original code -> OHLCV DataFrame; codes no loader could
serve are omitted (with a warning logged).
"""
# Import here to avoid circular
from backtest.loaders import registry
registry._ensure_registered()
price_series: Dict[str, pd.DataFrame] = {}
for code in codes:
market = infer_market(code)
# Loaders key instruments by the canonical exchange-suffixed symbol
# (AAPL.US / 600000.SH); a bare ticker fetches nothing. Fetch under the
# normalized symbol but keep the user's original code as the label.
symbol = _normalize_symbol(code, market)
# Walk the market's full fallback chain until a loader actually
# returns data. A loader can be "available" yet still serve nothing
# (network error, unsupported symbol), so stopping at the first
# available loader — as resolve_loader does — would silently drop
# the asset even when a later loader could serve it.
for name in registry.FALLBACK_CHAINS.get(market, []):
loader_cls = registry.LOADER_REGISTRY.get(name)
if loader_cls is None:
continue
try:
loader = loader_cls()
except Exception as exc:
logger.debug("correlation: loader %s failed to construct: %s", name, exc)
continue
if not loader.is_available():
continue
try:
result = loader.fetch(
codes=[symbol],
start_date=start_date,
end_date=end_date,
interval="1D",
fields=["trade_date", "open", "high", "low", "close", "volume"],
)
except Exception as exc:
logger.warning("correlation: %s fetch via %s failed: %s", symbol, name, exc)
continue
if symbol in result and not result[symbol].empty:
price_series[code] = result[symbol]
break
logger.warning("correlation: %s returned no data via %s", symbol, name)
else:
logger.warning(
"correlation: no loader in the %s chain returned data for %s "
"(normalized from %r)", market, symbol, code,
)
return price_series
def compute_correlation_matrix(
codes: list[str],
days: int = 90,
method: Literal["pearson", "spearman"] = "pearson",
) -> Dict[str, object]:
"""Fetch price data and compute correlation matrix for a list of assets.
Args:
codes: List of asset codes (e.g. ["BTC-USDT", "ETH-USDT", "SPY"]).
days: Lookback window in days (default 90).
method: Correlation method.
Returns:
Dict with keys: labels, matrix, window, method.
"""
from datetime import datetime, timedelta
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days + 60)).strftime("%Y-%m-%d")
price_series = _fetch_price_series(codes, start_date, end_date)
if len(price_series) < 2:
raise ValueError(
f"Could not fetch price data for at least 2 assets. "
f"Fetched: {list(price_series.keys())}"
)
labels, matrix = _rolling_correlation_matrix(price_series, days, method)
return {
"labels": labels,
"matrix": matrix,
"window": days,
"method": method,
}
+116 -2
View File
@@ -1,17 +1,131 @@
"""Extracted per-bar market hooks as pure functions.
"""Extracted per-bar market hooks and symbol-classification helpers.
Both the original engines (CryptoEngine, ForexEngine) and CompositeEngine
call these same functions. Zero duplication — one source of truth.
Also hosts symbol -> market detection helpers shared by ``runner.py`` and
``composite.py``: ``_MARKET_PATTERNS``, ``_detect_market``,
``_is_china_futures``, ``_detect_submarket``. Keep regex tables here so the
truncated-duplicate routing bug (bare ``RB2410`` getting routed to
GlobalFutures because composite.py used a suffix-only check) cannot recur.
"""
from __future__ import annotations
from typing import Dict
import re
from typing import Dict, List
import pandas as pd
from backtest.models import Position
# ── Symbol -> market classification (shared by runner.py + composite.py) ──
_MARKET_PATTERNS = [
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
# India equities: NSE (RELIANCE.NS) / BSE (500325.BO); tickers may carry
# '&' and '-' (e.g. M&M.NS, BAJAJ-AUTO.NS).
(re.compile(r"^[A-Z0-9&.\-]+\.(NS|BO)$", re.I), "india_equity"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
# China futures: product+delivery.exchange (e.g. IF2406.CFFEX, rb2410.SHFE)
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
# Global futures: product+month-code (e.g. ESZ4, CLF25, GCM2025)
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
# Global futures: product+YYMM (e.g. CL2412, ES2503)
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
# Global futures: bare product code with exchange (e.g. ES.CME)
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
# Forex pairs: XXX/YYY or XXXXXX.FX
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
]
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
# Known Chinese-futures product codes — used as a heuristic when a symbol
# lacks an exchange suffix (e.g. bare ``RB2410``, ``IF2406``). Without this
# table composite.py was misrouting such bare codes to GlobalFutures.
# Stored lowercase; ``_is_china_futures`` lowercases the extracted product
# before lookup so callers can pass any case (``RB2410`` and ``rb2410``
# both resolve correctly).
_CN_FUTURES_PRODUCTS = {
"if", "ic", "ih", "im", "t", "tf", "ts", "tl",
"au", "ag", "cu", "al", "zn", "pb", "ni", "sn", "ss",
"rb", "hc", "i", "j", "jm",
"sc", "fu", "lu", "bu", "nr",
"c", "cs", "m", "y", "a", "p", "jd", "lh",
"cf", "sr", "ta", "ma", "ap", "rm", "oi",
"pp", "l", "v", "eg", "eb", "pf", "sa", "fg", "ur",
"si", "lc",
}
def _detect_market(code: str) -> str:
"""Infer market type from symbol format.
Args:
code: Ticker / symbol string.
Returns:
Market type (a_share/us_equity/hk_equity/crypto/futures/forex);
unknown defaults to ``a_share``.
"""
for pattern, market in _MARKET_PATTERNS:
if pattern.match(code):
return market
return "a_share"
def _is_china_futures(code: str) -> bool:
"""Check whether a futures code belongs to a Chinese exchange.
Recognises two forms:
1. ``<product><delivery>.<exchange>`` where exchange is one of
CFFEX/SHFE/DCE/ZCE/INE/GFEX (e.g. ``IF2406.CFFEX``, ``rb2410.SHFE``).
2. Bare ``<product><delivery>`` with no exchange suffix — matched
against ``_CN_FUTURES_PRODUCTS`` (e.g. ``RB2410`` -> True).
Args:
code: Symbol string.
Returns:
True if it looks like a Chinese futures contract.
"""
parts = code.upper().split(".")
if len(parts) == 2:
# Has an exchange suffix — trust it. CN exchange = True, anything
# else = False. Without this guard the product-code heuristic below
# would misclassify global futures whose product letters happen to
# collide with a CN product (e.g. ``M2412.CBOT`` — US soybean meal).
return parts[1] in _CHINA_EXCHANGES
# Bare code (no exchange suffix): fall back to product-code heuristic.
m = re.match(r"([A-Za-z]+)\d+", parts[0])
if m:
product = m.group(1).lower()
if product in _CN_FUTURES_PRODUCTS:
return True
return False
def _detect_submarket(codes: List[str]) -> str:
"""Detect US vs HK from symbol suffixes.
Args:
codes: Instrument codes.
Returns:
``"hk"`` if any code ends with ``.HK``, else ``"us"``.
"""
for code in codes:
if code.upper().endswith(".HK"):
return "hk"
return "us"
# ── Crypto: OKX tiered maintenance margin table (simplified) ──
_TIER_TABLE = [
+596 -96
View File
@@ -11,28 +11,84 @@ from __future__ import annotations
import importlib
import json
import logging
import math
import re as _re
import sys
from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
from backtest.loaders.rsshub_events import (
FeedSpec,
RSSHubEventProvider,
enrich_price_frames_with_events,
feed_specs_from_config,
)
from backtest.loaders.tushare_fundamentals import (
TushareFundamentalProvider,
enrich_price_frames_with_fundamentals,
)
from backtest.metrics import (
by_exit_reason_stats,
by_symbol_stats,
calc_metrics,
calc_trade_turnover_series,
)
def _json_safe_scalar_metrics(metrics: Dict[str, Any]) -> Dict[str, Any]:
"""Scalar metrics for stdout JSON; non-finite floats become null."""
return {
k: (None if isinstance(v, float) and not math.isfinite(v) else v)
for k, v in metrics.items()
if not isinstance(v, dict)
}
from backtest.models import EquitySnapshot, Position, TradeRecord
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _OpenOrder:
"""A fully priced opening order that can be committed atomically."""
symbol: str
direction: int
price: float
size: float
leverage: float
margin: float
commission: float
@property
def cost(self) -> float:
"""Cash consumed by the fill."""
return self.margin + self.commission
def _run_card_data_sources(config: Dict[str, Any], loader: Any) -> List[str]:
"""Return source names for run-card evidence."""
configured = config.get("_run_card_effective_sources")
if isinstance(configured, list):
return [str(source) for source in configured if str(source).strip()]
if isinstance(configured, str) and configured.strip():
return [configured.strip()]
loader_name = getattr(loader, "name", None)
if loader_name:
return [str(loader_name)]
source = config.get("source")
return [str(source)] if source else []
# ─── Market detection (lightweight, for signal alignment only) ───
import re as _re
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
_FOREX_RE = _re.compile(r"^[A-Z]{3}/[A-Z]{3}$|^[A-Z]{6}\.FX$")
@@ -46,6 +102,31 @@ def _detect_market_for_align(code: str) -> str:
return "equity"
# ─── Forward-fill helpers (numpy, avoid pandas overhead) ───
def _ffill_1d(col: np.ndarray, limit: int) -> None:
"""In-place forward-fill a 1D array with limit."""
count = 0
last_valid = np.nan
for i in range(len(col)):
if np.isnan(col[i]):
count += 1
if count <= limit and not np.isnan(last_valid):
col[i] = last_valid
else:
last_valid = col[i]
count = 0
def _ffill_2d(arr: np.ndarray, limit: int = 5) -> np.ndarray:
"""Forward-fill NaN values column-wise with limit."""
out = arr.copy()
for col in range(out.shape[1]):
_ffill_1d(out[:, col], limit)
return out
# ─── Signal alignment (reused from daily_portfolio logic) ───
@@ -69,37 +150,74 @@ def _align(
Returns:
(dates, close_df, positions_df, returns_df)
"""
all_dates: set = set()
for c in codes:
all_dates.update(data_map[c].index)
dates = pd.DatetimeIndex(sorted(all_dates))
# Build unified sorted date index from all symbols' trading calendars
all_idx_arrays = [data_map[c].index.values for c in codes]
merged = np.unique(np.concatenate(all_idx_arrays))
dates = pd.DatetimeIndex(merged)
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
for c in codes:
close[c] = data_map[c]["close"].reindex(dates)
n_dates = len(dates)
n_codes = len(codes)
# Use int64 view for O(log n) searchsorted lookups
dates_i8 = dates.values.view("i8")
# ffill with limit to avoid masking long suspensions (e.g. 3-week halt)
# Cross-market needs larger limit (Chinese New Year can be 9-10 bars)
ffill_limit = 10 if len({_detect_market_for_align(c) for c in codes}) > 1 else 5
close = close.ffill(limit=ffill_limit)
# Build close matrix via numpy direct fill + searchsorted index mapping
close_arr = np.full((n_dates, n_codes), np.nan)
for j, c in enumerate(codes):
series = data_map[c]["close"]
row_idx = np.searchsorted(dates_i8, series.index.values.view("i8"))
close_arr[row_idx, j] = series.values
# Vectorized ffill with limit using pandas (C-optimized internals)
_tmp = pd.DataFrame(close_arr)
close_arr = _tmp.ffill(limit=ffill_limit).values
# Drop symbols that are entirely NaN (no data overlap with date range)
all_nan_cols = [c for c in codes if close[c].isna().all()]
all_nan_mask = np.all(np.isnan(close_arr), axis=0)
all_nan_cols = [codes[j] for j in range(n_codes) if all_nan_mask[j]]
if all_nan_cols:
logger.warning("Symbols dropped (no usable price data): %s", all_nan_cols)
codes = [c for c in codes if c not in all_nan_cols]
keep_mask = ~all_nan_mask
codes = [codes[j] for j in range(n_codes) if keep_mask[j]]
if not codes:
raise ValueError(f"All symbols have no data in the requested date range")
close = close[codes]
raise ValueError("All symbols have no data in the requested date range")
close_arr = close_arr[:, keep_mask]
n_codes = len(codes)
pos = pd.DataFrame(0.0, index=dates, columns=codes)
for c in codes:
# Shift on each symbol's OWN trading calendar, then ffill to unified
own_dates = data_map[c].index
raw = signal_map[c].reindex(own_dates).fillna(0.0).clip(-1.0, 1.0)
shifted = raw.shift(1).fillna(0.0)
pos[c] = shifted.reindex(dates).ffill(limit=ffill_limit).fillna(0.0)
# Build position matrix: shift on each symbol's OWN calendar, then fill
pos_arr = np.full((n_dates, n_codes), np.nan)
for j, c in enumerate(codes):
# Get signal values aligned to own trading calendar.
# copy=True guarantees a writable array: with copy=False, an already
# float64 source returns a read-only view (e.g. pandas copy-on-write),
# which the in-place nan_to_num/clip below would reject.
own_idx = data_map[c].index
sig_vals = signal_map[c].reindex(own_idx).values.astype(np.float64, copy=True)
# fillna(0) + clip in numpy
np.nan_to_num(sig_vals, copy=False, nan=0.0)
np.clip(sig_vals, -1.0, 1.0, out=sig_vals)
# shift(1) + fillna(0): prepend 0, drop last
shifted_vals = np.empty_like(sig_vals)
shifted_vals[0] = 0.0
shifted_vals[1:] = sig_vals[:-1]
# Place into unified grid via searchsorted
row_idx = np.searchsorted(dates_i8, own_idx.values.view("i8"))
pos_arr[row_idx, j] = shifted_vals
# Vectorized ffill with limit using pandas (C-optimized)
_tmp = pd.DataFrame(pos_arr)
# ``.values`` may be a read-only view under pandas copy-on-write; take the
# copy-returning nan_to_num (not in-place) so the fill never writes a
# read-only destination.
pos_arr = np.nan_to_num(_tmp.ffill(limit=ffill_limit).values, nan=0.0)
# Construct DataFrames for return
close = pd.DataFrame(close_arr, index=dates, columns=codes)
pos = pd.DataFrame(pos_arr, index=dates, columns=codes)
ret = close.pct_change().fillna(0.0)
if optimizer is not None:
@@ -132,6 +250,98 @@ def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
return None
def _normalise_fundamental_fields(config: Dict[str, Any]) -> dict[str, list[str]]:
"""Read the optional statement-table field map from backtest config."""
raw_fields = config.get("fundamental_fields")
if raw_fields in (None, {}):
return {}
if not isinstance(raw_fields, dict):
raise ValueError("fundamental_fields must map table names to field-name lists")
normalized: dict[str, list[str]] = {}
for table, fields in raw_fields.items():
if not isinstance(table, str) or not table.strip():
raise ValueError("fundamental_fields table names must be non-empty strings")
if fields is None:
continue
if isinstance(fields, str) or not isinstance(fields, Iterable):
raise ValueError(f"fundamental_fields[{table!r}] must be a list of field names")
field_list = list(fields)
if not field_list:
continue
invalid = [field for field in field_list if not isinstance(field, str) or not field.strip()]
if invalid:
raise ValueError(f"fundamental_fields[{table!r}] contains invalid field names")
normalized[table.strip()] = field_list
return normalized
def _maybe_enrich_fundamentals(
data_map: Dict[str, pd.DataFrame],
config: Dict[str, Any],
) -> Dict[str, pd.DataFrame]:
"""Attach configured Tushare statement fields before signal generation."""
fields_by_table = _normalise_fundamental_fields(config)
if not fields_by_table:
return data_map
try:
provider = TushareFundamentalProvider()
return enrich_price_frames_with_fundamentals(
data_map,
provider,
fields_by_table,
as_of=config.get("end_date", ""),
periods=config.get("fundamental_periods"),
)
except Exception as exc:
raise RuntimeError(
f"fundamental_fields requested but Tushare enrichment failed: {exc}"
) from exc
def _event_feed_specs(config: Dict[str, Any]) -> List[FeedSpec]:
"""Parse the optional ``event_feeds`` feed definitions from backtest config.
``event_feeds`` is a list of feed-definition dicts (there is no built-in
catalogue) — each with ``name``/``route_template``/``event_type`` and an
optional ``code_style``. An empty/absent value means "no event enrichment".
"""
raw_feeds = config.get("event_feeds")
if raw_feeds in (None, [], {}):
return []
if not isinstance(raw_feeds, (list, tuple)):
raise ValueError("event_feeds must be a list of feed definitions")
return feed_specs_from_config(raw_feeds)
def _maybe_enrich_events(
data_map: Dict[str, pd.DataFrame],
config: Dict[str, Any],
) -> Dict[str, pd.DataFrame]:
"""Attach a point-in-time-safe ``event_score`` column before signal generation."""
specs = _event_feed_specs(config)
if not specs:
return data_map
try:
provider = RSSHubEventProvider(feeds=specs)
if not provider.is_available():
raise RuntimeError(f"RSSHub base URL not configured (set ${'RSSHUB_BASE_URL'})")
return enrich_price_frames_with_events(
data_map,
provider,
as_of=config.get("end_date", ""),
decay_lambda=float(config.get("event_decay_lambda", 0.1)),
lookback=int(config.get("event_lookback", 30)),
)
except Exception as exc:
raise RuntimeError(
f"event_feeds requested but RSSHub enrichment failed: {exc}"
) from exc
# ─── Base Engine ───
@@ -238,6 +448,11 @@ class BaseEngine(ABC):
"""Convert target notional exposure to number of units/contracts."""
return target_notional / price
def _leverage_for_symbol(self, symbol: str) -> float:
"""Return leverage used to size and margin one symbol."""
del symbol
return self.default_leverage
# ── Main entry ──
def run_backtest(
@@ -277,9 +492,25 @@ class BaseEngine(ABC):
if not data_map:
print(json.dumps({"error": "No data fetched"}))
sys.exit(1)
data_map = _maybe_enrich_fundamentals(data_map, config)
data_map = _maybe_enrich_events(data_map, config)
# 2. Generate signals
signal_map = signal_engine.generate(data_map)
if not isinstance(signal_map, dict):
print(json.dumps({"error": (
f"SignalEngine.generate() must return Dict[str, pd.Series], "
f"got {type(signal_map).__name__}. "
"Return a dict mapping symbol codes to pandas Series of signals."
)}))
sys.exit(1)
for _code, _sig in signal_map.items():
if not isinstance(_sig, pd.Series):
print(json.dumps({"error": (
f"SignalEngine.generate() returned {type(_sig).__name__} for '{_code}', "
"expected pd.Series. Each value must be a pandas Series with DatetimeIndex."
)}))
sys.exit(1)
valid_codes = sorted(c for c in signal_map if c in data_map)
if not valid_codes:
print(json.dumps({"error": "No valid signals generated"}))
@@ -303,43 +534,63 @@ class BaseEngine(ABC):
index=[s.timestamp for s in self.equity_snapshots],
)
bench_ret = ret_df.mean(axis=1) if ret_df.shape[1] > 0 else pd.Series(0.0, index=dates)
benchmark_metadata = {}
# ── External benchmark fetch ──────────────────────────────────────────
bench_ticker = config.get("benchmark")
if bench_ticker and bench_ticker != "auto":
from backtest.benchmark import resolve_benchmark
bench_source = config.get("source", "yfinance")
bench_result = resolve_benchmark(
strategy_codes=codes,
source=config.get("source", "yfinance"),
source=bench_source,
start_date=config.get("start_date", ""),
end_date=config.get("end_date", ""),
interval=interval,
explicit=bench_ticker,
# Explicit source: fetch the benchmark through its own loader
# (keeps e.g. source=local offline). Auto keeps the yfinance
# default — its loader only wraps the preloaded strategy data.
loader=loader if bench_source != "auto" else None,
)
if bench_result is not None:
bench_ret = bench_result.ret_series.reindex(dates).fillna(0.0)
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
m["benchmark_ticker"] = bench_result.ticker
m["benchmark_return"] = bench_result.total_ret
benchmark_metadata = {
"benchmark_ticker": bench_result.ticker,
"benchmark_return": bench_result.total_ret,
}
# ── External benchmark fetch ──────────────────────────────────────────
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
# 6. Metrics
m = calc_metrics(equity_series, self.trades, self.initial_capital, bars_per_year, bench_ret)
realized_turnover = calc_trade_turnover_series(self.trades, equity_series)
m = calc_metrics(
equity_series,
self.trades,
self.initial_capital,
bars_per_year,
bench_ret,
target_pos,
turnover_series=realized_turnover,
)
m.update(benchmark_metadata)
m["by_symbol"] = by_symbol_stats(self.trades)
m["by_exit_reason"] = by_exit_reason_stats(self.trades)
# 7. Validation (optional — triggered by config["validation"])
if config.get("validation"):
from backtest.validation import run_validation
from backtest.validation import run_validation, write_validation_json
v_results = run_validation(
config, equity_series, self.trades, self.initial_capital, bars_per_year,
)
m["validation"] = v_results
# Write validation.json artifact
v_path = run_dir / "artifacts" / "validation.json"
v_path.write_text(json.dumps(v_results, indent=2, ensure_ascii=False), encoding="utf-8")
# Write validation.json through the shared strict writer so a
# non-finite validation metric is serialized as null rather than an
# invalid bare NaN/Infinity token (matching the standalone
# `python -m backtest.validation` path and run_card). The writer
# also creates the artifacts dir, which step 8 otherwise creates.
write_validation_json(run_dir / "artifacts" / "validation.json", v_results)
# 8. Artifacts
self._write_artifacts(
@@ -347,8 +598,21 @@ class BaseEngine(ABC):
target_pos, m, valid_codes,
)
# Print scalar metrics (skip nested dicts for JSON compat)
print(json.dumps({k: v for k, v in m.items() if not isinstance(v, dict)}, indent=2))
# 9. Trust Layer run card
from backtest.run_card import write_run_card
write_run_card(
run_dir,
config,
m,
data_sources=_run_card_data_sources(config, loader),
strategy_path=run_dir / "code" / "signal_engine.py",
warnings=config.get("content_filter_warnings") or None,
)
# Print scalar metrics (skip nested dicts for JSON compat).
# Explosive annual_return may be +inf; match options/run_card and emit
# null instead of a bare Infinity token (invalid RFC-8259 JSON).
print(json.dumps(_json_safe_scalar_metrics(m), indent=2, allow_nan=False))
return m
# ── Execution loop ──
@@ -362,29 +626,132 @@ class BaseEngine(ABC):
codes: List[str],
) -> None:
"""Bar-by-bar execution with market rule enforcement."""
# Pre-extract numpy arrays for O(1) indexed access instead of DataFrame.at[]
# Explicit column reindex ensures array column order matches codes parameter,
# regardless of DataFrame internal column ordering (which may be alphabetical).
_target_arr = target_pos[codes].values # (n_dates, n_codes) ndarray
_close_arr = close_df[codes].values # (n_dates, n_codes) ndarray
_code_to_col = {c: j for j, c in enumerate(codes)}
# Store as instance attrs for use in _calc_equity / _safe_price
self._close_arr = _close_arr
self._code_to_col = _code_to_col
for i, ts in enumerate(dates):
self._bar_idx = i
# a. Per-bar hooks (funding fees, liquidation checks)
# a. Value the book at prices observable when orders execute.
# Rebalances happen at the bar open, so using close_df[ts] here
# would let the yet-unknown decision-bar close affect order size.
equity = self._calc_open_equity(data_map, close_df, ts)
target_weights: Dict[str, Optional[float]] = {}
for c in codes:
try:
val = _target_arr[i, _code_to_col[c]]
target_weights[c] = float(val) if not np.isnan(val) else 0.0
except Exception as exc:
target_weights[c] = None
logger.warning("Target weight failed for %s at %s: %s", c, ts, exc)
# b. Release capital before opening replacement positions. A
# single mixed close/open pass makes rotations depend on symbol
# iteration order when the new name is visited before the old one.
for c in codes:
target_w = target_weights[c]
current_pos = self.positions.get(c)
if target_w is None or current_pos is None:
continue
target_dir = 1 if target_w > 1e-9 else (-1 if target_w < -1e-9 else 0)
if target_dir == 0 or target_dir != current_pos.direction:
try:
self._rebalance(c, 0.0, data_map.get(c), ts, equity)
except Exception as exc:
logger.warning(
"Rebalance close failed for %s at %s: %s", c, ts, exc
)
# c. Price every opening order before committing any of them. If
# the requested basket does not fit after fees/lot rounding, apply
# one common scale factor to all target weights. This preserves
# portfolio proportions and makes fills independent of input code
# order; sequential cash clipping would privilege the first name.
open_targets: list[tuple[str, float, Optional[pd.DataFrame]]] = []
for c in sorted(codes):
target_w = target_weights[c]
if target_w is None:
continue
target_dir = 1 if target_w > 1e-9 else (-1 if target_w < -1e-9 else 0)
current_pos = self.positions.get(c)
if current_pos is not None and (
target_dir == 0 or target_dir != current_pos.direction
):
continue
if current_pos is None and target_dir != 0:
open_targets.append((c, target_w, data_map.get(c)))
def _plans(scale: float) -> list[_OpenOrder]:
result: list[_OpenOrder] = []
for c, target_w, frame in open_targets:
try:
order = self._plan_open_order(
c, target_w * scale, frame, ts, equity
)
except Exception as exc:
logger.warning(
"Rebalance open plan failed for %s at %s: %s",
c,
ts,
exc,
)
continue
if order is not None:
result.append(order)
return result
planned = _plans(1.0)
if sum(order.cost for order in planned) > self.capital + 1e-9:
low, high = 0.0, 1.0
for _ in range(50):
mid = (low + high) / 2.0
candidate = _plans(mid)
if sum(order.cost for order in candidate) <= self.capital + 1e-9:
low, planned = mid, candidate
else:
high = mid
for order in planned:
self._execute_open_order(order, ts)
# d. Apply close/within-bar hooks after open execution. Hooks use
# the current bar's close for funding, swaps, and liquidation, so
# running them first could liquidate a position that was scheduled
# to exit at the open (or charge a position before it was opened).
for c in codes:
if ts in data_map[c].index:
self.on_bar(c, data_map[c].loc[ts], ts)
# b. Rebalance each symbol to target weight
equity = self._calc_equity(close_df, ts)
for c in codes:
try:
target_w = float(target_pos.at[ts, c]) if ts in target_pos.index else 0.0
self._rebalance(c, target_w, data_map.get(c), ts, equity)
except Exception as exc:
logger.warning("Rebalance failed for %s at %s: %s", c, ts, exc)
# c. Record equity snapshot
# e. Record equity snapshot
snap_equity = self._calc_equity(close_df, ts)
total_unrealized = 0.0
for p in self.positions.values():
cp = self._safe_price(close_df, ts, p.symbol, p.entry_price)
total_unrealized += self._calc_pnl(p.symbol, p.direction, p.size, p.entry_price, cp)
if self.positions and type(self)._calc_pnl is BaseEngine._calc_pnl:
_syms = list(self.positions.keys())
_eps = np.array([p.entry_price for p in self.positions.values()])
_dirs = np.array([p.direction for p in self.positions.values()])
_sizes = np.array([p.size for p in self.positions.values()])
_cps = np.array([
self._safe_price(
close_df, ts, s, ep,
_arr=_close_arr, _row=i, _col=_code_to_col.get(s),
)
for s, ep in zip(_syms, _eps)
])
total_unrealized = float(np.sum(_dirs * _sizes * (_cps - _eps)))
else:
total_unrealized = 0.0
for p in self.positions.values():
cp = self._safe_price(
close_df, ts, p.symbol, p.entry_price,
_arr=_close_arr, _row=i, _col=_code_to_col.get(p.symbol),
)
total_unrealized += self._calc_pnl(p.symbol, p.direction, p.size, p.entry_price, cp)
self.equity_snapshots.append(EquitySnapshot(
timestamp=ts,
capital=self.capital,
@@ -393,18 +760,120 @@ class BaseEngine(ABC):
positions=len(self.positions),
))
# d. Force close all remaining positions
# f. Force close all remaining positions
if len(dates) > 0:
last_ts = dates[-1]
_last_row = len(dates) - 1
for c in list(self.positions.keys()):
price = self._safe_price(close_df, last_ts, c, self.positions[c].entry_price)
self._close_position(c, price, last_ts, "end_of_backtest")
pos = self.positions[c]
mark_price = self._safe_price(
close_df, last_ts, c, pos.entry_price,
_arr=_close_arr, _row=_last_row, _col=_code_to_col.get(c),
)
self._active_symbol = c
exit_price = self.apply_slippage(mark_price, -pos.direction)
self._close_position(c, exit_price, last_ts, "end_of_backtest")
# The final snapshot feeds metrics and artifacts. Replace its
# pre-liquidation mark with post-liquidation cash so terminal
# slippage and exit commission are reflected in reported equity.
if self.equity_snapshots:
self.equity_snapshots[-1] = EquitySnapshot(
timestamp=last_ts,
capital=self.capital,
unrealized=0.0,
equity=self.capital,
positions=0,
)
# Clean up temporary instance attributes
self._close_arr = None
self._code_to_col = None
def _calc_open_equity(
self,
data_map: Dict[str, pd.DataFrame],
close_df: pd.DataFrame,
ts: pd.Timestamp,
) -> float:
"""Value current positions at the execution bar's observable open.
For a symbol that has a bar at ``ts``, its open is the mark available
when next-bar-open orders execute. Symbols without a bar on the
unified calendar retain the aligned close fallback, which is the most
recent price carried by ``_align``.
"""
if not self.positions:
return self.capital
def _calc_equity(self, close_df: pd.DataFrame, ts: pd.Timestamp) -> float:
"""Total equity = free cash + sum(margin + unrealised) per position."""
equity = self.capital
for sym, pos in self.positions.items():
cp = self._safe_price(close_df, ts, sym, pos.entry_price)
_arr = getattr(self, "_close_arr", None)
_row = getattr(self, "_bar_idx", None)
_c2c = getattr(self, "_code_to_col", None)
current_price = self._safe_price(
close_df, ts, sym, pos.entry_price,
_arr=_arr, _row=_row, _col=(_c2c.get(sym) if _c2c else None),
)
frame = data_map.get(sym)
if frame is not None and ts in frame.index:
open_price = frame.loc[ts].get("open")
if (
open_price is not None
and pd.notna(open_price)
and float(open_price) > 0
):
current_price = float(open_price)
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
unrealized = self._calc_pnl(
sym, pos.direction, pos.size, pos.entry_price, current_price
)
equity += margin + unrealized
return equity
def _calc_equity(self, close_df: pd.DataFrame, ts: pd.Timestamp) -> float:
"""Total equity = free cash + sum(margin + unrealised) per position.
Uses vectorized numpy path when _calc_pnl/_calc_margin are not
overridden by a subclass (FuturesBaseEngine, CompositeEngine).
"""
if not self.positions:
return self.capital
_base_pnl = type(self)._calc_pnl is BaseEngine._calc_pnl
_base_margin = type(self)._calc_margin is BaseEngine._calc_margin
# Use array fast-path when available
_arr = getattr(self, "_close_arr", None)
_row = getattr(self, "_bar_idx", None)
_c2c = getattr(self, "_code_to_col", None)
if _base_pnl and _base_margin:
syms = list(self.positions.keys())
sizes = np.array([p.size for p in self.positions.values()])
entry_prices = np.array([p.entry_price for p in self.positions.values()])
directions = np.array([p.direction for p in self.positions.values()])
leverages = np.array([p.leverage for p in self.positions.values()])
current_prices = np.array([
self._safe_price(
close_df, ts, s, ep,
_arr=_arr, _row=_row, _col=(_c2c.get(s) if _c2c else None),
)
for s, ep in zip(syms, entry_prices)
])
margins = sizes * entry_prices / leverages
pnls = directions * sizes * (current_prices - entry_prices)
return self.capital + float(np.sum(margins + pnls))
equity = self.capital
for sym, pos in self.positions.items():
cp = self._safe_price(
close_df, ts, sym, pos.entry_price,
_arr=_arr, _row=_row, _col=(_c2c.get(sym) if _c2c else None),
)
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
unrealized = self._calc_pnl(sym, pos.direction, pos.size, pos.entry_price, cp)
equity += margin + unrealized
@@ -442,50 +911,69 @@ class BaseEngine(ABC):
else:
return # blocked (e.g. limit-down can't sell)
# Open new if target non-zero and no remaining position
if target_dir != 0 and symbol not in self.positions:
if not self.can_execute(symbol, target_dir, bar):
return # blocked (e.g. A-share no-short)
order = self._plan_open_order(symbol, target_weight, df, ts, equity)
if order is not None and order.cost <= self.capital + 1e-9:
self._execute_open_order(order, ts)
open_price = float(bar.get("open", bar.get("close", 0)))
if open_price <= 0:
return
def _plan_open_order(
self,
symbol: str,
target_weight: float,
df: Optional[pd.DataFrame],
ts: pd.Timestamp,
equity: float,
) -> Optional[_OpenOrder]:
"""Price an opening order without mutating portfolio state."""
self._active_symbol = symbol
direction = 1 if target_weight > 1e-9 else (-1 if target_weight < -1e-9 else 0)
if direction == 0 or symbol in self.positions or df is None or ts not in df.index:
return None
bar = df.loc[ts]
if not self.can_execute(symbol, direction, bar):
return None
open_price = float(bar.get("open", bar.get("close", 0)))
if open_price <= 0:
return None
price = self.apply_slippage(open_price, direction)
leverage = self._leverage_for_symbol(symbol)
target_notional = abs(target_weight) * equity * leverage
size = self.round_size(
self._calc_raw_size(symbol, target_notional, price), price
)
if size <= 0:
return None
margin = self._calc_margin(symbol, size, price, leverage)
commission = self.calc_commission(
size, price, direction, is_open=True
)
return _OpenOrder(
symbol=symbol,
direction=direction,
price=price,
size=size,
leverage=leverage,
margin=margin,
commission=commission,
)
slipped = self.apply_slippage(open_price, target_dir)
leverage = self.default_leverage
target_notional = abs(target_weight) * equity * leverage
raw_size = self._calc_raw_size(symbol, target_notional, slipped)
size = self.round_size(raw_size, slipped)
if size <= 0:
return
margin = self._calc_margin(symbol, size, slipped, leverage)
comm = self.calc_commission(size, slipped, target_dir, is_open=True)
# Capital check — reduce if insufficient
if margin + comm > self.capital:
available = self.capital - comm
if available <= 0:
return
size = self.round_size(
self._calc_raw_size(symbol, available * leverage, slipped), slipped,
)
if size <= 0:
return
margin = self._calc_margin(symbol, size, slipped, leverage)
comm = self.calc_commission(size, slipped, target_dir, is_open=True)
self.capital -= (margin + comm)
self.positions[symbol] = Position(
symbol=symbol,
direction=target_dir,
entry_price=slipped,
entry_time=ts,
size=size,
leverage=leverage,
entry_bar_idx=self._bar_idx,
entry_commission=comm,
def _execute_open_order(self, order: _OpenOrder, ts: pd.Timestamp) -> None:
"""Commit a previously priced opening order."""
if order.cost > self.capital + 1e-7:
raise RuntimeError(
f"planned order for {order.symbol} exceeds available capital"
)
self.capital -= order.cost
self.positions[order.symbol] = Position(
symbol=order.symbol,
direction=order.direction,
entry_price=order.price,
entry_time=ts,
size=order.size,
leverage=order.leverage,
entry_bar_idx=self._bar_idx,
entry_commission=order.commission,
)
def _close_position(
self,
@@ -502,6 +990,7 @@ class BaseEngine(ABC):
pnl = self._calc_pnl(symbol, pos.direction, pos.size, pos.entry_price, exit_price)
margin = self._calc_margin(symbol, pos.size, pos.entry_price, pos.leverage)
exit_margin = self._calc_margin(symbol, pos.size, exit_price, pos.leverage)
pnl_pct = pnl / margin * 100 if margin > 1e-9 else 0.0
exit_comm = self.calc_commission(pos.size, exit_price, pos.direction, is_open=False)
@@ -523,6 +1012,8 @@ class BaseEngine(ABC):
exit_reason=reason,
holding_bars=holding_bars,
commission=pos.entry_commission + exit_comm,
entry_margin=margin,
exit_margin=exit_margin,
))
# ── Artifacts ──
@@ -612,8 +1103,17 @@ class BaseEngine(ABC):
ts: pd.Timestamp,
symbol: str,
fallback: float,
*,
_arr: "np.ndarray | None" = None,
_row: "int | None" = None,
_col: "int | None" = None,
) -> float:
"""Get close price with fallback."""
"""Get close price with fallback. Uses array fast-path when available."""
# Fast path: pre-computed array indexing (O(1) vs DataFrame.at hash lookup)
if _arr is not None and _row is not None and _col is not None:
val = _arr[_row, _col]
return float(val) if not np.isnan(val) else fallback
# Original path (backward compatible for subclasses)
if ts in close_df.index and symbol in close_df.columns:
val = close_df.at[ts, symbol]
if pd.notna(val):
+9 -2
View File
@@ -76,8 +76,12 @@ class ChinaAEngine(BaseEngine):
"""Round down to 100-share lots."""
return max(int(raw_size / 100) * 100, 0)
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""A-share fee structure: commission + stamp tax (sell) + transfer fee."""
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""A-share fee structure: commission + stamp tax (sell) + transfer fee.
``_direction`` is unused today — reserved for future asymmetric
long/short fee schedules (margin trading, securities lending).
"""
notional = size * price
# Commission: 万2.5, min ¥5
comm = max(notional * self.commission_rate, self.commission_min)
@@ -113,6 +117,9 @@ def _bar_date(bar: pd.Series):
return None
# Note: china_futures and global_futures have variants that prioritise
# settle/pre_settle (futures-native); see those modules for the
# futures-specific logic.
def _calc_pct_change(bar: pd.Series):
"""Calculate price change percentage from bar data."""
if "pct_chg" in bar.index:
+18 -2
View File
@@ -152,6 +152,7 @@ class ChinaFuturesEngine(FuturesBaseEngine):
config = {**config, "leverage": leverage}
super().__init__(config)
self.slippage_rate: float = config.get("slippage", 0.0005)
self._margin_rate_override = margin_override if margin_override else None
self._commission_override = config.get("commission_override")
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
@@ -191,8 +192,12 @@ class ChinaFuturesEngine(FuturesBaseEngine):
"""Minimum 1 contract, integer lots only."""
return max(int(raw_size), 0)
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""Commission varies by product: fixed per-lot or percentage of notional."""
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""Commission varies by product: fixed per-lot or percentage of notional.
``_direction`` is unused — reserved for future open/close-fee
asymmetry (some products charge different rates for close-today).
"""
if self._commission_override is not None:
return size * price * self._commission_override
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
@@ -239,10 +244,21 @@ class ChinaFuturesEngine(FuturesBaseEngine):
product = _extract_product(symbol)
return _MARGIN_RATE.get(product, 0.10)
def _leverage_for_symbol(self, symbol: str) -> float:
"""Derive leverage from this contract's own margin requirement."""
if self._margin_rate_override is not None:
return 1.0 / float(self._margin_rate_override)
return 1.0 / self.get_margin_rate(symbol)
# ── Helpers ──
# Note: china_a uses close/pre_close-only; global_futures prioritises
# close/pre_close before settle. This China-futures variant prefers
# settle/pre_settle because tushare reports settlement as the canonical
# daily price for domestic contracts. See those modules for the equity /
# global-futures equivalents.
def _calc_pct_change(bar: pd.Series):
"""Calculate bar price change percentage.
+21 -43
View File
@@ -8,53 +8,20 @@ All state (capital, positions, trades) lives in CompositeEngine.
from __future__ import annotations
import re
from typing import Dict, List
import pandas as pd
from backtest.engines.base import BaseEngine
from backtest.engines._market_hooks import (
_detect_market,
_is_china_futures,
calc_crypto_funding_fee,
check_crypto_liquidation,
calc_forex_swap,
)
# ── Market detection (same patterns as runner.py) ──
_MARKET_PATTERNS = [
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
]
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
def _detect_market(code: str) -> str:
"""Infer market type from symbol format."""
for pattern, market in _MARKET_PATTERNS:
if pattern.match(code):
return market
return "a_share"
def _is_china_futures(code: str) -> bool:
"""Check if futures code belongs to a Chinese exchange."""
parts = code.upper().split(".")
return len(parts) == 2 and parts[1] in _CHINA_EXCHANGES
def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]:
"""Instantiate one sub-engine per market type detected in codes."""
markets = {_detect_market(c) for c in codes}
@@ -70,6 +37,9 @@ def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]
elif market == "hk_equity":
from backtest.engines.global_equity import GlobalEquityEngine
engines["hk_equity"] = GlobalEquityEngine(config, market="hk")
elif market == "india_equity":
from backtest.engines.india_equity import IndiaEquityEngine
engines["india_equity"] = IndiaEquityEngine(config)
elif market == "crypto":
from backtest.engines.crypto import CryptoEngine
engines["crypto"] = CryptoEngine(config)
@@ -77,15 +47,13 @@ def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]
from backtest.engines.forex import ForexEngine
engines["forex"] = ForexEngine(config)
elif market == "futures":
china = any(
_is_china_futures(c) for c in codes if _detect_market(c) == "futures"
)
if china:
futures_codes = [c for c in codes if _detect_market(c) == "futures"]
if any(_is_china_futures(c) for c in futures_codes):
from backtest.engines.china_futures import ChinaFuturesEngine
engines["futures"] = ChinaFuturesEngine(config)
else:
engines["china_futures"] = ChinaFuturesEngine(config)
if any(not _is_china_futures(c) for c in futures_codes):
from backtest.engines.global_futures import GlobalFuturesEngine
engines["futures"] = GlobalFuturesEngine(config)
engines["global_futures"] = GlobalFuturesEngine(config)
return engines
@@ -120,7 +88,14 @@ class CompositeEngine(BaseEngine):
def _rule_for(self, symbol: str) -> BaseEngine:
"""Get the sub-engine that provides rules for this symbol."""
market = self._symbol_market.get(symbol, "a_share")
return self._rule_engines[market]
if market == "futures":
market = "china_futures" if _is_china_futures(symbol) else "global_futures"
engine = self._rule_engines.get(market)
if engine is None:
if not self._rule_engines:
raise ValueError("No sub-engines available for composite backtest")
engine = next(iter(self._rule_engines.values()))
return engine
# ── Stateless method dispatch ──
@@ -185,6 +160,9 @@ class CompositeEngine(BaseEngine):
) -> float:
return self._rule_for(symbol)._calc_raw_size(symbol, target_notional, price)
def _leverage_for_symbol(self, symbol: str) -> float:
return self._rule_for(symbol)._leverage_for_symbol(symbol)
# ── Stateful hooks (implemented directly, NO delegation) ──
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
+6 -2
View File
@@ -48,8 +48,12 @@ class CryptoEngine(BaseEngine):
"""Crypto supports fractional sizes, round to 6 decimals."""
return round(max(raw_size, 0.0), 6)
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""Maker/Taker separated. Opens typically hit taker, closes hit maker."""
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""Maker/Taker separated. Opens typically hit taker, closes hit maker.
``_direction`` is unused — reserved for future funding-rate asymmetry
between long/short legs on perp swaps.
"""
rate = self.taker_rate if is_open else self.maker_rate
return size * price * rate
+6 -19
View File
@@ -15,7 +15,9 @@ from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
from backtest.engines._market_hooks import calc_forex_swap
# ``_normalize_symbol`` lives in ``_market_hooks`` (single source of truth);
# re-imported here so external callers (tests) keep their existing import path.
from backtest.engines._market_hooks import _normalize_symbol, calc_forex_swap
# ── Typical spreads in pips (1 pip = 0.0001 for most pairs, 0.01 for JPY) ──
@@ -51,23 +53,6 @@ def _pip_value(symbol: str) -> float:
return 0.01 if quote.upper() == "JPY" else 0.0001
def _normalize_symbol(symbol: str) -> str:
"""Normalize forex symbol to 'XXX/YYY' format.
Args:
symbol: Raw symbol (e.g. 'EURUSD.FX', 'EUR/USD', 'EURUSD').
Returns:
Normalized pair (e.g. 'EUR/USD').
"""
s = symbol.replace(".FX", "").replace(".", "").strip()
if "/" in s:
return s.upper()
if len(s) == 6:
return f"{s[:3]}/{s[3:]}".upper()
return s.upper()
class ForexEngine(BaseEngine):
"""Forex engine for spot / CFD pairs.
@@ -100,11 +85,13 @@ class ForexEngine(BaseEngine):
"""
return max(int(raw_size / 1000) * 1000, 0)
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""Forex: spread is the cost, embedded in slippage. No explicit commission.
Some ECN brokers charge per-lot commission; for simplicity, zero here.
The cost is captured via apply_slippage (half-spread applied to execution).
``_direction`` is unused — reserved for future ECN per-lot fee
modelling (asymmetric long/short funding).
"""
return 0.0
+9 -2
View File
@@ -11,6 +11,9 @@ Market rules:
- Stamp tax 0.1% bilateral + levies
- Lot-size rounding (simplified to 100 shares)
- Higher slippage than US
India (NSE/BSE) is handled by the dedicated ``backtest.engines.india_equity``
``IndiaEquityEngine`` (T+1 delivery, circuit bands, STT/stamp/GST stack).
"""
from __future__ import annotations
@@ -56,8 +59,12 @@ class GlobalEquityEngine(BaseEngine):
return max(int(raw_size / 100) * 100, 0)
return round(max(raw_size, 0.0), 2)
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""US: zero commission. HK: stamp tax + levies."""
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""US: zero commission. HK: stamp tax + levies.
``_direction`` is unused — reserved for future short-borrow fees
(US Reg-T margin, HK SBL costs).
"""
if self.market == "hk":
notional = size * price
comm = notional * self.hk_commission # broker commission
+12 -2
View File
@@ -47,6 +47,7 @@ _MULTIPLIER: dict[str, float] = {
}
# ── Margin per contract (approximate USD, initial margin) ──
# Reference table — future use for margin-call checks. Not yet consumed.
_MARGIN_PER_CONTRACT: dict[str, float] = {
# Equity index
@@ -178,8 +179,12 @@ class GlobalFuturesEngine(FuturesBaseEngine):
"""Integer contracts, minimum 1."""
return max(int(raw_size), 0)
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""Per-contract fixed commission (uses _active_symbol for product lookup)."""
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""Per-contract fixed commission (uses _active_symbol for product lookup).
``_direction`` is unused — reserved for future borrow/financing
asymmetry on short positions.
"""
if self._comm_override is not None:
return size * self._comm_override
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
@@ -215,6 +220,11 @@ class GlobalFuturesEngine(FuturesBaseEngine):
# ── Helpers ──
# Note: china_a uses close/pre_close-only; china_futures prioritises
# settle/pre_settle. This global-futures variant prefers close/pre_close
# because CME data feeds (yfinance/IB) expose continuous close more
# reliably than settlement. See those modules for the equity /
# China-futures equivalents.
def _calc_pct_change(bar: pd.Series):
"""Calculate bar price change percentage.
+145
View File
@@ -0,0 +1,145 @@
"""India equity (NSE / BSE) backtest engine.
Models the Indian cash-equity **delivery** segment on daily bars. Intraday
(MIS) mechanics are not represented by a daily-bar engine, so the defaults
reflect overnight delivery rules; the knobs below let advanced users approximate
intraday behaviour.
Market rules:
- T+1 settlement: shares bought today cannot be sold the same bar (delivery).
- No short selling by default: retail cannot hold overnight short delivery
positions. Set ``allow_short=True`` to model intraday (MIS) shorting.
- Circuit bands: per-scrip price bands vary (2/5/10/20%); the exact band is
not derivable from the symbol alone, so a single configurable band applies
(default ±20%, the widest common band). Set ``price_limit`` to ``0`` /
``None`` to disable.
- Lot size: 1 share for cash equity (F&O lot sizes are not modelled here).
Cost stack (delivery, discount-broker defaults; all config-driven). NOTE: SEBI/
exchange tariffs change periodically — verify ``in_*`` rates against a current
broker schedule before relying on absolute cost figures:
- Brokerage: ₹0 (delivery on discount brokers) [in_brokerage]
- STT: 0.1% on buy + 0.1% on sell (bilateral) [in_stt]
- Exchange transaction charge: NSE ~0.00297% (bilateral) [in_exchange_txn]
- SEBI turnover fee: ₹10/crore = 0.0001% (bilateral) [in_sebi_fee]
- Stamp duty: 0.015% on buy only [in_stamp_duty]
- GST: 18% on (brokerage + exchange txn + SEBI fee) [in_gst]
- DP charge: flat per-scrip on sell (default ₹0) [in_dp_charge]
"""
from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
from backtest.engines.china_a import _calc_pct_change
class IndiaEquityEngine(BaseEngine):
"""NSE / BSE cash-equity (delivery) engine.
Config keys (all optional; defaults shown in the module docstring):
- allow_short: bool, default False
- price_limit: float fraction or None, default 0.20
- slippage: default 0.001
- in_brokerage / in_stt / in_exchange_txn / in_sebi_fee /
in_stamp_duty / in_gst / in_dp_charge
"""
def __init__(self, config: dict):
config = {**config, "leverage": 1.0} # cash delivery: no leverage
super().__init__(config)
self.allow_short: bool = bool(config.get("allow_short", False))
self.price_limit = config.get("price_limit", 0.20)
self.slippage_rate: float = config.get("slippage", 0.001)
# Cost stack
self.in_brokerage: float = config.get("in_brokerage", 0.0)
self.in_stt: float = config.get("in_stt", 0.001)
self.in_exchange_txn: float = config.get("in_exchange_txn", 0.0000297)
self.in_sebi_fee: float = config.get("in_sebi_fee", 0.000001)
self.in_stamp_duty: float = config.get("in_stamp_duty", 0.00015)
self.in_gst: float = config.get("in_gst", 0.18)
self.in_dp_charge: float = config.get("in_dp_charge", 0.0)
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""India delivery execution rules.
Args:
symbol: NSE/BSE symbol (e.g. ``RELIANCE.NS``).
direction: 1 (buy), -1 (short), 0 (sell/close).
bar: Current bar (needs ``close`` + ``pre_close``/``pct_chg`` for
circuit checks).
Returns:
True if the trade is allowed.
"""
# 1. Short selling: blocked unless explicitly modelling intraday (MIS).
if direction == -1 and not self.allow_short:
return False
# 2. T+1: can't sell shares bought today (delivery).
if direction == 0:
pos = self.positions.get(symbol)
if pos is not None:
bar_date = _bar_date(bar)
entry_date = pos.entry_time.date() if hasattr(pos.entry_time, "date") else None
if bar_date is not None and entry_date is not None and bar_date == entry_date:
return False
# 3. Circuit bands (single configurable band; disabled when falsy).
if self.price_limit:
pct_chg = _calc_pct_change(bar)
if pct_chg is not None:
limit = float(self.price_limit)
if direction == 1 and pct_chg >= limit - 0.001:
return False # upper circuit: can't buy
if direction == 0 and pct_chg <= -limit + 0.001:
return False # lower circuit: can't sell
return True
def round_size(self, raw_size: float, price: float) -> float:
"""Cash equity trades in 1-share lots."""
return float(max(int(raw_size), 0))
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""India delivery cost stack (see module docstring).
``_direction`` is unused — reserved for future asymmetric long/short
(intraday MIS) schedules.
"""
notional = size * price
brokerage = notional * self.in_brokerage
exchange_txn = notional * self.in_exchange_txn # bilateral
sebi_fee = notional * self.in_sebi_fee # bilateral
gst = (brokerage + exchange_txn + sebi_fee) * self.in_gst
stt = notional * self.in_stt # bilateral (delivery)
comm = brokerage + exchange_txn + sebi_fee + gst + stt
if is_open:
comm += notional * self.in_stamp_duty # stamp duty: buy-only
else:
comm += self.in_dp_charge # DP charge: sell-only, flat
return comm
def apply_slippage(self, price: float, direction: int) -> float:
"""India slippage (configurable)."""
return price * (1 + direction * self.slippage_rate)
# ── Helpers ──
def _bar_date(bar: pd.Series):
"""Extract date from bar, handling various column names."""
for col in ("trade_date", "date"):
if col in bar.index:
val = bar[col]
if hasattr(val, "date"):
return val.date()
try:
return pd.Timestamp(val).date()
except Exception:
pass
if hasattr(bar, "name") and hasattr(bar.name, "date"):
return bar.name.date()
return None
+170 -67
View File
@@ -16,7 +16,6 @@ Artifacts: equity.csv, metrics.csv, trades.csv, greeks.csv.
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -47,8 +46,9 @@ def bs_price(S: float, K: float, T: float, r: float, sigma: float,
>>> round(bs_price(100, 100, 1.0, 0.05, 0.2, "call"), 2)
10.45
"""
if T <= 0 or sigma <= 0:
# Expired: return intrinsic value
# Non-positive S/K makes log(S/K) undefined; reuse the intrinsic fallback
# (iv_smile_adjustment already soft-guards non-positive S/K).
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
if option_type == "call":
return max(S - K, 0.0)
return max(K - S, 0.0)
@@ -79,7 +79,7 @@ def bs_greeks(S: float, K: float, T: float, r: float, sigma: float,
Returns:
Dict containing delta, gamma, theta, vega.
"""
if T <= 0 or sigma <= 0:
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
intrinsic_call = 1.0 if S > K else 0.0
delta = intrinsic_call if option_type == "call" else intrinsic_call - 1.0
return {"delta": delta, "gamma": 0.0, "theta": 0.0, "vega": 0.0}
@@ -156,36 +156,6 @@ def iv_smile_adjustment(S: float, K: float, base_iv: float,
return max(adj, 0.01)
# --- American option early exercise heuristic (v2) ---
def american_exercise_value(
S: float, K: float, T: float, r: float, sigma: float,
option_type: str = "put",
) -> float:
"""Check whether early exercise is optimal for American options.
For American puts: exercise if intrinsic > BS continuation value.
For American calls on non-dividend stocks: never exercise early.
For American calls with dividends: simplified — exercise if deep ITM
and time value < dividend capture.
Args:
S: Spot price.
K: Strike price.
T: Time to expiry in years.
r: Risk-free rate.
sigma: Volatility.
option_type: "call" or "put".
Returns:
Max of (intrinsic, BS continuation value).
"""
intrinsic = max(K - S, 0.0) if option_type == "put" else max(S - K, 0.0)
continuation = bs_price(S, K, T, r, sigma, option_type)
return max(intrinsic, continuation)
# --- Option positions ---
@@ -290,7 +260,6 @@ def run_options_backtest(
commission = config.get("commission", 0.001)
options_cfg = config.get("options_config", {})
risk_free_rate = options_cfg.get("risk_free_rate", 0.05)
iv_source = options_cfg.get("iv_source", "historical")
contract_multiplier = options_cfg.get("contract_multiplier", 1.0)
exercise_style = options_cfg.get("exercise_style", "european") # v2: "european" or "american"
iv_skew = options_cfg.get("iv_skew", 0.0) # v2: smile skew param (0 = flat)
@@ -464,12 +433,26 @@ def run_options_backtest(
})
elif action == "close":
# Close: find matching position
# Close: find matching position, honoring a partial-close qty.
matched = _find_matching_position(
positions, underlying, leg_type, strike, expiry)
if matched:
pnl = (opt_price - matched.entry_price) * matched.qty * contract_multiplier
abs_close = opt_price * abs(matched.qty) * contract_multiplier
# An explicit leg ``qty`` closes only that many contracts
# (clamped to the open size); a close leg with no ``qty``
# closes the whole lot (legacy behavior). Cash/PnL and the
# remaining position all scale to the amount actually closed
# so a partial close no longer flattens the lot (#577).
requested = leg.get("qty")
full_mag = abs(matched.qty)
close_mag = full_mag if requested is None else min(abs(requested), full_mag)
if close_mag <= 0:
continue
sign = 1 if matched.qty > 0 else -1
closed_qty = sign * close_mag
remaining_qty = matched.qty - closed_qty
pnl = (opt_price - matched.entry_price) * closed_qty * contract_multiplier
abs_close = opt_price * close_mag * contract_multiplier
if matched.qty > 0:
# Long close: sell to recover
cash += abs_close * (1 - commission)
@@ -485,11 +468,24 @@ def run_options_backtest(
"expiry": expiry,
"side": "close",
"price": round(opt_price, 4),
"qty": matched.qty,
"qty": closed_qty,
"pnl": round(pnl, 4),
"entry_date": matched.entry_date,
})
positions.remove(matched)
if abs(remaining_qty) < 1e-9:
positions.remove(matched)
else:
# Reduce the open lot to its remainder instead of
# removing it (new object; positions stay immutable).
positions[positions.index(matched)] = OptionPosition(
option_type=matched.option_type,
strike=matched.strike,
expiry=matched.expiry,
qty=remaining_qty,
entry_price=matched.entry_price,
entry_date=matched.entry_date,
underlying_code=matched.underlying_code,
)
# 4. Compute portfolio mark-to-market value and Greeks
portfolio_value = cash
@@ -554,7 +550,17 @@ def run_options_backtest(
pd.DataFrame(greeks_records).to_csv(out / "greeks.csv", index=False)
pd.DataFrame([metrics]).to_csv(out / "metrics.csv", index=False)
print(json.dumps(metrics, indent=2))
from backtest.run_card import write_run_card
write_run_card(
run_dir,
config,
metrics,
data_sources=[str(getattr(loader, "name", config.get("source", "")))],
strategy_path=run_dir / "code" / "signal_engine.py",
warnings=config.get("content_filter_warnings") or None,
)
print(json.dumps(metrics, indent=2, allow_nan=False))
return metrics
@@ -607,34 +613,130 @@ def _calc_options_metrics(
Returns:
Metrics dictionary.
"""
warnings: List[str] = []
n = len(equity)
equity_vals = pd.to_numeric(equity, errors="coerce").astype(float)
path_is_finite = bool(n and np.isfinite(equity_vals.to_numpy()).all())
final_raw: float | None = None
final_value: float | None = None
if n:
terminal = float(equity_vals.iloc[-1])
if np.isfinite(terminal):
final_raw = terminal
final_value = round(terminal, 2)
else:
warnings.append(
"Final equity is non-finite; final and return metrics are undefined."
)
else:
warnings.append(
"No equity observations were produced; equity metrics are undefined."
)
valid_initial_cash = np.isfinite(initial_cash) and initial_cash > 0
total_ret: float | None = None
if final_raw is not None and valid_initial_cash:
total_ret = final_raw / float(initial_cash) - 1
elif final_raw is not None:
warnings.append(
"Total return is undefined because initial cash is not positive and finite."
)
ann_ret: float | None = None
if n < 2:
return {
"final_value": initial_cash, "total_return": 0, "annual_return": 0,
"max_drawdown": 0, "sharpe": 0, "calmar": 0, "sortino": 0,
"trade_count": len(trades), "win_rate": 0, "profit_loss_ratio": 0,
}
warnings.append("Annual return requires at least two equity observations.")
elif total_ret is None:
warnings.append(
"Annual return is undefined because total return is unavailable."
)
elif final_raw is not None and final_raw < 0:
warnings.append("Annual return is undefined when final equity is negative.")
elif bars_per_year <= 0:
warnings.append(
"Annual return is undefined because bars_per_year is not positive."
)
else:
growth = final_raw / float(initial_cash)
# Explosive paths (e.g. 1m bars) can OverflowError before isfinite.
try:
candidate = float(growth ** (bars_per_year / (n - 1)) - 1)
except OverflowError:
candidate = float("inf")
if np.isfinite(candidate):
ann_ret = candidate
else:
warnings.append("Annual return is non-finite for this equity path.")
equity_vals = equity.astype(float)
returns = equity_vals.pct_change().fillna(0.0)
returns: pd.Series | None = None
if n >= 2 and path_is_finite:
candidate_returns = equity_vals.pct_change(fill_method=None).iloc[1:]
if np.isfinite(candidate_returns.to_numpy()).all():
returns = candidate_returns
else:
warnings.append(
"Risk ratios are undefined because equity returns are non-finite."
)
elif n >= 2:
warnings.append(
"Path-dependent metrics are undefined because equity contains non-finite values."
)
total_ret = float(equity_vals.iloc[-1] / initial_cash - 1)
ann_ret = float((1 + total_ret) ** (bars_per_year / max(n, 1)) - 1)
max_dd: float | None = None
if path_is_finite:
peak = equity_vals.cummax()
if bool((peak > 0).all()):
dd = (equity_vals - peak) / peak
max_dd = float(dd.min())
else:
warnings.append(
"Maximum drawdown is undefined because peak equity is not positive."
)
vol = float(returns.std())
sharpe = float(returns.mean() / (vol + 1e-10) * np.sqrt(bars_per_year))
sharpe: float | None = None
if returns is not None and len(returns) > 1 and bars_per_year > 0:
vol = float(returns.std())
if np.isfinite(vol) and vol > 1e-12:
sharpe = float(returns.mean() / vol * np.sqrt(bars_per_year))
else:
warnings.append(
"Sharpe ratio is undefined because return volatility is zero."
)
elif bars_per_year <= 0:
warnings.append("Sharpe ratio requires a positive bars_per_year value.")
else:
warnings.append("Sharpe ratio requires at least two finite returns.")
peak = equity_vals.cummax()
dd = (equity_vals - peak) / peak.replace(0, 1)
max_dd = float(dd.min())
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
calmar: float | None = None
if ann_ret is not None and max_dd is not None and abs(max_dd) > 1e-12:
calmar = ann_ret / abs(max_dd)
else:
warnings.append(
"Calmar ratio requires a defined annual return and a nonzero drawdown."
)
downside = returns[returns < 0]
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
sortino = float(returns.mean() / (downside_std + 1e-10) * np.sqrt(bars_per_year))
sortino: float | None = None
if returns is not None and bars_per_year > 0:
downside = returns[returns < 0]
if len(downside) > 1:
downside_std = float(downside.std())
if np.isfinite(downside_std) and downside_std > 1e-12:
sortino = float(returns.mean() / downside_std * np.sqrt(bars_per_year))
if sortino is None:
warnings.append(
"Sortino ratio requires at least two varying downside returns."
)
elif bars_per_year <= 0:
warnings.append("Sortino ratio requires a positive bars_per_year value.")
else:
warnings.append("Sortino ratio requires finite returns.")
# Trade statistics
closed_pnl = [t["pnl"] for t in trades if t.get("pnl", 0) != 0]
closed_pnl = [
float(t["pnl"])
for t in trades
if t.get("pnl", 0) != 0 and np.isfinite(float(t["pnl"]))
]
wins = [p for p in closed_pnl if p > 0]
losses = [p for p in closed_pnl if p < 0]
win_rate = len(wins) / len(closed_pnl) if closed_pnl else 0.0
@@ -643,14 +745,15 @@ def _calc_options_metrics(
pl_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
return {
"final_value": round(float(equity_vals.iloc[-1]), 2),
"total_return": round(total_ret, 6),
"annual_return": round(ann_ret, 6),
"max_drawdown": round(max_dd, 6),
"sharpe": round(sharpe, 4),
"calmar": round(calmar, 4),
"sortino": round(sortino, 4),
"final_value": final_value,
"total_return": round(total_ret, 6) if total_ret is not None else None,
"annual_return": round(ann_ret, 6) if ann_ret is not None else None,
"max_drawdown": round(max_dd, 6) if max_dd is not None else None,
"sharpe": round(sharpe, 4) if sharpe is not None else None,
"calmar": round(calmar, 4) if calmar is not None else None,
"sortino": round(sortino, 4) if sortino is not None else None,
"trade_count": len(trades),
"win_rate": round(win_rate, 4),
"profit_loss_ratio": round(pl_ratio, 4),
"warnings": warnings,
}
@@ -0,0 +1,226 @@
"""Unified fundamental field schema and SEC XBRL concept aliases.
This module is deliberately metadata-only. Callers use ``SEC_CONCEPT_MAP`` to
extract sparse reported facts; when no concept hits, the caller must emit NaN
for that field and log the missing alias coverage. This module does not log or
fabricate fallback values.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Literal
import pandas as pd
FieldKind = Literal["raw", "derived"]
RawFieldSpec = dict[str, object]
DerivedCompute = Callable[[dict[str, pd.Series]], pd.Series]
DerivedFieldSpec = dict[str, object]
def _safe_divide(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
"""Divide two aligned series, returning NaN where the denominator is zero."""
clean_denominator = denominator.astype("float64").mask(denominator == 0)
return numerator.astype("float64") / clean_denominator
RAW_FIELDS: dict[str, RawFieldSpec] = {
"revenue": {
"statement": "IS",
"description": "Revenue / net sales.",
},
"cogs": {
"statement": "IS",
"description": "Cost of goods sold or cost of revenue.",
},
"gross_profit": {
"statement": "IS",
"description": "Gross profit; derived as revenue minus cogs when not directly reported.",
"dependencies": ["revenue", "cogs"],
"compute": lambda data: data["revenue"] - data["cogs"],
},
"operating_income": {
"statement": "IS",
"description": "Operating income or loss.",
},
"net_income": {
"statement": "IS",
"description": "Net income or loss attributable to the reporting entity.",
},
"total_assets": {
"statement": "BS",
"description": "Total assets.",
},
"total_equity": {
"statement": "BS",
"description": "Total shareholders' equity.",
},
"total_debt": {
"statement": "BS",
"description": "Interest-bearing debt; aggregate debt concept when present, otherwise loader may union current and noncurrent debt concepts.",
},
"cash": {
"statement": "BS",
"description": "Cash and cash equivalents.",
},
"shares_diluted": {
"statement": "IS/cover",
"description": "Weighted-average diluted shares outstanding.",
},
"cfo": {
"statement": "CF",
"description": "Net cash provided by or used in operating activities.",
},
"capex": {
"statement": "CF",
"description": "Capital expenditures for property, plant, equipment, and similar productive assets.",
},
}
# Ordered by priority. SEC extraction should union concepts across aliases, then
# keep the first filed value per period_end for PIT safety. The revenue order is
# intentionally new-standard first: recent AAPL filings report revenue under
# RevenueFromContractWithCustomer... rather than the old Revenues concept.
SEC_CONCEPT_MAP: dict[str, list[str]] = {
"revenue": [
"RevenueFromContractWithCustomerExcludingAssessedTax",
"RevenueFromContractWithCustomerIncludingAssessedTax",
"Revenues",
"SalesRevenueNet",
],
"cogs": [
"CostOfGoodsAndServicesSold",
"CostOfRevenue",
"CostOfGoodsSold",
],
"gross_profit": [
"GrossProfit",
],
"operating_income": [
"OperatingIncomeLoss",
],
"net_income": [
"NetIncomeLoss",
"ProfitLoss",
],
"total_assets": [
"Assets",
],
"total_equity": [
"StockholdersEquity",
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
],
# US-GAAP has both aggregate and split debt concepts. Prefer aggregate debt
# when reported; otherwise a future loader should combine current and
# noncurrent concepts instead of treating one leg as total debt.
"total_debt": [
"LongTermDebtAndFinanceLeaseObligations",
"LongTermDebtAndCapitalLeaseObligations",
"LongTermDebt",
"LongTermDebtNoncurrent",
"LongTermDebtCurrent",
"DebtCurrent",
"ShortTermBorrowings",
],
# Cash concept choice varies by industry and restricted-cash presentation.
# The first concept is the common operating-company aggregate; the rest keep
# banks and restricted-cash filers from hard-missing in Phase 1.
"cash": [
"CashAndCashEquivalentsAtCarryingValue",
"CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
"Cash",
"CashAndDueFromBanks",
],
"shares_diluted": [
"WeightedAverageNumberOfDilutedSharesOutstanding",
"WeightedAverageDilutedSharesOutstanding",
],
"cfo": [
"NetCashProvidedByUsedInOperatingActivities",
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations",
],
# Capex is usually reported as PPE purchases. Some filers include broader
# productive-asset or PPE-plus-intangibles concepts; they are lower priority
# because they may not be strictly comparable to plain PPE capex.
"capex": [
"PaymentsToAcquirePropertyPlantAndEquipment",
"PaymentsToAcquireProductiveAssets",
"PaymentsToAcquirePropertyPlantAndEquipmentAndIntangibleAssets",
"CapitalExpendituresIncurredButNotYetPaid",
],
}
DERIVED_FIELDS: dict[str, DerivedFieldSpec] = {
"roe": {
"dependencies": ["net_income", "total_equity"],
"compute": lambda data: _safe_divide(data["net_income"], data["total_equity"]),
},
"roa": {
"dependencies": ["net_income", "total_assets"],
"compute": lambda data: _safe_divide(data["net_income"], data["total_assets"]),
},
"gross_profitability": {
"dependencies": ["gross_profit", "total_assets"],
"compute": lambda data: _safe_divide(data["gross_profit"], data["total_assets"]),
},
# Annual semantics: input is one observation per annual period_end, sorted by
# period_end. Growth is period-over-period, value / value.shift(1) - 1.
# Quarterly or TTM callers must pre-aggregate before applying this formula.
"asset_growth": {
"dependencies": ["total_assets"],
"compute": lambda data: data["total_assets"].astype("float64")
/ data["total_assets"].astype("float64").shift(1)
- 1,
},
"accruals": {
"dependencies": ["net_income", "cfo", "total_assets"],
"compute": lambda data: _safe_divide(
data["net_income"] - data["cfo"],
data["total_assets"],
),
},
"leverage": {
"dependencies": ["total_debt", "total_equity"],
"compute": lambda data: _safe_divide(data["total_debt"], data["total_equity"]),
},
}
# Price-dependent value factors need market_cap = price * shares and therefore
# live in factor files after fundamental and price panels have been aligned.
# Do not add earnings_yield or book_to_market to DERIVED_FIELDS here.
def resolve_field(field: str) -> tuple[FieldKind, RawFieldSpec | DerivedFieldSpec]:
"""Resolve a supported fundamental field to its schema entry.
Args:
field: Unified fundamental field name.
Returns:
A ``("raw", spec)`` or ``("derived", spec)`` tuple.
Raises:
ValueError: If ``field`` is not part of the unified schema.
"""
if field in RAW_FIELDS:
return "raw", RAW_FIELDS[field]
if field in DERIVED_FIELDS:
return "derived", DERIVED_FIELDS[field]
raise ValueError(f"unknown fundamental field: {field}")
def list_supported_fields() -> list[str]:
"""Return all supported raw and schema-derived fundamental field names."""
return sorted(set(RAW_FIELDS) | set(DERIVED_FIELDS))
__all__ = [
"DERIVED_FIELDS",
"RAW_FIELDS",
"SEC_CONCEPT_MAP",
"list_supported_fields",
"resolve_field",
]
+179
View File
@@ -0,0 +1,179 @@
"""Shared HTTP helpers for direct-API loaders: per-host throttling + JSON/CSV GET.
Several free providers — Eastmoney most notably — rate-limit by source IP and
will temporarily ban a client that bursts requests. Rather than scatter sleep
calls across loaders, every ban-prone call routes through :func:`throttled_get`
here, which enforces a minimum spacing between consecutive requests to the same
*host bucket* (plus a little jitter so concurrent workers don't lock-step) and
reuses one :class:`requests.Session` per process so TCP/TLS setup is amortized.
This module is intentionally provider-agnostic: it knows nothing about
Eastmoney/Sina/Stooq field layouts, only how to space requests politely. A
loader picks its own ``host_key`` and ``min_interval`` and stays ignorant of
the locking mechanics.
All spacing is best-effort and process-local — it does not coordinate across
machines. For batch jobs raise the relevant ``*_MIN_INTERVAL`` env var.
"""
from __future__ import annotations
import logging
import random
import threading
import time
from typing import Any
import requests
from backtest.loaders.base import positive_env_float
logger = logging.getLogger(__name__)
# Default User-Agent. Many free quote endpoints reject the bare urllib/requests
# UA, so we present a normal desktop browser string. Loaders may override.
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
)
# Upper bound on the random jitter (seconds) added on top of the configured
# minimum interval, so parallel callers de-synchronize instead of all firing
# the instant the interval elapses.
_JITTER_MAX_S = 0.4
class HostThrottle:
"""Process-wide minimum-spacing gate keyed by an arbitrary host bucket.
One instance guards all callers; ``wait(bucket, min_interval)`` blocks until
at least ``min_interval`` seconds (plus jitter) have elapsed since the last
request tagged with the same ``bucket``. The lock is held only for the
bookkeeping arithmetic, not across the sleep, so distinct buckets never block
one another.
"""
def __init__(self) -> None:
self._last: dict[str, float] = {}
self._lock = threading.Lock()
def wait(self, bucket: str, min_interval: float) -> None:
"""Block until ``bucket`` is allowed to fire again, then record the slot.
The *reserved fire time* — jitter included — is what gets stored, so the
next caller spaces off this caller's actual fire instant rather than an
earlier un-jittered one. This keeps consecutive requests at least
``min_interval`` apart even when many callers burst concurrently (the
exact scenario the throttle exists for); the jitter only ever pushes a
slot later, never earlier.
"""
if min_interval <= 0:
return
with self._lock:
now = time.monotonic()
last = self._last.get(bucket)
if last is None or now >= last + min_interval:
# Slot is free right now — fire immediately, no jitter needed.
fire_at = now
else:
# Chain off the previous reservation and add jitter to desync
# concurrent callers, baking the jitter into the stored slot.
fire_at = last + min_interval + random.uniform(0.0, _JITTER_MAX_S)
self._last[bucket] = fire_at
sleep_for = fire_at - time.monotonic()
if sleep_for > 0:
time.sleep(sleep_for)
# One shared gate for the whole process.
_THROTTLE = HostThrottle()
# Per-process session reuse, keyed by host bucket so different providers keep
# independent connection pools and cookie jars.
_SESSIONS: dict[str, requests.Session] = {}
_SESSIONS_LOCK = threading.Lock()
def _session_for(bucket: str) -> requests.Session:
with _SESSIONS_LOCK:
session = _SESSIONS.get(bucket)
if session is None:
session = requests.Session()
_SESSIONS[bucket] = session
return session
def resolve_min_interval(env_name: str, default: float) -> float:
"""Resolve a per-provider minimum request interval from the environment.
Args:
env_name: Env var carrying an override in seconds (e.g.
``VIBE_TRADING_EASTMONEY_MIN_INTERVAL``).
default: Fallback interval when the env var is absent or invalid.
Returns:
The override when it parses to a positive float, else ``default``.
"""
return positive_env_float(env_name, default)
def throttled_get(
url: str,
*,
host_key: str,
min_interval: float,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float = 15.0,
) -> requests.Response:
"""GET ``url`` after waiting out the per-host minimum interval.
Args:
url: Fully-qualified request URL.
host_key: Throttle/session bucket. All calls sharing a key are spaced
by ``min_interval`` and reuse one session.
min_interval: Minimum seconds between consecutive calls to ``host_key``.
params: Optional query parameters.
headers: Optional headers merged over the default browser UA.
timeout: Per-request socket timeout in seconds.
Returns:
The :class:`requests.Response`; the caller decides how to parse it.
Raises:
requests.RequestException: Propagated unchanged for the caller's retry
policy to classify as transient.
"""
merged_headers = {"User-Agent": DEFAULT_USER_AGENT}
if headers:
merged_headers.update(headers)
_THROTTLE.wait(host_key, min_interval)
session = _session_for(host_key)
return session.get(url, params=params, headers=merged_headers, timeout=timeout)
def throttled_get_json(
url: str,
*,
host_key: str,
min_interval: float,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float = 15.0,
) -> Any:
"""Throttled GET that decodes the response body as JSON.
Same contract as :func:`throttled_get`, plus ``response.raise_for_status()``
and ``response.json()``. A non-2xx status or undecodable body raises, which
the caller's bounded-retry wrapper treats as transient.
"""
response = throttled_get(
url,
host_key=host_key,
min_interval=min_interval,
params=params,
headers=headers,
timeout=timeout,
)
response.raise_for_status()
return response.json()
+20
View File
@@ -0,0 +1,20 @@
"""Shared symbol-type detection utilities for loaders.
Exchange-listed ETF / LOF prefix codes (same pattern across loaders):
SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
"""
from __future__ import annotations
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
def _is_etf_listed(code: str) -> bool:
"""Detect exchange-listed ETF / LOF symbols (e.g. 510050.SH, 159915.SZ)."""
upper = code.upper()
if not upper.endswith((".SH", ".SZ")):
return False
digits = upper.split(".")[0]
if len(digits) != 6 or not digits.isdigit():
return False
return digits[:2] in _ETF_PREFIXES
+15 -19
View File
@@ -11,7 +11,8 @@ from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders._symbol_utils import _is_etf_listed
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
@@ -39,23 +40,8 @@ def _is_crypto(code: str) -> bool:
return "-USDT" in code.upper() or "/USDT" in code.upper()
# Exchange-listed ETF / LOF prefix codes:
# SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
# Issue #50 — these symbols look like A-shares (.SH / .SZ) but stock_zh_a_hist
# can't price them; route through fund_etf_hist_sina instead.
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
def _is_etf_listed(code: str) -> bool:
"""Detect exchange-listed ETF / LOF symbols (e.g. 518880.SH, 159915.SZ)."""
upper = code.upper()
if not upper.endswith((".SH", ".SZ")):
return False
digits = upper.split(".")[0]
if len(digits) != 6 or not digits.isdigit():
return False
return digits[:2] in _ETF_PREFIXES
def _is_forex(code: str) -> bool:
"""Detect forex pairs by matching against AKShare's symbol_market_map.
@@ -63,7 +49,9 @@ def _is_forex(code: str) -> bool:
Issue #54 — forex symbols (EURUSD, GBPUSD, etc.) have no exchange suffix
and previously fell through to the A-share endpoint.
"""
upper = code.upper().removesuffix(".FX")
# Accept the canonical slash form (EUR/USD) too, so the forex fallback
# chain (mt5 → akshare) actually engages for project-style codes.
upper = code.upper().removesuffix(".FX").replace("/", "")
try:
from akshare.forex.cons import symbol_market_map
except Exception:
@@ -116,7 +104,15 @@ class DataLoader:
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = self._fetch_one(code, start_date, end_date, interval)
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date, interval),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
@@ -204,7 +200,7 @@ class DataLoader:
— note ``最新价`` (latest) plays the role of close. Volume isn't reported,
so we synthesize a zero column to satisfy the OHLCV contract.
"""
symbol = code.upper().removesuffix(".FX")
symbol = code.upper().removesuffix(".FX").replace("/", "")
df = ak.forex_hist_em(symbol=symbol)
if df is None or df.empty:
return None
@@ -0,0 +1,251 @@
"""Alpha Vantage loader: US-equity daily OHLCV via the free key-gated REST API.
Alpha Vantage serves daily bars at ``/query?function=TIME_SERIES_DAILY`` and
gates access behind a free API key (env ``ALPHAVANTAGE_API_KEY``). The endpoint
rate-limits per IP, so every request routes through
:mod:`backtest.loaders._http` for per-host throttling and session reuse rather
than calling :mod:`requests` directly.
API format::
https://www.alphavantage.co/query?function=TIME_SERIES_DAILY
&symbol=AAPL&outputsize=full&apikey=<KEY>
The JSON payload nests the bars under ``"Time Series (Daily)"`` keyed by
``YYYY-MM-DD`` date strings, each mapping to numbered OHLCV fields
(``"1. open"`` .. ``"5. volume"``). Throttle/quota responses arrive as a plain
``"Note"`` / ``"Information"`` envelope, and bad symbols as ``"Error Message"``;
all three are surfaced as a transient-classified error so one symbol's failure
never aborts the batch.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders._http import resolve_min_interval, throttled_get_json
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_BASE_URL = "https://www.alphavantage.co/query"
# Throttle/session bucket shared by all Alpha Vantage calls. The free tier is
# quota-limited per key/IP, so requests are spaced out by default.
_HOST_KEY = "alphavantage"
_MIN_INTERVAL_ENV = "VIBE_TRADING_ALPHAVANTAGE_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL = 1.0
_API_KEY_ENV = "ALPHAVANTAGE_API_KEY"
# Env values that are present-but-meaningless and must be treated as absent.
_API_KEY_PLACEHOLDERS = {"", "your-alphavantage-api-key", "demo"}
# Bars live under this top-level key; the per-bar OHLCV fields are numbered.
_TIME_SERIES_KEY = "Time Series (Daily)"
_FIELD_MAP = {
"open": "1. open",
"high": "2. high",
"low": "3. low",
"close": "4. close",
"volume": "5. volume",
}
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
def _resolve_api_key() -> str:
"""Return the configured Alpha Vantage API key, or empty when unset.
Returns:
The trimmed key, or ``""`` when the env var is absent or a placeholder.
"""
from src.config.accessor import get_env_config
key = get_env_config().data.alphavantage_api_key.strip()
return "" if key in _API_KEY_PLACEHOLDERS else key
def _min_interval() -> float:
"""Resolve the per-call minimum Alpha Vantage request spacing in seconds."""
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL)
def _extract_provider_message(payload: object) -> Optional[str]:
"""Return any rate-limit / error envelope message from a payload.
Alpha Vantage signals quota exhaustion and bad requests in-band with a 200
status, using one of three string envelopes instead of the time series.
Args:
payload: The decoded JSON body.
Returns:
The envelope message when present, else ``None``.
"""
if not isinstance(payload, dict):
return None
for key in ("Error Message", "Note", "Information"):
message = payload.get(key)
if isinstance(message, str) and message.strip():
return message.strip()
return None
@register
class DataLoader:
"""Alpha Vantage US-equity daily OHLCV loader (key-gated REST, throttled)."""
name = "alphavantage"
markets = {"us_equity"}
requires_auth = True
def is_available(self) -> bool:
"""Available when a non-placeholder ``ALPHAVANTAGE_API_KEY`` is set."""
return bool(_resolve_api_key())
def __init__(self) -> None:
pass
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch daily OHLCV bars for ``codes`` within ``[start_date, end_date]``.
Args:
codes: US-equity tickers (e.g. ``["AAPL", "MSFT"]``).
start_date: Inclusive start date ``YYYY-MM-DD``.
end_date: Inclusive end date ``YYYY-MM-DD``.
interval: Bar interval; only ``"1D"`` (daily) is supported.
fields: Ignored — the full OHLCV column set is always returned.
Returns:
Mapping ``{symbol: DataFrame}`` with a ``trade_date`` DatetimeIndex
and float ``open/high/low/close/volume`` columns. Symbols that fail
or carry no in-range bars are omitted.
"""
validate_date_range(start_date, end_date)
api_key = _resolve_api_key()
if not api_key:
logger.warning("alphavantage skipped: %s not set", _API_KEY_ENV)
return {}
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(
code, start_date, end_date, api_key
),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort the batch
logger.warning("alphavantage failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, api_key: str,
) -> Optional[pd.DataFrame]:
"""Fetch and slice one symbol's daily bars into an OHLCV DataFrame.
Args:
code: US-equity ticker.
start_date: Inclusive start date ``YYYY-MM-DD``.
end_date: Inclusive end date ``YYYY-MM-DD``.
api_key: Resolved Alpha Vantage API key.
Returns:
The in-range OHLCV DataFrame, or ``None`` when the payload carries
no usable bars.
Raises:
ValueError: The provider returned a rate-limit / error envelope and
no usable time series (a data-bearing payload that also carries
an upsell note is parsed, not rejected).
requests.RequestException: Network failure, propagated unchanged.
"""
symbol = code.strip().upper()
if not symbol:
return None
payload = throttled_get_json(
_BASE_URL,
host_key=_HOST_KEY,
min_interval=_min_interval(),
params={
"function": "TIME_SERIES_DAILY",
"symbol": symbol,
"outputsize": "full",
"apikey": api_key,
},
)
series = payload.get(_TIME_SERIES_KEY) if isinstance(payload, dict) else None
if not isinstance(series, dict) or not series:
# No usable series: a premium-upsell payload can carry both the data
# key (empty/absent) and a Note/Information envelope, so only now —
# with nothing to parse — do we surface the envelope as an error.
message = _extract_provider_message(payload)
if message is not None:
raise ValueError(f"alphavantage rejected {symbol}: {message}")
return None
rows = [
row
for date, bar in series.items()
if (row := _parse_bar(date, bar)) is not None
]
if not rows:
return None
df = pd.DataFrame(rows)
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date").sort_index()
df = df[_OHLCV_COLUMNS].dropna(subset=["open", "high", "low", "close"])
start = pd.Timestamp(start_date)
end = pd.Timestamp(end_date)
df = df.loc[(df.index >= start) & (df.index <= end)]
return df
def _parse_bar(date: str, bar: object) -> Optional[dict]:
"""Parse one ``{date: {"1. open": ...}}`` entry into an OHLCV row dict.
Args:
date: The bar's ``YYYY-MM-DD`` date string.
bar: The per-bar field mapping from the payload.
Returns:
A ``{trade_date, open, high, low, close, volume}`` dict, or ``None``
when the entry is malformed or non-numeric.
"""
if not isinstance(bar, dict):
return None
try:
return {
"trade_date": date,
"open": float(bar[_FIELD_MAP["open"]]),
"high": float(bar[_FIELD_MAP["high"]]),
"low": float(bar[_FIELD_MAP["low"]]),
"close": float(bar[_FIELD_MAP["close"]]),
"volume": float(bar[_FIELD_MAP["volume"]]),
}
except (KeyError, TypeError, ValueError):
return None
+155
View File
@@ -0,0 +1,155 @@
"""BaoStock loader: free, no-auth A-share data via TCP protocol.
BaoStock (http://baostock.com) uses its own TCP protocol (not HTTP),
bypassing CDN IP blocks that affect HTTP-based data sources like
eastmoney.com. Completely free, no API token required.
Covers: A-shares (SH/SZ), does NOT cover HK/US/crypto.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
def _is_a_share(code: str) -> bool:
# Support both baostock native format (sh.601398) and tushare-style suffix (601398.SH)
code_lower = code.lower()
return (
code_lower.startswith(("sh.", "sz."))
or code.upper().endswith((".SZ", ".SH"))
)
@register
class DataLoader:
"""BaoStock A-share OHLCV loader (free, TCP protocol, no auth)."""
name = "baostock"
markets = {"a_share"}
requires_auth = False
def is_available(self) -> bool:
"""Available if baostock is installed."""
try:
import baostock # noqa: F401
return True
except ImportError:
return False
def __init__(self) -> None:
pass
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV data via BaoStock.
Args:
codes: Symbol list (e.g. ["601595.SH", "000001.SZ"]).
start_date: YYYY-MM-DD.
end_date: YYYY-MM-DD.
interval: Bar size (only 1D supported).
fields: Ignored.
Returns:
Mapping symbol -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
import baostock as bs
lg = bs.login()
if lg.error_code != "0":
logger.error("baostock login failed: %s", lg.error_msg)
return {}
result: Dict[str, pd.DataFrame] = {}
try:
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(bs, code, start_date, end_date),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("baostock failed for %s: %s", code, exc)
finally:
bs.logout()
return result
def _fetch_one(
self, bs, code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""Fetch a single A-share symbol."""
if not _is_a_share(code):
return None
# Support baostock native format (sh.601398 / sz.000001)
# and tushare-style suffix (601398.SH / 000001.SZ)
code_lower = code.lower()
if code_lower.startswith("sh.") or code_lower.startswith("sz."):
bs_code = code_lower
else:
parts = code.upper().split(".")
symbol = parts[0]
suffix = parts[1] if len(parts) > 1 else ""
if suffix == "SH":
bs_code = f"sh.{symbol}"
elif suffix == "SZ":
bs_code = f"sz.{symbol}"
else:
return None
rs = bs.query_history_k_data_plus(
bs_code,
"date,open,high,low,close,volume,amount",
start_date=start_date,
end_date=end_date,
frequency="d",
adjustflag="2", # 前复权
)
if rs.error_code != "0":
logger.warning("baostock query failed for %s: %s", code, rs.error_msg)
return None
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
return None
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume", "amount"])
df["date"] = pd.to_datetime(df["date"])
for col in ["open", "high", "low", "close", "volume", "amount"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.rename(columns={"date": "trade_date"})
df = df.set_index("trade_date").sort_index()
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return df
+552 -2
View File
@@ -1,11 +1,28 @@
"""DataLoader Protocol and shared exceptions for all data source loaders."""
"""DataLoader Protocol, shared exceptions, retry helpers, and loader cache.
The retry/budget helpers are the canonical pattern for any loader that calls
a flaky external API: a wall-clock deadline plus a small backoff schedule
applied only to a declared transient exception class. New loaders should
import :func:`check_budget` and :func:`retry_with_budget` rather than
re-implementing the loop.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
import datetime as dt
import hashlib
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Callable, Protocol, TypeVar, runtime_checkable
import pandas as pd
logger = logging.getLogger(__name__)
class NoAvailableSourceError(Exception):
"""Raised when no data source is available for a given market."""
@@ -30,6 +47,539 @@ def validate_date_range(start_date: str, end_date: str) -> None:
raise ValueError(f"start_date ({start_date}) > end_date ({end_date})")
def validate_ohlc(frame: pd.DataFrame, *, strategy: str = "drop") -> pd.DataFrame:
"""Drop, flag, or reject bars that violate OHLC invariants.
Loaders only drop NaN rows, so structurally dirty bars — ``high < low``,
a non-positive price, or a high/low that fails to bracket open/close —
flow straight into the backtest and surface downstream as NaN/inf metrics
that break the strict (``allow_nan=False``) JSON serializers. This is the
canonical loader-boundary check; call it after the existing ``dropna`` so a
single sanity pass guards every source.
Args:
frame: OHLCV frame with at least ``open``/``high``/``low``/``close``
columns. NaN handling is left to the caller's ``dropna``.
strategy: ``"drop"`` (remove offending rows, default), ``"warn"``
(log and keep), or ``"raise"`` (raise on any violation).
Returns:
The frame with invalid rows removed (``"drop"``) or unchanged
(``"warn"``). A frame that is empty or lacks OHLC columns is returned
as-is.
Raises:
ValueError: ``strategy="raise"`` and at least one bar is invalid.
"""
required = ("open", "high", "low", "close")
if frame.empty or not all(col in frame.columns for col in required):
return frame
open_, high, low, close = (frame[c] for c in required)
invalid = (
(high < low)
| (high < open_)
| (high < close)
| (low > open_)
| (low > close)
| (open_ <= 0)
| (high <= 0)
| (low <= 0)
| (close <= 0)
)
n_invalid = int(invalid.sum())
if n_invalid == 0:
return frame
if strategy == "raise":
raise ValueError(f"{n_invalid} bar(s) violate OHLC invariants")
if strategy == "warn":
logger.warning("OHLC validation: %d bar(s) violate invariants (kept)", n_invalid)
return frame
logger.warning("OHLC validation: dropping %d invalid bar(s)", n_invalid)
return frame[~invalid]
# ---------------------------------------------------------------------------
# Bounded retry / budget helpers (shared by ccxt_loader, okx, and any future
# loader calling a flaky external API).
# ---------------------------------------------------------------------------
DEFAULT_BACKOFF: tuple[float, ...] = (0.5, 1.5, 4.0)
DEFAULT_MAX_RETRIES = 3
def positive_env_int(name: str, default: int) -> int:
"""Read a positive integer env var, warning and falling back on invalid values."""
raw = os.getenv(name) # noqa: env-gate — generic env var helper
if raw is None or not raw.strip():
return default
try:
value = int(raw)
except ValueError:
logger.warning("invalid %s=%r, using default %s", name, raw, default)
return default
if value <= 0:
logger.warning("non-positive %s=%r, using default %s", name, raw, default)
return default
return value
def positive_env_float(name: str, default: float) -> float:
"""Read a positive float env var, warning and falling back on invalid values."""
raw = os.getenv(name) # noqa: env-gate — generic env var helper
if raw is None or not raw.strip():
return default
try:
value = float(raw)
except ValueError:
logger.warning("invalid %s=%r, using default %s", name, raw, default)
return default
if value <= 0:
logger.warning("non-positive %s=%r, using default %s", name, raw, default)
return default
return value
def check_budget(deadline: float, label: str, budget_s: float | None = None) -> None:
"""Raise :class:`TimeoutError` if the monotonic clock has crossed ``deadline``.
Use this between pages of a paginated fetch to fail fast instead of
grinding through more requests once the wall-clock budget is gone.
Args:
deadline: ``time.monotonic()`` instant past which we abort.
label: Free-form label used in the exception message
(e.g. ``"ccxt fetch for BTC/USDT"``).
budget_s: Original budget in seconds, included verbatim in the
message when present.
"""
if time.monotonic() > deadline:
suffix = f" exceeded {budget_s:.0f}s budget" if budget_s is not None else " exceeded budget"
raise TimeoutError(f"{label}{suffix}")
_T = TypeVar("_T")
def retry_with_budget(
fn: Callable[[], _T],
*,
transient: type[BaseException] | tuple[type[BaseException], ...],
deadline: float,
label: str,
max_retries: int = DEFAULT_MAX_RETRIES,
backoff: tuple[float, ...] = DEFAULT_BACKOFF,
) -> _T:
"""Call ``fn`` with a bounded retry budget on declared transient errors.
Between attempts sleeps ``min(backoff[attempt], remaining_budget)`` so a
short remaining budget never spends the full backoff. The terminal
transient failure — whether ``max_retries`` is exhausted OR the deadline
has passed — is wrapped in :class:`TimeoutError`, preserving the original
exception as ``__cause__``. Anything not in ``transient`` propagates
unchanged on the first occurrence (we never retry an exception class
the caller didn't opt in to).
Args:
fn: Zero-arg callable producing the result.
transient: Exception class(es) considered transient and retryable.
deadline: ``time.monotonic()`` instant past which retries are aborted.
label: Free-form label used in the TimeoutError message
(e.g. ``"OKX fetch for BTC-USDT"``).
max_retries: Additional attempts after the first call. Total
attempts = ``max_retries + 1``.
backoff: Per-retry sleep seconds. Must have at least
``max_retries`` entries.
Returns:
Whatever ``fn`` returns.
Raises:
ValueError: ``backoff`` is shorter than ``max_retries``.
TimeoutError: All retries exhausted or the deadline crossed.
Any non-transient exception: Propagated unchanged from ``fn``.
"""
if len(backoff) < max_retries:
raise ValueError(
f"backoff has {len(backoff)} entries; need >= max_retries ({max_retries})"
)
for attempt in range(max_retries + 1):
try:
return fn()
except transient as exc:
remaining = deadline - time.monotonic()
if attempt == max_retries or remaining <= 0:
raise TimeoutError(
f"{label} failed after {attempt + 1} attempt(s): {exc}"
) from exc
time.sleep(min(backoff[attempt], max(0.0, remaining)))
raise AssertionError("unreachable: retry loop must return or raise") # pragma: no cover
# ---------------------------------------------------------------------------
# Opt-in local loader cache.
# ---------------------------------------------------------------------------
LOADER_CACHE_ENV = "VIBE_TRADING_DATA_CACHE"
LOADER_CACHE_ROOT_ENV = "VIBE_TRADING_DATA_CACHE_ROOT"
_LOADER_CACHE_TRUE_VALUES = {"1", "true", "yes", "on"}
# Bump when the key payload or on-disk layout changes so stale entries are
# simply never matched (old files become unreachable garbage, safe to delete).
_LOADER_CACHE_VERSION = 3
def loader_cache_enabled() -> bool:
"""Return whether the local market-data cache is explicitly enabled."""
from src.config.accessor import get_env_config
return get_env_config().data.vibe_trading_data_cache
def loader_cache_root() -> Path:
"""Return the root directory for opt-in loader cache files."""
from src.config.accessor import get_env_config
root = get_env_config().data.vibe_trading_data_cache_root
if root and root.strip():
return Path(root).expanduser()
return Path.home() / ".vibe-trading" / "cache" / "loaders"
def make_loader_cache_key(
*,
source: str,
symbol: str,
timeframe: str,
start_date: str,
end_date: str,
fields: list[str] | tuple[str, ...] | None = None,
) -> str:
"""Build a stable content-addressed key for one loader payload."""
payload = _loader_cache_payload(
source=source,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
fields=fields,
)
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def loader_cache_path(
*,
source: str,
symbol: str,
timeframe: str,
start_date: str,
end_date: str,
fields: list[str] | tuple[str, ...] | None = None,
) -> Path:
"""Return the parquet cache path for one loader payload."""
key = make_loader_cache_key(
source=source,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
fields=fields,
)
source_dir = _sanitize_cache_segment(source)
return loader_cache_root() / source_dir / f"{key}.parquet"
def loader_cache_range_is_final(end_date: str) -> bool:
"""Return whether ``end_date`` is settled enough to cache.
The key is content-addressed on ``end_date`` but not on wall-clock fetch
time, so caching a range whose last bar is still forming (``end_date`` today
or in the future) would pin a provisional bar and serve it on every later
run. Only fully-elapsed days (strictly before today) are cacheable.
"""
try:
end = pd.Timestamp(end_date).normalize().date()
except Exception: # noqa: BLE001 - an unparseable date is treated as not cacheable
return False
return end < dt.date.today()
def loader_cache_get(
*,
source: str,
symbol: str,
timeframe: str,
start_date: str,
end_date: str,
fields: list[str] | tuple[str, ...] | None = None,
) -> pd.DataFrame | None:
"""Return a cached DataFrame for one payload, or ``None`` on any miss.
Misses include: cache disabled, range not yet settled, entry absent, or a
corrupt entry. A corrupt entry is non-fatal — the caller falls back to the
live provider.
"""
if not loader_cache_enabled() or not loader_cache_range_is_final(end_date):
return None
cache_path = loader_cache_path(
source=source,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
fields=fields,
)
return _read_loader_cache_frame(cache_path)
def loader_cache_put(
*,
source: str,
symbol: str,
timeframe: str,
start_date: str,
end_date: str,
fields: list[str] | tuple[str, ...] | None,
frame: pd.DataFrame | None,
) -> None:
"""Write one non-empty DataFrame to the cache; a no-op when not cacheable.
Skips a disabled cache, an unsettled range, and empty/non-DataFrame results.
Write failures are swallowed so a fetch never fails because of the cache.
"""
if not loader_cache_enabled() or not loader_cache_range_is_final(end_date):
return
if not isinstance(frame, pd.DataFrame) or frame.empty:
return
cache_path = loader_cache_path(
source=source,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
fields=fields,
)
_write_loader_cache_frame(cache_path, frame)
def cached_loader_fetch(
*,
source: str,
symbol: str,
timeframe: str,
start_date: str,
end_date: str,
fields: list[str] | tuple[str, ...] | None,
fetch: Callable[[], pd.DataFrame | None],
) -> pd.DataFrame | None:
"""Fetch one DataFrame through the opt-in local cache.
Convenience wrapper over :func:`loader_cache_get` / :func:`loader_cache_put`
for the common per-symbol loader loop: return the cached frame when present,
otherwise call ``fetch`` and cache a non-empty result. Cache read/write
failures are non-fatal and fall back to ``fetch``.
"""
cached = loader_cache_get(
source=source,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
fields=fields,
)
if cached is not None:
return cached
frame = fetch()
loader_cache_put(
source=source,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
fields=fields,
frame=frame,
)
return frame
def _loader_cache_payload(
*,
source: str,
symbol: str,
timeframe: str,
start_date: str,
end_date: str,
fields: list[str] | tuple[str, ...] | None,
) -> dict[str, object]:
return {
"version": _LOADER_CACHE_VERSION,
"source": str(source),
"symbol": str(symbol),
"timeframe": str(timeframe),
"start_date": _normalize_cache_date(start_date),
"end_date": _normalize_cache_date(end_date),
"fields": [str(field) for field in (fields or ())],
}
def _normalize_cache_date(value: str) -> str:
return pd.Timestamp(value).strftime("%Y-%m-%d")
def _sanitize_cache_segment(value: str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in value.strip().lower())
return cleaned or "unknown"
def _loader_cache_metadata_path(cache_path: Path) -> Path:
return cache_path.with_suffix(cache_path.suffix + ".json")
def _read_loader_cache_frame(cache_path: Path) -> pd.DataFrame | None:
if not cache_path.is_file():
return None
metadata_path = _loader_cache_metadata_path(cache_path)
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 - local cache miss is non-fatal
logger.warning("loader cache metadata read failed for %s: %s", cache_path.name, exc)
return None
con = None
try:
import duckdb
con = duckdb.connect(database=":memory:")
frame = con.execute(
f"SELECT * FROM read_parquet({_duckdb_sql_string(cache_path)})"
).fetchdf()
except Exception as exc: # noqa: BLE001 - corrupt cache falls back to provider
logger.warning("loader cache read failed for %s: %s", cache_path.name, exc)
return None
finally:
if con is not None:
con.close()
index_columns = metadata.get("index_columns") or []
if index_columns:
missing = [column for column in index_columns if column not in frame.columns]
if missing:
logger.warning("loader cache %s missing index column(s): %s", cache_path.name, missing)
return None
frame = frame.set_index(index_columns)
frame.index.names = metadata.get("index_names") or index_columns
frame = _restore_cache_index_dtypes(frame, metadata.get("index_dtypes"))
frame.columns.name = metadata.get("columns_name")
return frame
def _restore_cache_index_dtypes(frame: pd.DataFrame, index_dtypes: object) -> pd.DataFrame:
"""Best-effort restore of the per-level index dtypes recorded at write time.
Cosmetic and non-fatal: duckdb parquet may rewrite datetime resolution, so
we cast each level back to its original dtype. A failed cast leaves the
duckdb-provided dtype rather than failing the read.
"""
if not isinstance(index_dtypes, list) or frame.index.nlevels != len(index_dtypes):
return frame
try:
if frame.index.nlevels == 1:
frame.index = frame.index.astype(index_dtypes[0])
else:
for level, dtype in enumerate(index_dtypes):
frame.index = frame.index.set_levels(
frame.index.levels[level].astype(dtype), level=level
)
except Exception: # noqa: BLE001 - index dtype restore is cosmetic
logger.debug("loader cache index dtype restore skipped: %s", index_dtypes)
return frame
def _write_loader_cache_frame(cache_path: Path, frame: pd.DataFrame) -> None:
metadata_path = _loader_cache_metadata_path(cache_path)
# pid + uuid so two concurrent writers of the same key never share a tmp
# path; os.replace then swaps each file in atomically.
unique = f"{os.getpid()}.{uuid.uuid4().hex}"
tmp_path = cache_path.with_name(f"{cache_path.name}.{unique}.tmp")
tmp_metadata_path = metadata_path.with_name(f"{metadata_path.name}.{unique}.tmp")
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_frame, metadata = _frame_for_loader_cache(frame)
import duckdb
con = duckdb.connect(database=":memory:")
try:
con.register("cache_frame", cache_frame)
con.execute(f"COPY cache_frame TO {_duckdb_sql_string(tmp_path)} (FORMAT PARQUET)")
finally:
con.close()
tmp_metadata_path.write_text(
json.dumps(metadata, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
os.replace(tmp_path, cache_path)
os.replace(tmp_metadata_path, metadata_path)
except Exception as exc: # noqa: BLE001 - cache write failures should not fail fetches
logger.warning("loader cache write failed for %s: %s", cache_path.name, exc)
for path in (tmp_path, tmp_metadata_path):
try:
path.unlink()
except FileNotFoundError:
pass
except OSError:
pass
def _frame_for_loader_cache(frame: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, object]]:
cache_frame = frame.copy()
original_index_names = list(cache_frame.index.names)
columns_name = cache_frame.columns.name
index_dtypes = [
str(cache_frame.index.get_level_values(level).dtype)
for level in range(cache_frame.index.nlevels)
]
index_columns = _cache_index_columns(cache_frame)
cache_frame.index = cache_frame.index.set_names(index_columns)
metadata: dict[str, object] = {
"version": _LOADER_CACHE_VERSION,
"index_columns": index_columns,
"index_names": original_index_names,
# Preserve the columns-axis name (e.g. yfinance leaves "Price") and the
# per-level index dtypes so a cached frame round-trips byte-identical to
# a freshly fetched one (duckdb parquet otherwise rewrites datetime
# resolution, e.g. [s] -> [us]).
"columns_name": None if columns_name is None else str(columns_name),
"index_dtypes": index_dtypes,
}
return cache_frame.reset_index(), metadata
def _cache_index_columns(frame: pd.DataFrame) -> list[str]:
columns = {str(column) for column in frame.columns}
used: set[str] = set()
index_columns: list[str] = []
for pos, name in enumerate(frame.index.names):
base = str(name) if name is not None else f"__vibe_loader_index_{pos}__"
candidate = base
suffix = 1
while candidate in columns or candidate in used:
candidate = f"{base}_{suffix}"
suffix += 1
index_columns.append(candidate)
used.add(candidate)
return index_columns
def _duckdb_sql_string(path: Path) -> str:
return "'" + str(path).replace("'", "''") + "'"
@runtime_checkable
class DataLoaderProtocol(Protocol):
"""Interface that every data source loader must satisfy."""
+44
View File
@@ -0,0 +1,44 @@
"""Binance spot / USD-M perpetual OHLCV loader (via CCXT).
Dedicated source name ``binance`` so it can sit **alongside** ``okx`` in the
crypto auto-fallback chain — not as a replacement. Public market data only;
no API key required.
Use explicitly with ``source="binance"``, or let ``source="auto"`` fall through
to Binance when OKX is unavailable.
"""
from __future__ import annotations
from typing import Any
from backtest.loaders.ccxt_loader import (
_CCXT_TIMEOUT_MS,
_ccxt_proxy_config,
)
from backtest.loaders.ccxt_loader import DataLoader as CcxtDataLoader
from backtest.loaders.registry import register
@register
class DataLoader(CcxtDataLoader):
"""Binance-only crypto OHLCV loader (public REST via CCXT)."""
name = "binance"
markets = {"crypto"}
requires_auth = False
def _get_exchange(self, instrument_type: str = "spot") -> Any:
"""Always use Binance spot or Binance USD-M (swap), ignore CCXT_EXCHANGE."""
import ccxt
if instrument_type == "swap":
exchange_cls = ccxt.binanceusdm
else:
exchange_cls = ccxt.binance
config: dict[str, Any] = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS}
proxies = _ccxt_proxy_config()
if proxies:
config["proxies"] = proxies
return exchange_cls(config)
+375 -11
View File
@@ -7,13 +7,24 @@ No API key required for public market data.
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import time
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders.base import (
cached_loader_fetch,
check_budget,
positive_env_float,
positive_env_int,
retry_with_budget,
validate_date_range,
)
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
@@ -23,6 +34,149 @@ _INTERVAL_MAP = {
"1H": "1h", "4H": "4h", "1D": "1d",
}
_TIMEFRAME_DELTA = {
"1m": pd.Timedelta(minutes=1),
"5m": pd.Timedelta(minutes=5),
"15m": pd.Timedelta(minutes=15),
"30m": pd.Timedelta(minutes=30),
"1h": pd.Timedelta(hours=1),
"4h": pd.Timedelta(hours=4),
"1d": pd.Timedelta(days=1),
}
# P12-b: ccxt had no request timeout and an unbounded paginated fetch with
# no retry budget, so a transient disconnect hung get_market_data for 10+
# minutes. Cap each HTTP call, bound transient retries, and enforce a hard
# wall-clock budget so the fetch fails fast instead of hanging. Retry
# scheduling is delegated to :mod:`backtest.loaders.base`.
_CCXT_TIMEOUT_MS = positive_env_int("CCXT_TIMEOUT_MS", 15_000)
_CCXT_FETCH_BUDGET_S = positive_env_float("CCXT_FETCH_BUDGET_S", 60.0)
_FUNDING_HOURS = {0, 8, 16}
def _parse_ccxt_symbol(code: str) -> tuple[str, str]:
"""Return the canonical CCXT symbol and instrument type for ``code``."""
normalized = code.strip().upper()
if normalized.endswith("-PERP"):
match = re.fullmatch(r"([A-Z0-9]+)-USDT-PERP", normalized)
if match is None:
raise ValueError(
"USD-M perpetual symbol must use BASE-USDT-PERP, e.g. BTC-USDT-PERP"
)
return f"{match.group(1)}/USDT:USDT", "swap"
return normalized.replace("-", "/"), "spot"
def _first_proxy_env(*names: str) -> str:
for name in names:
value = os.getenv(name, "").strip() # noqa: env-gate — system proxy vars
if value:
return value
return ""
def _ccxt_proxy_config() -> dict[str, str]:
"""Build CCXT proxy settings from conventional proxy environment variables."""
all_proxy = _first_proxy_env("ALL_PROXY", "all_proxy")
http_proxy = _first_proxy_env("HTTP_PROXY", "http_proxy") or all_proxy
https_proxy = _first_proxy_env("HTTPS_PROXY", "https_proxy") or all_proxy or http_proxy
proxies: dict[str, str] = {}
if http_proxy:
proxies["http"] = http_proxy
if https_proxy:
proxies["https"] = https_proxy
return proxies
_BRACKET_SCHEMA_VERSION = 1
def _validate_bracket_artifact(artifact: dict, *, expected_symbol: str) -> tuple[list[dict], str]:
"""Validate a caller-supplied maintenance-bracket artifact.
``binanceusdm.fetch_leverage_tiers()`` routes to Binance's signed
``GET /fapi/v1/leverageBracket`` USER_DATA endpoint — it requires an API
key even though the response carries no account-specific data. This
loader no longer calls it: maintenance brackets are supplied out of band
as a versioned artifact and validated here, never fetched live. Fails
closed (raises ``ValueError``) on any schema, symbol, provenance,
ordering, or content-hash problem.
"""
if not isinstance(artifact, dict):
raise ValueError("bracket artifact must be a dict")
schema_version = artifact.get("schema_version")
if schema_version != _BRACKET_SCHEMA_VERSION:
raise ValueError(
f"bracket artifact schema_version must be {_BRACKET_SCHEMA_VERSION}, "
f"got {schema_version!r}"
)
symbol = artifact.get("symbol")
if symbol != expected_symbol:
raise ValueError(
f"bracket artifact symbol mismatch: expected {expected_symbol!r}, got {symbol!r}"
)
provenance_timestamp = artifact.get("provenance_timestamp")
if not provenance_timestamp:
raise ValueError("bracket artifact is missing provenance_timestamp")
try:
pd.Timestamp(provenance_timestamp)
except (ValueError, TypeError) as exc:
raise ValueError(
f"bracket artifact provenance_timestamp is not a valid timestamp: "
f"{provenance_timestamp!r}"
) from exc
brackets = artifact.get("brackets")
if not brackets:
raise ValueError("bracket artifact has no brackets")
normalized: list[dict] = []
for tier in brackets:
try:
record = {
"bracket_tier": int(tier["bracket_tier"]),
"notional_cap": float(tier["notional_cap"]),
"maintenance_rate": float(tier["maintenance_rate"]),
"cumulative_maintenance_amount": float(tier["cumulative_maintenance_amount"]),
}
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(
f"bracket artifact tier for {expected_symbol} is missing a required field: {exc}"
) from exc
# Optional: reserved for future risk-model calibration, not part of
# Binance's own bracket schema. Validated only if present.
coefficient = tier.get("notional_coefficient")
if coefficient is not None:
try:
record["notional_coefficient"] = float(coefficient)
except (TypeError, ValueError) as exc:
raise ValueError(
f"bracket artifact notional_coefficient must be numeric: {coefficient!r}"
) from exc
normalized.append(record)
normalized.sort(key=lambda row: row["bracket_tier"])
caps = [row["notional_cap"] for row in normalized]
if caps != sorted(caps) or len(caps) != len(set(caps)):
raise ValueError(
f"bracket artifact notional caps for {expected_symbol} are not strictly increasing"
)
blob = json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
computed_hash = hashlib.sha256(blob).hexdigest()[:16]
content_hash = artifact.get("content_hash")
if content_hash != computed_hash:
raise ValueError(
f"bracket artifact content_hash mismatch for {expected_symbol}: "
f"expected {computed_hash}, artifact declares {content_hash!r}"
)
return normalized, computed_hash
@register
class DataLoader:
@@ -43,15 +197,28 @@ class DataLoader:
def __init__(self) -> None:
pass
def _get_exchange(self):
"""Create exchange instance."""
def _get_exchange(self, instrument_type: str = "spot"):
"""Create an exchange instance for spot or Binance USD-M swaps."""
import ccxt
exchange_id = os.getenv("CCXT_EXCHANGE", "binance").lower()
from src.config.accessor import get_env_config
exchange_id = get_env_config().data.ccxt_exchange.lower()
if instrument_type == "swap":
if exchange_id not in {"binance", "binanceusdm"}:
raise ValueError(
"BASE-USDT-PERP currently requires CCXT_EXCHANGE=binance"
)
exchange_id = "binanceusdm"
exchange_cls = getattr(ccxt, exchange_id, None)
if exchange_cls is None:
logger.warning("Unknown CCXT exchange %s, falling back to binance", exchange_id)
exchange_cls = ccxt.binance
return exchange_cls({"enableRateLimit": True})
config = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS}
proxies = _ccxt_proxy_config()
if proxies:
config["proxies"] = proxies
return exchange_cls(config)
def fetch(
self,
@@ -61,6 +228,8 @@ class DataLoader:
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
bracket_artifacts: Optional[Dict[str, dict]] = None,
require_brackets: bool = False,
) -> Dict[str, pd.DataFrame]:
"""Fetch crypto OHLCV via CCXT.
@@ -70,44 +239,228 @@ class DataLoader:
end_date: End date (YYYY-MM-DD).
interval: Bar size.
fields: Ignored.
bracket_artifacts: Optional ``{code: artifact}`` map of caller-supplied,
versioned maintenance-bracket artifacts (see
``_validate_bracket_artifact``) for ``-PERP`` codes. Normal
execution/mark/funding data never needs this — it stays
zero-credential regardless. This loader does not fetch brackets
live: ``binanceusdm.fetch_leverage_tiers()`` requires a Binance
API key, which this historical/backtest path never carries.
require_brackets: When True, a ``-PERP`` code without a matching,
valid artifact in ``bracket_artifacts`` fails closed instead of
silently returning data without bracket columns. Intended for
strict margin-risk consumers.
Returns:
Mapping symbol -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
exchange = self._get_exchange()
timeframe = _INTERVAL_MAP.get(interval, "1d")
since_ms = int(pd.Timestamp(start_date).timestamp() * 1000)
end_ms = int((pd.Timestamp(end_date) + pd.Timedelta(days=1)).timestamp() * 1000)
# Build the exchange lazily so a full cache hit never imports ccxt or
# opens an exchange object.
exchange_holder: Dict[str, object] = {}
def get_exchange(instrument_type: str):
if instrument_type not in exchange_holder:
exchange_holder[instrument_type] = self._get_exchange(instrument_type)
return exchange_holder[instrument_type]
result: Dict[str, pd.DataFrame] = {}
for code in codes:
instrument_type = "spot"
try:
ccxt_symbol = code.replace("-", "/").upper()
df = self._fetch_one(exchange, ccxt_symbol, timeframe, since_ms, end_ms)
ccxt_symbol, instrument_type = _parse_ccxt_symbol(code)
exchange = get_exchange(instrument_type)
artifact = (bracket_artifacts or {}).get(code)
def fetch_frame():
if instrument_type == "swap":
return self._fetch_perpetual(
exchange, ccxt_symbol, timeframe, since_ms, end_ms,
bracket_artifact=artifact, require_brackets=require_brackets,
)
return self._fetch_one(
exchange, ccxt_symbol, timeframe, since_ms, end_ms
)
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=fetch_frame,
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
if instrument_type == "swap" or code.strip().upper().endswith("-PERP"):
raise
logger.warning("CCXT failed for %s: %s", code, exc)
return result
@classmethod
def _fetch_perpetual(
cls, exchange, symbol: str, timeframe: str, since_ms: int, end_ms: int,
*, bracket_artifact: dict | None = None, require_brackets: bool = False,
) -> pd.DataFrame:
"""Fetch aligned trade-price and mark-price candles for one USD-M swap.
Maintenance brackets are never fetched live (see
``_validate_bracket_artifact``): they're attached only when the caller
supplies a validated artifact. A strict caller (``require_brackets``)
fails closed before any network call when no artifact is supplied.
"""
if require_brackets and bracket_artifact is None:
raise ValueError(
f"strict margin-risk fetch for {symbol} requires a maintenance-bracket "
"artifact, but none was supplied. This loader does not perform a live "
"authenticated bracket fetch (binanceusdm.fetch_leverage_tiers requires "
"a Binance API key this backtest path does not carry) — pass a validated "
"artifact via DataLoader.fetch(bracket_artifacts={code: artifact})."
)
trade = cls._fetch_one(exchange, symbol, timeframe, since_ms, end_ms)
mark = cls._fetch_one(
exchange,
symbol,
timeframe,
since_ms,
end_ms,
params={"price": "mark"},
)
if trade is None or mark is None or not trade.index.equals(mark.index):
raise ValueError(f"mark-price timestamps are incomplete or unsynchronized for {symbol}")
result = trade.copy()
result["execution_open"] = trade["open"]
for column in ("open", "high", "low", "close"):
result[f"mark_{column}"] = mark[column]
funding = cls._fetch_funding_history(exchange, symbol, since_ms, end_ms)
if funding.index.has_duplicates:
raise ValueError(f"duplicate funding settlement for {symbol}")
required = result.index[result.index.hour.isin(_FUNDING_HOURS)]
missing = required.difference(funding.index)
if not missing.empty:
raise ValueError(
f"funding settlement data is missing for {symbol}: "
f"{', '.join(str(ts) for ts in missing)}"
)
result["funding_rate"] = 0.0
result["funding_settlement_time"] = pd.NaT
aligned = funding.index.intersection(result.index)
if not aligned.empty:
result.loc[aligned, "funding_rate"] = funding.loc[aligned, "funding_rate"]
result.loc[aligned, "funding_settlement_time"] = aligned
if bracket_artifact is not None:
brackets, version = _validate_bracket_artifact(
bracket_artifact, expected_symbol=symbol
)
result["maintenance_brackets"] = json.dumps(brackets)
result["maintenance_bracket_version"] = version
return result
@staticmethod
def _fetch_funding_history(
exchange, symbol: str, since_ms: int, end_ms: int,
) -> pd.DataFrame:
"""Fetch bounded historical funding settlements for one USD-M swap."""
import ccxt
rows: list[dict] = []
cursor = since_ms
limit = 1000
deadline = time.monotonic() + _CCXT_FETCH_BUDGET_S
label = f"ccxt funding fetch for {symbol}"
for _ in range(200):
check_budget(deadline, label, budget_s=_CCXT_FETCH_BUDGET_S)
page = retry_with_budget(
lambda: exchange.fetch_funding_rate_history(
symbol, since=cursor, limit=limit
),
transient=ccxt.NetworkError,
deadline=deadline,
label=label,
)
if not page:
break
rows.extend(page)
last_ts = int(page[-1]["timestamp"])
if last_ts >= end_ms or len(page) < limit:
break
cursor = last_ts + 1
if not rows:
return pd.DataFrame(
{"funding_rate": pd.Series(dtype=float)},
index=pd.DatetimeIndex([], name="trade_date"),
)
frame = pd.DataFrame({
"trade_date": pd.to_datetime(
[row["timestamp"] for row in rows], unit="ms"
),
"funding_rate": pd.to_numeric(
[row["fundingRate"] for row in rows], errors="raise"
),
}).set_index("trade_date").sort_index()
start_dt = pd.Timestamp(since_ms, unit="ms")
end_dt = pd.Timestamp(end_ms, unit="ms")
return frame[(frame.index >= start_dt) & (frame.index < end_dt)]
@staticmethod
def _fetch_one(
exchange, symbol: str, timeframe: str, since_ms: int, end_ms: int,
exchange,
symbol: str,
timeframe: str,
since_ms: int,
end_ms: int,
*,
params: dict[str, str] | None = None,
) -> Optional[pd.DataFrame]:
"""Paginated OHLCV fetch for one symbol."""
import ccxt
all_rows: list = []
cursor = since_ms
limit = 1000
deadline = time.monotonic() + _CCXT_FETCH_BUDGET_S
label = f"ccxt fetch for {symbol}"
hit_page_cap = True
for _ in range(200):
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=cursor, limit=limit)
check_budget(deadline, label, budget_s=_CCXT_FETCH_BUDGET_S)
# ``ccxt.NetworkError`` covers RequestTimeout / DDoSProtection /
# ExchangeNotAvailable — the transient family. Anything else
# (e.g. ``ExchangeError`` for a bad symbol) is not retried.
def fetch_page():
kwargs = {"since": cursor, "limit": limit}
if params is not None:
kwargs["params"] = params
return exchange.fetch_ohlcv(symbol, timeframe, **kwargs)
ohlcv = retry_with_budget(
fetch_page,
transient=ccxt.NetworkError,
deadline=deadline,
label=label,
)
if not ohlcv:
hit_page_cap = False
break
all_rows.extend(ohlcv)
last_ts = ohlcv[-1][0]
if last_ts >= end_ms or len(ohlcv) < limit:
hit_page_cap = False
break
cursor = last_ts + 1
@@ -128,4 +481,15 @@ class DataLoader:
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return df if not df.empty else None
if df.empty:
return None
tolerance = _TIMEFRAME_DELTA.get(timeframe)
if tolerance is None:
raise ValueError(f"unsupported CCXT timeframe: {timeframe}")
if hit_page_cap and df.index[-1] < end_dt - tolerance:
raise ValueError(
f"incomplete CCXT history for {symbol}: requested "
f"[{start_dt}, {end_dt}), received [{df.index[0]}, {df.index[-1]}]"
)
return df
+322
View File
@@ -0,0 +1,322 @@
"""Shared Eastmoney HTTP client: secid resolution + push2his kline fetch.
Eastmoney exposes free, no-auth quote endpoints but rate-limits aggressively by
source IP and will temporarily ban a bursting client, so every request here
routes through :mod:`backtest.loaders._http` for per-host throttling and session
reuse. This module is provider-internal plumbing shared by the Eastmoney-backed
loaders; it only knows Eastmoney's ``secid`` addressing scheme and the
``push2his`` kline JSON layout, not any loader's DataFrame conventions.
Eastmoney addresses every instrument by a ``secid`` of the form ``<market>.<code>``:
* A-shares — Shanghai (``.SH``, plus ``.BJ`` Beijing exchange) use market ``1``
for SH and ``0`` for SZ/BJ.
* Hong Kong (``.HK``) uses market ``116`` with the numeric code zero-padded to
five digits.
* US (``.US``) markets (NASDAQ ``105`` / NYSE ``106`` / AMEX ``107``) are not
derivable from the ticker alone, so the market prefix is discovered once via
Eastmoney's search/suggest endpoint and cached for the life of the process.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from backtest.loaders._http import resolve_min_interval, throttled_get_json
logger = logging.getLogger(__name__)
# Eastmoney kline endpoints. push2his serves historical bars; searchapi resolves
# a free-text ticker to its fully-qualified secid (needed for US tickers).
_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
_SEARCH_URL = "https://searchapi.eastmoney.com/api/suggest/get"
# Throttle/session bucket shared by all Eastmoney calls.
_HOST_KEY = "eastmoney"
_MIN_INTERVAL_ENV = "VIBE_TRADING_EASTMONEY_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL = 1.0
# Eastmoney kline period codes (``klt``) keyed by our interval labels.
KLT_BY_INTERVAL: dict[str, int] = {
"1D": 101,
"1W": 102,
"1M": 103,
"1m": 1,
"5m": 5,
"15m": 15,
"30m": 30,
"1H": 60,
"60m": 60,
}
# push2his column selectors: fields1 picks the per-request meta, fields2 picks
# the per-bar columns in order: date, open, close, high, low, volume, amount.
_FIELDS1 = "f1,f2,f3,f4,f5,f6"
_FIELDS2 = "f51,f52,f53,f54,f55,f56,f57"
# Process-level cache of resolved US secids so a repeated ticker never re-hits
# the search endpoint. Keyed by the upper-cased bare ticker (e.g. "AAPL").
_US_SECID_CACHE: dict[str, str | None] = {}
# A JSONP envelope is a JS callback identifier followed by a parenthesized body,
# optionally terminated by ';'. The identifier is restricted to legal JS names
# (incl. dotted namespaces like "jQuery.cb") so a plain JSON array/object body —
# which never starts this way — is left untouched.
_JSONP_WRAPPER = re.compile(
r"^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\((.*)\)\s*;?\s*$",
re.DOTALL,
)
def _min_interval() -> float:
"""Resolve the per-call minimum Eastmoney request spacing in seconds."""
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL)
def get_json(url: str, *, params: dict[str, Any]) -> Any:
"""Issue a throttled Eastmoney GET and decode the body as JSON.
Args:
url: Fully-qualified Eastmoney endpoint URL.
params: Query parameters for the request.
Returns:
The decoded JSON payload (typically a ``dict``).
Raises:
requests.RequestException: Network failure, propagated for the caller's
retry policy.
requests.HTTPError: Non-2xx response status.
ValueError: Body is not valid JSON.
"""
return throttled_get_json(
url,
host_key=_HOST_KEY,
min_interval=_min_interval(),
params=params,
)
def _resolve_a_share_secid(code: str, suffix: str) -> str | None:
"""Map an A-share ``code`` + exchange ``suffix`` to its Eastmoney secid.
SH instruments live on market ``1``; SZ and BJ (Beijing exchange) on ``0``.
"""
if suffix == "SH":
return f"1.{code}"
if suffix in ("SZ", "BJ"):
return f"0.{code}"
return None
def _parse_us_secid(payload: Any) -> str | None:
"""Extract a US ``<market>.<code>`` secid from a search/suggest payload.
Eastmoney's suggest endpoint returns candidates under
``QuotationCodeTable.Data``, each carrying a ``QuoteID`` already in
``<market>.<code>`` form. The first US-market (105/106/107) candidate wins.
Args:
payload: Decoded JSON from the search endpoint.
Returns:
The first matching US secid, or ``None`` when no US candidate is found.
"""
if not isinstance(payload, dict):
return None
table = payload.get("QuotationCodeTable")
if not isinstance(table, dict):
return None
candidates = table.get("Data")
if not isinstance(candidates, list):
return None
for candidate in candidates:
if not isinstance(candidate, dict):
continue
quote_id = candidate.get("QuoteID")
if not isinstance(quote_id, str) or "." not in quote_id:
continue
market = quote_id.split(".", 1)[0]
if market in ("105", "106", "107"):
return quote_id
return None
def _strip_jsonp(text: str) -> Any:
"""Decode a possibly JSONP-wrapped body to a Python object.
The suggest endpoint sometimes wraps its JSON in a ``callback(...)`` envelope.
A plain JSON body is parsed as-is first; only a body that actually matches a
leading callback identifier is unwrapped. This avoids corrupting plain JSON
whose string values contain parentheses (an earlier first-``(`` / last-``)``
slice mangled such bodies and returned ``None``).
Args:
text: Raw response body.
Returns:
The decoded object, or ``None`` when nothing parseable is found.
"""
stripped = text.strip()
try:
return json.loads(stripped)
except (ValueError, TypeError):
pass
match = _JSONP_WRAPPER.match(stripped)
if match is None:
return None
try:
return json.loads(match.group(1).strip())
except (ValueError, TypeError):
return None
def _resolve_us_secid(code: str) -> str | None:
"""Resolve a US ticker to its Eastmoney secid via search, with caching.
Args:
code: Bare upper-cased US ticker (e.g. ``"AAPL"``).
Returns:
The resolved ``<market>.<code>`` secid, or ``None`` when unresolvable.
"""
if code in _US_SECID_CACHE:
return _US_SECID_CACHE[code]
secid: str | None = None
try:
payload = get_json(
_SEARCH_URL,
params={"input": code, "type": "14", "count": "10"},
)
if isinstance(payload, str):
payload = _strip_jsonp(payload)
secid = _parse_us_secid(payload)
except Exception as exc: # noqa: BLE001 - an unresolved ticker is non-fatal
logger.warning("eastmoney secid search failed for %s: %s", code, exc)
secid = None
_US_SECID_CACHE[code] = secid
return secid
def resolve_secid(symbol: str) -> str | None:
"""Map a Vibe-Trading symbol to its Eastmoney secid.
Supported suffixes: ``.SH`` / ``.SZ`` / ``.BJ`` (A-share), ``.HK`` (Hong
Kong, code zero-padded to five digits), ``.US`` (resolved via search and
cached). A symbol with no recognized suffix, or a US ticker the search
cannot place, returns ``None``.
Args:
symbol: Symbol such as ``"600519.SH"``, ``"00700.HK"`` or ``"AAPL.US"``.
Returns:
The Eastmoney secid (e.g. ``"1.600519"``), or ``None`` if unresolvable.
"""
if not symbol or "." not in symbol:
return None
code, _, suffix = symbol.rpartition(".")
code = code.strip().upper()
suffix = suffix.strip().upper()
if not code:
return None
if suffix in ("SH", "SZ", "BJ"):
return _resolve_a_share_secid(code, suffix)
if suffix == "HK":
return f"116.{code.zfill(5)}"
if suffix == "US":
return _resolve_us_secid(code)
return None
def _parse_kline_row(raw: str) -> dict[str, Any] | None:
"""Parse one comma-joined push2his kline row into an OHLCV dict.
Column order follows ``fields2``: date, open, close, high, low, volume,
amount.
Args:
raw: One row string from ``data.klines``.
Returns:
A dict ``{trade_date, open, high, low, close, volume, amount}``, or
``None`` when the row is malformed.
"""
parts = raw.split(",")
if len(parts) < 7:
return None
try:
return {
"trade_date": parts[0],
"open": float(parts[1]),
"close": float(parts[2]),
"high": float(parts[3]),
"low": float(parts[4]),
"volume": float(parts[5]),
"amount": float(parts[6]),
}
except (ValueError, TypeError):
return None
def fetch_kline(
secid: str,
*,
klt: int,
fqt: int = 1,
beg: str = "0",
end: str = "20500101",
) -> list[dict]:
"""Fetch ascending OHLCV bars for one ``secid`` from push2his.
Args:
secid: Eastmoney secid (e.g. ``"1.600519"``).
klt: Period code from :data:`KLT_BY_INTERVAL`.
fqt: Adjustment mode (0 raw, 1 forward-adjusted, 2 back-adjusted).
beg: Inclusive start date ``YYYYMMDD`` or ``"0"`` for earliest.
end: Inclusive end date ``YYYYMMDD``.
Returns:
Ascending list of ``{trade_date, open, high, low, close, volume,
amount}`` dicts. Empty when the payload carries no bars.
Raises:
requests.RequestException: Network failure.
requests.HTTPError: Non-2xx response status.
ValueError: Body is not valid JSON.
"""
payload = get_json(
_KLINE_URL,
params={
"secid": secid,
"klt": str(klt),
"fqt": str(fqt),
"beg": beg,
"end": end,
"fields1": _FIELDS1,
"fields2": _FIELDS2,
"rev": "1",
"lmt": "1000000",
},
)
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict):
return []
klines = data.get("klines")
if not isinstance(klines, list):
return []
rows: list[dict] = []
for raw in klines:
if not isinstance(raw, str):
continue
parsed = _parse_kline_row(raw)
if parsed is not None:
rows.append(parsed)
return rows
+176
View File
@@ -0,0 +1,176 @@
"""Eastmoney loader: free, no-auth OHLCV across A-share, HK and US equities.
Eastmoney's ``push2his`` quote endpoints are free and require no token, but the
service rate-limits aggressively by source IP. All HTTP goes through the shared
:mod:`backtest.loaders.eastmoney_client`, which routes every call through the
per-host throttle in :mod:`backtest.loaders._http`. This loader only maps our
symbol/interval/DataFrame conventions onto that client; it owns no HTTP itself.
Symbol routing is delegated to :func:`eastmoney_client.resolve_secid`:
* A-share — ``600519.SH`` / ``000001.SZ`` / ``430139.BJ``
* Hong Kong — ``00700.HK`` (numeric code zero-padded to five digits)
* US — ``AAPL.US`` (market prefix discovered via Eastmoney search, cached)
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders import eastmoney_client
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
# OHLCV columns the engine consumes, in canonical order.
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
def _to_compact_date(value: str) -> str:
"""Convert a ``YYYY-MM-DD`` date into Eastmoney's ``YYYYMMDD`` form.
Args:
value: Date string in any pandas-parseable form.
Returns:
The date rendered as ``YYYYMMDD``.
Raises:
ValueError: ``value`` is not a parseable date.
"""
try:
return pd.Timestamp(value).strftime("%Y%m%d")
except Exception as exc: # noqa: BLE001 - surfaced as a clear ValueError
raise ValueError(f"Invalid date for eastmoney: {value!r}") from exc
@register
class DataLoader:
"""Eastmoney OHLCV loader (free, throttled HTTP, no auth)."""
name = "eastmoney"
markets = {"a_share", "hk_equity", "us_equity"}
requires_auth = False
def is_available(self) -> bool:
"""Always available — uses unauthenticated throttled HTTP."""
return True
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV for each symbol; a single failure never aborts the batch.
Args:
codes: Symbols such as ``"600519.SH"``, ``"00700.HK"``, ``"AAPL.US"``.
start_date: Inclusive start date (``YYYY-MM-DD``).
end_date: Inclusive end date (``YYYY-MM-DD``).
interval: Bar interval label (e.g. ``"1D"``, ``"1H"``, ``"5m"``).
fields: Accepted for protocol parity; the OHLCV columns are fixed.
Returns:
Mapping ``{symbol: DataFrame}`` for every symbol that yielded bars.
Each DataFrame has a ``trade_date`` ``DatetimeIndex`` and float
columns ``open/high/low/close/volume``. Symbols that resolve to no
data are omitted.
Raises:
ValueError: ``start_date``/``end_date`` are malformed or inverted.
"""
validate_date_range(start_date, end_date)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(
code, start_date, end_date, interval
),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort
logger.warning("eastmoney failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, interval: str,
) -> Optional[pd.DataFrame]:
"""Resolve one symbol and build its OHLCV frame, or ``None`` on a miss.
Args:
code: A single Vibe-Trading symbol.
start_date: Inclusive start date (``YYYY-MM-DD``).
end_date: Inclusive end date (``YYYY-MM-DD``).
interval: Bar interval label.
Returns:
An OHLCV DataFrame, or ``None`` when the symbol/interval is
unsupported or Eastmoney returns no bars.
"""
klt = eastmoney_client.KLT_BY_INTERVAL.get(interval)
if klt is None:
logger.warning("eastmoney unsupported interval %r for %s", interval, code)
return None
secid = eastmoney_client.resolve_secid(code)
if not secid:
return None
rows = eastmoney_client.fetch_kline(
secid,
klt=klt,
fqt=1,
beg=_to_compact_date(start_date),
end=_to_compact_date(end_date),
)
return self._frame_from_rows(rows)
@staticmethod
def _frame_from_rows(rows: List[dict]) -> Optional[pd.DataFrame]:
"""Assemble the canonical OHLCV DataFrame from client kline rows.
Args:
rows: Ascending ``{trade_date, open, high, low, close, volume, ...}``
dicts from :func:`eastmoney_client.fetch_kline`.
Returns:
A DataFrame indexed by ``trade_date`` with float OHLCV columns, or
``None`` when ``rows`` is empty or fully unparseable.
"""
if not rows:
return None
df = pd.DataFrame(rows)
if df.empty or "trade_date" not in df.columns:
return None
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date").sort_index()
df.index.name = "trade_date"
for column in _OHLCV_COLUMNS:
if column not in df.columns:
return None
df[column] = pd.to_numeric(df[column], errors="coerce").astype(float)
df = df[_OHLCV_COLUMNS].dropna(subset=["open", "high", "low", "close"])
if df.empty:
return None
return df
+271
View File
@@ -0,0 +1,271 @@
"""Finnhub loader: key-gated US-equity OHLCV via the public stock-candle API.
Finnhub (https://finnhub.io) serves daily stock candles from a single REST
endpoint, gated by a free API key passed as the ``token`` query parameter:
https://finnhub.io/api/v1/stock/candle?symbol=AAPL&resolution=D&from=...&to=...&token=...
Like other free quote providers it rate-limits by source IP, so every request
routes through :func:`backtest.loaders._http.throttled_get_json` for per-host
spacing plus session reuse. The endpoint answers with parallel arrays
(``o``/``h``/``l``/``c``/``v`` plus epoch ``t``) and a status field ``s`` that
reads ``"ok"`` on a hit or ``"no_data"`` for an empty window.
Symbol convention (Vibe-Trading -> Finnhub):
* US ``AAPL.US`` -> ``AAPL`` (Finnhub carries US tickers bare)
* Anything else is passed through uppercased (e.g. a bare ``MSFT``).
No market data is ever persisted in the repo; settled ranges may be cached to
the user-home loader cache via :func:`cached_loader_fetch`.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import pandas as pd
from backtest.loaders._http import resolve_min_interval, throttled_get_json
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_API_KEY_ENV = "FINNHUB_API_KEY"
_CANDLE_URL = "https://finnhub.io/api/v1/stock/candle"
# Throttle bucket + default minimum spacing (free tier is ~30 req/s but bursts
# get a 429; one shared bucket keeps the whole process polite).
_HOST_KEY = "finnhub"
_MIN_INTERVAL_ENV = "VIBE_TRADING_FINNHUB_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL_S = 0.4
# Only daily candles are wired here; Finnhub also exposes intraday resolutions
# but the project's us_equity fallback chain is daily-only.
_RESOLUTION = "D"
# Finnhub packs parallel arrays under these keys; ``t`` carries epoch seconds.
_FINNHUB_TO_OHLCV = {"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"}
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
def _min_interval() -> float:
"""Resolve the per-call minimum spacing, honoring the env override."""
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
def _to_finnhub_symbol(code: str) -> str:
"""Translate a project symbol into Finnhub's ticker convention.
Args:
code: Project-side symbol, e.g. ``AAPL.US`` or a bare ``MSFT``.
Returns:
The Finnhub ticker: the ``.US`` suffix stripped, otherwise the
uppercased symbol unchanged.
"""
upper = code.strip().upper()
if upper.endswith(".US"):
return upper[: -len(".US")]
return upper
def _to_epoch_seconds(date_str: str, *, end_of_day: bool) -> int:
"""Convert a ``YYYY-MM-DD`` date to a UTC epoch-second bound.
Args:
date_str: Inclusive date string.
end_of_day: When ``True`` push the instant to 23:59:59 so the day's
bar falls inside Finnhub's inclusive ``[from, to]`` window.
Returns:
Epoch seconds (UTC) for the requested bound.
"""
ts = pd.Timestamp(date_str).normalize()
if end_of_day:
ts = ts + pd.Timedelta(hours=23, minutes=59, seconds=59)
return int(ts.value // 1_000_000_000)
def _rows_from_payload(payload: Any) -> List[Dict[str, Any]]:
"""Zip Finnhub's parallel candle arrays into ascending row dicts.
Args:
payload: Decoded JSON body from the stock-candle endpoint.
Returns:
Ascending list of ``{trade_date, open, high, low, close, volume}`` dicts
where ``trade_date`` is the raw epoch-second ``int`` (normalized to a
``datetime64[ns]`` index later in :meth:`DataLoader._fetch_one`).
Empty when the status is not ``"ok"`` or the timestamp array is missing,
and per-bar gaps (a ``None`` in any OHLC slot) are skipped.
"""
if not isinstance(payload, dict) or payload.get("s") != "ok":
return []
timestamps = payload.get("t") or []
if not isinstance(timestamps, list):
return []
rows: List[Dict[str, Any]] = []
for index, epoch in enumerate(timestamps):
values = {
field: _at(payload.get(key), index)
for key, field in _FINNHUB_TO_OHLCV.items()
}
if any(values[field] is None for field in ("open", "high", "low", "close")):
continue
# Keep the raw epoch here; the resolution is normalized in one place
# (``_fetch_one``) so the index dtype matches the other loaders.
row: Dict[str, Any] = {"trade_date": int(epoch)}
row.update({field: _to_float(values[field]) for field in _OHLCV_COLUMNS})
rows.append(row)
return rows
def _at(series: Any, index: int) -> Any:
"""Return ``series[index]`` when in range, else ``None``."""
if isinstance(series, list) and 0 <= index < len(series):
return series[index]
return None
def _to_float(value: Any) -> Optional[float]:
"""Coerce a Finnhub numeric (or ``None``) to ``float``; ``None`` on failure."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@register
class DataLoader:
"""Finnhub US-equity daily OHLCV loader (key-gated, throttled HTTP)."""
name = "finnhub"
markets = {"us_equity"}
requires_auth = True
def __init__(self) -> None:
"""Initialize the loader without touching the network or credentials.
Construction never raises on a missing key; availability is reported
separately via :meth:`is_available` so the fallback chain can keep
walking when the key is absent.
"""
pass
def is_available(self) -> bool:
"""Return whether a Finnhub API key is present in the environment."""
from src.config.accessor import get_env_config
return bool(get_env_config().data.finnhub_api_key)
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch daily OHLCV history keyed by the original project symbols.
Each symbol is fetched independently through the opt-in loader cache;
a single failing symbol is logged and skipped so it never aborts the
batch.
Args:
codes: Project symbols such as ``AAPL.US``.
start_date: Inclusive start date in ``YYYY-MM-DD`` format.
end_date: Inclusive end date in ``YYYY-MM-DD`` format.
interval: Backtest interval; only daily (``1D``) is supported.
fields: Ignored; included for interface compatibility.
Returns:
Mapping of input symbol to a DataFrame indexed by a ``trade_date``
:class:`~pandas.DatetimeIndex` with float ``open``/``high``/``low``/
``close``/``volume`` columns. Symbols without data are omitted.
Raises:
ValueError: If ``start_date`` > ``end_date``.
"""
del fields
validate_date_range(start_date, end_date)
from src.config.accessor import get_env_config
api_key = get_env_config().data.finnhub_api_key
if not api_key:
logger.warning("finnhub fetch skipped: %s not set", _API_KEY_ENV)
return {}
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(
code, start_date, end_date, api_key
),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc: # noqa: BLE001 - one symbol must not abort the batch
logger.warning("finnhub failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, api_key: str
) -> Optional[pd.DataFrame]:
"""Fetch and normalize one symbol's daily candles.
Args:
code: Project-side symbol.
start_date: Inclusive start date (``YYYY-MM-DD``).
end_date: Inclusive end date (``YYYY-MM-DD``).
api_key: Finnhub API token for the ``token`` query parameter.
Returns:
A normalized OHLCV DataFrame, or ``None`` when Finnhub reports no
usable data for the window.
"""
payload = throttled_get_json(
_CANDLE_URL,
host_key=_HOST_KEY,
min_interval=_min_interval(),
params={
"symbol": _to_finnhub_symbol(code),
"resolution": _RESOLUTION,
"from": _to_epoch_seconds(start_date, end_of_day=False),
"to": _to_epoch_seconds(end_date, end_of_day=True),
"token": api_key,
},
)
rows = _rows_from_payload(payload)
if not rows:
return None
df = pd.DataFrame(rows)
# Epoch seconds -> nanosecond-resolution DatetimeIndex so the index
# dtype (``datetime64[ns]``) stays consistent with the other loaders
# rather than collapsing to the second resolution pandas infers from a
# ``unit="s"`` conversion.
df["trade_date"] = pd.to_datetime(df["trade_date"], unit="s").astype(
"datetime64[ns]"
)
df = df.set_index("trade_date").sort_index()
df = df[_OHLCV_COLUMNS].astype(float).dropna(
subset=["open", "high", "low", "close"]
)
return df
+221
View File
@@ -0,0 +1,221 @@
"""Financial Modeling Prep (FMP) loader: key-gated US-equity OHLCV via HTTP.
FMP exposes a daily historical-price endpoint that, like other free quote
providers, rate-limits by source IP and must be throttled. Every request here
routes through :mod:`backtest.loaders._http` so calls share one process-wide
minimum-spacing gate and a reused session.
API format (public, documented):
https://financialmodelingprep.com/api/v3/historical-price-full/{SYMBOL}
?from=YYYY-MM-DD&to=YYYY-MM-DD&apikey=KEY
The JSON body is ``{"symbol": "AAPL", "historical": [{date, open, high, low,
close, volume}, ...]}`` in descending date order; an unknown symbol or empty
window yields an empty/absent ``historical`` array.
Auth: set ``FMP_API_KEY`` in the environment. Covers US equities only.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import pandas as pd
from backtest.loaders._http import resolve_min_interval, throttled_get_json
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_API_KEY_ENV = "FMP_API_KEY"
_BASE_URL = "https://financialmodelingprep.com/api/v3/historical-price-full"
# Shared throttle/session bucket for every FMP request in this process.
_HOST_KEY = "fmp"
_MIN_INTERVAL_ENV = "VIBE_TRADING_FMP_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL_S = 0.3
# FMP daily bars carry these numeric fields; emitted in this column order.
_OHLCV_FIELDS = ("open", "high", "low", "close", "volume")
def _api_key() -> str:
"""Return the FMP API key from the environment, stripped (``""`` if unset)."""
from src.config.accessor import get_env_config
return get_env_config().data.fmp_api_key.strip()
def _min_interval() -> float:
"""Resolve the per-call minimum spacing, honoring the env override."""
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
def _fmp_symbol(code: str) -> str:
"""Translate a project symbol into FMP's bare-ticker convention.
FMP carries US tickers bare, so a trailing ``.US`` suffix (the project's
US-equity marker) is dropped; everything else is upper-cased and passed
through unchanged.
Args:
code: Project-side symbol, e.g. ``AAPL`` or ``AAPL.US``.
Returns:
The FMP ticker (suffix stripped, upper-cased).
"""
cleaned = code.strip().upper()
if cleaned.endswith(".US"):
cleaned = cleaned[: -len(".US")]
return cleaned
@register
class DataLoader:
"""Financial Modeling Prep US-equity OHLCV loader (key-gated, HTTP)."""
name = "fmp"
markets = {"us_equity"}
requires_auth = True
def __init__(self) -> None:
pass
def is_available(self) -> bool:
"""Available when ``FMP_API_KEY`` is set to a non-empty value."""
return bool(_api_key())
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch daily OHLCV bars from FMP, one symbol at a time.
A single failing symbol is logged and skipped so it never aborts the
rest of the batch.
Args:
codes: Project symbols (e.g. ``["AAPL", "MSFT.US"]``).
start_date: Inclusive start date, ``YYYY-MM-DD``.
end_date: Inclusive end date, ``YYYY-MM-DD``.
interval: Bar size; only ``"1D"`` is supported (others skipped).
fields: Ignored — FMP returns a fixed OHLCV schema.
Returns:
Mapping ``{symbol: DataFrame(trade_date, open, high, low, close,
volume)}`` for every symbol that returned non-empty data.
Raises:
ValueError: If ``start_date`` > ``end_date`` (via
:func:`validate_date_range`).
"""
validate_date_range(start_date, end_date)
if interval != "1D":
logger.warning("fmp only supports 1D bars; got interval=%r", interval)
return {}
if not self.is_available():
logger.warning("fmp fetch skipped: %s not set", _API_KEY_ENV)
return {}
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("fmp failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""Fetch and parse one symbol's daily bars; ``None`` on no data.
Args:
code: Project symbol to fetch.
start_date: Inclusive start date, ``YYYY-MM-DD``.
end_date: Inclusive end date, ``YYYY-MM-DD``.
Returns:
An ascending OHLCV DataFrame indexed by ``trade_date``, or ``None``
when FMP reports no bars for the symbol/window.
Raises:
RuntimeError: If the API key is missing at call time.
requests.RequestException: Propagated from the HTTP layer.
"""
api_key = _api_key()
if not api_key:
raise RuntimeError(f"{_API_KEY_ENV} is not set")
symbol = _fmp_symbol(code)
if not symbol:
return None
payload = throttled_get_json(
f"{_BASE_URL}/{symbol}",
host_key=_HOST_KEY,
min_interval=_min_interval(),
params={"from": start_date, "to": end_date, "apikey": api_key},
)
return _parse_historical(payload)
def _parse_historical(payload: Any) -> Optional[pd.DataFrame]:
"""Convert an FMP historical-price body into an ascending OHLCV frame.
Args:
payload: Decoded JSON body from the historical-price endpoint.
Returns:
DataFrame indexed by ``trade_date`` with float ``open/high/low/close/
volume`` columns, or ``None`` when no usable rows are present.
"""
historical = (payload or {}).get("historical") if isinstance(payload, dict) else None
if not historical:
return None
rows = []
for bar in historical:
if not isinstance(bar, dict) or "date" not in bar:
continue
rows.append(
{
"trade_date": bar["date"],
**{field: bar.get(field) for field in _OHLCV_FIELDS},
}
)
if not rows:
return None
df = pd.DataFrame(rows)
df["trade_date"] = pd.to_datetime(df["trade_date"])
for field in _OHLCV_FIELDS:
# Cast to float (not just to_numeric) so integer volume from the API
# does not leave the column int64 and break the float-OHLCV contract.
df[field] = pd.to_numeric(df[field], errors="coerce").astype(float)
df = df.set_index("trade_date").sort_index()
df = df[list(_OHLCV_FIELDS)].dropna(subset=["open", "high", "low", "close"])
if df.empty:
return None
return df
@@ -0,0 +1,479 @@
"""Translate sparse SEC facts into PIT-safe daily fundamental panels.
The loader's core invariant is point-in-time safety: values become visible on
their SEC ``filed`` date, never on ``period_end``. Phase 1 implements the SEC
branch only. ``freq="ttm"`` uses a pragmatic Phase-1 approximation: known
income-statement and cash-flow concepts are rolling four-quarter sums keyed by
``period_end`` and anchored by the latest ``filed`` date in the rolling window;
balance-sheet and share-count concepts use the latest quarterly value. SEC
companyfacts can mix true-quarter, year-to-date, and annual facts, so upstream
schema normalization may still be needed for issuer-specific precision.
"""
from __future__ import annotations
import importlib
import logging
from collections.abc import Iterable
from typing import Any
import pandas as pd
from backtest.loaders import sec_edgar_client
from backtest.loaders.base import cached_loader_fetch, validate_date_range
logger = logging.getLogger(__name__)
_VALID_FREQS = {"annual", "quarterly", "ttm"}
_ANNUAL_FORMS = {"10-K"}
_QUARTERLY_FORMS = {"10-Q", "10-K"}
_FLOW_CONCEPTS = {
"RevenueFromContractWithCustomerExcludingAssessedTax",
"RevenueFromContractWithCustomerIncludingAssessedTax",
"Revenues",
"SalesRevenueNet",
"CostOfGoodsAndServicesSold",
"CostOfRevenue",
"OperatingIncomeLoss",
"NetIncomeLoss",
"ProfitLoss",
"NetCashProvidedByUsedInOperatingActivities",
"PaymentsToAcquirePropertyPlantAndEquipment",
}
def _extract_concept_series(
facts: dict[str, Any],
concepts: list[str],
freq: str,
*,
pit: bool = True,
) -> pd.DataFrame:
"""Extract a sparse ``period_end``/``filed``/``value`` series.
Args:
facts: SEC companyfacts payload.
concepts: Ordered us-gaap concept aliases to union.
freq: ``"annual"``, ``"quarterly"``, or ``"ttm"``.
pit: When true, duplicate ``period_end`` rows keep the earliest filing;
when false, they keep the latest filing for research mode.
Returns:
DataFrame with ``period_end``, ``filed``, and ``value`` columns.
Raises:
ValueError: If ``freq`` is unsupported.
"""
if freq not in _VALID_FREQS:
raise ValueError(f"unsupported fundamental freq: {freq}")
rows: list[dict[str, Any]] = []
us_gaap = facts.get("facts", {}).get("us-gaap", {})
for concept_order, concept in enumerate(concepts):
concept_node = us_gaap.get(concept, {})
units = concept_node.get("units", {}) if isinstance(concept_node, dict) else {}
if not isinstance(units, dict):
continue
for unit_items in units.values():
if not isinstance(unit_items, list):
continue
for item in unit_items:
if not isinstance(item, dict):
continue
if not _form_allowed(str(item.get("form", "")), freq):
continue
rows.append(
{
"period_start": item.get("start"),
"period_end": item.get("end"),
"filed": item.get("filed"),
"value": item.get("val"),
"_concept_order": concept_order,
}
)
columns = ["period_end", "filed", "value"]
if not rows:
return pd.DataFrame(columns=columns)
df = pd.DataFrame(rows)
df["period_start"] = pd.to_datetime(df["period_start"], errors="coerce").dt.normalize()
df["period_end"] = pd.to_datetime(df["period_end"], errors="coerce").dt.normalize()
df["filed"] = pd.to_datetime(df["filed"], errors="coerce").dt.normalize()
df["value"] = pd.to_numeric(df["value"], errors="coerce")
df = df.dropna(subset=["period_end", "filed", "value"])
if df.empty:
return pd.DataFrame(columns=columns)
keep = "first" if pit else "last"
if _is_flow_concept_series(concepts):
# Duration entries mix true quarters, year-to-date frames sharing the
# same end date, and full-year frames; summing them blindly double
# counts. Select by (start, end) span before any dedupe or rolling.
df = df.dropna(subset=["period_start"])
df = df.sort_values(["period_start", "period_end", "filed", "_concept_order"])
df = df.drop_duplicates(subset=["period_start", "period_end"], keep=keep)
duration = (df["period_end"] - df["period_start"]).dt.days
if freq == "annual":
df = df[duration.between(330, 380)]
else:
df = _quarterly_flow_frames(df, duration)
else:
df = df.sort_values(["period_end", "filed", "_concept_order"])
df = df.drop_duplicates(subset=["period_end"], keep=keep).sort_values("period_end")
if freq == "ttm" and _is_flow_concept_series(concepts):
df = _to_ttm_flow_series(df)
return df.loc[:, columns].sort_values("period_end").reset_index(drop=True)
def _quarterly_flow_frames(df: pd.DataFrame, duration: pd.Series) -> pd.DataFrame:
"""Return true-quarter flow frames, deriving fiscal Q4 from 10-K full years.
Filers like AAPL report Q4 only inside the 10-K full-year frame, so a
missing quarter ending at the fiscal year end is synthesized as
``FY - (Q1 + Q2 + Q3)``. The synthesized row is anchored on the 10-K
``filed`` date, which keeps it PIT-safe.
"""
quarters = df[duration.between(60, 120)].copy()
annuals = df[duration.between(330, 380)]
quarter_ends = set(quarters["period_end"])
synthesized: list[dict[str, Any]] = []
for annual in annuals.to_dict("records"):
if annual["period_end"] in quarter_ends:
continue
inside = quarters[
(quarters["period_start"] >= annual["period_start"])
& (quarters["period_end"] < annual["period_end"])
]
if len(inside) != 3:
continue
synthesized.append(
{
"period_start": inside["period_end"].max(),
"period_end": annual["period_end"],
"filed": max(annual["filed"], inside["filed"].max()),
"value": annual["value"] - inside["value"].sum(),
"_concept_order": annual["_concept_order"],
}
)
if synthesized:
quarters = pd.concat([quarters, pd.DataFrame(synthesized)], ignore_index=True)
return quarters
def _ffill_pit(series: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series:
"""Forward-fill sparse values onto ``index`` using ``filed`` as the anchor.
Args:
series: DataFrame containing ``filed`` and ``value`` columns.
index: Target dates.
Returns:
Dense Series indexed like ``index``.
"""
target_index = pd.DatetimeIndex(index)
if series.empty:
return pd.Series(index=target_index, dtype="float64")
sparse = series.copy()
sparse["filed"] = pd.to_datetime(sparse["filed"], errors="coerce").dt.normalize()
sparse["value"] = pd.to_numeric(sparse["value"], errors="coerce")
sparse = sparse.dropna(subset=["filed", "value"]).sort_values("filed")
if sparse.empty:
return pd.Series(index=target_index, dtype="float64")
values = sparse.set_index("filed")["value"].sort_index()
values = values[~values.index.duplicated(keep="last")]
aligned_index = values.index.union(target_index)
return values.reindex(aligned_index).ffill().reindex(target_index)
def load_fundamental_panel(
symbols: list[str],
fields: list[str],
start: str,
end: str,
freq: str = "ttm",
pit: bool = True,
source: str = "auto",
index: pd.DatetimeIndex | None = None,
) -> dict[str, pd.DataFrame]:
"""Load SEC fundamental fields as dense date-by-symbol panels.
Args:
symbols: Tickers to load.
fields: Unified schema field names, raw or derived.
start: Inclusive start date.
end: Inclusive end date.
freq: ``"annual"``, ``"quarterly"``, or ``"ttm"``.
pit: True keeps first filed facts per period; false keeps latest filed.
source: ``"auto"`` or ``"sec"``. Phase 1 routes both to SEC.
index: Optional target date index. When omitted, calendar days from
``start`` through ``end`` are used.
Returns:
Mapping ``field -> DataFrame(index=dates, columns=symbols)``.
Raises:
ValueError: If source or frequency is unsupported.
RuntimeError: If the fundamental schema module is unavailable.
"""
validate_date_range(start, end)
if freq not in _VALID_FREQS:
raise ValueError(f"unsupported fundamental freq: {freq}")
if source not in {"auto", "sec"}:
raise ValueError("Phase 1 fundamentals loader supports source='auto' or 'sec' only")
target_index = _target_index(start, end, index)
schema = _load_schema()
requested_fields = [_resolve_field_name(schema, field) for field in fields]
raw_fields = sorted(_collect_raw_fields(schema, requested_fields))
symbol_list = list(symbols)
ciks = _resolve_ciks(symbol_list)
symbol_raw_frames = {
symbol: _load_symbol_raw_frame(
symbol=symbol,
cik=ciks.get(symbol),
raw_fields=raw_fields,
schema=schema,
start=start,
end=end,
freq=freq,
pit=pit,
index=target_index,
)
for symbol in symbol_list
}
panels: dict[str, pd.DataFrame] = {}
for field in raw_fields:
panels[field] = pd.DataFrame(
{symbol: symbol_raw_frames[symbol][field] for symbol in symbol_list},
index=target_index,
columns=symbol_list,
)
def panel_for(field: str) -> pd.DataFrame:
if field in panels:
return panels[field]
derived = _derived_field(schema, field)
if derived is None:
panels[field] = _empty_panel(target_index, symbol_list)
return panels[field]
deps = {
_resolve_field_name(schema, dep): panel_for(_resolve_field_name(schema, dep))
for dep in _derived_dependencies(derived)
}
computed = _compute_derived(derived, deps)
panels[field] = computed.reindex(index=target_index, columns=symbol_list)
return panels[field]
return {field: panel_for(field) for field in requested_fields}
def _form_allowed(form: str, freq: str) -> bool:
if freq == "annual":
return form in _ANNUAL_FORMS
if freq in {"quarterly", "ttm"}:
return form in _QUARTERLY_FORMS
return False
def _is_flow_concept_series(concepts: Iterable[str]) -> bool:
return any(concept in _FLOW_CONCEPTS for concept in concepts)
def _to_ttm_flow_series(df: pd.DataFrame) -> pd.DataFrame:
ttm = df.sort_values("period_end").copy()
ttm["value"] = ttm["value"].rolling(window=4, min_periods=4).sum()
filed_values = list(ttm["filed"])
ttm["filed"] = [
pd.NaT if pos < 3 else max(filed_values[pos - 3 : pos + 1])
for pos in range(len(filed_values))
]
return ttm.dropna(subset=["filed", "value"])
def _target_index(
start: str,
end: str,
index: pd.DatetimeIndex | None,
) -> pd.DatetimeIndex:
if index is not None:
return pd.DatetimeIndex(index)
return pd.date_range(pd.Timestamp(start), pd.Timestamp(end), freq="D")
def _load_schema() -> Any:
try:
return importlib.import_module("backtest.loaders._fundamental_schema")
except ModuleNotFoundError as exc:
raise RuntimeError(
"backtest.loaders._fundamental_schema is required for fundamentals loading"
) from exc
def _resolve_field_name(schema: Any, field: str) -> str:
normalized = str(field).removeprefix("fund:")
resolver = getattr(schema, "resolve_field", None)
if not callable(resolver):
return normalized
resolved = resolver(normalized)
if isinstance(resolved, str):
return resolved.removeprefix("fund:")
name = getattr(resolved, "name", None)
if isinstance(name, str):
return name.removeprefix("fund:")
return normalized
def _collect_raw_fields(schema: Any, fields: Iterable[str]) -> set[str]:
raw: set[str] = set()
visiting: set[str] = set()
def visit(field: str) -> None:
if field in visiting:
raise ValueError(f"cyclic derived fundamental field: {field}")
derived = _derived_field(schema, field)
if derived is None:
raw.add(field)
return
visiting.add(field)
for dep in _derived_dependencies(derived):
visit(_resolve_field_name(schema, dep))
visiting.remove(field)
for field in fields:
visit(field)
return raw
def _derived_field(schema: Any, field: str) -> Any | None:
return getattr(schema, "DERIVED_FIELDS", {}).get(field)
def _spec_get(derived: Any, key: str) -> Any:
"""Read a derived-field spec entry from either a mapping or an object."""
if isinstance(derived, dict):
return derived.get(key)
return getattr(derived, key, None)
def _derived_dependencies(derived: Any) -> list[str]:
deps = _spec_get(derived, "dependencies") or ()
return [str(dep) for dep in deps]
def _compute_derived(derived: Any, dependencies: dict[str, pd.DataFrame]) -> pd.DataFrame:
compute = _spec_get(derived, "compute")
if not callable(compute):
raise ValueError(f"derived field has no callable compute: {derived!r}")
try:
result = compute(dependencies)
except TypeError:
result = compute(**dependencies)
if not isinstance(result, pd.DataFrame):
result = pd.DataFrame(result)
return result
def _resolve_ciks(symbols: list[str]) -> dict[str, str | None]:
ciks: dict[str, str | None] = {}
missing: list[str] = []
for symbol in symbols:
cik = sec_edgar_client.cik_for(symbol)
ciks[symbol] = cik
if cik is None:
missing.append(symbol)
if missing:
logger.warning("No SEC CIK for symbols: %s", ", ".join(missing))
return ciks
def _load_symbol_raw_frame(
*,
symbol: str,
cik: str | None,
raw_fields: list[str],
schema: Any,
start: str,
end: str,
freq: str,
pit: bool,
index: pd.DatetimeIndex,
) -> pd.DataFrame:
if cik is None:
return _empty_field_frame(index, raw_fields)
def fetch() -> pd.DataFrame:
return _fetch_symbol_raw_frame(
symbol=symbol,
cik=cik,
raw_fields=raw_fields,
schema=schema,
freq=freq,
pit=pit,
index=index,
)
cached = cached_loader_fetch(
source="sec_fundamentals",
symbol=symbol,
timeframe=f"{freq}:pit={int(pit)}",
start_date=start,
end_date=end,
fields=raw_fields,
fetch=fetch,
)
if cached is None:
return _empty_field_frame(index, raw_fields)
return cached.reindex(index=index, columns=raw_fields)
def _fetch_symbol_raw_frame(
*,
symbol: str,
cik: str,
raw_fields: list[str],
schema: Any,
freq: str,
pit: bool,
index: pd.DatetimeIndex,
) -> pd.DataFrame:
try:
facts = sec_edgar_client.get_company_facts(cik)
except Exception as exc: # noqa: BLE001 - one bad symbol should not abort the panel
logger.warning("SEC companyfacts failed for %s: %s", symbol, exc)
return _empty_field_frame(index, raw_fields)
frame = _empty_field_frame(index, raw_fields)
concept_map = getattr(schema, "SEC_CONCEPT_MAP", {})
for field in raw_fields:
concepts = list(concept_map.get(field, ()))
if not concepts:
logger.warning("SEC concept miss for %s field %s: no aliases configured", symbol, field)
continue
sparse = _extract_concept_series(facts, concepts, freq, pit=pit)
if sparse.empty:
logger.warning(
"SEC concept miss for %s field %s: aliases=%s",
symbol,
field,
",".join(concepts),
)
continue
frame[field] = _ffill_pit(sparse, index)
return frame
def _empty_field_frame(index: pd.DatetimeIndex, fields: list[str]) -> pd.DataFrame:
return pd.DataFrame(index=index, columns=fields, dtype="float64")
def _empty_panel(index: pd.DatetimeIndex, symbols: list[str]) -> pd.DataFrame:
return pd.DataFrame(index=index, columns=symbols, dtype="float64")
+107 -22
View File
@@ -3,12 +3,16 @@
from __future__ import annotations
import logging
import os
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import NoAvailableSourceError, validate_date_range
from backtest.loaders.base import (
NoAvailableSourceError,
loader_cache_get,
loader_cache_put,
validate_date_range,
)
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
@@ -16,6 +20,10 @@ logger = logging.getLogger(__name__)
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
_INTERVAL_MAP: dict[str, str] = {
"1m": "K_1M",
"5m": "K_5M",
"15m": "K_15M",
"30m": "K_30M",
"1D": "K_DAY",
"1H": "K_60M",
"4H": "K_240M",
@@ -47,10 +55,24 @@ def _to_futu_ktype(interval: str):
"""Map project interval string to a futu KLType enum value.
Lazy-imports futu so the module can be imported without futu installed.
Unknown intervals fall back to K_DAY.
Unsupported intervals fail explicitly so requested bar fidelity is never
changed silently.
"""
from futu import KLType # noqa: PLC0415
return getattr(KLType, _INTERVAL_MAP.get(interval.strip(), "K_DAY"))
token = interval.strip()
attr = _INTERVAL_MAP.get(token)
if attr is None:
raise NoAvailableSourceError(
f"unsupported Futu interval: {interval!r}; "
f"supported intervals: {sorted(_INTERVAL_MAP)}"
)
try:
return getattr(KLType, attr)
except AttributeError as exc:
raise NoAvailableSourceError(
f"installed Futu SDK does not expose KLType.{attr}"
) from exc
def _normalize_frame(df: pd.DataFrame) -> pd.DataFrame:
@@ -73,7 +95,8 @@ def _normalize_frame(df: pd.DataFrame) -> pd.DataFrame:
result = result.apply(pd.to_numeric, errors="coerce")
result["volume"] = result["volume"].fillna(0.0)
result = result.dropna(subset=["open", "high", "low", "close"])
return result.sort_index()
result = result.sort_index()
return result[~result.index.duplicated(keep="last")]
@register
@@ -89,12 +112,18 @@ class FutuLoader:
requires_auth = True
def __init__(self) -> None:
self._host = os.environ.get("FUTU_HOST", "127.0.0.1")
self._port = int(os.environ.get("FUTU_PORT", "11111"))
from src.config.accessor import get_env_config
cfg = get_env_config().data
self._host = cfg.futu_host
self._port = cfg.futu_port
def is_available(self) -> bool:
"""Return True if env vars are set and FutuOpenD is reachable."""
if not os.environ.get("FUTU_HOST") or not os.environ.get("FUTU_PORT"):
"""Return True if FutuOpenD is reachable."""
from src.config.accessor import get_env_config
cfg = get_env_config().data
if not cfg.futu_host or not cfg.futu_port:
return False
try:
import futu # noqa: PLC0415
@@ -119,7 +148,7 @@ class FutuLoader:
codes: Project symbols such as ``700.HK`` or ``000001.SZ``.
start_date: Start date in ``YYYY-MM-DD`` format.
end_date: End date in ``YYYY-MM-DD`` format.
interval: Backtest interval — ``1D``, ``1H``, or ``4H``.
interval: Backtest interval supported by :data:`_INTERVAL_MAP`.
fields: Ignored; included for interface compatibility.
Returns:
@@ -132,9 +161,38 @@ class FutuLoader:
if not codes:
return {}
validate_date_range(start_date, end_date)
if interval.strip() not in _INTERVAL_MAP:
raise NoAvailableSourceError(
f"unsupported Futu interval: {interval!r}; "
f"supported intervals: {sorted(_INTERVAL_MAP)}"
)
results: Dict[str, pd.DataFrame] = {}
# Serve cached symbols first; only open a FutuOpenD connection when at
# least one symbol is uncached, so a fully-cached request needs no
# running gateway.
pending: List[str] = []
for code in codes:
cached = loader_cache_get(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
)
if cached is not None and not cached.empty:
results[code] = cached.copy()
else:
pending.append(code)
if not pending:
return results
try:
import futu # noqa: PLC0415
ktype = _to_futu_ktype(interval)
ctx = futu.OpenQuoteContext(host=self._host, port=self._port)
except Exception as exc:
@@ -142,21 +200,48 @@ class FutuLoader:
f"Cannot connect to FutuOpenD at {self._host}:{self._port}: {exc}"
) from exc
results: Dict[str, pd.DataFrame] = {}
try:
for code in codes:
for code in pending:
futu_code = _to_futu_symbol(code)
ret, data = ctx.request_history_kline(
futu_code,
start=start_date,
end=end_date,
ktype=ktype,
max_count=10_000,
)
if ret != futu.RET_OK:
logger.warning("Futu returned error for %s: %s", futu_code, data)
page_key = None
pages: List[pd.DataFrame] = []
failed = False
while True:
ret, data, next_page_key = ctx.request_history_kline(
futu_code,
start=start_date,
end=end_date,
ktype=ktype,
max_count=10_000,
page_req_key=page_key,
)
if ret != futu.RET_OK:
logger.warning(
"Futu returned error for %s: %s", futu_code, data
)
failed = True
break
if isinstance(data, pd.DataFrame) and not data.empty:
pages.append(data)
if not next_page_key:
break
page_key = next_page_key
if failed or not pages:
continue
results[code] = _normalize_frame(data)
normalized = _normalize_frame(pd.concat(pages, ignore_index=True))
if normalized.empty:
continue
loader_cache_put(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
frame=normalized,
)
results[code] = normalized
finally:
ctx.close()
@@ -0,0 +1,175 @@
"""India broker data bridge: feed Shoonya / Dhan history into the backtest layer.
The Shoonya (Finvasia) and Dhan connectors already expose live-account market
data via ``get_historical_bars`` (read path). This loader adapts that envelope
into the standard OHLCV frame so a user's *broker* history can back the same
backtests as the public Yahoo feed — useful when matching a live account exactly
or pulling symbols Yahoo lacks.
It is an OPT-IN source (``requires_auth``): ``is_available`` is True only when a
broker SDK is importable AND a broker is configured, so it trails Yahoo/yfinance
in the ``india_equity`` fallback chain and never fires in CI / unconfigured runs.
Symbol convention: project ``RELIANCE.NS`` / ``500325.BO`` → broker ``RELIANCE``
on exchange ``NSE`` / ``BSE`` (the suffix selects the exchange; the base symbol
is passed bare).
Limitation: broker endpoints return a bounded window of *recent* bars (period +
limit), not an arbitrary historical start. This loader requests enough bars to
cover the window and clips to ``[start_date, end_date]``; very old ranges may
come back short. For deep history prefer Yahoo.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_OUTPUT_COLUMNS = ["open", "high", "low", "close", "volume"]
# Project interval -> broker ``period`` token (both connectors share this set).
_PERIOD_MAP = {"1D": "1d", "1H": "1h", "5m": "5m", "15m": "15m", "30m": "30m", "1m": "1m"}
def _resolve_broker():
"""Return ``(broker_key, sdk_module)`` for the first available India broker.
Prefers Shoonya, then Dhan. Returns ``(None, None)`` when neither the SDK
nor a config is present. Import is deferred and defensive so a missing
``src.trading`` package or broker SDK simply means "unavailable", never a
crash in the loader registry.
"""
try:
from src.trading.connectors.shoonya import sdk as shoonya_sdk
if shoonya_sdk.shoonya_available():
return "shoonya", shoonya_sdk
except Exception as exc: # noqa: BLE001 — optional dependency / config
logger.debug("shoonya bridge unavailable: %s", exc)
try:
from src.trading.connectors.dhan import sdk as dhan_sdk
if dhan_sdk.dhan_available():
return "dhan", dhan_sdk
except Exception as exc: # noqa: BLE001
logger.debug("dhan bridge unavailable: %s", exc)
return None, None
def _exchange_for(code: str) -> str:
"""Map the project suffix to the broker exchange code (NSE / BSE)."""
return "BSE" if code.strip().upper().endswith(".BO") else "NSE"
def _base_symbol(code: str) -> str:
"""Strip the ``.NS`` / ``.BO`` suffix, leaving the broker's bare symbol."""
cleaned = code.strip()
upper = cleaned.upper()
if upper.endswith((".NS", ".BO")):
return cleaned[:-3]
return cleaned
def _bars_to_frame(bars: list[dict], start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Convert the broker ``bars`` list into a clipped OHLCV frame, or ``None``."""
if not bars:
return None
frame = pd.DataFrame(bars)
if "time" not in frame.columns:
return None
# ``time`` is epoch seconds (Shoonya ssboe / Dhan candle[0]) or an ISO string.
ts = pd.to_numeric(frame["time"], errors="coerce")
if ts.notna().any():
index = pd.to_datetime(ts, unit="s", errors="coerce")
else:
index = pd.to_datetime(frame["time"], errors="coerce")
frame = frame.drop(columns=["time"])
frame.index = pd.DatetimeIndex(index).tz_localize(None)
frame.index.name = "trade_date"
for col in _OUTPUT_COLUMNS:
if col not in frame.columns:
frame[col] = 0.0 if col == "volume" else pd.NA
frame = frame[_OUTPUT_COLUMNS].apply(pd.to_numeric, errors="coerce")
frame = frame.dropna(subset=["open", "high", "low", "close"]).sort_index()
lower = pd.Timestamp(start_date).normalize()
upper = pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)
frame = frame[(frame.index >= lower) & (frame.index < upper)]
return frame.astype(float) if not frame.empty else None
@register
class DataLoader:
"""Shoonya / Dhan history adapter for the ``india_equity`` market."""
name = "india_broker"
markets = {"india_equity"}
requires_auth = True
def __init__(self) -> None:
pass
def is_available(self) -> bool:
"""True only when an India broker SDK is importable and configured."""
broker, _ = _resolve_broker()
return broker is not None
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV history for ``codes`` from the configured India broker."""
del fields
if not codes:
return {}
validate_date_range(start_date, end_date)
broker, sdk = _resolve_broker()
if sdk is None:
return {}
period = _PERIOD_MAP.get(str(interval).strip(), "1d")
# Request enough bars to cover the window (business days + headroom).
span_days = max((pd.Timestamp(end_date) - pd.Timestamp(start_date)).days, 1)
limit = min(max(span_days, 30), 2000)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
envelope = sdk.get_historical_bars(
_base_symbol(code),
exchange=_exchange_for(code),
period=period,
limit=limit,
)
except TypeError:
# Dhan uses ``exchange_segment``; retry without the ``exchange`` kw.
try:
envelope = sdk.get_historical_bars(
_base_symbol(code), period=period, limit=limit
)
except Exception as exc: # noqa: BLE001 — one bad symbol never aborts
logger.warning("%s bridge failed for %s: %s", broker, code, exc)
continue
except Exception as exc: # noqa: BLE001
logger.warning("%s bridge failed for %s: %s", broker, code, exc)
continue
if not isinstance(envelope, dict) or str(envelope.get("status", "")).lower() != "ok":
continue
frame = _bars_to_frame(envelope.get("bars", []), start_date, end_date)
if frame is not None and not frame.empty:
result[code] = frame
return result
+350
View File
@@ -0,0 +1,350 @@
"""Local data loader: reads CSV, Parquet, and DuckDB files from user config.
Configuration lives at ``~/.vibe-trading/data-bridge/config.yaml``.
Each entry maps a symbol to a data file with optional column-name overrides,
date format, and (for DuckDB) an SQL query.
Example config::
sources:
- symbol: "AAPL.US"
type: csv
path: "~/data/aapl_2024.csv"
columns:
date: "Date"
open: "Open"
high: "High"
low: "Low"
close: "Close"
volume: "Volume"
date_format: "%Y-%m-%d"
- symbol: "BTC-USDT"
type: parquet
path: "~/data/btc.parquet"
- symbol: "MYINDEX"
type: duckdb
db_path: "~/data/market.duckdb"
query: "SELECT * FROM prices WHERE ticker = 'MYINDEX'"
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
import yaml
from backtest.loaders.base import cached_loader_fetch, validate_date_range, validate_ohlc
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_CONFIG_DIR = Path.home() / ".vibe-trading" / "data-bridge"
_CONFIG_PATH = _CONFIG_DIR / "config.yaml"
_DEFAULT_COLUMNS = {
"date": "date",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"volume": "volume",
}
# Map the project's bar intervals (see ``backtest.runner._VALID_INTERVALS``) to
# pandas offset aliases used for resampling. All entries are fixed-duration so
# ``pd.Timedelta`` of the alias is well defined.
_RESAMPLE_RULES = {
"1m": "1min",
"5m": "5min",
"15m": "15min",
"30m": "30min",
"1H": "1h",
"4H": "4h",
"1D": "1D",
}
_OHLCV_AGG = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
def _resample_to_interval(df: pd.DataFrame, interval: str, symbol: str) -> pd.DataFrame:
"""Resample an OHLCV frame to the requested bar ``interval``.
Local files can hold any native granularity, so a requested interval the
file does not already match must be honored explicitly. Previously
``interval`` was passed only to the cache key, so requesting e.g. ``4H``
against hourly data silently returned the file's native bars.
Coarser-than-source requests are downsampled with the standard OHLCV
aggregation. A finer-than-source request cannot be fabricated from a file,
so the source bars are returned unchanged with a warning. Requests that
already match the source granularity are returned unchanged.
"""
rule = _RESAMPLE_RULES.get(interval)
if df.empty:
return df
if rule is None:
logger.warning(
"local loader: unsupported interval %r for %s; returning source bars",
interval,
symbol,
)
return df
target = pd.Timedelta(rule)
if len(df.index) >= 2:
source = df.index.to_series().diff().dropna().median()
if pd.notna(source):
if target < source:
logger.warning(
"local loader: cannot upsample %s source bars to %s for %s; "
"returning source bars",
source,
interval,
symbol,
)
return df
if target == source:
return df
resampled = df.resample(rule).agg(_OHLCV_AGG)
resampled = resampled.dropna(subset=["open", "high", "low", "close"])
resampled.index.name = df.index.name
return resampled
def _load_config() -> dict[str, Any] | None:
if not _CONFIG_PATH.exists():
return None
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def _normalize_columns(
df: pd.DataFrame,
col_map: dict[str, str],
date_fmt: str | None,
) -> pd.DataFrame | None:
rename: dict[str, str] = {}
for std_name, src_name in col_map.items():
if std_name == "date":
continue
if src_name in df.columns:
rename[src_name] = std_name
df = df.rename(columns=rename)
required = {"open", "high", "low", "close"}
if not required.issubset(df.columns):
return None
date_col = col_map.get("date", "date")
if date_col not in df.columns:
return None
if date_fmt:
parsed_dates = pd.to_datetime(
df[date_col], format=date_fmt, errors="coerce", utc=True
)
else:
parsed_dates = pd.to_datetime(df[date_col], errors="coerce", utc=True)
# Normalize every input to the loader's UTC-naive index contract. Parsing
# as UTC first also handles files that span daylight-saving offset changes.
df["trade_date"] = parsed_dates.dt.tz_convert(None)
df = df.dropna(subset=["trade_date"])
df = df.set_index("trade_date").sort_index()
ohlcv_cols: list[str] = ["open", "high", "low", "close"]
if "volume" in df.columns:
ohlcv_cols.append("volume")
df = df[ohlcv_cols]
for col in ohlcv_cols:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["open", "high", "low", "close"])
df = validate_ohlc(df)
for col in ohlcv_cols:
df[col] = df[col].astype("float64")
if "volume" not in df.columns:
df["volume"] = 0.0
return df
def _read_csv(path: str, col_map: dict[str, str], date_fmt: str | None) -> pd.DataFrame | None:
df = pd.read_csv(path)
return _normalize_columns(df, col_map, date_fmt)
def _read_parquet(path: str, col_map: dict[str, str], date_fmt: str | None) -> pd.DataFrame | None:
df = pd.read_parquet(path)
if isinstance(df.index, pd.DatetimeIndex):
df = df.reset_index()
if date_fmt is None and col_map.get("date", "date") not in df.columns:
col_map = dict(col_map)
col_map["date"] = df.columns[0]
return _normalize_columns(df, col_map, date_fmt)
def _read_duckdb(
db_path: str, query: str, col_map: dict[str, str], date_fmt: str | None
) -> pd.DataFrame | None:
import duckdb
with duckdb.connect(db_path, read_only=True) as conn:
df = conn.execute(query).df()
return _normalize_columns(df, col_map, date_fmt)
_READERS = {
"csv": _read_csv,
"parquet": _read_parquet,
"duckdb": _read_duckdb,
}
@register
class DataLoader:
"""Config-driven local data loader for CSV, Parquet, and DuckDB."""
name = "local"
markets = {"us_equity", "a_share", "hk_equity", "crypto", "futures", "forex", "macro", "fund"}
requires_auth = False
def __init__(self) -> None:
self._config: dict[str, Any] | None = None
self._source_by_symbol: dict[str, dict[str, Any]] = {}
def is_available(self) -> bool:
"""Return True when the YAML config file exists and has sources."""
config = _load_config()
if config is None:
return False
sources = config.get("sources")
return isinstance(sources, list) and len(sources) > 0
def _ensure_config(self) -> None:
if self._config is not None:
return
self._config = _load_config() or {}
self._source_by_symbol = {}
for entry in self._config.get("sources", []):
symbol = entry.get("symbol", "").strip()
if symbol:
self._source_by_symbol[symbol] = entry
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV data for each code from configured local sources.
Args:
codes: Symbol list, optionally prefixed with ``local:``.
start_date: YYYY-MM-DD.
end_date: YYYY-MM-DD.
interval: Bar size (all intervals supported if data contains them).
fields: Ignored.
Returns:
Mapping clean_symbol -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
self._ensure_config()
result: Dict[str, pd.DataFrame] = {}
for code in codes:
clean = code.split(":", 1)[1] if code.startswith("local:") else code
entry = self._source_by_symbol.get(clean)
if entry is None:
logger.warning("local loader: no config entry for symbol %s", clean)
continue
try:
df = cached_loader_fetch(
source=self.name,
symbol=clean,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda c=clean: self._fetch_one(c, start_date, end_date, interval),
)
if df is not None and not df.empty:
result[clean] = df
except Exception as exc:
logger.warning("local loader failed for %s: %s", clean, exc)
return result
def _fetch_one(
self, symbol: str, start_date: str, end_date: str, interval: str = "1D",
) -> pd.DataFrame | None:
entry = self._source_by_symbol.get(symbol)
if entry is None:
return None
src_type: str = entry.get("type", "csv").strip().lower()
reader = _READERS.get(src_type)
if reader is None:
logger.warning("local loader: unsupported type %r for symbol %s", src_type, symbol)
return None
col_map: dict[str, str] = dict(_DEFAULT_COLUMNS)
user_cols = entry.get("columns")
if isinstance(user_cols, dict):
for k, v in user_cols.items():
if isinstance(v, str):
col_map[k] = v
date_fmt: str | None = None
user_fmt = entry.get("date_format")
if isinstance(user_fmt, str) and user_fmt.strip():
date_fmt = user_fmt.strip()
if src_type == "duckdb":
db_path = str(Path(entry.get("db_path", "")).expanduser())
query = entry.get("query", "").strip()
if not db_path or not query:
logger.warning(
"local loader: missing db_path or query for duckdb symbol %s", symbol
)
return None
df = _read_duckdb(db_path, query, col_map, date_fmt)
else:
path = entry.get("path", "").strip()
if not path:
logger.warning("local loader: missing path for symbol %s", symbol)
return None
expanded = str(Path(path).expanduser())
df = reader(expanded, col_map, date_fmt)
if df is None:
return None
start = pd.Timestamp(start_date)
# Treat ``end_date`` as inclusive of the whole day so intraday bars on
# the end day survive the filter (a bare midnight bound dropped them,
# which would defeat any sub-daily ``interval``).
end = pd.Timestamp(end_date) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
df = df[(df.index >= start) & (df.index <= end)]
if df.empty:
return None
df = _resample_to_interval(df, interval, symbol)
if df.empty:
return None
return df
+409
View File
@@ -0,0 +1,409 @@
"""LongPort (Longbridge) OpenAPI-backed loader for US and HK equity OHLCV data.
Wraps the ``longbridge`` SDK :class:`~longbridge.openapi.QuoteContext` to fetch
historical candlesticks for backtesting. Supports US and HK equities.
Auth requires the three LongPort credentials declared in
``src.config.env_schema.DataConfig``:
``LONGBRIDGE_APP_KEY``, ``LONGBRIDGE_APP_SECRET``, ``LONGBRIDGE_ACCESS_TOKEN``.
Paper-vs-live identity guard: this loader does **not** discriminate between
paper and live environments (LongPort exposes no API field for it). The loaded
Access Token implicitly selects the environment. For backtest purposes the
historical bars are identical regardless of source account.
The LongPort ``history_candlesticks_by_date`` endpoint caps responses at
~1000 bars per call. Date ranges longer than ~4 years of daily bars are
automatically split into sequential 180-day windows. Requests wider than the
bounded window budget fail explicitly instead of returning truncated history.
This module is for backtest data only; live trading uses the separate
``src.trading.connectors.longbridge.sdk`` module.
"""
from __future__ import annotations
import datetime as dt
import logging
from typing import Any, List, Optional
import pandas as pd
from backtest.loaders.base import (
NoAvailableSourceError,
loader_cache_get,
loader_cache_put,
validate_date_range,
)
from backtest.loaders.registry import register
from src.trading.connectors.longbridge.credentials import (
LongbridgeCredentialError,
resolve_longbridge_credentials,
)
logger = logging.getLogger(__name__)
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
_INTERVAL_MAP: dict[str, str] = {
"1D": "Day",
"1W": "Week",
"1M": "Month",
"1H": "Min_60",
"1h": "Min_60",
"1m": "Min_1",
"5m": "Min_5",
"15m": "Min_15",
"30m": "Min_30",
}
# LongPort returns at most ~1000 bars per call. For wide ranges we split into
# sequential windows of this many days to avoid silent truncation.
_MAX_WINDOW_DAYS = 180
# Cap on the number of windows so a pathological request can't loop forever.
_MAX_WINDOWS = 20
class LongbridgeDependencyError(RuntimeError):
"""Raised when the ``longbridge`` SDK is not installed."""
def _require_longbridge():
"""Import and return the ``longbridge.openapi`` module.
Raises:
LongbridgeDependencyError: If the SDK is not installed.
"""
try:
from longbridge import openapi # noqa: PLC0415
except ImportError as exc:
raise LongbridgeDependencyError(
"The 'longbridge' SDK is not installed. "
"Run: pip install 'vibe-trading-ai[longbridge]'"
) from exc
return openapi
def _to_longport_symbol(code: str) -> str:
"""Convert a project symbol to LongPort format.
LongPort accepts ``AAPL.US``, ``700.HK``, ``000001.SZ``, ``600519.SH``.
Bare codes without a ``.`` suffix get ``.US`` appended so the resolver
treats them as US equities (loader ``markets`` is us_equity + hk_equity;
ambiguous codes lean US).
Examples:
AAPL -> AAPL.US
AAPL.US -> AAPL.US
700.HK -> 700.HK
0700.HK -> 0700.HK
000001.SZ -> 000001.SZ
600519.SH -> 600519.SH
"""
upper = code.strip().upper()
if "." in upper:
return upper
return f"{upper}.US"
def _to_longport_period(interval: str):
"""Map a project interval string to a LongPort ``Period`` enum value.
Lazy-imports the SDK so this module can be imported without it installed.
Unsupported intervals fail explicitly so requested bar fidelity is never
changed silently.
"""
openapi = _require_longbridge()
period_cls = getattr(openapi, "Period")
token = interval.strip()
attr = _INTERVAL_MAP.get(token)
if attr is None:
raise NoAvailableSourceError(
f"unsupported Longbridge interval: {interval!r}; "
f"supported intervals: {sorted(_INTERVAL_MAP)}"
)
try:
return getattr(period_cls, attr)
except AttributeError as exc:
raise NoAvailableSourceError(
f"installed Longbridge SDK does not expose Period.{attr}"
) from exc
def _date_windows(start: dt.date, end: dt.date) -> list[tuple[dt.date, dt.date]]:
"""Split a wide date range into sequential windows to respect the
LongPort ~1000-bar per-call cap.
Each window spans at most ``_MAX_WINDOW_DAYS`` days. Windows are capped
at ``_MAX_WINDOWS`` so a pathological request cannot loop forever.
"""
requested_days = (end - start).days + 1
maximum_days = _MAX_WINDOW_DAYS * _MAX_WINDOWS
if requested_days > maximum_days:
raise NoAvailableSourceError(
f"Longbridge date range spans {requested_days} days and exceeds "
f"the {maximum_days}-day window limit"
)
windows: list[tuple[dt.date, dt.date]] = []
cursor = start
while cursor <= end and len(windows) < _MAX_WINDOWS:
window_end = min(cursor + dt.timedelta(days=_MAX_WINDOW_DAYS - 1), end)
windows.append((cursor, window_end))
cursor = window_end + dt.timedelta(days=1)
return windows
def _normalize_frame(bars: list[Any]) -> pd.DataFrame:
"""Normalise a list of LongPort Candlestick objects to OHLCV schema.
Args:
bars: List of candlestick objects returned by the SDK.
Returns:
DataFrame with columns [open, high, low, close, volume] indexed
by ``trade_date`` (timezone-naive DatetimeIndex), sorted ascending.
"""
if not bars:
return pd.DataFrame(columns=_OHLCV_COLUMNS)
rows = []
for bar in bars:
ts = getattr(bar, "timestamp", None)
rows.append({
"open": float(getattr(bar, "open", 0) or 0),
"high": float(getattr(bar, "high", 0) or 0),
"low": float(getattr(bar, "low", 0) or 0),
"close": float(getattr(bar, "close", 0) or 0),
"volume": float(getattr(bar, "volume", 0) or 0),
"trade_date": pd.to_datetime(ts) if ts is not None else pd.NaT,
})
result = pd.DataFrame(rows)
result.index = result["trade_date"]
result.index.name = "trade_date"
result = result[_OHLCV_COLUMNS].copy()
# Standardise to timezone-naive UTC. Aware timestamps may be represented
# in an exchange timezone by SDK versions even when the instant is UTC.
if isinstance(result.index, pd.DatetimeIndex) and result.index.tz is not None:
result.index = result.index.tz_convert("UTC").tz_localize(None)
result = result.dropna(subset=["open", "high", "low", "close"])
result["volume"] = result["volume"].fillna(0.0)
return result.sort_index()
@register
class LongbridgeLoader:
"""Fetch US and HK equity bars from LongPort OpenAPI.
Resolves one atomic credential set from the ``LONGBRIDGE_*`` environment
variables or the backward-compatible runtime file.
"""
name = "longbridge"
markets = {"us_equity", "hk_equity"}
requires_auth = True
def __init__(self) -> None:
try:
resolution = resolve_longbridge_credentials()
except LongbridgeCredentialError as exc:
self._credential_source = None
self._credential_error = exc
self._app_key = self._app_secret = self._access_token = ""
return
self._credential_source = resolution.source
self._credential_error: LongbridgeCredentialError | None = None
if resolution.credentials is None:
if resolution.conflict_fields:
code = "credentials_conflict"
fields = resolution.conflict_fields
else:
code = (
"credentials_missing"
if resolution.source is None
else "credentials_partial"
)
fields = resolution.missing_fields
self._credential_error = LongbridgeCredentialError(code, fields)
self._app_key = self._app_secret = self._access_token = ""
return
credentials = resolution.credentials
self._app_key = credentials.app_key
self._app_secret = credentials.app_secret
self._access_token = credentials.access_token
def is_available(self) -> bool:
"""Return True if the LongPort SDK is installed and credentials exist.
Availability checks are side-effect free: they validate configured
credentials and SDK importability without consuming quote API quota.
"""
if not (self._app_key and self._app_secret and self._access_token):
return False
try:
_require_longbridge()
return True
except Exception:
return False
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> dict[str, pd.DataFrame]:
"""Fetch OHLCV history from LongPort OpenAPI.
Args:
codes: Project symbols such as ``AAPL``, ``AAPL.US``, ``700.HK``,
``000001.SZ`` or ``600519.SH``.
start_date: Start date in ``YYYY-MM-DD`` format.
end_date: End date in ``YYYY-MM-DD`` format.
interval: Backtest interval — ``1D``, ``1W``, ``1M``, ``1H``.
fields: Ignored; included for interface compatibility.
Returns:
Mapping of input symbol to normalised OHLCV dataframe.
Raises:
NoAvailableSourceError: If the SDK connection fails or
credentials are missing.
"""
del fields
if not codes:
return {}
validate_date_range(start_date, end_date)
results: dict[str, pd.DataFrame] = {}
# Serve cached symbols first; only open an SDK connection when at
# least one symbol is uncached, so a fully-cached request needs no
# network call.
pending: List[str] = []
for code in codes:
cached = loader_cache_get(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
)
if cached is not None:
results[code] = cached.copy()
else:
pending.append(code)
if not pending:
return results
credential_error = getattr(self, "_credential_error", None)
if credential_error is not None:
if credential_error.code == "credentials_missing":
message = (
"Longbridge credentials are not configured; missing fields: "
+ ", ".join(credential_error.fields)
)
elif credential_error.code == "credentials_partial":
message = (
"Longbridge credentials_partial; missing fields: "
+ ", ".join(credential_error.fields)
)
else:
message = (
"Longbridge credentials_conflict; differing fields: "
+ ", ".join(credential_error.fields)
)
raise NoAvailableSourceError(message) from None
if not (self._app_key and self._app_secret and self._access_token):
raise NoAvailableSourceError(
"Longbridge credentials are not configured; set "
"LONGBRIDGE_APP_KEY, LONGBRIDGE_APP_SECRET, and "
"LONGBRIDGE_ACCESS_TOKEN"
)
openapi = _require_longbridge()
_init_error: str | None = None
try:
cfg = openapi.Config(
self._app_key, self._app_secret, self._access_token,
)
ctx = openapi.QuoteContext(cfg)
except LongbridgeDependencyError:
raise
except Exception:
# Capture only stable text; do NOT raise inside the handler so
# the original exception (which may contain secrets) cannot
# leak into __context__.
_init_error = "Longbridge SDK initialization failed."
if _init_error is not None:
raise NoAvailableSourceError(_init_error)
period = _to_longport_period(interval)
adjust_type = getattr(openapi, "AdjustType").NoAdjust
try:
start = dt.date.fromisoformat(start_date)
end = dt.date.fromisoformat(end_date)
except (TypeError, ValueError):
raise NoAvailableSourceError(
"Invalid Longbridge date range."
) from None
windows = _date_windows(start, end)
try:
for code in pending:
lp_symbol = _to_longport_symbol(code)
all_bars: list[Any] = []
_window_error: str | None = None
for w_start, w_end in windows:
try:
bars = ctx.history_candlesticks_by_date(
lp_symbol, period, adjust_type,
start=w_start, end=w_end,
)
if isinstance(bars, (list, tuple)):
all_bars.extend(bars)
elif bars is not None:
all_bars.append(bars)
except Exception:
_window_error = "Longbridge history request failed."
break
else:
_window_error = None
if _window_error is not None:
raise NoAvailableSourceError(_window_error)
if not all_bars:
logger.warning(
"LongPort returned no data for %s in [%s, %s]",
lp_symbol, start_date, end_date,
)
continue
normalized = _normalize_frame(all_bars)
loader_cache_put(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
frame=normalized,
)
results[code] = normalized
finally:
# LongPort QuoteContext has no explicit close(); the SDK manages
# its own connection pool. No cleanup needed.
pass
return results
+252
View File
@@ -0,0 +1,252 @@
"""Mootdx loader: A-share OHLCV via TCP-direct 通达信 servers (no IP ban).
Mootdx (https://github.com/mootdx/mootdx) talks the native 通达信 binary
protocol over TCP and is not subject to the HTTP scraping rate limits that
periodically break the akshare → East Money path. Public market data only,
no token required, no per-IP throttling.
Scope: A-share OHLCV only (沪/深/京 auto-detected from symbol). Mootdx's
extended-market endpoint (futures/options) is upstream-broken as of
v0.11.7 — falls through to tushare/akshare for those markets.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
# Mootdx frequency codes (see mootdx.consts).
_INTRADAY_FREQ: dict[str, int] = {
"1m": 8,
"5m": 0,
"15m": 1,
"30m": 2,
"1H": 3,
}
_DAILY_FREQ: dict[str, int] = {
"1D": 4,
"1W": 5,
"1M": 6,
}
# bars() returns one page of N rows ending at the latest bar. Pages older
# than this need to be requested with ``start=offset_into_history``. We cap
# pagination at MAX_PAGES so a wildly out-of-range request can't grind for
# minutes against the TDX server.
_BARS_PAGE = 800
_MAX_PAGES = 25 # 25 × 800 = 20 000 bars (~10y daily, ~5y 1H, ~3mo 1m)
def _is_a_share(code: str) -> bool:
"""Accept either explicit `.SH/.SZ/.BJ` suffix or bare 6-digit ticker."""
upper = code.upper()
if upper.endswith((".SH", ".SZ", ".BJ")):
return True
return len(code) == 6 and code.isdigit()
def _is_bj(code: str) -> bool:
"""Detect 北交所 symbols. Mootdx std factory does not serve BJ data
(get_k_data raises KeyError, bars() returns empty), so the loader logs
a warning and skips these instead of silently returning nothing."""
upper = code.upper()
if upper.endswith(".BJ"):
return True
# 4xxxxx / 8xxxxx are BJ prefixes (bare 6-digit form).
return len(code) == 6 and code.isdigit() and code[0] in ("4", "8")
@register
class DataLoader:
"""Mootdx-backed A-share OHLCV loader (TCP-direct, no auth)."""
name = "mootdx"
markets = {"a_share"}
requires_auth = False
def __init__(self) -> None:
self._client = None
def is_available(self) -> bool:
"""Available if mootdx is installed."""
try:
import mootdx # noqa: F401
return True
except ImportError:
return False
def _get_client(self):
if self._client is None:
from mootdx.quotes import Quotes
self._client = Quotes.factory(market="std")
return self._client
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch A-share OHLCV via mootdx.
Args:
codes: Symbol list. `.SH/.SZ/.BJ` suffix or bare 6-digit
tickers; non-A-share symbols are silently skipped.
start_date: YYYY-MM-DD.
end_date: YYYY-MM-DD.
interval: One of ``1m / 5m / 15m / 30m / 1H / 1D / 1W / 1M``.
fields: Ignored.
Returns:
Mapping symbol -> OHLCV DataFrame.
Raises:
ValueError: If ``interval`` is not in the supported set.
"""
validate_date_range(start_date, end_date)
if interval not in _DAILY_FREQ and interval not in _INTRADAY_FREQ:
raise ValueError(
f"Unsupported interval for mootdx: {interval!r}. "
f"Supported: {sorted(_DAILY_FREQ) + sorted(_INTRADAY_FREQ)}"
)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
if not _is_a_share(code):
logger.debug("mootdx: skipping non-A-share symbol %s", code)
continue
if _is_bj(code):
logger.warning(
"mootdx: 北交所 (%s) not supported upstream; use akshare/tushare",
code,
)
continue
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date, interval),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("mootdx failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, interval: str,
) -> Optional[pd.DataFrame]:
symbol = code.split(".")[0]
client = self._get_client()
# Daily has a native date-range API; intraday and weekly/monthly
# only expose offset-from-latest, so we page back through history
# until the first row of the page is older than start_date.
if interval == "1D":
df = client.get_k_data(code=symbol, start_date=start_date, end_date=end_date)
return self._normalize_daily(df)
freq = _DAILY_FREQ.get(interval) or _INTRADAY_FREQ[interval]
return self._fetch_bars_paginated(client, symbol, freq, start_date, end_date)
@staticmethod
def _fetch_bars_paginated(
client, symbol: str, freq: int, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""Walk backward through ``bars()`` pages until the requested
window is covered, then clip and concatenate.
Mootdx ``bars()`` returns the latest ``_BARS_PAGE`` rows by default;
``start=N`` skips the newest N rows. We page until the oldest row
in a page is at or before ``start_date``, or until ``_MAX_PAGES``
is exhausted (very old or thinly-traded symbols).
"""
start_ts = pd.Timestamp(start_date)
chunks: list[pd.DataFrame] = []
for page in range(_MAX_PAGES):
df = client.bars(
symbol=symbol,
frequency=freq,
start=page * _BARS_PAGE,
offset=_BARS_PAGE,
)
if df is None or df.empty:
break
chunks.append(df)
first_dt = pd.to_datetime(df["datetime"].iloc[0])
if first_dt <= start_ts:
break
else:
raise ValueError(
"incomplete mootdx history: "
f"{symbol} frequency={freq} hit {_MAX_PAGES} pages "
f"without reaching {start_date}"
)
if not chunks:
return None
combined = pd.concat(chunks, ignore_index=False)
return DataLoader._normalize_bars(combined, start_date, end_date)
@staticmethod
def _normalize_daily(df: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]:
"""Normalize `get_k_data()` output to the OHLCV contract.
get_k_data returns columns ``[open, close, high, low, vol, amount,
date, code]`` with ``date`` as the index name.
"""
if df is None or df.empty:
return None
out = df.rename(columns={"vol": "volume"}).copy()
out.index = pd.to_datetime(out.index)
out.index.name = "trade_date"
for col in ("open", "high", "low", "close", "volume"):
out[col] = pd.to_numeric(out[col], errors="coerce")
out = out[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return out.sort_index() if not out.empty else None
@staticmethod
def _normalize_bars(
df: Optional[pd.DataFrame], start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""Normalize `bars()` output and clip to the requested window.
bars() returns ``[open, close, high, low, vol, amount, year, month,
day, hour, minute, datetime, volume]`` with a datetime index.
``volume`` (lowercase, last column) is the canonical share count;
``vol`` is a historical alias kept by mootdx for compatibility.
"""
if df is None or df.empty:
return None
out = df.copy()
if "datetime" in out.columns:
out["trade_date"] = pd.to_datetime(out["datetime"])
out = out.set_index("trade_date")
else:
out.index = pd.to_datetime(out.index)
out.index.name = "trade_date"
out = out.sort_index()
for col in ("open", "high", "low", "close", "volume"):
out[col] = pd.to_numeric(out[col], errors="coerce")
out = out[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
# Inclusive end-of-day so a `2025-02-01` window keeps the 15:00 bar.
end_ts = pd.Timestamp(end_date) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
out = out.loc[pd.Timestamp(start_date):end_ts]
return out if not out.empty else None
+243
View File
@@ -0,0 +1,243 @@
"""MetaTrader 5 data loader — forex/metals history from a local MT5 terminal.
Feeds backtests and ``get_market_data`` from the user's own broker feed
(e.g. Exness), with the broker's exact symbols and session times. Requires
Windows, the optional ``MetaTrader5`` package (``pip install
"vibe-trading-ai[mt5]"``), and a running, logged-in MT5 terminal — when any of
those are missing :meth:`DataLoader.is_available` is ``False`` and the forex
fallback chain degrades to akshare/yfinance/local.
Config is shared with the MT5 trading connector via ``~/.vibe-trading/mt5.json``
(read directly — this module deliberately does not import the connector).
With no config file, ``mt5.initialize()`` attaches to the last-used, already
logged-in terminal, which is the primary read-only path.
History depth is bounded by the terminal's "Max bars in chart" setting.
Broker account-type suffixes (Exness ``EURUSDm``) are discovered via
``symbols_get`` and memoized; results are keyed by the caller's ORIGINAL
code so the runner's coverage check (``set(codes) - set(data_map)``) holds.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import ModuleType
from typing import Any
import pandas as pd
from backtest.loaders.base import cached_loader_fetch, validate_date_range, validate_ohlc
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
#: Shared file contract with the MT5 trading connector (extra keys tolerated).
_MT5_CONFIG_PATH = Path.home() / ".vibe-trading" / "mt5.json"
#: Canonical interval token → MetaTrader5 timeframe constant name.
_INTERVAL_MAP = {
"1m": "TIMEFRAME_M1", "5m": "TIMEFRAME_M5", "15m": "TIMEFRAME_M15",
"30m": "TIMEFRAME_M30", "1H": "TIMEFRAME_H1", "4H": "TIMEFRAME_H4",
"1D": "TIMEFRAME_D1",
}
#: Process-lifetime attach cache: ``initialize`` can take seconds (it may even
#: launch the terminal), and chain resolution probes ``is_available`` often.
_init_state: bool | None = None
#: base symbol → resolved broker symbol memo.
_symbol_cache: dict[str, str] = {}
def _import_mt5() -> ModuleType | None:
"""Lazy-import the Windows-only SDK; ``None`` when absent."""
try:
import MetaTrader5 # type: ignore[import-not-found]
except ImportError:
return None
return MetaTrader5
def _read_mt5_config() -> dict[str, Any]:
"""Best-effort read of the shared mt5.json ({} on missing/corrupt)."""
try:
raw = json.loads(_MT5_CONFIG_PATH.read_text(encoding="utf-8"))
return raw if isinstance(raw, dict) else {}
except (OSError, ValueError):
return {}
def _ensure_initialized() -> bool:
"""Attach to the terminal once per process (result cached, even negative)."""
global _init_state
if _init_state is not None:
return _init_state
mt5 = _import_mt5()
if mt5 is None:
_init_state = False
return False
config = _read_mt5_config()
kwargs: dict[str, Any] = {}
if config.get("login"):
kwargs["login"] = int(config["login"])
kwargs["password"] = str(config.get("password") or "")
kwargs["server"] = str(config.get("server") or "")
if config.get("timeout"):
kwargs["timeout"] = int(float(config["timeout"]) * 1000)
args = (str(config["terminal_path"]),) if config.get("terminal_path") else ()
try:
ok = bool(mt5.initialize(*args, **kwargs))
except Exception as exc: # noqa: BLE001 - availability probe must not raise
logger.warning("mt5: initialize raised: %s", exc)
ok = False
if not ok:
try:
logger.warning("mt5: terminal attach failed: %s", mt5.last_error())
except Exception: # noqa: BLE001
pass
_init_state = ok
return ok
def _to_query_base(code: str) -> str:
"""``EUR/USD`` / ``EURUSD.FX`` → ``EURUSD`` (upper, separators stripped)."""
token = code.strip().upper()
if token.endswith(".FX"):
token = token[: -len(".FX")]
for separator in ("/", "-", "_", " "):
token = token.replace(separator, "")
return token
def _resolve_broker_symbol(mt5: ModuleType, code: str) -> str | None:
"""Resolve a project code to the broker's symbol name (memoized).
Order: the raw token exactly (case-sensitive broker names pass through),
the normalized base, then suffix discovery via ``symbols_get(f"{base}*")``
— shortest name first, then alphabetical, which deterministically picks
``EURUSDm`` over ``EURUSDz`` on Exness-style brokers.
"""
base = _to_query_base(code)
cached = _symbol_cache.get(base)
if cached is not None:
return cached
raw = code.strip()
for candidate in dict.fromkeys((raw, base)): # ordered, deduped
if candidate and mt5.symbol_info(candidate) is not None:
_symbol_cache[base] = candidate
return candidate
matches = sorted(
(getattr(info, "name", "") for info in (mt5.symbols_get(group=f"{base}*") or ())),
key=lambda name: (len(name), name),
)
matches = [m for m in matches if m]
if not matches:
logger.warning("mt5: symbol %r not offered by this broker (no match for %s*)", code, base)
return None
_symbol_cache[base] = matches[0]
return matches[0]
def _rates_to_frame(rates: Any, start_date: str, end_date: str) -> pd.DataFrame | None:
"""Map a ``copy_rates_range`` structured array to the loader frame contract."""
if rates is None or len(rates) == 0:
return None
frame = pd.DataFrame(rates)
if "time" not in frame.columns or "close" not in frame.columns:
return None
frame["trade_date"] = pd.to_datetime(frame["time"], unit="s")
frame = frame.set_index("trade_date").sort_index()
# Forex real_volume is zero on most brokers; tick_volume is the standard proxy.
frame["volume"] = frame.get("tick_volume", 0)
# Bars carry intraday timestamps; the end date is inclusive of its whole day.
start = pd.Timestamp(start_date)
end = pd.Timestamp(end_date) + pd.Timedelta(days=1)
frame = frame[(frame.index >= start) & (frame.index < end)]
frame = frame[["open", "high", "low", "close", "volume"]].dropna()
frame = validate_ohlc(frame)
return frame if not frame.empty else None
@register
class DataLoader:
"""Forex/metals OHLCV from the local MT5 terminal (Windows, opt-in extra)."""
name = "mt5"
markets = {"forex"}
#: A running, logged-in terminal is the auth surface (futu precedent).
requires_auth = True
def __init__(self) -> None: # never raises — registry availability contract
pass
def is_available(self) -> bool:
"""SDK importable AND the terminal attach succeeded (cached)."""
return _import_mt5() is not None and _ensure_initialized()
def fetch(
self,
codes: list[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: list[str] | None = None,
) -> dict[str, pd.DataFrame]:
"""Fetch OHLCV frames keyed by the original input codes.
Per-symbol failures log and skip (never raise) so the runner's
runtime fallback can engage for the missing symbols.
"""
validate_date_range(start_date, end_date)
timeframe_name = _INTERVAL_MAP.get(interval)
if timeframe_name is None:
logger.warning("unsupported MT5 interval %s, using 1D", interval)
interval, timeframe_name = "1D", "TIMEFRAME_D1"
result: dict[str, pd.DataFrame] = {}
for code in codes:
clean = code.strip()
if not clean:
continue
try:
frame = cached_loader_fetch(
source=self.name,
symbol=_to_query_base(clean),
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda c=clean: self._fetch_one(c, start_date, end_date, timeframe_name),
)
except Exception as exc: # noqa: BLE001 - one symbol never poisons the batch
logger.warning("mt5: fetch failed for %s: %s", clean, exc)
continue
if frame is not None and not frame.empty:
result[code] = frame
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, timeframe_name: str
) -> pd.DataFrame | None:
"""Fetch one symbol from the terminal (``None`` on any failure)."""
mt5 = _import_mt5()
if mt5 is None or not _ensure_initialized():
return None
name = _resolve_broker_symbol(mt5, code)
if name is None:
return None
try:
mt5.symbol_select(name, True)
timeframe = getattr(mt5, timeframe_name)
# tz-aware UTC is mandatory: the MT5 API shifts naive datetimes by
# the local timezone, silently offsetting the requested range.
date_from = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
date_to = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc) + timedelta(days=1)
rates = mt5.copy_rates_range(name, timeframe, date_from, date_to)
except Exception as exc: # noqa: BLE001 - terminal hiccups degrade, not raise
logger.warning("mt5: rates fetch failed for %s (%s): %s", code, name, exc)
return None
return _rates_to_frame(rates, start_date, end_date)
+247 -36
View File
@@ -1,20 +1,86 @@
"""OKX spot candle loader (crypto).
Uses OKX V5 public REST API (no auth).
Supports 1m/5m/15m/30m/1H/4H/1D.
Up to 300 bars per request; paginates with ``after`` for longer history.
Endpoints
---------
- ``/market/candles`` — recent bars only (limited depth; not enough for multi-year
backtests).
- ``/market/history-candles`` — multi-year history; used whenever the requested
range is older than a few months or recent endpoint returns empty.
Hardening (2026-07 local audit)
--------------------------------
- Explicit proxy support (same env vars as CCXT loader); required on networks
that time out direct HTTPS to www.okx.com.
- Raise / retry on HTTP 429/5xx and OKX business ``code != "0"``.
- Prefer ``history-candles`` for deep ranges so 2020-era backtests no longer
return empty frames.
- ``is_available()`` does a short probe instead of always returning True.
"""
from __future__ import annotations
import logging
import os
import time
from typing import Dict, List, Optional
import pandas as pd
import requests
from backtest.loaders.base import validate_date_range
logger = logging.getLogger(__name__)
from backtest.loaders.base import (
cached_loader_fetch,
check_budget,
positive_env_float,
positive_env_int,
retry_with_budget,
validate_date_range,
)
from backtest.loaders.registry import register
BASE_URL = "https://www.okx.com/api/v5"
CANDLES_PATH = f"{BASE_URL}/market/candles"
HISTORY_CANDLES_PATH = f"{BASE_URL}/market/history-candles"
_MAX_PER_PAGE = 300
# Recent endpoint typically only covers ~months of 1D bars; beyond this age
# always hit history-candles first.
_RECENT_ONLY_DAYS = 400
_OKX_TIMEOUT = positive_env_int("OKX_TIMEOUT_S", 20)
_OKX_FETCH_BUDGET_S = positive_env_float("OKX_FETCH_BUDGET_S", 90.0)
_OKX_PROBE_TIMEOUT = positive_env_int("OKX_PROBE_TIMEOUT_S", 8)
def _first_proxy_env(*names: str) -> str:
for name in names:
value = os.getenv(name, "").strip() # noqa: env-gate — system proxy vars
if value:
return value
return ""
def _okx_proxy_config() -> dict[str, str]:
"""Build requests proxies from conventional env vars (parity with CCXT)."""
all_proxy = _first_proxy_env("ALL_PROXY", "all_proxy")
http_proxy = _first_proxy_env("HTTP_PROXY", "http_proxy") or all_proxy
https_proxy = _first_proxy_env("HTTPS_PROXY", "https_proxy") or all_proxy or http_proxy
proxies: dict[str, str] = {}
if http_proxy:
proxies["http"] = http_proxy
if https_proxy:
proxies["https"] = https_proxy
return proxies
def _okx_session() -> requests.Session:
session = requests.Session()
proxies = _okx_proxy_config()
if proxies:
session.proxies.update(proxies)
return session
@register
@@ -26,8 +92,22 @@ class DataLoader:
requires_auth = False
def is_available(self) -> bool:
"""Always available (public API, no auth)."""
return True
"""Probe public candles with a short timeout (honours proxy env)."""
try:
session = _okx_session()
resp = session.get(
CANDLES_PATH,
params={"instId": "BTC-USDT", "bar": "1D", "limit": "1"},
timeout=_OKX_PROBE_TIMEOUT,
)
if resp.status_code != 200:
logger.warning("OKX probe HTTP %s", resp.status_code)
return False
data = resp.json()
return data.get("code") == "0" and bool(data.get("data"))
except Exception as exc: # noqa: BLE001 — availability probe
logger.warning("OKX probe failed: %s", exc)
return False
def __init__(self) -> None:
"""No credentials required for public candles."""
@@ -38,8 +118,9 @@ class DataLoader:
codes: List[str],
start_date: str,
end_date: str,
fields: Optional[List[str]] = None,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch crypto OHLCV via OKX public API.
@@ -56,11 +137,11 @@ class DataLoader:
validate_date_range(start_date, end_date)
if fields:
print(f"[WARN] OKX ignores extra fields: {fields}")
logger.warning("OKX ignores extra fields: %s", fields)
valid_intervals = {"1m", "5m", "15m", "30m", "1H", "4H", "1D"}
if interval not in valid_intervals:
print(f"[WARN] unsupported OKX interval {interval}, using 1D")
logger.warning("unsupported OKX interval %s, using 1D", interval)
interval = "1D"
codes = [c.replace("/", "-").upper() for c in codes]
@@ -68,73 +149,203 @@ class DataLoader:
start_ts = int(pd.Timestamp(start_date).timestamp() * 1000)
end_ts = int((pd.Timestamp(end_date) + pd.Timedelta(days=1)).timestamp() * 1000)
max_pages = 200 if interval in ("1m", "5m") else 50 if interval in ("15m", "30m") else 20
# More pages for minute bars; history endpoint still needs walk-back.
if interval in ("1m", "5m"):
max_pages = 200
elif interval in ("15m", "30m"):
max_pages = 80
else:
max_pages = 40
use_history = self._should_use_history(start_date)
session = _okx_session()
result: Dict[str, pd.DataFrame] = {}
for symbol in codes:
try:
df = self._fetch_candles(symbol, start_ts, end_ts, interval, max_pages)
df = cached_loader_fetch(
source=self.name,
symbol=symbol,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda symbol=symbol, use_history=use_history: self._fetch_candles(
session,
symbol,
start_ts,
end_ts,
interval,
max_pages,
prefer_history=use_history,
),
)
if df is not None and not df.empty:
result[symbol] = df
except Exception as exc:
print(f"[WARN] failed to fetch {symbol}: {exc}")
logger.warning("failed to fetch %s: %s", symbol, exc)
return result
@staticmethod
def _should_use_history(start_date: str) -> bool:
"""True when the range starts older than recent-only window."""
try:
start = pd.Timestamp(start_date)
age_days = (pd.Timestamp.utcnow().tz_localize(None) - start).days
return age_days > _RECENT_ONLY_DAYS
except Exception:
return True
def _fetch_candles(
self, inst_id: str, start_ts: int, end_ts: int,
bar: str = "1D", max_pages: int = 20,
self,
session: requests.Session,
inst_id: str,
start_ts: int,
end_ts: int,
bar: str = "1D",
max_pages: int = 20,
*,
prefer_history: bool = True,
) -> Optional[pd.DataFrame]:
"""Paginated candle download.
"""Paginated candle download (history endpoint for deep ranges)."""
endpoints: list[str] = []
if prefer_history:
endpoints = [HISTORY_CANDLES_PATH, CANDLES_PATH]
else:
endpoints = [CANDLES_PATH, HISTORY_CANDLES_PATH]
Args:
inst_id: OKX instrument id.
start_ts: Start time (ms).
end_ts: End time (ms).
bar: Bar size.
max_pages: Max pagination rounds.
last_error: Exception | None = None
for endpoint in endpoints:
try:
df = self._paginate(
session,
endpoint,
inst_id,
start_ts,
end_ts,
bar,
max_pages,
)
if df is not None and not df.empty:
return df
except Exception as exc:
last_error = exc
logger.warning(
"OKX %s failed for %s: %s — trying next endpoint",
endpoint.rsplit("/", 1)[-1],
inst_id,
exc,
)
Returns:
OHLCV DataFrame or None.
"""
if last_error is not None:
logger.warning("OKX empty/failed for %s: %s", inst_id, last_error)
else:
logger.warning("OKX empty response: %s", inst_id)
return None
def _paginate(
self,
session: requests.Session,
endpoint: str,
inst_id: str,
start_ts: int,
end_ts: int,
bar: str,
max_pages: int,
) -> Optional[pd.DataFrame]:
all_rows: list = []
after = str(end_ts)
deadline = time.monotonic() + _OKX_FETCH_BUDGET_S
label = f"OKX fetch for {inst_id} via {endpoint.rsplit('/', 1)[-1]}"
for _ in range(max_pages):
check_budget(deadline, label, budget_s=_OKX_FETCH_BUDGET_S)
params = {
"instId": inst_id,
"bar": bar,
"limit": str(_MAX_PER_PAGE),
"after": after,
}
resp = requests.get(f"{BASE_URL}/market/candles", params=params, timeout=15)
data = resp.json()
if data.get("code") != "0" or not data.get("data"):
def _do_request(params=params) -> dict:
resp = session.get(
endpoint,
params=params,
timeout=_OKX_TIMEOUT,
)
# Transient gateway / rate-limit → raise for retry_with_budget
if resp.status_code in {429, 500, 502, 503, 504}:
raise requests.HTTPError(
f"OKX HTTP {resp.status_code}",
response=resp,
)
resp.raise_for_status()
try:
data = resp.json()
except ValueError as exc:
raise requests.RequestException(
f"OKX non-JSON response HTTP {resp.status_code}"
) from exc
code = str(data.get("code", ""))
if code != "0":
# Business errors are not always transient; still surface.
msg = data.get("msg") or data.get("error_message") or code
raise requests.RequestException(f"OKX API code={code} msg={msg}")
return data
data = retry_with_budget(
_do_request,
transient=(requests.RequestException, TimeoutError),
deadline=deadline,
label=label,
)
raw_rows = data.get("data") or []
if not raw_rows:
break
rows = data["data"]
rows = [r for r in rows if r[8] == "1"]
# Keep confirmed bars (confirm=="1"); also keep unconfirmed when
# it is the only data returned so live partial days are not empty.
confirmed = [r for r in raw_rows if len(r) > 8 and str(r[8]) == "1"]
rows = confirmed if confirmed else list(raw_rows)
all_rows.extend(rows)
oldest_ts = int(rows[-1][0]) if rows else start_ts
if oldest_ts <= start_ts or len(data["data"]) < _MAX_PER_PAGE:
oldest_ts = int(raw_rows[-1][0])
if oldest_ts <= start_ts or len(raw_rows) < _MAX_PER_PAGE:
break
after = str(oldest_ts)
if not all_rows:
print(f"[WARN] OKX empty response: {inst_id}")
return None
columns = ["ts", "open", "high", "low", "close", "vol", "volCcy", "volCcyQuote", "confirm"]
df = pd.DataFrame(all_rows, columns=columns)
df["trade_date"] = pd.to_datetime(df["ts"].astype("int64"), unit="ms")
columns = [
"ts", "open", "high", "low", "close",
"vol", "volCcy", "volCcyQuote", "confirm",
]
# Rows may be shorter if API schema changes — pad safely
normalized = []
for r in all_rows:
row = list(r) + [""] * (len(columns) - len(r))
normalized.append(row[: len(columns)])
df = pd.DataFrame(normalized, columns=columns)
# OKX daily open is UTC+8 midnight (= 16:00 UTC). Keep absolute UTC
# timestamps so multi-source merges stay consistent; floor to second.
df["trade_date"] = pd.to_datetime(
pd.to_numeric(df["ts"], errors="coerce"),
unit="ms",
utc=True,
).dt.tz_convert(None)
for col in ["open", "high", "low", "close"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["volume"] = pd.to_numeric(df["vol"], errors="coerce").fillna(0)
df = df.set_index("trade_date").sort_index()
df = df.dropna(subset=["trade_date"]).set_index("trade_date").sort_index()
df = df[~df.index.duplicated(keep="last")]
start_dt = pd.Timestamp(start_ts, unit="ms")
end_dt = pd.Timestamp(end_ts, unit="ms")
df = df[(df.index >= start_dt) & (df.index < end_dt)]
df = df[["open", "high", "low", "close", "volume"]].dropna(subset=["open", "high", "low", "close"])
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return df if not df.empty else None
+675
View File
@@ -0,0 +1,675 @@
"""QVeris loader: explicit, key-gated OHLCV fetches through QVeris tools.
This loader is intentionally self-contained for the QVeris integration parcel:
it reads the shared ``~/.vibe-trading/qveris.json`` config schema, applies the
``QVERIS_API_KEY`` / ``QVERIS_BASE_URL`` environment overrides, and embeds the
small HTTP client it needs for ``POST /search``, ``POST /tools/execute``, and
truncated-result downloads.
QVeris is a paid-capability router, so it must only run when the user explicitly
requests ``source="qveris"``. The registry keeps it out of every auto fallback
chain.
"""
from __future__ import annotations
import json
import logging
import math
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import pandas as pd
import requests
from backtest.loaders.base import (
cached_loader_fetch,
validate_date_range,
validate_ohlc,
)
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_CONFIG_PATH = Path.home() / ".vibe-trading" / "qveris.json"
_DEFAULT_BASE_URL = "https://qveris.ai/api/v1"
_API_KEY_ENV = "QVERIS_API_KEY"
_BASE_URL_ENV = "QVERIS_BASE_URL"
_MIN_INTERVAL_ENV = "VIBE_TRADING_QVERIS_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL_S = 0.5
_HTTP_TIMEOUT_S = 30.0
_MAX_RETRIES = 3
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
_DATE_KEYS = (
"trade_date",
"date",
"datetime",
"timestamp",
"time",
"period",
)
_FIELD_ALIASES = {
"open": ("open", "o", "1. open"),
"high": ("high", "h", "2. high"),
"low": ("low", "l", "3. low"),
"close": ("close", "c", "adj_close", "adjusted_close", "4. close"),
"volume": ("volume", "vol", "v", "5. volume", "6. volume"),
}
@dataclass(frozen=True)
class QVerisConfig:
"""Resolved QVeris loader configuration."""
enabled: bool
base_url: str
api_key: str
mode: str
budget_credits_per_session: float
def _load_config() -> QVerisConfig:
"""Read QVeris config with environment overrides.
Returns:
Resolved config. Missing or malformed config files fall back to the
disabled default; env vars only override matching fields and do not
implicitly enable the integration.
"""
raw: dict[str, Any] = {
"enabled": False,
"base_url": _DEFAULT_BASE_URL,
"api_key": "",
"mode": "free",
"budget_credits_per_session": 50.0,
}
try:
if _CONFIG_PATH.is_file():
loaded = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
raw.update(loaded)
except Exception as exc: # noqa: BLE001 - config read failures mean unavailable
logger.warning("qveris config ignored: %s", exc)
from src.config.accessor import get_env_config
api_key = (get_env_config().data.qveris_api_key or str(raw.get("api_key") or "")).strip()
base_url = (get_env_config().data.qveris_base_url or str(raw.get("base_url") or _DEFAULT_BASE_URL)).strip()
try:
budget = float(raw.get("budget_credits_per_session", 50.0))
except (TypeError, ValueError):
budget = 50.0
if not math.isfinite(budget):
budget = 50.0
return QVerisConfig(
enabled=bool(raw.get("enabled")),
base_url=(base_url or _DEFAULT_BASE_URL).rstrip("/"),
api_key=api_key,
mode=_normalize_mode(str(raw.get("mode") or "free")),
budget_credits_per_session=max(budget, 0.0),
)
def _normalize_mode(mode: str) -> str:
"""Normalize QVeris paid-route mode."""
return {"preview": "free", "allow_paid": "paid", "free": "free", "paid": "paid"}.get(mode.strip(), "free")
def _min_interval() -> float:
"""Resolve the minimum interval between QVeris requests."""
raw = os.getenv(_MIN_INTERVAL_ENV) # noqa: env-gate — loader-specific rate limit
if raw is None or not raw.strip():
return _DEFAULT_MIN_INTERVAL_S
try:
value = float(raw)
except ValueError:
logger.warning(
"invalid %s=%r, using default %s",
_MIN_INTERVAL_ENV,
raw,
_DEFAULT_MIN_INTERVAL_S,
)
return _DEFAULT_MIN_INTERVAL_S
if value < 0:
logger.warning(
"negative %s=%r, using default %s",
_MIN_INTERVAL_ENV,
raw,
_DEFAULT_MIN_INTERVAL_S,
)
return _DEFAULT_MIN_INTERVAL_S
return value
class QVerisClient:
"""Minimal QVeris HTTP client for search, execute, and full-result GET."""
def __init__(self, config: QVerisConfig) -> None:
"""Initialize a session-bound client.
Args:
config: Resolved QVeris config containing base URL and API key.
"""
self._config = config
self._session = requests.Session()
self._last_request_at = 0.0
def search(self, query: str, *, limit: int = 20) -> dict[str, Any]:
"""Call ``POST /search``.
Args:
query: Natural-language capability query.
limit: Maximum result count.
Returns:
Decoded response body.
"""
return self._post_json("/search", {"query": query, "limit": limit})
def execute(
self,
tool_id: str,
parameters: dict[str, Any],
*,
search_id: str | None = None,
) -> dict[str, Any]:
"""Call ``POST /tools/execute`` and hydrate truncated results.
Args:
tool_id: QVeris tool identifier returned by search.
parameters: Provider parameters.
search_id: Optional search correlation id.
Returns:
Decoded execute response, with ``result`` replaced by downloaded
full JSON when QVeris returned ``full_content_file_url``.
"""
body: dict[str, Any] = {
"parameters": parameters,
"max_response_size": 20480,
}
if search_id:
body["search_id"] = search_id
payload = self._post_json(f"/tools/execute?tool_id={tool_id}", body)
if isinstance(payload, dict) and isinstance(payload.get("result"), dict):
full_url = payload["result"].get("full_content_file_url")
if isinstance(full_url, str) and full_url:
payload = dict(payload)
payload["result"] = self._get_json(full_url)
return payload
def _post_json(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
url = f"{self._config.base_url}{path}"
response = self._request("post", url, json=body, auth=True)
decoded = response.json()
return decoded if isinstance(decoded, dict) else {}
def _get_json(self, url: str) -> Any:
response = self._request("get", url, auth=False)
try:
return response.json()
except ValueError:
return json.loads(response.text)
def _request(self, method: str, url: str, *, auth: bool, **kwargs: Any) -> requests.Response:
headers = {"User-Agent": "Vibe-Trading/1.0"}
if auth:
headers["Authorization"] = f"Bearer {self._config.api_key}"
for attempt in range(_MAX_RETRIES + 1):
self._wait()
response = self._session.request(
method,
url,
headers=headers,
timeout=_HTTP_TIMEOUT_S,
**kwargs,
)
if response.status_code != 429:
response.raise_for_status()
return response
if attempt == _MAX_RETRIES:
response.raise_for_status()
time.sleep(_retry_after_seconds(response))
raise AssertionError("unreachable: retry loop must return or raise")
def _wait(self) -> None:
interval = _min_interval()
if interval <= 0:
return
now = time.monotonic()
sleep_for = self._last_request_at + interval - now
if sleep_for > 0:
time.sleep(sleep_for)
self._last_request_at = time.monotonic()
def _retry_after_seconds(response: requests.Response) -> float:
"""Parse a Retry-After header, falling back to the default interval."""
raw = response.headers.get("Retry-After", "")
try:
value = float(raw)
except (TypeError, ValueError):
value = _min_interval()
return max(value, 0.0)
@register
class DataLoader:
"""QVeris OHLCV loader, available only when explicitly configured."""
name = "qveris"
markets = {"us_equity", "hk_equity", "a_share", "crypto", "forex", "fund", "macro"}
requires_auth = True
def __init__(self) -> None:
"""Initialize without network access."""
self._config = _load_config()
def is_available(self) -> bool:
"""Return whether paid QVeris routing is enabled and keyed."""
return self._config.enabled and bool(self._config.api_key) and self._config.mode == "paid"
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch daily OHLCV history through selected QVeris capabilities.
Args:
codes: Symbols to fetch.
start_date: Inclusive ``YYYY-MM-DD`` start date.
end_date: Inclusive ``YYYY-MM-DD`` end date.
interval: Bar interval. QVeris selection is optimized for daily
bars; non-daily values are passed through when a tool accepts an
interval-like parameter.
fields: Ignored; QVeris loader returns the standard OHLCV columns.
Returns:
Mapping of input symbol to normalized OHLCV DataFrames.
Raises:
ValueError: If ``start_date`` > ``end_date``.
"""
del fields
validate_date_range(start_date, end_date)
if not self.is_available():
logger.warning("qveris fetch skipped: disabled or %s not set", _API_KEY_ENV)
return {}
client = QVerisClient(self._config)
budget_state = {"spent": 0.0}
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(
client,
code,
start_date,
end_date,
interval,
budget_state,
),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc: # noqa: BLE001 - one symbol must not abort the batch
logger.warning("qveris failed for %s: %s", code, exc)
return result
def _fetch_one(
self,
client: QVerisClient,
code: str,
start_date: str,
end_date: str,
interval: str,
budget_state: dict[str, float],
) -> Optional[pd.DataFrame]:
search_payload = client.search(_search_query(code, interval))
candidates = _select_capabilities(search_payload.get("results"), interval)
search_id = _str_or_none(search_payload.get("search_id"))
for capability in candidates:
tool_id = str(capability.get("tool_id") or "").strip()
if not tool_id:
continue
quoted_cost = _expected_cost(capability.get("expected_cost"))
if (
not math.isfinite(quoted_cost)
or quoted_cost < 0.0
or budget_state["spent"] + quoted_cost
> self._config.budget_credits_per_session
):
logger.warning(
"QVeris paid capability skipped for %s: credit budget exceeded",
code,
)
continue
parameters = _build_parameters(capability, code, start_date, end_date, interval)
# Reserve the quoted cost before the request. If transport fails
# after the provider accepted it, later symbols still fail closed.
budget_state["spent"] += quoted_cost
execute_payload = client.execute(tool_id, parameters, search_id=search_id)
try:
actual_cost = float(execute_payload.get("cost"))
except (TypeError, ValueError):
actual_cost = quoted_cost
if math.isfinite(actual_cost) and actual_cost > quoted_cost:
budget_state["spent"] += actual_cost - quoted_cost
if execute_payload.get("success") is False:
logger.warning("QVeris execute failed for %s via %s", code, tool_id)
continue
frame = _result_to_frame(execute_payload.get("result"), start_date, end_date)
if frame is not None:
return frame
logger.warning("QVeris result for %s via %s had no parseable bars", code, tool_id)
return None
_MAX_CANDIDATES = 3
def _search_query(symbol: str, interval: str) -> str:
"""Build a capability-search query for one symbol."""
return f"daily OHLCV historical market data for {symbol.strip().upper()} interval {interval}"
def _select_capabilities(results: Any, interval: str) -> list[dict[str, Any]]:
"""Rank OHLCV-like capabilities, excluding wrong-granularity series."""
if not isinstance(results, list):
return []
wanted, unwanted = _granularity_tokens(interval)
candidates = []
for item in results:
if not isinstance(item, dict) or not _looks_ohlcv(item):
continue
text = _capability_text(item)
if any(token in text for token in unwanted):
continue
priority = 0 if any(token in text for token in wanted) else 1
candidates.append((priority, item))
ranked = sorted(candidates, key=lambda pair: (pair[0],) + _capability_rank(pair[1]))
return [item for _, item in ranked[:_MAX_CANDIDATES]]
def _capability_text(item: dict[str, Any]) -> str:
return " ".join(
str(item.get(key) or "").lower() for key in ("tool_id", "name", "description")
)
def _granularity_tokens(interval: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""Return (wanted, unwanted) capability-text tokens for a bar interval."""
norm = interval.strip().lower()
intraday = ("intraday", "minute", "1min", "5min", "15min", "30min", "60min", "hourly")
if norm in ("", "1d", "d", "day", "daily"):
return ("daily", "eod", "end-of-day", "end of day"), ("monthly", "weekly") + intraday
if "min" in norm or norm in ("1h", "4h") or "hour" in norm:
return intraday, ("monthly", "weekly")
if "w" in norm or "week" in norm:
return ("weekly",), ("monthly",) + intraday
if "mo" in norm or "month" in norm:
return ("monthly",), ("weekly",) + intraday
return (), ()
def _looks_ohlcv(item: dict[str, Any]) -> bool:
text_parts = [
item.get("name"),
item.get("description"),
item.get("provider_name"),
json.dumps(item.get("params") or "", default=str),
json.dumps(item.get("examples") or "", default=str),
]
text = " ".join(str(part or "").lower() for part in text_parts)
has_price = any(token in text for token in ("ohlcv", "open", "high", "low", "close", "candle", "historical price"))
has_symbol = any(token in text for token in ("symbol", "ticker", "instrument", "code"))
return has_price and has_symbol
def _capability_rank(item: dict[str, Any]) -> tuple[float, float, str]:
success_rate = _success_rate(item.get("stats"))
cost = _expected_cost(item.get("expected_cost"))
return (-success_rate, cost, str(item.get("tool_id") or item.get("name") or ""))
def _success_rate(stats: Any) -> float:
if not isinstance(stats, dict):
return 0.0
value = stats.get("success_rate", 0.0)
try:
parsed = float(value)
except (TypeError, ValueError):
return 0.0
return parsed / 100.0 if parsed > 1.0 else parsed
def _expected_cost(value: Any) -> float:
if value is None:
return float("inf")
text = str(value)
number = ""
for char in text:
if char.isdigit() or char == ".":
number += char
elif number:
break
try:
return float(number)
except ValueError:
return float("inf")
def _build_parameters(
capability: dict[str, Any],
code: str,
start_date: str,
end_date: str,
interval: str,
) -> dict[str, Any]:
"""Build an execute parameter object from examples plus known date keys."""
parameters = _sample_parameters(capability)
for param in capability.get("params") or []:
if not isinstance(param, dict):
continue
name = str(param.get("name") or "").strip()
if not name:
continue
lower = name.lower()
if _is_symbol_param(lower):
parameters[name] = _provider_symbol(code)
elif _is_start_param(lower):
parameters[name] = start_date
elif _is_end_param(lower):
parameters[name] = end_date
elif _is_interval_param(lower):
parameters[name] = _interval_value(param, interval)
return parameters
def _sample_parameters(capability: dict[str, Any]) -> dict[str, Any]:
examples = capability.get("examples")
if not isinstance(examples, dict):
return {}
sample = examples.get("sample_parameters")
return dict(sample) if isinstance(sample, dict) else {}
def _provider_symbol(code: str) -> str:
"""Normalize common project US suffixes while preserving other markets."""
upper = code.strip().upper()
if upper.endswith(".US"):
return upper[: -len(".US")]
return upper
def _is_symbol_param(name: str) -> bool:
return any(token in name for token in ("symbol", "ticker", "instrument", "code"))
def _is_start_param(name: str) -> bool:
return any(token in name for token in ("start", "from", "begin")) and "end" not in name
def _is_end_param(name: str) -> bool:
return any(token in name for token in ("end", "to", "until"))
def _is_interval_param(name: str) -> bool:
return any(token in name for token in ("interval", "timeframe", "resolution", "frequency"))
def _interval_value(param: dict[str, Any], interval: str) -> str:
enum = param.get("enum")
if isinstance(enum, list):
lowered = {str(value).lower(): value for value in enum}
for candidate in (interval, interval.lower(), "1d", "d", "daily"):
if candidate.lower() in lowered:
return str(lowered[candidate.lower()])
return "daily" if interval.upper() == "1D" else interval
def _result_to_frame(result: Any, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
rows = [_normalize_record(record) for record in _iter_ohlcv_records(result)]
cleaned = [row for row in rows if row is not None]
if not cleaned:
return None
df = pd.DataFrame(cleaned)
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce").astype(
"datetime64[ns]"
)
for field in _OHLCV_COLUMNS:
if field not in df.columns:
df[field] = None
df[field] = pd.to_numeric(df[field], errors="coerce").astype(float)
df = df.dropna(subset=["trade_date", "open", "high", "low", "close"])
if df.empty:
return None
df = df.set_index("trade_date").sort_index()
df = df[_OHLCV_COLUMNS]
start = pd.Timestamp(start_date)
end = pd.Timestamp(end_date)
df = df.loc[(df.index >= start) & (df.index <= end)]
df = validate_ohlc(df)
return None if df.empty else df
def _iter_ohlcv_records(payload: Any) -> Iterable[dict[str, Any]]:
"""Yield dict-like OHLCV records from common QVeris/provider shapes."""
if isinstance(payload, list):
for item in payload:
yield from _iter_ohlcv_records(item)
return
if not isinstance(payload, dict):
return
if _record_has_ohlc(payload):
yield payload
date_keyed = _date_keyed_records(payload)
if date_keyed is not None:
yield from date_keyed
return
yielded = False
for key in (
"data",
"results",
"result",
"historical",
"prices",
"items",
"rows",
"candles",
"values",
"time_series",
"Time Series (Daily)",
):
if key in payload:
for record in _iter_ohlcv_records(payload[key]):
yielded = True
yield record
if yielded:
return
# Provider-specific series containers ("Weekly Adjusted Time Series",
# "Time Series (5min)", ...) — any nested dict of date-keyed records.
for value in payload.values():
if isinstance(value, dict):
date_keyed = _date_keyed_records(value)
if date_keyed:
yield from date_keyed
def _date_keyed_records(payload: dict[str, Any]) -> list[dict[str, Any]] | None:
rows: list[dict[str, Any]] = []
for key, value in payload.items():
if not isinstance(value, dict) or not _looks_like_date(key):
return None
row = dict(value)
row.setdefault("trade_date", key)
rows.append(row)
return rows if rows else None
def _looks_like_date(value: Any) -> bool:
if not isinstance(value, str):
return False
try:
pd.Timestamp(value)
except Exception:
return False
return True
def _record_has_ohlc(record: dict[str, Any]) -> bool:
lowered = {str(key).lower() for key in record}
return all(any(alias in lowered for alias in aliases) for aliases in (
_FIELD_ALIASES["open"],
_FIELD_ALIASES["high"],
_FIELD_ALIASES["low"],
_FIELD_ALIASES["close"],
))
def _normalize_record(record: dict[str, Any]) -> dict[str, Any] | None:
lowered = {str(key).lower(): value for key, value in record.items()}
date = _first_value(lowered, _DATE_KEYS)
if date is None:
return None
row = {"trade_date": date}
for field, aliases in _FIELD_ALIASES.items():
row[field] = _first_value(lowered, aliases)
if any(row[field] is None for field in ("open", "high", "low", "close")):
return None
return row
def _first_value(mapping: dict[str, Any], keys: Iterable[str]) -> Any:
for key in keys:
if key in mapping:
return mapping[key]
return None
def _str_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
+90 -8
View File
@@ -24,6 +24,39 @@ LOADER_REGISTRY: dict[str, Type[Any]] = {}
_registered = False
# Canonical set of accepted data-source names: every registered loader plus the
# ``"auto"`` cross-market selector. Single source of truth shared by the backtest
# config schema (``backtest.runner.BacktestConfigSchema``) and the agent-facing
# backtest tool (``src.tools.backtest_tool``) so the two can never drift apart.
# Keep in sync with ``_loader_modules`` below — the regression test
# ``test_valid_sources_covers_all_registered_loaders`` enforces full coverage.
VALID_SOURCES: set[str] = {
"tushare",
"okx",
"binance",
"yfinance",
"akshare",
"baostock",
"tencent",
"mootdx",
"ccxt",
"futu",
"eastmoney",
"sina",
"stooq",
"yahoo",
"finnhub",
"alphavantage",
"tiingo",
"fmp",
"qveris", # QVERIS-INTEGRATION
"india_broker",
"longbridge",
"mt5",
"local",
"auto",
}
def register(cls: Type[Any]) -> Type[Any]:
"""Class decorator: register a loader into the global registry.
@@ -49,10 +82,27 @@ def _ensure_registered() -> None:
_loader_modules = [
"backtest.loaders.tushare",
"backtest.loaders.okx",
"backtest.loaders.binance_loader",
"backtest.loaders.yfinance_loader",
"backtest.loaders.akshare_loader",
"backtest.loaders.baostock_loader",
"backtest.loaders.tencent_loader",
"backtest.loaders.mootdx_loader",
"backtest.loaders.ccxt_loader",
"backtest.loaders.futu",
"backtest.loaders.eastmoney_loader",
"backtest.loaders.sina_loader",
"backtest.loaders.stooq_loader",
"backtest.loaders.yahoo_loader",
"backtest.loaders.finnhub_loader",
"backtest.loaders.alphavantage_loader",
"backtest.loaders.tiingo_loader",
"backtest.loaders.fmp_loader",
"backtest.loaders.qveris_loader", # QVERIS-INTEGRATION
"backtest.loaders.india_broker_loader",
"backtest.loaders.longbridge",
"backtest.loaders.mt5_loader",
"backtest.loaders.local_loader",
]
import importlib
for mod in _loader_modules:
@@ -62,19 +112,38 @@ def _ensure_registered() -> None:
pass
# Sources that must NEVER silently fall through to a network loader when the
# caller asked for them explicitly. ``local`` reads the user's own configured
# files (``~/.vibe-trading/data-bridge/config.yaml``); its ``markets`` set spans
# every market only so the cross-market auto-resolver can *reach* it, not so an
# unavailable ``local`` request can degrade into an unrelated network source.
# An explicit ``local`` request that is unavailable is a config problem the user
# must see, not something to paper over with a Yahoo/Tencent fetch.
_NO_NETWORK_FALLBACK_SOURCES: frozenset[str] = frozenset({"local", "qveris"}) # QVERIS-INTEGRATION
# ---------------------------------------------------------------------------
# Fallback chains: market_type -> ordered list of source names
# ---------------------------------------------------------------------------
# Chains are ordered by IP-ban risk first (lighter, throttle-tolerant public
# endpoints lead; key-gated REST and rate-limit-prone sources trail), then by
# data quality. Eastmoney/Sina/Stooq/Yahoo are unauthenticated public sources
# that must be politely throttled; Finnhub/AlphaVantage/Tiingo/FMP are key-gated
# REST fallbacks placed deeper in the chain.
FALLBACK_CHAINS: dict[str, list[str]] = {
"a_share": ["tushare", "akshare"],
"us_equity": ["yfinance", "akshare"],
"hk_equity": ["yfinance", "futu", "akshare"],
"crypto": ["okx", "ccxt"],
"futures": ["tushare", "akshare"],
"fund": ["tushare", "akshare"],
"macro": ["akshare", "tushare"],
"forex": ["akshare", "yfinance"],
"a_share": ["tencent", "mootdx", "eastmoney", "baostock", "akshare", "tushare", "local"],
"us_equity": ["yahoo", "stooq", "sina", "eastmoney", "yfinance", "tiingo", "fmp", "finnhub", "alphavantage", "longbridge", "akshare", "local"],
"hk_equity": ["eastmoney", "yahoo", "futu", "yfinance", "akshare", "longbridge", "local"],
"india_equity": ["yahoo", "yfinance", "india_broker", "local"],
# OKX first (native), then dedicated Binance, then generic CCXT / Yahoo.
"crypto": ["okx", "binance", "ccxt", "yfinance", "local"],
"futures": ["tushare", "akshare", "local"],
"fund": ["tushare", "akshare", "local"],
"macro": ["akshare", "tushare", "local"],
# mt5 leads when a local MetaTrader 5 terminal is attached (Windows-only,
# broker feed); otherwise it reports unavailable and the chain proceeds.
"forex": ["mt5", "akshare", "yfinance", "local"],
}
@@ -141,6 +210,19 @@ def get_loader_cls_with_fallback(source: str) -> Type[Any]:
if instance is not None and instance.is_available():
return loader_cls
# Some sources must never silently degrade to an unrelated network loader
# when explicitly requested. ``local`` is the canonical case: its broad
# ``markets`` set exists only to make it reachable from the cross-market
# auto-resolver, so falling back through it would fetch network data the
# user never asked for and mask a Data Bridge config problem. Fail loudly.
if source in _NO_NETWORK_FALLBACK_SOURCES:
raise NoAvailableSourceError(
f"Data source '{source}' is unavailable and does not fall back to a "
f"network source. Check your local Data Bridge config "
f"(~/.vibe-trading/data-bridge/config.yaml) — it must exist and list "
f"at least one source."
)
# Source unavailable — try same-market fallback
for market in loader_cls.markets:
try:
+568
View File
@@ -0,0 +1,568 @@
"""RSSHub event/sentiment provider with point-in-time safeguards.
A news / announcement / sentiment provider that runs parallel to the Tushare
fundamental layer (:mod:`backtest.loaders.tushare_fundamentals`). It pulls feeds
from a self-hosted `RSSHub <https://docs.rsshub.app>`_ instance, normalises each
item into the ``event-driven`` skill schema (``date, event_type, score, source,
summary``), and attaches a point-in-time-safe ``event_score`` column to daily
price frames.
Point-in-time discipline (mirrors the fundamentals enricher): every item is
stamped with a *knowable date* — the date a backtest could first have acted on
it (items published after the close roll to the next session) — and only items
with ``knowable_date <= as_of`` are ever returned. No feed item can leak into a
bar dated before it became knowable.
Scoring is pluggable. A deterministic, dependency-free lexicon scorer is used by
default; callers may pass any ``scorer(title, summary) -> float`` (e.g. an LLM
judge, as the ``event-driven`` skill describes) to override it.
"""
from __future__ import annotations
import logging
import os
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from typing import Any, Callable, Iterable, Mapping
from xml.etree.ElementTree import ParseError
import numpy as np
import pandas as pd
from defusedxml import ElementTree as ET
from defusedxml.common import DefusedXmlException
from backtest.loaders.base import positive_env_float, retry_with_budget
logger = logging.getLogger(__name__)
RSSHUB_BASE_URL_ENV = "RSSHUB_BASE_URL"
RSSHUB_BASE_URL_PLACEHOLDERS = {"", "https://rsshub.example.com"}
RSSHUB_TIMEOUT_ENV = "RSSHUB_TIMEOUT_S"
RSSHUB_BUDGET_ENV = "RSSHUB_FETCH_BUDGET_S"
DEFAULT_TIMEOUT_S = 15.0
DEFAULT_BUDGET_S = 60.0
#: Hour (local to the feed timestamps) at/after which a publication is treated as
#: knowable only on the next calendar day, matching the event-driven skill's
#: "released after market close -> use the next trading day" rule.
DEFAULT_CLOSE_CUTOFF_HOUR = 16
#: Canonical column order for the tidy event frame.
EVENT_COLUMNS: tuple[str, ...] = (
"ts_code",
"knowable_date",
"event_type",
"score",
"source",
"summary",
)
ScorerFn = Callable[[str, str], float]
class EventProviderError(Exception):
"""Base error for event-provider failures."""
class UnknownFeedError(EventProviderError):
"""Raised when a requested feed name is not registered."""
#: Supported ``code_style`` values for per-symbol routes.
CODE_STYLES: frozenset[str] = frozenset({"raw", "exchange_prefix", "bare"})
def format_code_for_route(code: str, style: str) -> str:
"""Convert a backtest code (e.g. ``"600519.SH"``) to a route's expected form.
RSSHub routes disagree on instrument formatting: some take the exchange-
prefixed form (``SH600519``, e.g. xueqiu), some the bare number (``600519``),
some a vendor-specific string. The backtest engine always passes the dotted
``"600519.SH"`` form, so a feed declares the shape its route needs.
Args:
code: Dotted instrument code (``"600519.SH"``); passed through if it has
no ``.`` suffix.
style: One of :data:`CODE_STYLES` — ``"raw"`` (unchanged), ``"bare"``
(symbol only, ``"600519"``), or ``"exchange_prefix"`` (``"SH600519"``).
Returns:
The code formatted for the route.
Raises:
ValueError: If ``style`` is not a recognised code style.
"""
if style not in CODE_STYLES:
raise ValueError(f"Unknown code_style {style!r}; expected one of {sorted(CODE_STYLES)}")
if style == "raw":
return code
symbol, _, suffix = code.partition(".")
if style == "bare":
return symbol
return f"{suffix.upper()}{symbol}" if suffix else symbol
@dataclass(frozen=True)
class FeedSpec:
"""Machine-readable metadata for one RSSHub feed route.
Attributes:
name: Stable feed identifier used in configs (e.g. ``"sina_announcements"``).
route_template: RSSHub route with a ``{code}`` placeholder, joined to the
base URL (e.g. ``"/sina/announcement/{code}"``). Routes without a
``{code}`` placeholder are treated as market-wide feeds.
event_type: Event class emitted for items from this feed; one of the
event-driven taxonomy (``earnings``/``macro``/``policy``/
``sentiment``/``insider``/``technical_break``).
code_style: How the dotted backtest code is rewritten before it is
substituted into a per-symbol ``route_template`` — one of
:data:`CODE_STYLES`. Ignored for market-wide routes.
"""
name: str
route_template: str
event_type: str
code_style: str = "raw"
def __post_init__(self) -> None:
if self.code_style not in CODE_STYLES:
raise ValueError(
f"FeedSpec {self.name!r}: unknown code_style {self.code_style!r}; "
f"expected one of {sorted(CODE_STYLES)}"
)
@property
def is_per_symbol(self) -> bool:
"""Whether the route is parameterised by instrument code."""
return "{code}" in self.route_template
def feed_specs_from_config(entries: Iterable[Mapping[str, Any]]) -> list[FeedSpec]:
"""Build :class:`FeedSpec` objects from backtest-config dicts.
Each entry must carry ``name``, ``route_template`` and ``event_type``; an
optional ``code_style`` defaults to ``"raw"``. There is intentionally no
built-in feed catalogue — routes evolve and silently rot, so feeds are always
declared explicitly in config (or passed to the provider constructor).
Args:
entries: Iterable of mapping objects, one per feed.
Returns:
Parsed feed specs.
Raises:
ValueError: If an entry is missing a required key or has a duplicate name.
"""
specs: list[FeedSpec] = []
seen: set[str] = set()
for entry in entries:
if not isinstance(entry, Mapping):
raise ValueError("each event feed must be a mapping with name/route_template/event_type")
missing = [k for k in ("name", "route_template", "event_type") if not str(entry.get(k, "")).strip()]
if missing:
raise ValueError(f"event feed is missing required field(s): {', '.join(missing)}")
name = str(entry["name"]).strip()
if name in seen:
raise ValueError(f"duplicate event feed name: {name!r}")
seen.add(name)
specs.append(
FeedSpec(
name=name,
route_template=str(entry["route_template"]).strip(),
event_type=str(entry["event_type"]).strip(),
code_style=str(entry.get("code_style", "raw")).strip() or "raw",
)
)
return specs
#: No built-in feed catalogue: RSSHub routes change and break, so feeds are always
#: supplied explicitly (via config ``event_feeds`` or the provider constructor).
DEFAULT_FEEDS: tuple[FeedSpec, ...] = ()
# ── Default dependency-free lexicon scorer ───────────────────────────────────
_POSITIVE_TERMS: frozenset[str] = frozenset(
{
"beat",
"beats",
"surge",
"surges",
"soar",
"record",
"growth",
"profit",
"upgrade",
"upgraded",
"outperform",
"bullish",
"approval",
"approved",
"win",
"wins",
"buyback",
"dividend",
"expansion",
"breakthrough",
}
)
_NEGATIVE_TERMS: frozenset[str] = frozenset(
{
"miss",
"misses",
"plunge",
"plunges",
"fall",
"falls",
"loss",
"losses",
"downgrade",
"downgraded",
"underperform",
"bearish",
"fraud",
"probe",
"lawsuit",
"recall",
"default",
"bankruptcy",
"halt",
"warning",
"decline",
}
)
def default_lexicon_scorer(title: str, summary: str) -> float:
"""Score text in ``[-1, 1]`` by net positive/negative term frequency.
A deterministic, dependency-free baseline. It is intentionally simple — the
provider is the data layer; richer scoring (e.g. an LLM judge) is plugged in
via the ``scorer`` argument on :meth:`RSSHubEventProvider.query_events`.
Args:
title: Item headline.
summary: Item summary/description (may be empty).
Returns:
Sentiment score in ``[-1.0, 1.0]``; ``0.0`` when no terms match.
"""
tokens = f"{title} {summary}".lower().replace("/", " ").split()
if not tokens:
return 0.0
pos = sum(1 for tok in tokens if tok.strip(".,;:!?\"'()") in _POSITIVE_TERMS)
neg = sum(1 for tok in tokens if tok.strip(".,;:!?\"'()") in _NEGATIVE_TERMS)
hits = pos + neg
if hits == 0:
return 0.0
return max(-1.0, min(1.0, (pos - neg) / hits))
def _knowable_date(published: pd.Timestamp, cutoff_hour: int) -> pd.Timestamp:
"""Return the date an item becomes actionable (post-cutoff rolls to next day).
Args:
published: Parsed publication timestamp (tz dropped to naive local).
cutoff_hour: Hour at/after which publication rolls to the next day.
Returns:
Normalised (midnight) timestamp of the knowable date.
"""
stamp = published.tz_localize(None) if published.tzinfo is not None else published
base = stamp.normalize()
if stamp.hour >= cutoff_hour:
base = base + pd.Timedelta(days=1)
return base
def _clean_summary(text: str | None) -> str:
"""Collapse whitespace and strip commas so the value is CSV-safe."""
if not text:
return ""
return " ".join(text.replace(",", " ").split())
class RSSHubEventProvider:
"""Point-in-time-safe event/sentiment provider backed by RSSHub.
Attributes:
base_url: Root of the RSSHub instance (no trailing slash).
feeds: Registered feed specs keyed by name.
close_cutoff_hour: Hour after which items roll to the next knowable day.
"""
def __init__(
self,
base_url: str | None = None,
*,
feeds: Iterable[FeedSpec] | None = None,
client: Any | None = None,
close_cutoff_hour: int = DEFAULT_CLOSE_CUTOFF_HOUR,
) -> None:
"""Initialise the provider.
Args:
base_url: RSSHub root URL; defaults to ``$RSSHUB_BASE_URL``.
feeds: Feed specs to register; defaults to :data:`DEFAULT_FEEDS`.
client: Optional injected HTTP client exposing ``get(url, timeout=...)``
that returns an object with ``.text`` and ``.raise_for_status()``
(an ``httpx.Client``-like). Injectable for offline testing.
close_cutoff_hour: Knowable-date roll cutoff (0-23).
"""
from src.config.accessor import get_env_config
resolved = (base_url if base_url is not None else get_env_config().data.rsshub_base_url).strip()
self.base_url = resolved.rstrip("/")
specs = tuple(feeds) if feeds is not None else DEFAULT_FEEDS
self.feeds: dict[str, FeedSpec] = {spec.name: spec for spec in specs}
self.close_cutoff_hour = close_cutoff_hour
self._client = client
def is_available(self) -> bool:
"""Whether a usable RSSHub base URL is configured."""
return self.base_url not in {p.rstrip("/") for p in RSSHUB_BASE_URL_PLACEHOLDERS}
def list_feeds(self) -> list[str]:
"""Return registered feed names in stable order."""
return sorted(self.feeds)
def describe_feed(self, feed: str) -> FeedSpec:
"""Return the spec for a registered feed.
Raises:
UnknownFeedError: If ``feed`` is not registered.
"""
try:
return self.feeds[feed]
except KeyError as exc:
raise UnknownFeedError(f"Unknown RSSHub feed: {feed}") from exc
def query_events(
self,
codes: Iterable[str],
*,
as_of: str | pd.Timestamp,
feeds: Iterable[str] | None = None,
scorer: ScorerFn | None = None,
) -> pd.DataFrame:
"""Fetch and normalise feed items, dropping anything not yet knowable.
Args:
codes: Instrument codes to query.
as_of: Point-in-time boundary; items with ``knowable_date > as_of``
are excluded (no look-ahead).
feeds: Feed names to query; defaults to all registered feeds.
scorer: Optional ``scorer(title, summary) -> float`` override;
defaults to :func:`default_lexicon_scorer`.
Returns:
Tidy frame with :data:`EVENT_COLUMNS`, sorted by
``(ts_code, knowable_date)`` and de-duplicated.
"""
score_fn = scorer or default_lexicon_scorer
feed_names = list(feeds) if feeds is not None else self.list_feeds()
as_of_date = pd.Timestamp(as_of).normalize()
code_list = list(codes)
rows: list[dict[str, Any]] = []
attempted = 0
failed = 0
for feed_name in feed_names:
spec = self.describe_feed(feed_name)
for code in code_list:
attempted += 1
xml_text = self._fetch_feed(spec, code)
if not xml_text:
failed += 1
continue
rows.extend(self._parse_items(xml_text, code, spec, score_fn))
# A configured-but-fully-unreachable provider must fail loudly rather than
# silently scoring every bar 0.0 (which reads as "sentiment considered, no
# signal"). A reachable feed that simply returned no items is legitimate.
if attempted and failed == attempted:
raise EventProviderError(
f"All {attempted} RSSHub feed fetch(es) failed for base_url "
f"{self.base_url!r}; verify the instance is reachable and the "
f"configured routes exist (feeds={feed_names})"
)
if not rows:
return pd.DataFrame(columns=EVENT_COLUMNS)
frame = pd.DataFrame(rows, columns=list(EVENT_COLUMNS))
frame = frame[frame["knowable_date"] <= as_of_date]
frame = frame.drop_duplicates(subset=["ts_code", "knowable_date", "event_type", "summary"])
frame = frame.sort_values(["ts_code", "knowable_date"]).reset_index(drop=True)
return frame
def _fetch_feed(self, spec: FeedSpec, code: str) -> str:
"""Fetch raw RSS XML for one feed/code, bounded by a wall-clock budget.
Returns the response body on success, or ``""`` when the fetch could not
complete within the budget — the caller logs and counts that as a failed
feed so an all-feeds-unreachable run can be surfaced loudly.
"""
if spec.is_per_symbol:
route = spec.route_template.format(code=format_code_for_route(code, spec.code_style))
else:
route = spec.route_template
url = f"{self.base_url}{route}"
timeout = positive_env_float(RSSHUB_TIMEOUT_ENV, DEFAULT_TIMEOUT_S)
budget = positive_env_float(RSSHUB_BUDGET_ENV, DEFAULT_BUDGET_S)
client = self._client or self._default_client()
deadline = time.monotonic() + budget
def _do() -> str:
response = client.get(url, timeout=timeout)
response.raise_for_status()
return response.text
try:
return retry_with_budget(
_do,
transient=Exception,
deadline=deadline,
label=f"rsshub fetch {spec.name} for {code}",
)
except TimeoutError:
logger.warning(
"RSSHub feed %r for %s unreachable within %.0fs budget (%s); "
"no items contributed from this feed",
spec.name,
code,
budget,
url,
)
return ""
@staticmethod
def _default_client() -> Any:
"""Construct a default httpx client (imported lazily)."""
import httpx
return httpx.Client(follow_redirects=True)
def _parse_items(
self,
xml_text: str,
code: str,
spec: FeedSpec,
score_fn: ScorerFn,
) -> list[dict[str, Any]]:
"""Parse RSS 2.0 ``<item>`` nodes into normalised event rows.
Uses ``defusedxml`` to neutralise XXE / entity-expansion attacks in the
untrusted feed payload; malformed or hostile XML yields no rows.
"""
try:
root = ET.fromstring(xml_text)
except (ParseError, DefusedXmlException):
return []
rows: list[dict[str, Any]] = []
for item in root.iter("item"):
title = (item.findtext("title") or "").strip()
summary = _clean_summary(item.findtext("description"))
pub_raw = item.findtext("pubDate")
published = _parse_pubdate(pub_raw)
if published is None:
continue
rows.append(
{
"ts_code": code,
"knowable_date": _knowable_date(published, self.close_cutoff_hour),
"event_type": spec.event_type,
"score": float(score_fn(title, summary)),
"source": spec.name,
"summary": summary or title,
}
)
return rows
def _parse_pubdate(value: str | None) -> pd.Timestamp | None:
"""Parse an RFC-822 (RSS) or ISO ``pubDate`` into a Timestamp, or None."""
if not value or not value.strip():
return None
try:
return pd.Timestamp(parsedate_to_datetime(value))
except (TypeError, ValueError):
pass
try:
return pd.Timestamp(value)
except (TypeError, ValueError):
return None
def enrich_price_frames_with_events(
data_map: dict[str, pd.DataFrame],
provider: RSSHubEventProvider,
*,
as_of: str | pd.Timestamp,
feeds: Iterable[str] | None = None,
decay_lambda: float = 0.1,
lookback: int = 30,
min_abs_score: float = 0.0,
scorer: ScorerFn | None = None,
) -> dict[str, pd.DataFrame]:
"""Attach a point-in-time-safe ``event_score`` column to price frames.
For each bar ``t`` the score aggregates only events whose ``knowable_date``
falls in ``(t - lookback, t]``, exponentially decayed by age — the same
formula as the ``event-driven`` skill — so no future item can influence an
earlier bar. Two columns are added: ``event_score`` (decayed sum, clipped to
``[-1, 1]``) and ``event_count`` (events in the window).
Args:
data_map: Mapping ``{code: OHLCV DataFrame}`` (DatetimeIndex).
provider: Configured :class:`RSSHubEventProvider`.
as_of: Point-in-time boundary passed through to the provider.
feeds: Feed names to query; defaults to all registered feeds.
decay_lambda: Exponential decay per day (higher decays faster).
lookback: Window in days; older events are excluded.
min_abs_score: Drop events with ``|score|`` below this threshold.
scorer: Optional scoring override forwarded to the provider.
Returns:
A new mapping with the same frames plus ``event_score``/``event_count``.
"""
if not data_map:
return data_map
codes = list(data_map)
events = provider.query_events(codes, as_of=as_of, feeds=feeds, scorer=scorer)
enriched = {code: frame.copy() for code, frame in data_map.items()}
if events.empty:
for frame in enriched.values():
frame["event_score"] = 0.0
frame["event_count"] = 0
return enriched
if min_abs_score > 0.0:
events = events[events["score"].abs() >= min_abs_score]
for code, frame in enriched.items():
rows = events[events["ts_code"] == code]
bar_dates = pd.DatetimeIndex(pd.to_datetime(frame.index)).normalize()
scores = pd.Series(0.0, index=frame.index)
counts = pd.Series(0, index=frame.index)
if not rows.empty and len(frame) > 0:
ev_dates = rows["knowable_date"].to_numpy(dtype="datetime64[ns]")
ev_scores = rows["score"].to_numpy(dtype=float)
window = np.timedelta64(lookback, "D")
for pos, bar in enumerate(bar_dates):
bar64 = np.datetime64(bar, "ns")
mask = (ev_dates <= bar64) & (ev_dates > bar64 - window)
if not mask.any():
continue
ages = (bar64 - ev_dates[mask]) / np.timedelta64(1, "D")
decayed = float((ev_scores[mask] * np.exp(-decay_lambda * ages)).sum())
scores.iloc[pos] = max(-1.0, min(1.0, decayed))
counts.iloc[pos] = int(mask.sum())
frame["event_score"] = scores
frame["event_count"] = counts
return enriched
+216
View File
@@ -0,0 +1,216 @@
"""Shared SEC EDGAR REST client: ticker->CIK mapping + filings/facts fetch.
The U.S. SEC publishes free, no-auth JSON endpoints for company filings and
XBRL financial facts. Three public facts drive this module:
* ``https://www.sec.gov/files/company_tickers.json`` maps every reporting
ticker to its numeric CIK (Central Index Key).
* ``https://data.sec.gov/submissions/CIK##########.json`` returns a company's
recent filing index, keyed by the **zero-padded 10-digit** CIK.
* ``https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json`` returns the
full set of reported XBRL financial concepts for that same padded CIK.
SEC rate-limits by source IP and asks every client to send a descriptive
``User-Agent`` carrying a contact address; bursting without one earns a
temporary block. Every request therefore routes through the shared
:mod:`backtest.loaders._http` throttle under the ``"sec"`` host bucket, and the
UA defaults to a Vibe-Trading contact string overridable via
``VIBE_TRADING_SEC_UA``.
This module is a thin transport client, not a :class:`DataLoaderProtocol`
implementation: it returns raw decoded JSON for downstream loaders/tools to
shape. The ticker->CIK table is fetched once per process and memoized.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict, Optional
from backtest.loaders._http import (
DEFAULT_USER_AGENT,
resolve_min_interval,
throttled_get_json,
)
logger = logging.getLogger(__name__)
_HOST_KEY = "sec"
# SEC asks for no more than ~10 requests/second; 0.12s spacing keeps us well
# under that even with jitter, and is the floor mandated by the parcel contract.
_MIN_INTERVAL_DEFAULT = 0.12
_MIN_INTERVAL_ENV = "VIBE_TRADING_SEC_MIN_INTERVAL"
_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json"
_COMPANY_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
_UA_ENV = "VIBE_TRADING_SEC_UA"
# SEC's fair-access policy wants a real contact in the UA. Override via env to
# present your own address; this default keeps the client compliant out of box.
_DEFAULT_SEC_UA = f"Vibe-Trading/1.0 (contact: vibe-trading@example.com) {DEFAULT_USER_AGENT}"
# Process-wide memoized ticker->CIK map ("AAPL" -> "0000320193"), built lazily.
_TICKER_CACHE: Optional[Dict[str, str]] = None
_TICKER_CACHE_LOCK = threading.Lock()
def _min_interval() -> float:
"""Resolve the effective per-request spacing, never below the 0.12s floor."""
return max(_MIN_INTERVAL_DEFAULT, resolve_min_interval(_MIN_INTERVAL_ENV, _MIN_INTERVAL_DEFAULT))
def _user_agent() -> str:
"""Return the compliant contact UA, honoring the ``VIBE_TRADING_SEC_UA`` override."""
from src.config.accessor import get_env_config
override = get_env_config().data.vibe_trading_sec_ua
if override and override.strip():
return override.strip()
return _DEFAULT_SEC_UA
def _sec_get_json(url: str) -> Any:
"""GET ``url`` as JSON through the shared SEC-bucket throttle.
Args:
url: Fully-qualified SEC endpoint URL.
Returns:
The decoded JSON body (typically a ``dict``).
Raises:
requests.RequestException: On a non-2xx status or undecodable body,
propagated unchanged for the caller to classify.
"""
return throttled_get_json(
url,
host_key=_HOST_KEY,
min_interval=_min_interval(),
headers={"User-Agent": _user_agent(), "Accept": "application/json"},
)
def _pad_cik(cik: str | int) -> str:
"""Normalize a CIK to the SEC's zero-padded 10-digit string form.
Args:
cik: A CIK as an int or a string that may already be padded or carry a
leading ``"CIK"`` prefix.
Returns:
The CIK as exactly 10 digits, e.g. ``"0000320193"``.
Raises:
ValueError: If ``cik`` contains no digits.
"""
digits = "".join(ch for ch in str(cik) if ch.isdigit())
if not digits:
raise ValueError(f"CIK has no digits: {cik!r}")
return digits.zfill(10)
def _build_ticker_map(payload: Any) -> Dict[str, str]:
"""Build an uppercase ticker->padded-CIK map from the tickers payload.
The SEC payload is a dict of positional string keys to records shaped like
``{"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."}``. A
malformed row is skipped rather than aborting the whole map.
Args:
payload: Decoded ``company_tickers.json`` body.
Returns:
Mapping of upper-cased ticker to zero-padded 10-digit CIK.
"""
records = payload.values() if isinstance(payload, dict) else payload
mapping: Dict[str, str] = {}
for record in records or []:
if not isinstance(record, dict):
continue
ticker = record.get("ticker")
cik = record.get("cik_str", record.get("cik"))
if not ticker or cik is None:
continue
try:
mapping[str(ticker).strip().upper()] = _pad_cik(cik)
except ValueError:
logger.debug("skipping ticker row with bad CIK: %r", record)
return mapping
def _ticker_map() -> Dict[str, str]:
"""Return the process-wide ticker->CIK map, fetching once and memoizing.
Returns:
Mapping of upper-cased ticker to zero-padded 10-digit CIK.
Raises:
requests.RequestException: If the one-time tickers fetch fails.
"""
global _TICKER_CACHE
if _TICKER_CACHE is not None:
return _TICKER_CACHE
with _TICKER_CACHE_LOCK:
if _TICKER_CACHE is None:
payload = _sec_get_json(_TICKERS_URL)
_TICKER_CACHE = _build_ticker_map(payload)
return _TICKER_CACHE
def cik_for(ticker: str) -> Optional[str]:
"""Resolve a ticker symbol to its zero-padded 10-digit CIK.
Args:
ticker: A U.S. equity ticker (case-insensitive), e.g. ``"AAPL"``.
Returns:
The padded CIK string (``"0000320193"``) or ``None`` when the ticker is
empty or absent from the SEC table.
Raises:
requests.RequestException: If the one-time tickers fetch fails.
"""
if not ticker or not ticker.strip():
return None
return _ticker_map().get(ticker.strip().upper())
def get_submissions(cik: str | int) -> Dict[str, Any]:
"""Fetch the filing-index ("submissions") JSON for a CIK.
Args:
cik: A CIK as int or string; padded to 10 digits internally.
Returns:
The decoded submissions document (company metadata + recent filings).
Raises:
ValueError: If ``cik`` contains no digits.
requests.RequestException: On a non-2xx status or undecodable body.
"""
return _sec_get_json(_SUBMISSIONS_URL.format(cik=_pad_cik(cik)))
def get_company_facts(cik: str | int) -> Dict[str, Any]:
"""Fetch the XBRL ``companyfacts`` JSON for a CIK.
Args:
cik: A CIK as int or string; padded to 10 digits internally.
Returns:
The decoded company-facts document (all reported XBRL concepts).
Raises:
ValueError: If ``cik`` contains no digits.
requests.RequestException: On a non-2xx status or undecodable body.
"""
return _sec_get_json(_COMPANY_FACTS_URL.format(cik=_pad_cik(cik)))
def _reset_ticker_cache_for_tests() -> None:
"""Clear the memoized ticker map. Test-only hook; never called in prod."""
global _TICKER_CACHE
with _TICKER_CACHE_LOCK:
_TICKER_CACHE = None
+200
View File
@@ -0,0 +1,200 @@
"""Sina Finance loader: free, no-auth US daily OHLCV via the JSONP K-line API.
Sina exposes US daily candles through a JSONP endpoint that wraps a JSON array
of ``{d,o,h,l,c,v}`` bars in a JavaScript variable assignment. We request it,
strip the JSONP wrapper, and reshape into the loader's standard OHLCV frame.
API format (JSONP)::
https://stock.finance.sina.com.cn/usstock/api/jsonp_v2.php/var%20x=/US_MinKService.getDailyK?symbol=AAPL
Like Eastmoney, Sina rate-limits by source IP, so every request routes through
the shared per-host throttle in :mod:`backtest.loaders._http`.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders._http import resolve_min_interval, throttled_get
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_BASE_URL = (
"https://stock.finance.sina.com.cn/usstock/api/jsonp_v2.php/"
"var%20x=/US_MinKService.getDailyK"
)
_HOST_KEY = "sina"
_MIN_INTERVAL_ENV = "VIBE_TRADING_SINA_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL = 0.5
# Sina wraps the payload as ``var x=([...]);`` — the JSON array sits inside an
# optional ``(...)``. Capture the outermost bracketed array, ignoring the
# assignment/paren scaffolding around it.
_JSONP_ARRAY_RE = re.compile(r"(\[.*\])", re.DOTALL)
def _is_us_equity(code: str) -> bool:
"""Return whether ``code`` is a US-equity symbol this loader handles."""
return bool(code) and code.upper().endswith(".US")
def _to_sina_symbol(code: str) -> str:
"""Map an internal code to Sina's bare ticker (``"AAPL.US"`` -> ``"AAPL"``)."""
return code.upper().rsplit(".", 1)[0]
def _strip_jsonp(raw: str) -> list:
"""Extract the JSON array embedded in Sina's JSONP wrapper.
Args:
raw: Raw response body, e.g. ``var x=([{"d":"2024-01-02",...}]);``.
Returns:
The decoded list of bar dicts.
Raises:
ValueError: If no JSON array can be located or decoded.
"""
match = _JSONP_ARRAY_RE.search(raw.strip())
if not match:
raise ValueError("no JSON array found in Sina JSONP response")
bars = json.loads(match.group(1))
if not isinstance(bars, list):
raise ValueError("Sina JSONP payload is not a list of bars")
return bars
def _bars_to_frame(bars: list, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Reshape Sina ``{d,o,h,l,c,v}`` bars into the standard OHLCV frame.
Args:
bars: List of per-day dicts with ``d/o/h/l/c/v`` keys.
start_date: Inclusive window start (YYYY-MM-DD).
end_date: Inclusive window end (YYYY-MM-DD).
Returns:
A DatetimeIndex (named ``trade_date``) frame with float columns
``open/high/low/close/volume`` clipped to the window, or ``None`` when
no usable rows survive parsing.
"""
rows = []
for bar in bars:
if not isinstance(bar, dict) or "d" not in bar:
continue
try:
rows.append(
{
"trade_date": bar["d"],
"open": float(bar["o"]),
"high": float(bar["h"]),
"low": float(bar["l"]),
"close": float(bar["c"]),
"volume": float(bar["v"]),
}
)
except (KeyError, TypeError, ValueError):
continue
if not rows:
return None
frame = pd.DataFrame(rows)
frame["trade_date"] = pd.to_datetime(frame["trade_date"])
frame = frame.set_index("trade_date").sort_index()
frame = frame[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
window = frame.loc[
(frame.index >= pd.Timestamp(start_date))
& (frame.index <= pd.Timestamp(end_date))
]
return window if not window.empty else None
@register
class DataLoader:
"""Sina Finance US-equity daily OHLCV loader (free, HTTP/JSONP, no auth)."""
name = "sina"
markets = {"us_equity"}
requires_auth = False
def __init__(self) -> None:
pass
def is_available(self) -> bool:
"""Always available — uses plain throttled HTTP, no credentials."""
return True
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch US daily OHLCV for each code; skip and log per-symbol failures.
Args:
codes: Symbols in ``TICKER.US`` form (e.g. ``"AAPL.US"``).
start_date: Inclusive window start (YYYY-MM-DD).
end_date: Inclusive window end (YYYY-MM-DD).
interval: Bar interval; only daily (``"1D"``) is supported.
fields: Ignored — the standard OHLCV columns are always returned.
Returns:
Mapping ``{code: DataFrame}`` for every code that yielded bars.
Raises:
ValueError: If ``interval`` is not daily or the date range is invalid.
"""
if interval.upper() not in {"1D", "D", "DAY", "DAILY"}:
raise ValueError(f"Unsupported interval {interval!r}; sina is daily-only")
validate_date_range(start_date, end_date)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("sina failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""Fetch and parse one symbol's daily bars, or ``None`` if non-US/empty."""
if not _is_us_equity(code):
return None
symbol = _to_sina_symbol(code)
response = throttled_get(
_BASE_URL,
host_key=_HOST_KEY,
min_interval=resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL),
params={"symbol": symbol},
headers={"Referer": "https://stock.finance.sina.com.cn/"},
)
response.raise_for_status()
bars = _strip_jsonp(response.text)
return _bars_to_frame(bars, start_date, end_date)
+190
View File
@@ -0,0 +1,190 @@
"""Stooq loader: free, no-auth US-equity EOD OHLCV via CSV download.
Stooq publishes free end-of-day bars from a plain CSV endpoint
(``https://stooq.com/q/d/l/``) with no API key. Like other free quote
providers it rate-limits by source IP and must be throttled, so every request
routes through :mod:`backtest.loaders._http` (shared per-host spacing + session
reuse) under the ``"stooq"`` host bucket.
API format:
https://stooq.com/q/d/l/?s=aapl.us&d1=20240101&d2=20240131&i=d
Symbol convention (Vibe-Trading -> Stooq):
* ``AAPL.US`` -> ``aapl.us`` (Stooq tickers are lowercase; the ``.US`` market
suffix is kept, just lower-cased).
Response body is CSV ``Date,Open,High,Low,Close,Volume``. An unknown symbol or
empty window yields the literal ``"N/D"`` or an empty body, both treated as
"no data" for that symbol (skipped, never fatal to the batch).
"""
from __future__ import annotations
import io
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders._http import resolve_min_interval, throttled_get
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_BASE_URL = "https://stooq.com/q/d/l/"
HOST_KEY = "stooq"
_MIN_INTERVAL_ENV = "VIBE_TRADING_STOOQ_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL_S = 0.6
# Stooq's CSV header columns mapped to our output field names.
_COLUMN_MAP = {
"Open": "open",
"High": "high",
"Low": "low",
"Close": "close",
"Volume": "volume",
}
_OUTPUT_COLUMNS = ["open", "high", "low", "close", "volume"]
def _min_interval() -> float:
"""Resolve the per-call minimum spacing, honoring the env override."""
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
def map_symbol(symbol: str) -> str:
"""Translate a Vibe-Trading symbol into Stooq's ticker convention.
Args:
symbol: Project-side symbol, e.g. ``AAPL.US``.
Returns:
The lower-cased Stooq ticker, e.g. ``aapl.us``.
"""
return symbol.strip().lower()
def _compact_date(value: str) -> str:
"""Render a date string as Stooq's ``YYYYMMDD`` form."""
return pd.Timestamp(value).strftime("%Y%m%d")
@register
class DataLoader:
"""Stooq US-equity EOD OHLCV loader (free, HTTP CSV, no auth)."""
name = "stooq"
markets = {"us_equity"}
requires_auth = False
def __init__(self) -> None:
pass
def is_available(self) -> bool:
"""Always available — uses plain throttled HTTP, no credentials."""
return True
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch daily OHLCV bars for ``codes`` over ``[start_date, end_date]``.
Args:
codes: Project-side symbols (e.g. ``["AAPL.US", "MSFT.US"]``).
start_date: Inclusive start date (``YYYY-MM-DD``).
end_date: Inclusive end date (``YYYY-MM-DD``).
interval: Bar size; only daily (``"1D"``) is supported by the
endpoint and any other value is fetched as daily.
fields: Unused — the CSV always carries the full OHLCV set.
Returns:
Mapping ``{symbol: DataFrame}`` for symbols that returned data. Each
frame has a ``DatetimeIndex`` named ``trade_date`` and float columns
``open/high/low/close/volume`` in ascending date order. A symbol that
errors or has no data is omitted, never aborting the batch.
Raises:
ValueError: If ``start_date > end_date`` or a date is unparseable.
"""
validate_date_range(start_date, end_date)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc: # noqa: BLE001 - one bad symbol must not abort the batch
logger.warning("stooq failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""Fetch and parse one symbol's CSV; ``None`` when Stooq has no data."""
params = {
"s": map_symbol(code),
"d1": _compact_date(start_date),
"d2": _compact_date(end_date),
"i": "d",
}
response = throttled_get(
_BASE_URL,
host_key=HOST_KEY,
min_interval=_min_interval(),
params=params,
)
response.raise_for_status()
return _parse_csv(response.text)
def _parse_csv(body: str) -> Optional[pd.DataFrame]:
"""Convert a Stooq EOD CSV body into our OHLCV frame, ``None`` on no data.
Args:
body: Raw CSV text ``Date,Open,High,Low,Close,Volume``. An empty body or
the sentinel ``"N/D"`` (unknown symbol / empty window) means no data.
Returns:
A frame indexed by ``trade_date`` with float OHLCV columns sorted
ascending, or ``None`` when the body carries no usable rows.
"""
text = (body or "").strip()
if not text or text.upper().startswith("N/D"):
return None
frame = pd.read_csv(io.StringIO(text))
if frame.empty or "Date" not in frame.columns:
return None
rename = {col: _COLUMN_MAP[col] for col in _COLUMN_MAP if col in frame.columns}
frame = frame.rename(columns=rename)
if not all(col in frame.columns for col in _OUTPUT_COLUMNS):
return None
frame["trade_date"] = pd.to_datetime(frame["Date"], errors="coerce")
frame = frame.dropna(subset=["trade_date"]).set_index("trade_date").sort_index()
frame.index.name = "trade_date"
for col in _OUTPUT_COLUMNS:
frame[col] = pd.to_numeric(frame[col], errors="coerce")
frame = frame[_OUTPUT_COLUMNS].dropna(subset=["open", "high", "low", "close"])
if frame.empty:
return None
return frame.astype(float)
+150
View File
@@ -0,0 +1,150 @@
"""Tencent Finance loader: free, no-auth A-share data via HTTP API.
Uses Tencent's ifzq.gtimg.cn API which is not blocked by eastmoney's CDN.
Covers: A-shares (SH/SZ). No API token required.
API format:
https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh601595,day,2026-06-01,2026-06-13,500,qfq
"""
from __future__ import annotations
import json
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_BASE_URL = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
def _is_a_share(code: str) -> bool:
return code.upper().endswith((".SZ", ".SH"))
@register
class DataLoader:
"""Tencent Finance A-share OHLCV loader (free, HTTP, no auth)."""
name = "tencent"
markets = {"a_share"}
requires_auth = False
def is_available(self) -> bool:
"""Always available — uses plain HTTP."""
return True
def __init__(self) -> None:
pass
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
validate_date_range(start_date, end_date)
del fields
if interval.strip().lower() not in {"1d", "d", "day", "daily"}:
logger.warning(
"tencent supports daily bars only; rejecting interval=%s",
interval,
)
return {}
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("tencent failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
if not _is_a_share(code):
return None
parts = code.upper().split(".")
symbol = parts[0]
suffix = parts[1] if len(parts) > 1 else ""
if suffix == "SH":
tencent_code = f"sh{symbol}"
elif suffix == "SZ":
tencent_code = f"sz{symbol}"
else:
return None
url = (
f"{_BASE_URL}?param={tencent_code},day,"
f"{start_date},{end_date},500,qfq"
)
import urllib.request
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://web.ifzq.gtimg.cn/",
})
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8")
data = json.loads(raw)
# Response: {"code":0,"data":{"sh601595":{"day":[["2026-06-01","21.32",...], ...]}}}
stock_data = data.get("data", {})
if not stock_data:
return None
# Get the first (only) key
stock_key = next(iter(stock_data), None)
if not stock_key:
return None
# Try "day" first, then "qfqday" (forward-adjusted)
klines = stock_data[stock_key].get("qfqday") or stock_data[stock_key].get("day")
if not klines:
return None
# Each row: ["date", "open", "close", "high", "low", "volume"]
rows = []
for k in klines:
if len(k) >= 6:
rows.append({
"trade_date": k[0],
"open": float(k[1]),
"close": float(k[2]),
"high": float(k[3]),
"low": float(k[4]),
"volume": float(k[5]),
})
if not rows:
return None
df = pd.DataFrame(rows)
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date").sort_index()
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return df
+237
View File
@@ -0,0 +1,237 @@
"""Tiingo loader: US-equity daily OHLCV via the Tiingo REST API (key-gated).
Tiingo serves end-of-day US-equity bars from a documented public endpoint:
GET https://api.tiingo.com/tiingo/daily/{symbol}/prices
?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&token={KEY}
The response is a JSON array of per-day objects, each carrying ``date`` plus
``open``/``high``/``low``/``close``/``volume`` (and adjusted variants we ignore).
A token is required; it is read from the ``TIINGO_API_KEY`` environment variable
and is never hard-coded. Like every other ban-prone HTTP loader in this package,
all requests route through :mod:`backtest.loaders._http` for per-host throttling
and session reuse — Tiingo rate-limits by client and rejects unspaced bursts.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders._http import resolve_min_interval, throttled_get_json
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_BASE_URL = "https://api.tiingo.com/tiingo/daily"
# Throttle/session bucket plus the per-provider minimum spacing override.
_HOST_KEY = "tiingo"
_MIN_INTERVAL_ENV = "VIBE_TRADING_TIINGO_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL = 0.5
_AUTH_ENV = "TIINGO_API_KEY"
# Placeholder values that must not count as a real key (mirrors how other
# key-gated loaders treat blank/example tokens as "not configured").
_KEY_PLACEHOLDERS = {"", "your_tiingo_api_key", "changeme", "xxx"}
# Target OHLCV schema, in column order.
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
def _resolve_key() -> str:
"""Return the configured Tiingo API key, or an empty string when unset.
Returns:
The trimmed ``TIINGO_API_KEY`` value, or ``""`` when absent or a known
placeholder.
"""
from src.config.accessor import get_env_config
key = get_env_config().data.tiingo_api_key.strip()
return "" if key.lower() in _KEY_PLACEHOLDERS else key
def _to_tiingo_symbol(code: str) -> Optional[str]:
"""Map a project symbol to a Tiingo ticker, or ``None`` when unsupported.
Args:
code: Project symbol such as ``AAPL.US`` or ``AAPL``.
Returns:
The bare lower-cased ticker Tiingo expects (e.g. ``aapl``), or ``None``
for symbols that are clearly not US equities (HK/crypto/A-share).
"""
upper = code.strip().upper()
if not upper:
return None
if upper.endswith(".US"):
upper = upper[:-3]
# Reject non-US market suffixes and crypto pairs outright.
if "." in upper or "-" in upper:
return None
if not upper.isalnum():
return None
return upper.lower()
def _rows_to_frame(rows: List[dict]) -> Optional[pd.DataFrame]:
"""Convert Tiingo's per-day records into the normalized OHLCV frame.
Args:
rows: JSON array decoded from the prices endpoint; each item carries a
``date`` plus ``open``/``high``/``low``/``close``/``volume``.
Returns:
A DataFrame indexed by a ``trade_date`` :class:`~pandas.DatetimeIndex`
with float ``open``/``high``/``low``/``close``/``volume`` columns, or
``None`` when no usable bar is present.
"""
parsed: List[dict] = []
for row in rows:
date = row.get("date")
if date is None:
continue
parsed.append(
{
"trade_date": date,
"open": row.get("open"),
"high": row.get("high"),
"low": row.get("low"),
"close": row.get("close"),
"volume": row.get("volume"),
}
)
if not parsed:
return None
frame = pd.DataFrame(parsed)
# Tiingo stamps each bar with an ISO-8601 datetime (e.g.
# "2024-01-02T00:00:00.000Z"); normalize to a tz-naive midnight index.
index = pd.DatetimeIndex(pd.to_datetime(frame["trade_date"], utc=True, errors="coerce"))
if getattr(index, "tz", None) is not None:
index = index.tz_convert(None)
frame = frame.drop(columns=["trade_date"])
frame.index = index.normalize()
frame.index.name = "trade_date"
frame = frame.apply(pd.to_numeric, errors="coerce")
# Coerce every OHLCV column to float64 so the schema is uniform: integer
# JSON volumes would otherwise land as int64, breaking downstream code that
# assumes a single numeric dtype.
frame = frame.astype("float64")
frame["volume"] = frame["volume"].fillna(0.0)
frame = frame.loc[:, _OHLCV_COLUMNS].sort_index()
frame = frame.dropna(subset=["open", "high", "low", "close"])
return frame if not frame.empty else None
@register
class DataLoader:
"""Fetch US-equity daily OHLCV bars from Tiingo (key-gated REST)."""
name = "tiingo"
markets = {"us_equity"}
requires_auth = True
def __init__(self) -> None:
"""Initialize the loader (no network or SDK touched at construction)."""
pass
def is_available(self) -> bool:
"""Return whether a usable Tiingo API key is configured."""
return bool(_resolve_key())
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV history keyed by the original project symbols.
Each symbol is fetched independently so one failing ticker (bad symbol,
transient HTTP error) never aborts the rest of the batch.
Args:
codes: Project symbols such as ``AAPL.US`` or ``MSFT``.
start_date: Inclusive start date in ``YYYY-MM-DD`` format.
end_date: Inclusive end date in ``YYYY-MM-DD`` format.
interval: Backtest interval; only ``1D`` is served by this endpoint.
fields: Ignored; included for interface compatibility.
Returns:
Mapping of input symbol to a normalized OHLCV DataFrame. Symbols
with no usable data are omitted.
Raises:
ValueError: If ``start_date`` > ``end_date`` or dates are unparseable.
"""
del fields
validate_date_range(start_date, end_date)
key = _resolve_key()
if not key:
logger.warning("tiingo skipped: %s not configured", _AUTH_ENV)
return {}
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
frame = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(code, start_date, end_date, key),
)
if frame is not None and not frame.empty:
result[code] = frame
except Exception as exc: # noqa: BLE001 - one symbol must not kill the batch
logger.warning("tiingo failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, key: str,
) -> Optional[pd.DataFrame]:
"""Fetch and normalize a single symbol's daily bars.
Args:
code: Original project symbol.
start_date: Inclusive start date in ``YYYY-MM-DD`` format.
end_date: Inclusive end date in ``YYYY-MM-DD`` format.
key: Resolved Tiingo API key.
Returns:
Normalized OHLCV DataFrame, or ``None`` when the symbol is not a US
equity or the endpoint returns no usable bars.
"""
ticker = _to_tiingo_symbol(code)
if ticker is None:
return None
url = f"{_BASE_URL}/{ticker}/prices"
payload = throttled_get_json(
url,
host_key=_HOST_KEY,
min_interval=resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL),
params={
"startDate": start_date,
"endDate": end_date,
"token": key,
},
)
# The prices endpoint returns a JSON array; anything else is empty/error.
if not isinstance(payload, list) or not payload:
return None
return _rows_to_frame(payload)
+138 -27
View File
@@ -4,14 +4,48 @@ Supports ``interval``: 1D (default) / 1m / 5m / 15m / 30m / 1H.
Minute data uses ``pro.stk_mins()`` (Tushare points >= 2000).
"""
import os
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders._symbol_utils import _is_etf_listed
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
def _is_index(code: str) -> bool:
"""Detect A-share index symbols (000xxx.SH, 000300.SH, 399xxx.SZ)."""
upper = code.upper()
if upper.endswith(".SH"):
digits = upper.split(".")[0]
return len(digits) == 6 and digits.isdigit() and digits.startswith("000")
if upper.endswith(".SZ"):
digits = upper.split(".")[0]
return len(digits) == 6 and digits.isdigit() and digits.startswith("399")
return False
def _is_hk_equity(code: str) -> bool:
"""Detect Hong Kong equity symbols (e.g. 00700.HK)."""
return code.upper().endswith(".HK")
def _is_us_equity(code: str) -> bool:
"""Detect US equity symbols (e.g. AAPL.US)."""
return code.upper().endswith(".US")
def _is_crypto(code: str) -> bool:
"""Detect crypto symbols (e.g. BTC-USDT, ETH/USDT)."""
upper = code.upper()
return upper.endswith("-USDT") or upper.endswith("/USDT")
@register
class DataLoader:
@@ -23,13 +57,17 @@ class DataLoader:
def is_available(self) -> bool:
"""Available when TUSHARE_TOKEN is set."""
return bool(os.getenv("TUSHARE_TOKEN", ""))
from src.config.accessor import get_env_config
return get_env_config().data.tushare_token.strip() not in TUSHARE_TOKEN_PLACEHOLDERS
def __init__(self) -> None:
"""Initialize Tushare pro API."""
import tushare as ts
token = os.getenv("TUSHARE_TOKEN", "")
from src.config.accessor import get_env_config
token = get_env_config().data.tushare_token
self.api = ts.pro_api(token)
def fetch(
@@ -37,8 +75,9 @@ class DataLoader:
codes: List[str],
start_date: str,
end_date: str,
fields: Optional[List[str]] = None,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch A-share bars via Tushare API.
@@ -59,28 +98,78 @@ class DataLoader:
sd = start_date.replace("-", "")
ed = end_date.replace("-", "")
cache_fields = list(fields or [])
result: Dict[str, pd.DataFrame] = {}
# Every code goes through the opt-in cache helper, which is a direct
# passthrough when the cache is disabled. Fundamentals are merged inside
# the cached unit so a cached entry already carries its extra columns.
for code in codes:
try:
df = self.api.daily(ts_code=code, start_date=sd, end_date=ed)
if df is None or df.empty:
continue
df = df.sort_values("trade_date")
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date")
df = df.rename(columns={"vol": "volume"})
for col in ["open", "high", "low", "close", "volume"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
ohlcv = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
result[code] = ohlcv
except Exception as exc:
print(f"[WARN] failed to fetch {code}: {exc}")
def _fetch_one(code: str = code) -> Optional[pd.DataFrame]:
try:
df = self._fetch_daily_frame(code, sd, ed)
if df is None:
return None
merged = self._merge_basic_fields(
{code: df}, [code], start_date, end_date, cache_fields
)
return merged.get(code)
except Exception as exc:
logger.warning("failed to fetch %s: %s", code, exc)
return None
return self._merge_basic_fields(result, codes, start_date, end_date, fields)
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe="1D",
start_date=start_date,
end_date=end_date,
fields=cache_fields,
fetch=_fetch_one,
)
if df is not None and not df.empty:
result[code] = df
return result
def _fetch_daily_frame(
self,
code: str,
start_date: str,
end_date: str,
) -> Optional[pd.DataFrame]:
"""Fetch and normalize one daily OHLCV frame, routing by symbol type."""
if _is_us_equity(code) or _is_crypto(code):
logger.warning("tushare does not support %s (US/crypto); skipping", code)
return None
if _is_etf_listed(code):
endpoint_name = "fund_daily"
df = self.api.fund_daily(ts_code=code, start_date=start_date, end_date=end_date)
elif _is_index(code):
endpoint_name = "index_daily"
df = self.api.index_daily(ts_code=code, start_date=start_date, end_date=end_date)
elif _is_hk_equity(code):
endpoint_name = "hk_daily"
df = self.api.hk_daily(ts_code=code, start_date=start_date, end_date=end_date)
else:
endpoint_name = "daily"
df = self.api.daily(ts_code=code, start_date=start_date, end_date=end_date)
if df is None or df.empty:
logger.warning("tushare returned empty for %s via %s", code, endpoint_name)
return None
df = df.sort_values("trade_date")
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date")
df = df.rename(columns={"vol": "volume"})
for col in ["open", "high", "low", "close", "volume"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
ohlcv = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return ohlcv
def _merge_basic_fields(
self,
@@ -110,6 +199,15 @@ class DataLoader:
active_codes = [c for c in codes if c in result]
for code in active_codes:
if (
_is_etf_listed(code)
or _is_index(code)
or _is_hk_equity(code)
or _is_us_equity(code)
or _is_crypto(code)
):
# daily_basic is stock-only; skip fundamental enrichment for non-stock symbols
continue
try:
basic = self.api.daily_basic(
ts_code=code,
@@ -124,7 +222,7 @@ class DataLoader:
if f in basic.columns:
result[code][f] = basic[f]
except Exception as exc:
print(f"[WARN] daily_basic for {code} failed: {exc}")
logger.warning("daily_basic for %s failed: %s", code, exc)
return result
@@ -149,7 +247,7 @@ class DataLoader:
freq_map = {"1m": "1min", "5m": "5min", "15m": "15min", "30m": "30min", "1H": "60min"}
freq = freq_map.get(interval)
if not freq:
print(f"[ERROR] unsupported Tushare interval: {interval}")
logger.error("unsupported Tushare interval: %s", interval)
return {}
sd = start_date.replace("-", "")
@@ -157,10 +255,23 @@ class DataLoader:
result: Dict[str, pd.DataFrame] = {}
for code in codes:
if _is_etf_listed(code):
# tushare has no fund_mins endpoint; ETF intraday is unavailable
logger.warning("tushare does not support intraday data for %s (ETF); skipping", code)
continue
if _is_index(code) or _is_hk_equity(code) or _is_us_equity(code) or _is_crypto(code):
sym_type = (
"index" if _is_index(code)
else "HK" if _is_hk_equity(code)
else "US" if _is_us_equity(code)
else "crypto"
)
logger.warning("tushare does not support intraday data for %s (%s); skipping", code, sym_type)
continue
try:
df = self.api.stk_mins(ts_code=code, freq=freq, start_date=sd, end_date=ed)
if df is None or df.empty:
print(f"[WARN] empty Tushare minute data: {code} (points >= 2000 required)")
logger.warning("empty Tushare minute data: %s (points >= 2000 required)", code)
continue
df = df.sort_values("trade_time")
df["trade_date"] = pd.to_datetime(df["trade_time"])
@@ -174,5 +285,5 @@ class DataLoader:
)
result[code] = ohlcv
except Exception as exc:
print(f"[WARN] failed to fetch minute data {code}: {exc}")
logger.warning("failed to fetch minute data %s: %s", code, exc)
return result
@@ -0,0 +1,287 @@
"""Tushare fundamental data provider with point-in-time safeguards."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable
import pandas as pd
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
class DataProviderError(Exception):
"""Base error for fundamental provider failures."""
class UnknownTableError(DataProviderError):
"""Raised when a requested fundamental table is not supported."""
class SchemaValidationError(DataProviderError):
"""Raised when provider output is missing required columns."""
@dataclass(frozen=True)
class ColumnSchema:
"""Machine-readable column metadata for a provider table."""
name: str
dtype: str
required: bool = False
@dataclass(frozen=True)
class TableSchema:
"""Machine-readable metadata for a Tushare fundamental table."""
name: str
api_name: str
point_in_time_column: str
columns: tuple[ColumnSchema, ...]
@property
def required_columns(self) -> tuple[str, ...]:
return tuple(column.name for column in self.columns if column.required)
_SCHEMAS: dict[str, TableSchema] = {
"balancesheet": TableSchema(
name="balancesheet",
api_name="balancesheet",
point_in_time_column="f_ann_date",
columns=(
ColumnSchema("ts_code", "str", required=True),
ColumnSchema("ann_date", "date", required=True),
ColumnSchema("f_ann_date", "date", required=False),
ColumnSchema("end_date", "date", required=True),
ColumnSchema("total_assets", "float"),
ColumnSchema("total_liab", "float"),
ColumnSchema("total_hldr_eqy_exc_min_int", "float"),
),
),
"cashflow": TableSchema(
name="cashflow",
api_name="cashflow",
point_in_time_column="f_ann_date",
columns=(
ColumnSchema("ts_code", "str", required=True),
ColumnSchema("ann_date", "date", required=True),
ColumnSchema("f_ann_date", "date", required=False),
ColumnSchema("end_date", "date", required=True),
ColumnSchema("net_profit", "float"),
ColumnSchema("n_cashflow_act", "float"),
ColumnSchema("c_cash_equ_end_period", "float"),
),
),
"fina_indicator": TableSchema(
name="fina_indicator",
api_name="fina_indicator",
point_in_time_column="ann_date",
columns=(
ColumnSchema("ts_code", "str", required=True),
ColumnSchema("ann_date", "date", required=True),
ColumnSchema("end_date", "date", required=True),
ColumnSchema("eps", "float"),
ColumnSchema("grossprofit_margin", "float"),
ColumnSchema("netprofit_margin", "float"),
ColumnSchema("roe", "float"),
ColumnSchema("debt_to_assets", "float"),
),
),
"income": TableSchema(
name="income",
api_name="income",
point_in_time_column="f_ann_date",
columns=(
ColumnSchema("ts_code", "str", required=True),
ColumnSchema("ann_date", "date", required=True),
ColumnSchema("f_ann_date", "date", required=False),
ColumnSchema("end_date", "date", required=True),
ColumnSchema("total_revenue", "float"),
ColumnSchema("revenue", "float"),
ColumnSchema("operate_profit", "float"),
ColumnSchema("n_income", "float"),
),
),
}
class TushareFundamentalProvider:
"""Small DataProvider contract for Tushare financial statement tables."""
def __init__(self, api: Any | None = None) -> None:
if api is None:
import tushare as ts
from src.config.accessor import get_env_config
token = get_env_config().data.tushare_token.strip()
if token in TUSHARE_TOKEN_PLACEHOLDERS:
token = ""
api = ts.pro_api(token)
self.api = api
def list_tables(self) -> list[str]:
"""Return supported fundamental tables in stable order."""
return sorted(_SCHEMAS)
def describe_table(self, table: str) -> TableSchema:
"""Return schema metadata for a supported table."""
try:
return _SCHEMAS[table]
except KeyError as exc:
raise UnknownTableError(f"Unsupported Tushare fundamental table: {table}") from exc
def query_fundamentals(
self,
table: str,
codes: Iterable[str],
*,
as_of: str | pd.Timestamp,
periods: Iterable[str] | None = None,
fields: Iterable[str] | None = None,
) -> pd.DataFrame:
"""Query a fundamental table and filter out rows unpublished by ``as_of``."""
schema = self.describe_table(table)
requested_periods = set(periods or [])
frames: list[pd.DataFrame] = []
api_method = getattr(self.api, schema.api_name, None)
if api_method is None:
raise DataProviderError(f"Tushare API object has no method: {schema.api_name}")
for code in codes:
frame = api_method(ts_code=code, period=None)
if frame is not None and not frame.empty:
frames.append(frame.copy())
if not frames:
return self._empty_frame(schema, fields)
result = pd.concat(frames, ignore_index=True)
self._validate_schema(schema, result)
if requested_periods:
result = result[result["end_date"].astype(str).isin(requested_periods)]
pit_column = schema.point_in_time_column
if pit_column not in result.columns or result[pit_column].isna().all():
pit_column = "ann_date"
as_of_date = _parse_tushare_date(as_of)
pit_values = result[pit_column]
if pit_column != "ann_date" and "ann_date" in result.columns:
pit_values = pit_values.where(pit_values.notna(), result["ann_date"])
pit_dates = pit_values.map(_parse_tushare_date)
result = result[pit_dates <= as_of_date]
output_columns = self._output_columns(schema, result, fields)
result = result.loc[:, output_columns].sort_values(["ts_code", "end_date"]).reset_index(drop=True)
return result
def _validate_schema(self, schema: TableSchema, frame: pd.DataFrame) -> None:
missing = [column for column in schema.required_columns if column not in frame.columns]
if missing:
raise SchemaValidationError(f"{schema.name} missing required columns: {', '.join(missing)}")
def _output_columns(
self,
schema: TableSchema,
frame: pd.DataFrame,
fields: Iterable[str] | None,
) -> list[str]:
identity = ["ts_code", "end_date", "ann_date"]
if schema.point_in_time_column in frame.columns and schema.point_in_time_column not in identity:
identity.append(schema.point_in_time_column)
wanted = identity + list(fields or [])
return [column for column in dict.fromkeys(wanted) if column in frame.columns]
def _empty_frame(self, schema: TableSchema, fields: Iterable[str] | None) -> pd.DataFrame:
columns = self._output_columns(schema, pd.DataFrame(columns=[c.name for c in schema.columns]), fields)
return pd.DataFrame(columns=columns)
def _parse_tushare_date(value: str | pd.Timestamp) -> pd.Timestamp:
"""Parse Tushare YYYYMMDD strings and common timestamp/date strings."""
if isinstance(value, pd.Timestamp):
return value.normalize()
text = str(value)
if len(text) == 8 and text.isdigit():
return pd.to_datetime(text, format="%Y%m%d")
return pd.to_datetime(text).normalize()
def enrich_price_frames_with_fundamentals(
data_map: dict[str, pd.DataFrame],
provider: TushareFundamentalProvider,
fields_by_table: dict[str, Iterable[str]],
*,
as_of: str | pd.Timestamp,
periods: Iterable[str] | None = None,
) -> dict[str, pd.DataFrame]:
"""Attach PIT-safe fundamental snapshots to daily price frames.
Fundamental columns are prefixed with their table name, for example
``income_total_revenue`` and ``fina_indicator_roe``. Each row becomes
visible only on or after its announcement/disclosure date.
"""
if not data_map or not fields_by_table:
return data_map
enriched = {code: frame.copy() for code, frame in data_map.items()}
codes = list(enriched)
for table, fields in fields_by_table.items():
field_list = list(fields or [])
fundamentals = provider.query_fundamentals(
table,
codes,
as_of=as_of,
periods=periods,
fields=field_list,
)
if fundamentals.empty:
continue
schema = provider.describe_table(table)
pit_column = schema.point_in_time_column
if pit_column not in fundamentals.columns or fundamentals[pit_column].isna().all():
pit_column = "ann_date"
for code, frame in enriched.items():
rows = fundamentals[fundamentals["ts_code"] == code].copy()
if rows.empty or frame.empty:
continue
pit_values = rows[pit_column]
if pit_column != "ann_date" and "ann_date" in rows.columns:
pit_values = pit_values.where(pit_values.notna(), rows["ann_date"])
rows["_pit_date"] = pit_values.map(_parse_tushare_date)
rows = rows.dropna(subset=["_pit_date"]).sort_values("_pit_date")
if rows.empty:
continue
value_columns = [column for column in rows.columns if column not in {"ts_code", "_pit_date"}]
right = rows[["_pit_date", *value_columns]].rename(
columns={column: f"{table}_{column}" for column in value_columns}
)
left = frame.copy()
original_index = left.index
left["_trade_date"] = pd.to_datetime(left.index).normalize()
left["_original_order"] = range(len(left))
merged = pd.merge_asof(
left.sort_values("_trade_date"),
right.sort_values("_pit_date"),
left_on="_trade_date",
right_on="_pit_date",
direction="backward",
)
merged = merged.sort_values("_original_order").drop(
columns=["_trade_date", "_original_order", "_pit_date"]
)
merged.index = original_index
enriched[code] = merged
return enriched
+414
View File
@@ -0,0 +1,414 @@
"""Shared Yahoo Finance public-API client: chart, quote summary, options, search.
Yahoo Finance exposes several unauthenticated JSON endpoints (the same ones the
finance.yahoo.com site itself calls) that, like other free quote providers,
rate-limit by source IP and must be throttled. Every request here routes
through :mod:`backtest.loaders._http` so calls to ``query*.finance.yahoo.com``
share one process-wide minimum-spacing gate and a reused session.
The ``v10/finance/quoteSummary`` endpoint additionally requires a session
cookie plus a matching ``crumb`` token. This client fetches both lazily on the
first call that needs them and refreshes them once on a 401 (the documented
"crumb expired / unauthorized" signal), so callers never manage that handshake.
Symbol convention (Vibe-Trading -> Yahoo):
* US ``AAPL.US`` -> ``AAPL`` (Yahoo carries US tickers bare)
* HK ``00700.HK`` -> ``0700.HK`` (Yahoo drops the leading zero to 4 digits)
* India ``RELIANCE.NS`` / ``500325.BO`` -> unchanged (Yahoo carries the
``.NS``/``.BO`` suffix verbatim)
* Anything else is passed through unchanged (e.g. ``BTC-USD``, ``^GSPC``).
This module is provider-specific glue only; it returns plain Python
dicts/lists so a downstream loader can map them into the project's OHLCV frame.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict, List, Optional
import requests
from backtest.loaders._http import (
DEFAULT_USER_AGENT,
resolve_min_interval,
throttled_get,
throttled_get_json,
)
logger = logging.getLogger(__name__)
# All Yahoo endpoints share one throttle/session bucket so spacing is enforced
# across chart/quote/options/search regardless of which host alias is used.
HOST_KEY = "yahoo"
# Yahoo serves the same data from query1/query2; query2 is conventionally used
# for the crumb-gated endpoints. We keep the chart on query1 like the website.
_CHART_BASE = "https://query1.finance.yahoo.com/v8/finance/chart"
_QUOTE_SUMMARY_BASE = "https://query2.finance.yahoo.com/v10/finance/quoteSummary"
_OPTIONS_BASE = "https://query2.finance.yahoo.com/v7/finance/options"
_SEARCH_BASE = "https://query2.finance.yahoo.com/v1/finance/search"
_CRUMB_URL = "https://query2.finance.yahoo.com/v1/test/getcrumb"
# Hitting fc.yahoo.com first hands back the consent/session cookie the crumb
# endpoint then validates.
_COOKIE_URL = "https://fc.yahoo.com"
_MIN_INTERVAL_ENV = "VIBE_TRADING_YAHOO_MIN_INTERVAL"
_DEFAULT_MIN_INTERVAL_S = 0.6
# The columns Yahoo packs into indicators.quote[0], in our output field order.
_QUOTE_FIELDS = ("open", "high", "low", "close", "volume")
def _min_interval() -> float:
"""Resolve the per-call minimum spacing, honoring the env override."""
return resolve_min_interval(_MIN_INTERVAL_ENV, _DEFAULT_MIN_INTERVAL_S)
def map_symbol(symbol: str) -> str:
"""Translate a Vibe-Trading symbol into Yahoo's ticker convention.
Args:
symbol: Project-side symbol, e.g. ``AAPL.US``, ``00700.HK``, ``BTC-USD``.
Returns:
The Yahoo ticker: ``.US`` suffix stripped; ``.HK`` codes normalized to
a 4-digit base (``00700.HK`` -> ``0700.HK``); India ``.NS``/``.BO`` and
everything else unchanged (Yahoo carries those suffixes verbatim).
"""
cleaned = symbol.strip()
upper = cleaned.upper()
if upper.endswith(".US"):
return cleaned[: -len(".US")]
if upper.endswith(".HK"):
base = cleaned[: -len(".HK")]
digits = base.lstrip("0") or "0"
return f"{digits.zfill(4)}.HK"
return cleaned
class _CrumbStore:
"""Process-wide cookie jar + crumb token for the quoteSummary handshake.
Holds a single :class:`requests.Session`-independent crumb string and the
cookies returned by Yahoo's consent endpoint. Refreshing is serialized by a
lock so a burst of concurrent callers performs the handshake at most once.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._crumb: Optional[str] = None
self._cookies: Dict[str, str] = {}
def get(self, *, force_refresh: bool = False) -> tuple[str, Dict[str, str]]:
"""Return ``(crumb, cookies)``, fetching them on first use or on refresh.
Args:
force_refresh: Discard any cached crumb/cookies and re-handshake.
Returns:
A tuple of the crumb token and the cookie mapping to send with the
crumb-gated request.
Raises:
requests.RequestException: If the cookie or crumb fetch fails.
ValueError: If Yahoo returns an empty crumb.
"""
with self._lock:
if force_refresh or self._crumb is None:
self._crumb, self._cookies = self._handshake()
return self._crumb, dict(self._cookies)
def _handshake(self) -> tuple[str, Dict[str, str]]:
cookie_resp = throttled_get(
_COOKIE_URL,
host_key=HOST_KEY,
min_interval=_min_interval(),
)
cookies = requests.utils.dict_from_cookiejar(cookie_resp.cookies)
crumb_resp = throttled_get(
_CRUMB_URL,
host_key=HOST_KEY,
min_interval=_min_interval(),
headers={"Cookie": _cookie_header(cookies)} if cookies else None,
)
crumb_resp.raise_for_status()
crumb = (crumb_resp.text or "").strip()
if not crumb:
raise ValueError("Yahoo returned an empty crumb token")
return crumb, cookies
def _cookie_header(cookies: Dict[str, str]) -> str:
"""Render a cookie mapping as a single ``Cookie:`` header value."""
return "; ".join(f"{name}={value}" for name, value in cookies.items())
# One shared crumb/cookie store for the whole process.
_CRUMB_STORE = _CrumbStore()
def get_chart(
symbol: str,
*,
interval: str = "1d",
period1: Optional[int] = None,
period2: Optional[int] = None,
range_: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Fetch OHLCV bars from the v8 chart endpoint as ascending rows.
Either supply a ``range_`` (e.g. ``"1y"``, ``"max"``) or a
``period1``/``period2`` epoch-second window; ``range_`` wins when both are
given. Yahoo returns parallel timestamp/indicator arrays which this function
zips into row dicts, dropping any bar whose OHLC is incomplete (Yahoo emits
``null`` for non-trading slots).
Args:
symbol: Project-side symbol (mapped via :func:`map_symbol`).
interval: Bar size accepted by Yahoo (``1d``, ``1h``, ``5m``, ...).
period1: Inclusive start as epoch seconds, or ``None``.
period2: Exclusive end as epoch seconds, or ``None``.
range_: Relative range string; takes precedence over period1/period2.
Returns:
Ascending list of ``{trade_date, open, high, low, close, volume}`` dicts
(``trade_date`` is the bar's epoch-second timestamp). Empty when Yahoo
reports no data for the symbol/window.
Raises:
requests.RequestException: On a network/HTTP failure.
ValueError: If Yahoo reports an error for the symbol or the payload is
structurally unusable.
"""
yahoo_symbol = map_symbol(symbol)
params: Dict[str, Any] = {"interval": interval}
if range_:
params["range"] = range_
else:
if period1 is not None:
params["period1"] = int(period1)
if period2 is not None:
params["period2"] = int(period2)
payload = throttled_get_json(
f"{_CHART_BASE}/{yahoo_symbol}",
host_key=HOST_KEY,
min_interval=_min_interval(),
params=params,
)
return _parse_chart(payload, yahoo_symbol)
def _parse_chart(payload: Any, yahoo_symbol: str) -> List[Dict[str, Any]]:
"""Convert a v8 chart payload into ascending OHLCV row dicts."""
chart = (payload or {}).get("chart") or {}
error = chart.get("error")
if error:
description = error.get("description") if isinstance(error, dict) else error
raise ValueError(f"Yahoo chart error for {yahoo_symbol}: {description}")
results = chart.get("result") or []
if not results:
return []
result = results[0] or {}
timestamps = result.get("timestamp") or []
quotes = (((result.get("indicators") or {}).get("quote")) or [{}])[0] or {}
rows: List[Dict[str, Any]] = []
for index, ts in enumerate(timestamps):
values = {field: _at(quotes.get(field), index) for field in _QUOTE_FIELDS}
# A non-trading slot leaves OHLC null; skip rather than emit a NaN bar.
if any(values[field] is None for field in ("open", "high", "low", "close")):
continue
row: Dict[str, Any] = {"trade_date": ts}
row.update({field: _to_float(values[field]) for field in _QUOTE_FIELDS})
rows.append(row)
return rows
def _at(series: Any, index: int) -> Any:
"""Return ``series[index]`` when in range, else ``None``."""
if isinstance(series, list) and 0 <= index < len(series):
return series[index]
return None
def _to_float(value: Any) -> Optional[float]:
"""Coerce a Yahoo numeric (or ``None``) to ``float``, ``None`` on failure."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def get_quote_summary(symbol: str, modules: List[str]) -> Dict[str, Any]:
"""Fetch v10 quoteSummary modules, handling the cookie+crumb handshake.
On a 401 (expired/invalid crumb) the crumb and cookies are refreshed once
and the request is retried; a second 401 propagates.
Args:
symbol: Project-side symbol (mapped via :func:`map_symbol`).
modules: Yahoo module names, e.g. ``["price", "summaryDetail"]``.
Returns:
The ``quoteSummary.result[0]`` mapping, or ``{}`` when Yahoo returns no
result for the symbol.
Raises:
requests.RequestException: On a non-401 HTTP failure or a second 401.
ValueError: If Yahoo reports an error for the symbol.
"""
yahoo_symbol = map_symbol(symbol)
modules_param = ",".join(modules)
payload = _quote_summary_request(yahoo_symbol, modules_param, force_refresh=False)
return _parse_quote_summary(payload, yahoo_symbol)
def _quote_summary_request(
yahoo_symbol: str, modules_param: str, *, force_refresh: bool
) -> Any:
"""Issue one crumb-gated quoteSummary GET, retrying once on a 401."""
crumb, cookies = _CRUMB_STORE.get(force_refresh=force_refresh)
headers = {"Cookie": _cookie_header(cookies)} if cookies else None
response = throttled_get(
f"{_QUOTE_SUMMARY_BASE}/{yahoo_symbol}",
host_key=HOST_KEY,
min_interval=_min_interval(),
params={"modules": modules_param, "crumb": crumb},
headers=headers,
)
if response.status_code == 401 and not force_refresh:
logger.info("Yahoo quoteSummary 401 for %s; refreshing crumb", yahoo_symbol)
return _quote_summary_request(yahoo_symbol, modules_param, force_refresh=True)
response.raise_for_status()
return response.json()
def _parse_quote_summary(payload: Any, yahoo_symbol: str) -> Dict[str, Any]:
"""Extract the first quoteSummary result, raising on a reported error."""
summary = (payload or {}).get("quoteSummary") or {}
error = summary.get("error")
if error:
description = error.get("description") if isinstance(error, dict) else error
raise ValueError(f"Yahoo quoteSummary error for {yahoo_symbol}: {description}")
results = summary.get("result") or []
if not results:
return {}
return results[0] or {}
def get_options(symbol: str, *, expiration: Optional[int] = None) -> Dict[str, Any]:
"""Fetch the v7 option chain for a symbol, handling the cookie+crumb handshake.
Like ``v10/quoteSummary``, the ``v7/finance/options`` endpoint now rejects
bare requests with HTTP 401 and demands the same session cookie plus a
matching ``crumb`` token. This routes through the shared
:data:`_CRUMB_STORE` handshake and, on a 401 (expired/invalid crumb),
refreshes the crumb once and retries; a second 401 propagates.
Args:
symbol: Project-side symbol (mapped via :func:`map_symbol`).
expiration: Optional expiration as epoch seconds; omit for the nearest.
Returns:
The ``optionChain.result[0]`` mapping (expirationDates, strikes, the
calls/puts arrays for the chosen expiry), or ``{}`` when none.
Raises:
requests.RequestException: On a non-401 HTTP failure or a second 401.
ValueError: If Yahoo reports an error for the symbol.
"""
yahoo_symbol = map_symbol(symbol)
payload = _options_request(yahoo_symbol, expiration, force_refresh=False)
return _parse_options(payload, yahoo_symbol)
def _options_request(
yahoo_symbol: str, expiration: Optional[int], *, force_refresh: bool
) -> Any:
"""Issue one crumb-gated v7 options GET, retrying once on a 401."""
crumb, cookies = _CRUMB_STORE.get(force_refresh=force_refresh)
headers = {"Cookie": _cookie_header(cookies)} if cookies else None
params: Dict[str, Any] = {"crumb": crumb}
if expiration is not None:
params["date"] = int(expiration)
response = throttled_get(
f"{_OPTIONS_BASE}/{yahoo_symbol}",
host_key=HOST_KEY,
min_interval=_min_interval(),
params=params,
headers=headers,
)
if response.status_code == 401 and not force_refresh:
logger.info("Yahoo options 401 for %s; refreshing crumb", yahoo_symbol)
return _options_request(yahoo_symbol, expiration, force_refresh=True)
response.raise_for_status()
return response.json()
def _parse_options(payload: Any, yahoo_symbol: str) -> Dict[str, Any]:
"""Extract the first optionChain result, raising on a reported error."""
chain = (payload or {}).get("optionChain") or {}
error = chain.get("error")
if error:
description = error.get("description") if isinstance(error, dict) else error
raise ValueError(f"Yahoo options error for {yahoo_symbol}: {description}")
results = chain.get("result") or []
if not results:
return {}
return results[0] or {}
def search(query: str) -> List[Dict[str, Any]]:
"""Look up matching instruments via the v1 search endpoint.
Args:
query: Free-text query (ticker fragment or company name).
Returns:
The ``quotes`` list (each a dict with ``symbol``, ``shortname``,
``exchange``, ``quoteType``, ...), or an empty list when none match.
Raises:
requests.RequestException: On a network/HTTP failure.
"""
payload = throttled_get_json(
_SEARCH_BASE,
host_key=HOST_KEY,
min_interval=_min_interval(),
params={"q": query},
)
quotes = (payload or {}).get("quotes") or []
return [quote for quote in quotes if isinstance(quote, dict)]
def search_news(query: str, count: int) -> List[Dict[str, Any]]:
"""Look up matching news via the v1 search endpoint.
Args:
query: Free-text query (ticker fragment or company name).
count: Maximum number of news items requested from Yahoo.
Returns:
The ``news`` list (each a dict with ``title``, ``publisher``, ``link``,
``providerPublishTime``, ``relatedTickers``, ...), or an empty list when
none match.
Raises:
requests.RequestException: On a network/HTTP failure.
"""
payload = throttled_get_json(
_SEARCH_BASE,
host_key=HOST_KEY,
min_interval=_min_interval(),
params={"q": query, "newsCount": count},
)
news = (payload or {}).get("news") or []
return [item for item in news if isinstance(item, dict)]
+261
View File
@@ -0,0 +1,261 @@
"""Yahoo Finance loader: free, no-auth US/HK equity OHLCV via direct HTTP.
Wraps the shared :mod:`backtest.loaders.yahoo_client` (the public v8 chart
endpoint) rather than the ``yfinance`` package, so it pulls in no new
dependency and shares the process-wide throttle/session that keeps Yahoo from
IP-rate-limiting us. Covers US equities (``AAPL.US``) and HK equities
(``00700.HK``); the client maps each project symbol to Yahoo's ticker form.
The chart endpoint returns each bar's ``trade_date`` as an epoch-second
timestamp; this loader converts those to a tz-naive ``DatetimeIndex`` and clips
the ascending series to the requested inclusive date window.
"""
from __future__ import annotations
import datetime as dt
import logging
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders import yahoo_client
from backtest.loaders.base import cached_loader_fetch, validate_date_range
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_OHLCV_COLUMNS = ("open", "high", "low", "close", "volume")
# Project interval -> Yahoo chart interval. Daily is the only granularity this
# loader exposes for equities; anything else falls back to lowercasing so an
# explicit ``5m``/``1h`` still reaches Yahoo unchanged.
_INTERVAL_MAP = {
"1D": "1d",
"1H": "1h",
"1W": "1wk",
"1M": "1mo",
}
def _is_supported(code: str) -> bool:
"""Return whether *code* is a symbol this loader handles.
Covers US/HK/India equities plus Yahoo's own futures (``GC=F``) and forex
(``EURUSD=X``) suffix conventions, which the public chart endpoint serves
verbatim (the code is used as-is in the request URL, no conversion) (#718).
"""
upper = code.strip().upper()
return upper.endswith((".US", ".HK", ".NS", ".BO", "=F", "=X"))
def _to_yahoo_interval(interval: str) -> str:
"""Map a project interval string to Yahoo's chart interval string.
Args:
interval: Backtest interval such as ``1D`` or ``1H``.
Returns:
Yahoo-compatible interval string (e.g. ``1d``).
"""
normalized = str(interval or "1D").strip()
if not normalized:
return "1d"
# Bare trailing ``m`` is minutes; do not uppercase into monthly ``1M`` → ``1mo``.
if normalized.endswith("m") and not normalized.endswith("M"):
return normalized.lower()
return _INTERVAL_MAP.get(normalized.upper(), normalized.lower())
def _is_intraday_interval(interval: str) -> bool:
"""Return whether *interval* is finer than one day (minute/hour bars).
Daily-and-coarser intervals (``1D``/``1W``/``1M``) carry no meaningful
intraday time and must be midnight-indexed to align with every other
loader's daily bars; minute/hour intervals keep their real timestamp.
Project convention follows the interval map: a bare trailing ``M`` is a
month (e.g. ``1M``) while a bare trailing lowercase ``m`` is a minute (e.g.
``5m``), so case is significant for that one suffix and is NOT folded.
Args:
interval: Backtest interval such as ``1D``, ``1H``, ``5m`` or ``30min``.
Returns:
``True`` for minute/hour granularities, ``False`` for day/week/month.
"""
raw = str(interval or "1D").strip()
if not raw:
return False
lowered = raw.lower()
# Multi-letter month forms ('mo'/'mth'/'mon') are daily-coarser.
if lowered.endswith(("mo", "mth", "mon")):
return False
# Explicit minute spellings and hours are intraday.
if lowered.endswith(("min", "h")):
return True
# Bare trailing letter: lowercase 'm' = minute (intraday), uppercase
# 'M' = month (coarser), 'd'/'w' = day/week (coarser).
return raw.endswith("m")
def _epoch_seconds(date_str: str) -> int:
"""Convert a ``YYYY-MM-DD`` date to UTC midnight epoch seconds.
Computed from the parsed calendar date (not ``time.time()``) so the window
is reproducible and independent of wall-clock fetch time.
Args:
date_str: Date string in ``YYYY-MM-DD`` form.
Returns:
Epoch seconds for that date's UTC midnight.
"""
day = pd.Timestamp(date_str).normalize().date()
moment = dt.datetime(day.year, day.month, day.day, tzinfo=dt.timezone.utc)
return int(moment.timestamp())
def _rows_to_frame(
rows: List[dict], start_date: str, end_date: str, interval: str = "1D"
) -> pd.DataFrame:
"""Build the OHLCV frame from chart rows, clipped to the inclusive window.
For daily-and-coarser intervals the DatetimeIndex is normalized to midnight
so the dates line up with every other loader's midnight-indexed daily bars
(Yahoo stamps daily bars at the US market-open epoch, e.g. 14:30 UTC, which
would otherwise break fallback merge/join/dedup). Minute/hour intervals keep
their real intraday timestamp.
Args:
rows: Ascending ``{trade_date(epoch s), open, high, low, close, volume}``
dicts from :func:`yahoo_client.get_chart`.
start_date: Inclusive window start (``YYYY-MM-DD``).
end_date: Inclusive window end (``YYYY-MM-DD``).
interval: Backtest interval such as ``1D`` or ``1H``; selects whether the
index is normalized to midnight.
Returns:
DataFrame indexed by a tz-naive ``trade_date`` with float OHLCV columns,
or an empty frame when no row carries usable OHLC.
"""
if not rows:
return pd.DataFrame()
frame = pd.DataFrame(rows)
index = pd.to_datetime(frame["trade_date"], unit="s", utc=True).dt.tz_convert(None)
if not _is_intraday_interval(interval):
index = index.dt.normalize()
frame = frame.drop(columns=["trade_date"])
frame.index = pd.DatetimeIndex(index)
frame.index.name = "trade_date"
for column in _OHLCV_COLUMNS:
if column not in frame.columns:
frame[column] = 0.0 if column == "volume" else pd.NA
frame = frame.loc[:, list(_OHLCV_COLUMNS)].apply(pd.to_numeric, errors="coerce")
frame["volume"] = frame["volume"].fillna(0.0)
frame = frame.dropna(subset=["open", "high", "low", "close"])
lower = pd.Timestamp(start_date).normalize()
upper = pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)
frame = frame[(frame.index >= lower) & (frame.index < upper)]
return frame.astype(float).sort_index()
@register
class DataLoader:
"""Yahoo Finance US/HK equity OHLCV loader (free, direct HTTP, no auth)."""
name = "yahoo"
markets = {"us_equity", "hk_equity", "india_equity"}
requires_auth = False
def is_available(self) -> bool:
"""Always available — uses the throttled public HTTP client."""
return True
def __init__(self) -> None:
"""Initialize the loader (no credentials needed for public data)."""
pass
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV history keyed by the original project symbols.
Args:
codes: Project symbols such as ``AAPL.US`` and ``00700.HK``.
start_date: Inclusive start date (``YYYY-MM-DD``).
end_date: Inclusive end date (``YYYY-MM-DD``).
interval: Backtest interval such as ``1D`` or ``1H``.
fields: Ignored; included for interface compatibility.
Returns:
Mapping of input symbol to a normalized OHLCV DataFrame. A symbol
that fails or returns no data is omitted; one failure never aborts
the batch.
"""
del fields
if not codes:
return {}
validate_date_range(start_date, end_date)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = cached_loader_fetch(
source=self.name,
symbol=code,
timeframe=interval,
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(
code, start_date, end_date, interval
),
)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("yahoo failed for %s: %s", code, exc)
return result
def _fetch_one(
self, code: str, start_date: str, end_date: str, interval: str
) -> Optional[pd.DataFrame]:
"""Fetch and normalize one symbol's chart, or ``None`` when unusable.
Args:
code: A single project symbol.
start_date: Inclusive start date (``YYYY-MM-DD``).
end_date: Inclusive end date (``YYYY-MM-DD``).
interval: Backtest interval string.
Returns:
The OHLCV DataFrame for *code*, ``None`` if it is not a US/HK/India
symbol or Yahoo returns no usable bars.
"""
if not _is_supported(code):
return None
# period2 is exclusive on Yahoo; extend one day past end_date so the
# final inclusive bar is fetched, then clip precisely in _rows_to_frame.
period1 = _epoch_seconds(start_date)
period2 = _epoch_seconds(end_date) + 86400
rows = yahoo_client.get_chart(
code,
interval=_to_yahoo_interval(interval),
period1=period1,
period2=period2,
)
frame = _rows_to_frame(rows, start_date, end_date, interval)
return frame if not frame.empty else None
+62 -10
View File
@@ -2,13 +2,21 @@
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Dict, List, Optional, Union
import pandas as pd
import yfinance as yf
from backtest.loaders.base import validate_date_range
logger = logging.getLogger(__name__)
from backtest.loaders.base import (
loader_cache_get,
loader_cache_put,
validate_date_range,
validate_ohlc,
)
from backtest.loaders.registry import register
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
@@ -47,6 +55,12 @@ def _to_yfinance_symbol(code: str) -> str:
digits = upper[:-3]
width = max(4, len(digits))
return f"{digits.zfill(width)}.HK"
# Crypto: BTC-USDT -> BTC-USD, ETH-USDT -> ETH-USD, etc.
if upper.endswith("-USDT"):
return upper[:-5] + "-USD"
if upper.endswith("-USDC"):
return upper[:-5] + "-USD"
# India NSE/BSE (RELIANCE.NS, 500325.BO): yfinance carries the suffix as-is.
return upper
@@ -63,6 +77,11 @@ def _to_yfinance_interval(interval: str) -> str:
return _INTERVAL_MAP.get(normalized, normalized.lower())
def _to_yfinance_exclusive_end(end_date: str) -> str:
"""Convert the project-inclusive end date to yfinance's exclusive end."""
return (pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
def _download_history(
tickers: Union[List[str], str],
start_date: str,
@@ -172,6 +191,7 @@ def _normalize_frame(frame: pd.DataFrame, requested_interval: str) -> pd.DataFra
normalized = normalized.sort_index()
normalized["volume"] = normalized["volume"].fillna(0.0)
normalized = normalized.dropna(subset=["open", "high", "low", "close"])
normalized = validate_ohlc(normalized)
if requested_interval == "4H" and not normalized.empty:
normalized = normalized.resample("4h").agg(
@@ -194,7 +214,7 @@ class DataLoader:
"""Fetch HK/US equity bars from Yahoo Finance via yfinance."""
name = "yfinance"
markets = {"us_equity", "hk_equity"}
markets = {"us_equity", "hk_equity", "india_equity", "crypto"}
requires_auth = False
def is_available(self) -> bool:
@@ -213,8 +233,9 @@ class DataLoader:
codes: List[str],
start_date: str,
end_date: str,
fields: Optional[List[str]] = None,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV history keyed by the original project symbols.
@@ -235,6 +256,7 @@ class DataLoader:
requested_interval = str(interval or "1D").strip()
yf_interval = _to_yfinance_interval(requested_interval)
yf_end_date = _to_yfinance_exclusive_end(end_date)
symbol_groups: Dict[str, List[str]] = defaultdict(list)
for code in codes:
@@ -243,27 +265,57 @@ class DataLoader:
unique_symbols = list(symbol_groups.keys())
results: Dict[str, pd.DataFrame] = {}
# Serve cached symbols first so a fully-cached request skips the bulk
# download entirely; only uncached symbols hit the network.
pending: List[str] = []
for symbol in unique_symbols:
cached = loader_cache_get(
source=self.name,
symbol=symbol,
timeframe=requested_interval,
start_date=start_date,
end_date=end_date,
fields=None,
)
if cached is not None:
for original_code in symbol_groups[symbol]:
results[original_code] = cached.copy()
else:
pending.append(symbol)
if not pending:
return results
try:
bulk_data = _download_history(unique_symbols, start_date, end_date, yf_interval)
bulk_data = _download_history(pending, start_date, yf_end_date, yf_interval)
except Exception as exc:
print(f"[WARN] yfinance bulk download failed for {unique_symbols}: {exc}")
logger.warning("yfinance bulk download failed for %s: %s", pending, exc)
bulk_data = pd.DataFrame()
for symbol in unique_symbols:
for symbol in pending:
try:
symbol_frame = _extract_symbol_frame(bulk_data, symbol, len(unique_symbols))
symbol_frame = _extract_symbol_frame(bulk_data, symbol, len(pending))
if symbol_frame.empty:
symbol_frame = _download_history(symbol, start_date, end_date, yf_interval)
symbol_frame = _download_history(symbol, start_date, yf_end_date, yf_interval)
normalized = _normalize_frame(symbol_frame, requested_interval)
if normalized.empty:
print(f"[WARN] yfinance returned no usable data for {symbol}")
logger.warning("yfinance returned no usable data for %s", symbol)
continue
loader_cache_put(
source=self.name,
symbol=symbol,
timeframe=requested_interval,
start_date=start_date,
end_date=end_date,
fields=None,
frame=normalized,
)
for original_code in symbol_groups[symbol]:
results[original_code] = normalized.copy()
except Exception as exc:
print(f"[WARN] Failed to fetch data for {symbol}: {exc}")
logger.warning("Failed to fetch data for %s: %s", symbol, exc)
continue
return results
+151 -16
View File
@@ -14,15 +14,21 @@ from backtest.models import TradeRecord
# ─── Annualisation factor mapping ───
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365}
# mootdx (A-share) and futu (HK + A-share) are equity sources, so they mirror
# the tushare/akshare column: 252 trading days and a 240-minute session. HK
# sessions are marginally longer (~330 min) — an approximation in line with the
# rest of this annualisation table; the key fix is that intraday mootdx/futu no
# longer fall back to the bars_per_day=1 default, which mis-annualised vol/Sharpe.
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365, "mootdx": 252, "futu": 252, "mt5": 260}
# mt5 is a forex/CFD feed: 24x5 sessions → 260 trading days, 24h intraday bars.
_BARS_PER_DAY = {
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390, "akshare": 240, "ccxt": 1440},
"5m": {"tushare": 48, "okx": 288, "yfinance": 78, "akshare": 48, "ccxt": 288},
"15m": {"tushare": 16, "okx": 96, "yfinance": 26, "akshare": 16, "ccxt": 96},
"30m": {"tushare": 8, "okx": 48, "yfinance": 13, "akshare": 8, "ccxt": 48},
"1H": {"tushare": 4, "okx": 24, "yfinance": 7, "akshare": 4, "ccxt": 24},
"4H": {"tushare": 1, "okx": 6, "yfinance": 2, "akshare": 1, "ccxt": 6},
"1D": {"tushare": 1, "okx": 1, "yfinance": 1, "akshare": 1, "ccxt": 1},
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390, "akshare": 240, "ccxt": 1440, "mootdx": 240, "futu": 240, "mt5": 1440},
"5m": {"tushare": 48, "okx": 288, "yfinance": 78, "akshare": 48, "ccxt": 288, "mootdx": 48, "futu": 48, "mt5": 288},
"15m": {"tushare": 16, "okx": 96, "yfinance": 26, "akshare": 16, "ccxt": 96, "mootdx": 16, "futu": 16, "mt5": 96},
"30m": {"tushare": 8, "okx": 48, "yfinance": 13, "akshare": 8, "ccxt": 48, "mootdx": 8, "futu": 8, "mt5": 48},
"1H": {"tushare": 4, "okx": 24, "yfinance": 7, "akshare": 4, "ccxt": 24, "mootdx": 4, "futu": 4, "mt5": 24},
"4H": {"tushare": 1, "okx": 6, "yfinance": 2, "akshare": 1, "ccxt": 6, "mootdx": 1, "futu": 1, "mt5": 6},
"1D": {"tushare": 1, "okx": 1, "yfinance": 1, "akshare": 1, "ccxt": 1, "mootdx": 1, "futu": 1, "mt5": 1},
}
@@ -143,12 +149,82 @@ def by_exit_reason_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]
return result
def calc_turnover_series(positions: pd.DataFrame) -> pd.Series:
"""Per-bar weight-implied portfolio turnover from a position frame.
Turnover for a bar is ``0.5 * sum_i |w_{t,i} - w_{t-1,i}|``, so a full
rotation from one asset to another counts as 1.0 (matching the
``turnover_aware`` optimizer's convention). The first bar's turnover is
``0.5 * sum_i |w_{0,i}|``, treating the initial allocation as entry from
cash. Turnover is measured on the weight frame the caller supplies. It
does not know whether the execution engine filled, rounded, or rejected
those target positions.
Args:
positions: Position-weight matrix (index=timestamp, columns=codes).
Returns:
Per-bar turnover series indexed like ``positions``; empty when the
input is empty.
"""
if positions is None or positions.empty:
return pd.Series(dtype=float)
filled = positions.fillna(0.0)
prev = filled.shift(1).fillna(0.0)
return 0.5 * (filled - prev).abs().sum(axis=1)
def calc_trade_turnover_series(
trades: List[TradeRecord],
equity_curve: pd.Series,
) -> pd.Series:
"""Per-bar turnover from actual entry and exit allocations.
Each filled leg contributes its margin-equivalent traded value. Dividing
gross traded value by twice the portfolio equity preserves the existing
convention: entering a 100% allocation counts as 0.5 and rotating a 100%
allocation between two assets counts as 1.0.
Args:
trades: Completed trades carrying actual entry/exit margin values.
equity_curve: Portfolio equity used to normalize traded values.
Returns:
Per-bar realized turnover aligned to ``equity_curve``. Bars without
fills are zero and remain part of the average-turnover denominator.
"""
if equity_curve is None or equity_curve.empty:
return pd.Series(dtype=float)
traded_margin = pd.Series(0.0, index=equity_curve.index, dtype=float)
for trade in trades:
for timestamp, margin in (
(trade.entry_time, trade.entry_margin),
(trade.exit_time, trade.exit_margin),
):
try:
margin_value = float(margin)
except (TypeError, ValueError):
continue
if (
timestamp in traded_margin.index
and np.isfinite(margin_value)
and margin_value > 0
):
traded_margin.loc[timestamp] += margin_value
denominator = 2.0 * equity_curve.abs().replace(0.0, np.nan)
return (traded_margin / denominator).replace([np.inf, -np.inf], np.nan).fillna(0.0)
def calc_metrics(
equity_curve: pd.Series,
trades: List[TradeRecord],
initial_cash: float,
bars_per_year: Optional[int] = 252,
bench_ret: Optional[pd.Series] = None,
positions: Optional[pd.DataFrame] = None,
turnover_series: Optional[pd.Series] = None,
) -> Dict[str, Any]:
"""Full set of performance metrics.
@@ -159,6 +235,10 @@ def calc_metrics(
bars_per_year: Bars per year for annualisation. None = auto-detect
from equity curve dates (calendar-day method, for cross-market).
bench_ret: Benchmark per-bar return series (optional).
positions: Position-weight frame used as a backward-compatible
turnover fallback when ``turnover_series`` is not supplied.
turnover_series: Actual per-bar execution turnover (optional). When
supplied, it takes precedence over position-implied turnover.
Returns:
Metrics dictionary (compatible with daily_portfolio format).
@@ -178,11 +258,38 @@ def calc_metrics(
bpy = bars_per_year
port_ret = equity_curve.pct_change().fillna(0.0)
# Equity that touches zero then recovers (100 → 0 → 50) yields non-finite
# pct_change values; options metrics already skip risk ratios in that case.
returns_finite = bool(np.isfinite(port_ret.to_numpy(dtype=float, copy=False)).all())
total_ret = float(equity_curve.iloc[-1] / initial_cash - 1)
ann_ret = float((1 + total_ret) ** (bpy / max(n, 1)) - 1)
vol = float(port_ret.std())
sharpe = float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
# A leveraged/short book can end at or below zero equity (``total_ret <= -1``).
# ``(1 + total_ret) ** fractional`` would then raise a negative base to a
# fractional power, which Python evaluates to a ``complex`` and crashes the
# subsequent ``float(...)``. A total wipeout annualises to -100%.
growth = 1 + total_ret
if growth <= 0:
ann_ret = -1.0
else:
# Explosive equity paths (e.g. 1 → 1e6 in a few bars) overflow
# ``float(growth ** …)`` on CPython; treat as non-finite annualisation.
try:
ann_ret = float(growth ** (bpy / max(n, 1)) - 1)
except OverflowError:
ann_ret = float("inf")
if not np.isfinite(ann_ret):
ann_ret = float("inf")
# ``Series.std()`` uses ddof=1, so a single-observation return series
# (e.g. a one-bar backtest) yields NaN and poisons the Sharpe ratio.
# Guard the small sample the same way ``downside_std`` is guarded below.
vol = float(port_ret.std()) if len(port_ret) > 1 and returns_finite else 0.0
sharpe = (
float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
if returns_finite
else 0.0
)
if not np.isfinite(sharpe):
sharpe = 0.0
# Drawdown
peak = equity_curve.cummax()
@@ -192,12 +299,29 @@ def calc_metrics(
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
# Sortino
downside = port_ret[port_ret < 0]
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
if returns_finite:
downside = port_ret[port_ret < 0]
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
else:
sortino = 0.0
if not np.isfinite(sortino):
sortino = 0.0
trade_stats = win_rate_and_stats(trades)
# Prefer execution-derived turnover; retain the position-frame fallback
# for external callers of calc_metrics that do not have fill records.
turnover_values = (
turnover_series.reindex(equity_curve.index).fillna(0.0).clip(lower=0.0)
if turnover_series is not None
else calc_turnover_series(positions)
if positions is not None
else pd.Series(dtype=float)
)
avg_turnover = float(turnover_values.mean()) if len(turnover_values) > 0 else 0.0
total_turnover = float(turnover_values.sum()) if len(turnover_values) > 0 else 0.0
# Benchmark comparison
bench_return = 0.0
excess = 0.0
@@ -206,8 +330,16 @@ def calc_metrics(
bench_return = float((1 + bench_ret).prod() - 1)
excess = total_ret - bench_return
active_ret = port_ret - bench_ret.reindex(port_ret.index).fillna(0.0)
active_std = float(active_ret.std())
ir = float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
# Same ddof=1 small-sample guard as ``vol`` / ``downside_std`` so the
# information ratio stays finite for a single-observation series.
active_std = float(active_ret.std()) if len(active_ret) > 1 and returns_finite else 0.0
ir = (
float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
if returns_finite
else 0.0
)
if not np.isfinite(ir):
ir = 0.0
return {
"final_value": float(equity_curve.iloc[-1]),
@@ -226,6 +358,8 @@ def calc_metrics(
"benchmark_return": round(bench_return, 6),
"excess_return": round(excess, 6),
"information_ratio": round(ir, 4),
"avg_turnover": round(avg_turnover, 6),
"total_turnover": round(total_turnover, 6),
}
@@ -238,4 +372,5 @@ def _empty_metrics(initial_cash: float) -> Dict[str, Any]:
"win_rate": 0, "profit_loss_ratio": 0, "profit_factor": 0,
"max_consecutive_loss": 0, "avg_holding_days": 0, "trade_count": 0,
"benchmark_return": 0, "excess_return": 0, "information_ratio": 0,
"avg_turnover": 0.0, "total_turnover": 0.0,
}
+4
View File
@@ -53,6 +53,8 @@ class TradeRecord:
exit_reason: Why closed (signal / liquidation / end_of_backtest).
holding_bars: Number of bars held.
commission: Total commission (entry + exit).
entry_margin: Actual margin allocated at entry, after size rounding.
exit_margin: Margin-equivalent value traded at the exit price.
"""
symbol: str
@@ -68,6 +70,8 @@ class TradeRecord:
exit_reason: str
holding_bars: int
commission: float
entry_margin: float = 0.0
exit_margin: float = 0.0
@dataclass(frozen=True)
+2 -1
View File
@@ -1,10 +1,11 @@
"""Portfolio optimizer package.
Provides four weighting schemes:
Provides five weighting schemes:
- equal_volatility: inverse-volatility weights
- risk_parity: equal risk contribution (Spinu-style)
- mean_variance: max Sharpe via scipy
- max_diversification: maximize diversification ratio
- turnover_aware: mean-variance utility with an L1 turnover penalty
Select via ``optimizer`` in ``config.json``; default is off (1/N).
Add a new optimizer by dropping a module here that exposes ``optimize()``.
+10 -3
View File
@@ -16,7 +16,7 @@ class BaseOptimizer(ABC):
Subclasses implement ``_calc_weights``; the base handles:
- active asset selection
- rolling window slicing and sanity checks
- causal rolling window slicing and sanity checks
- covariance matrix + NaN checks
- applying weights while preserving signal sign
@@ -42,7 +42,9 @@ class BaseOptimizer(ABC):
"""Apply optimizer to position weights.
Args:
ret: Return matrix (dates x codes).
ret: Return matrix (dates x codes). For a decision at ``dt``,
only rows strictly earlier than ``dt`` are visible to the
optimizer because execution occurs at the decision bar's open.
pos: Raw signal positions.
dates: Date index aligned with ``pos``.
@@ -59,7 +61,12 @@ class BaseOptimizer(ABC):
if not active or i < self.lookback:
continue
window = ret.loc[:dt, active].tail(self.lookback)
# Signals are executed at the decision bar's open. ``ret[dt]``
# is a close-to-close return that is not observable until that
# bar closes, so including it here would leak future information
# into the weights applied at the open.
history = ret.loc[ret.index < dt, active]
window = history.tail(self.lookback)
if len(window) < max(self.lookback // 2, 5):
continue
+25 -17
View File
@@ -1,7 +1,4 @@
"""Risk parity: equalize marginal risk contributions.
Iterative refinement so w_i * MRC_i is approximately equal across assets.
"""
"""Long-only risk parity: equalize marginal risk contributions."""
from typing import Any, Dict
@@ -12,33 +9,44 @@ from backtest.optimizers.base import BaseOptimizer
class RiskParityOptimizer(BaseOptimizer):
"""Spinu (2013)-style inverse-vol seed + Newton-style refinement."""
"""Equal-risk-contribution weights on the long-only simplex."""
def _calc_weights(self, ctx: Dict[str, Any]) -> np.ndarray:
"""Equal risk contribution weights."""
from scipy.optimize import minimize
cov = ctx["cov"]
n = cov.shape[0]
if n == 0:
return self._equal_weight(0)
vols = np.sqrt(np.diag(cov))
if np.any(vols < 1e-12):
if not np.isfinite(cov).all() or np.any(vols < 1e-12):
return self._equal_weight(n)
inv_vol = 1.0 / vols
w = inv_vol / inv_vol.sum()
seed = inv_vol / inv_vol.sum()
for _ in range(5):
port_vol = np.sqrt(w @ cov @ w)
if port_vol < 1e-12:
break
mrc = (cov @ w) / port_vol
rc = w * mrc
target = port_vol / n
w = w * (target / (rc + 1e-12))
w = w / w.sum()
def contribution_error(w: np.ndarray) -> float:
variance = float(w @ cov @ w)
if not np.isfinite(variance) or variance <= 1e-18:
return 1e12
contributions = w * (cov @ w)
target = variance / n
return float(np.sum((contributions - target) ** 2) / variance**2)
return w
result = minimize(
contribution_error,
seed,
method="SLSQP",
bounds=[(0.0, 1.0)] * n,
constraints={"type": "eq", "fun": lambda w: w.sum() - 1.0},
options={"maxiter": 200, "ftol": 1e-12},
)
if result.success and np.isfinite(result.x).all():
return self._normalize(result.x)
return self._normalize(seed)
def optimize(
+250
View File
@@ -0,0 +1,250 @@
"""Turnover-aware optimizer: mean-variance utility with an L1 turnover penalty.
Solves, per rebalance date::
min -w'mu + lambda * w'Sigma w + gamma * ||w - w_prev||_1
s.t. w >= 0, sum(w) = 1
w_i <= max_per_name (per-name cap)
sum_{i in group_g} w_i <= max_per_group[g] (per-group cap)
where ``w_prev`` is the weight vector applied at the previous rebalance,
restricted to the current active set (assets absent last time have prior
weight 0, so entries and exits both count as turnover). With ``gamma == 0``
the objective reduces to the mean-variance utility baseline.
The penalty ``gamma`` is scale-sensitive: it is measured against the return
term ``w'mu``, so an appropriate magnitude depends on the return units of the
input window. For daily returns (~1e-3), even ``gamma`` around 0.5 makes the
optimizer strongly prefer holding still. Callers should tune ``gamma`` relative
to their data frequency.
Realized per-rebalance turnover (``0.5 * ||w_t - w_{t-1}||_1``) is accumulated
on the instance for cost-adjusted analysis. This is a class-API affordance:
the engine's module-level ``optimize`` entry constructs a fresh instance and
returns only the positions frame, so callers who want the turnover series must
instantiate ``TurnoverAwareOptimizer`` directly.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import numpy as np
import pandas as pd
from backtest.optimizers.base import BaseOptimizer
class TurnoverAwareOptimizer(BaseOptimizer):
"""Mean-variance weights penalized for turnover against prior weights.
Attributes:
risk_aversion: Weight on the variance term (lambda).
turnover_penalty: Weight on the L1 turnover term (gamma). 0 reduces to
the mean-variance baseline.
max_per_name: Per-asset weight cap (None = no limit).
groups: Asset-code → group-name mapping for per-group caps.
max_per_group: Group-name → maximum total weight for that group.
realized_turnover: Per-rebalance realized turnover collected during
``optimize`` (``0.5 * ||w_t - w_{t-1}||_1``).
"""
def __init__(
self,
lookback: int = 60,
risk_aversion: float = 1.0,
turnover_penalty: float = 0.0,
max_per_name: Optional[float] = None,
groups: Optional[Dict[str, str]] = None,
max_per_group: Optional[Dict[str, float]] = None,
**kwargs: Any,
) -> None:
super().__init__(lookback=lookback, **kwargs)
self.risk_aversion = float(risk_aversion)
self.turnover_penalty = float(turnover_penalty)
if isinstance(max_per_name, (bool, np.bool_)):
raise ValueError("max_per_name must be numeric, not boolean")
self.max_per_name = float(max_per_name) if max_per_name is not None else None
self.groups: Dict[str, str] = dict(groups) if groups else {}
self.max_per_group: Dict[str, float] = dict(max_per_group) if max_per_group else {}
if self.max_per_name is not None and (
not np.isfinite(self.max_per_name) or not 0.0 < self.max_per_name <= 1.0
):
raise ValueError("max_per_name must be finite and in (0, 1]")
if any(not isinstance(code, str) or not isinstance(group, str) for code, group in self.groups.items()):
raise ValueError("groups must map string asset codes to string group names")
unknown_groups = set(self.max_per_group) - set(self.groups.values())
if unknown_groups:
raise ValueError(
"max_per_group references groups with no mapped assets: "
+ ", ".join(sorted(unknown_groups))
)
for group, cap in self.max_per_group.items():
if isinstance(cap, (bool, np.bool_)):
raise ValueError(f"cap for group {group!r} must be numeric, not boolean")
try:
numeric_cap = float(cap)
except (TypeError, ValueError) as exc:
raise ValueError(f"cap for group {group!r} must be numeric") from exc
if not np.isfinite(numeric_cap) or not 0.0 < numeric_cap <= 1.0:
raise ValueError(f"cap for group {group!r} must be finite and in (0, 1]")
self.max_per_group[group] = numeric_cap
self._prev: Dict[str, float] = {}
self.realized_turnover: List[float] = []
def _build_context(
self, window: pd.DataFrame, active: List[str]
) -> "Dict[str, Any] | None":
"""Mean vector, covariance, and active codes for the current window."""
mu = window.mean().values
cov = window.cov().values
if np.isnan(cov).any() or np.isnan(mu).any():
return None
return {"cov": cov, "mu": mu, "active": list(active)}
def _calc_weights(self, ctx: Dict[str, Any]) -> np.ndarray:
"""SLSQP weights for the penalized objective; updates turnover state."""
from scipy.optimize import minimize
mu = np.asarray(ctx["mu"], dtype=float)
cov = np.asarray(ctx["cov"], dtype=float)
active: List[str] = ctx["active"]
n = len(mu)
if n == 0:
return self._equal_weight(0)
w_prev = np.array([self._prev.get(code, 0.0) for code in active], dtype=float)
lam = self.risk_aversion
gamma = self.turnover_penalty
def objective(w: np.ndarray) -> float:
ret = w @ mu
var = w @ cov @ w
turn = np.abs(w - w_prev).sum()
return -ret + lam * var + gamma * turn
# --- bounds: per-name cap ---
upper = 1.0
if self.max_per_name is not None:
upper = min(1.0, float(self.max_per_name))
bounds = [(0.0, upper)] * n
# --- constraints: simplex + per-group caps ---
constraints: list = [
{"type": "eq", "fun": lambda w: w.sum() - 1.0},
]
group_rows: list[np.ndarray] = []
group_caps: list[float] = []
group_constraint_indices: list[List[int]] = []
group_indices: Dict[str, List[int]] = {}
if self.groups and self.max_per_group:
for i, code in enumerate(active):
g = self.groups.get(code)
if g is not None:
group_indices.setdefault(g, []).append(i)
for g, cap in self.max_per_group.items():
indices = group_indices.get(g, [])
if not indices:
continue
cap = float(cap)
row = np.zeros(n)
row[indices] = 1.0
group_rows.append(row)
group_caps.append(cap)
group_constraint_indices.append(indices)
constraints.append({
"type": "ineq",
"fun": lambda w, idx=indices, c=cap: c - w[np.array(idx)].sum(),
})
has_effective_caps = upper < 1.0 or any(cap < 1.0 for cap in group_caps)
if not has_effective_caps:
x0 = w_prev if w_prev.sum() > 1e-12 else self._equal_weight(n)
elif (
np.isfinite(w_prev).all()
and (w_prev >= 0.0).all()
and abs(w_prev.sum() - 1.0) <= 1e-12
and (w_prev <= upper + 1e-12).all()
and all(
row @ w_prev <= cap + 1e-12
for row, cap in zip(group_rows, group_caps)
)
):
x0 = w_prev
else:
# Groups are a single label per asset, so their caps are disjoint.
# Allocate each bucket by capacity to obtain a feasible simplex point.
buckets: list[tuple[List[int], float]] = []
capped_indices: set[int] = set()
for indices, cap in zip(group_constraint_indices, group_caps):
capacity = min(cap, len(indices) * upper)
buckets.append((indices, capacity))
capped_indices.update(indices)
uncapped = [i for i in range(n) if i not in capped_indices]
if uncapped:
buckets.append((uncapped, len(uncapped) * upper))
total_capacity = sum(capacity for _, capacity in buckets)
if total_capacity < 1.0 - 1e-12:
raise ValueError(
"exposure caps are infeasible for active assets "
f"{active}: total capacity is {total_capacity:.6g}"
)
x0 = np.zeros(n)
for indices, capacity in buckets:
x0[indices] = capacity / total_capacity / len(indices)
result = minimize(
objective,
x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 200, "ftol": 1e-10},
)
if not result.success:
raise RuntimeError(f"turnover-aware optimization failed: {result.message}")
weights = np.maximum(np.asarray(result.x, dtype=float), 0.0)
weights /= weights.sum()
if (
not np.isfinite(weights).all()
or abs(weights.sum() - 1.0) > 1e-7
or weights.max() > upper + 1e-7
or any(row @ weights > cap + 1e-7 for row, cap in zip(group_rows, group_caps))
):
raise RuntimeError("optimizer returned weights that violate exposure caps")
self._record_turnover(active, weights)
return weights
def _record_turnover(self, active: List[str], weights: np.ndarray) -> None:
"""Accumulate realized turnover and roll prior weights forward."""
codes = set(active) | set(self._prev)
new_map = {code: float(weights[i]) for i, code in enumerate(active)}
turnover = 0.5 * sum(
abs(new_map.get(code, 0.0) - self._prev.get(code, 0.0)) for code in codes
)
self.realized_turnover.append(turnover)
self._prev = new_map
def optimize(
ret: pd.DataFrame,
pos: pd.DataFrame,
dates: pd.DatetimeIndex,
lookback: int = 60,
risk_aversion: float = 1.0,
turnover_penalty: float = 0.0,
max_per_name: Optional[float] = None,
groups: Optional[Dict[str, str]] = None,
max_per_group: Optional[Dict[str, float]] = None,
) -> pd.DataFrame:
"""Module-level entry: turnover-penalized mean-variance positions."""
return TurnoverAwareOptimizer(
lookback=lookback,
risk_aversion=risk_aversion,
turnover_penalty=turnover_penalty,
max_per_name=max_per_name,
groups=groups,
max_per_group=max_per_group,
).optimize(ret, pos, dates)
+233
View File
@@ -0,0 +1,233 @@
"""Correlation-regime timeline: edge density + hysteresis over time.
Implements Mode 1 of the ``correlation-regime`` skill on top of the same
price data the /correlation endpoint uses: rolling pairwise correlations are
reduced to an edge-density scalar per bar, the density series is smoothed
with a trailing (causal) window, and a two-threshold hysteresis state
machine labels each bar FUSED or not. Used by the /correlation/regime API
endpoint. Descriptive risk context — not a trading signal.
"""
from __future__ import annotations
import logging
from typing import Dict, Optional
import numpy as np
import pandas as pd
from backtest.correlation import _close_series, _fetch_price_series
logger = logging.getLogger(__name__)
def compute_edge_density(
returns: pd.DataFrame,
corr_window: int = 60,
edge_threshold: float = 0.5,
) -> pd.Series:
"""Reduce rolling correlation matrices to an edge-density series.
Edge density is the fraction of distinct asset pairs whose rolling
|correlation| clears ``edge_threshold`` — a scalar "how fused is the
market" gauge in [0, 1].
Args:
returns: Multi-asset return matrix, columns are symbols
corr_window: Rolling window length (bars) for pairwise correlation
edge_threshold: |ρ| level at which a pair counts as an "edge"
Returns:
Edge-density series aligned to ``returns.index`` (NaN during warmup)
"""
n_assets = returns.shape[1]
n_pairs = n_assets * (n_assets - 1) // 2
upper_mask = np.triu(np.ones((n_assets, n_assets), dtype=bool), k=1)
density = pd.Series(np.nan, index=returns.index)
for i in range(corr_window, len(returns) + 1):
corr = returns.iloc[i - corr_window:i].corr().abs().to_numpy()
density.iloc[i - 1] = float((corr[upper_mask] >= edge_threshold).sum()) / n_pairs
return density
def detect_regimes(
density: pd.Series,
smooth_window: int = 5,
enter_threshold: float = 0.65,
exit_threshold: float = 0.45,
) -> pd.DataFrame:
"""Hysteresis (Schmitt-trigger) regime state machine on smoothed density.
The market is FUSED once smoothed density reaches ``enter_threshold`` and
stays FUSED until it falls back to ``exit_threshold``. The dead band
between the two thresholds is what suppresses chatter.
Args:
density: Edge-density series from :func:`compute_edge_density`
smooth_window: Trailing smoothing window (causal; never centered)
enter_threshold: Density level that opens a FUSED regime
exit_threshold: Density level that closes it (must be < enter_threshold)
Returns:
DataFrame with columns ``density``, ``smoothed``, ``fused`` (0/1)
"""
if exit_threshold >= enter_threshold:
raise ValueError("exit_threshold must be below enter_threshold")
# Trailing mean = causal. A centered window here silently reads the future.
smoothed = density.rolling(smooth_window, min_periods=1).mean()
fused = False
states = np.zeros(len(smoothed), dtype=int)
for i, value in enumerate(smoothed.to_numpy()):
if np.isnan(value):
states[i] = int(fused)
continue
if not fused and value >= enter_threshold:
fused = True
elif fused and value <= exit_threshold:
fused = False
states[i] = int(fused)
return pd.DataFrame(
{"density": density, "smoothed": smoothed, "fused": states},
index=density.index,
)
def _aligned_returns(price_series: Dict[str, pd.DataFrame]) -> pd.DataFrame:
"""Build a date-aligned daily-returns frame (one column per code).
Mirrors the alignment in ``_rolling_correlation_matrix``: indexes are
normalized to midnight so cross-market assets (e.g. crypto at UTC
midnight vs US equity at EDT midnight) share dates, then inner-joined.
``fill_method=None`` is explicit because under the project's
pandas>=2,<3 pin the ``pct_change`` default forward-fills missing
prices, silently manufacturing 0% returns.
"""
returns_frames = []
for code in sorted(price_series):
ts = _close_series(code, price_series[code])
ts.index = ts.index.normalize()
rets = ts.pct_change(fill_method=None).dropna()
rets.name = code
returns_frames.append(rets)
aligned = pd.concat(returns_frames, axis=1).dropna()
if aligned.empty:
raise ValueError("No overlapping return data between assets")
return aligned
def _fused_episodes(dates: list[str], fused: list[int]) -> list[Dict[str, Optional[str]]]:
"""Contiguous FUSED intervals within the returned window.
``end`` is the last date observed FUSED, or None while the final bar is
still FUSED (episode ongoing).
"""
episodes: list[Dict[str, Optional[str]]] = []
start: Optional[str] = None
last_fused: Optional[str] = None
for date, state in zip(dates, fused):
if state:
if start is None:
start = date
last_fused = date
elif start is not None:
episodes.append({"start": start, "end": last_fused})
start = None
if start is not None:
episodes.append({"start": start, "end": None})
return episodes
def compute_regime_timeline(
codes: list[str],
days: int = 90,
corr_window: int = 60,
edge_threshold: float = 0.5,
smooth_window: int = 5,
enter_threshold: float = 0.65,
exit_threshold: float = 0.45,
) -> Dict[str, object]:
"""Fetch price data and compute the correlation-regime timeline.
Args:
codes: List of asset codes, as for :func:`compute_correlation_matrix`.
days: Number of trailing timeline bars to return.
corr_window: Rolling window (bars) for pairwise correlation.
edge_threshold: |ρ| level at which a pair counts as an "edge".
smooth_window: Trailing smoothing window (bars) for the density series.
enter_threshold: Smoothed density that opens a FUSED regime.
exit_threshold: Smoothed density that closes it (must be below
``enter_threshold``).
Returns:
Dict with keys: labels, dates, density, smoothed, fused, episodes,
params. ``density``/``smoothed`` use None for warmup bars; ``fused``
is 0/1 per bar; ``episodes`` lists FUSED intervals with ``end=None``
while the final bar is still FUSED.
"""
from datetime import datetime, timedelta
if exit_threshold >= enter_threshold:
raise ValueError("exit_threshold must be below enter_threshold")
# Each returned bar needs a full corr_window of history behind it, so the
# warmup falls outside the returned window: extend /correlation's +60
# calendar-day fetch buffer by the correlation window (and a margin for
# non-trading days).
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (
datetime.now() - timedelta(days=days + corr_window + 90)
).strftime("%Y-%m-%d")
price_series = _fetch_price_series(codes, start_date, end_date)
if len(price_series) < 2:
raise ValueError(
f"Could not fetch price data for at least 2 assets. "
f"Fetched: {list(price_series.keys())}"
)
returns = _aligned_returns(price_series)
density = compute_edge_density(
returns, corr_window=corr_window, edge_threshold=edge_threshold
)
regimes = detect_regimes(
density,
smooth_window=smooth_window,
enter_threshold=enter_threshold,
exit_threshold=exit_threshold,
)
# Trim to the requested window only after the state machine has run, so
# the regime state at the window's first bar reflects the full history.
if len(regimes) > days:
regimes = regimes.iloc[-days:]
dates = [d.strftime("%Y-%m-%d") for d in regimes.index]
density_out = [
None if np.isnan(v) else round(float(v), 4) for v in regimes["density"]
]
smoothed_out = [
None if np.isnan(v) else round(float(v), 4) for v in regimes["smoothed"]
]
fused_out = [int(v) for v in regimes["fused"]]
return {
"labels": list(returns.columns),
"dates": dates,
"density": density_out,
"smoothed": smoothed_out,
"fused": fused_out,
"episodes": _fused_episodes(dates, fused_out),
"params": {
"days": days,
"corr_window": corr_window,
"edge_threshold": edge_threshold,
"smooth_window": smooth_window,
"enter_threshold": enter_threshold,
"exit_threshold": exit_threshold,
},
}
+249
View File
@@ -0,0 +1,249 @@
"""Trust Layer run card generator for backtest runs."""
from __future__ import annotations
import hashlib
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence
SCHEMA_VERSION = "0.1"
BACKTEST_SUMMARY_KEYS = (
"codes",
"start_date",
"end_date",
"interval",
"engine",
"initial_cash",
"source",
)
def write_run_card(
run_dir: Path,
config: Mapping[str, Any],
metrics: Mapping[str, Any],
*,
data_sources: Sequence[str] | None = None,
strategy_path: Path | None = None,
warnings: Sequence[str] | None = None,
artifact_refs: Sequence[Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
"""Write JSON and Markdown run cards for a backtest run.
Args:
run_dir: Directory where run_card.json and run_card.md are written.
config: Full backtest configuration. Only a summary and hash are stored.
metrics: Backtest metrics. Scalar values are stored; ``validation`` is
stored separately when present.
data_sources: Data sources used by the run.
strategy_path: Optional strategy source file to hash for reproducibility.
warnings: Optional warnings to include in the card.
artifact_refs: Optional IRR-AGL artifact references.
Returns:
The run card payload written to ``run_card.json``.
"""
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
config_file = run_dir / "config.json"
reproducibility: dict[str, Any] = {
"config_hash": _file_hash(config_file) if config_file.exists() else _json_hash(config),
}
if strategy_path is not None:
strategy_file = Path(strategy_path)
if strategy_file.exists() and strategy_file.is_file():
reproducibility["strategy_hash"] = _file_hash(strategy_file)
card: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"generated_at": _utc_now(),
"run_dir": str(run_dir),
"backtest": _backtest_summary(config),
"reproducibility": reproducibility,
"data_sources": list(data_sources or []),
"metrics": _scalar_metrics(metrics),
"warnings": list(warnings or []),
"artifacts": _list_artifacts(run_dir),
}
normalized_refs = _normalize_artifact_refs(artifact_refs)
if normalized_refs:
card["artifact_refs"] = normalized_refs
if "validation" in metrics:
card["validation"] = metrics["validation"]
card = _json_safe(card)
json_path = run_dir / "run_card.json"
md_path = run_dir / "run_card.md"
json_path.write_text(
json.dumps(
card,
ensure_ascii=False,
indent=2,
sort_keys=True,
default=str,
allow_nan=False,
)
+ "\n",
encoding="utf-8",
)
md_path.write_text(_render_markdown(card), encoding="utf-8")
return card
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _json_hash(value: Mapping[str, Any]) -> str:
payload = json.dumps(
{key: val for key, val in value.items() if not str(key).startswith("_")},
sort_keys=True,
default=str,
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _file_hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _backtest_summary(config: Mapping[str, Any]) -> dict[str, Any]:
return {key: config.get(key) for key in BACKTEST_SUMMARY_KEYS if key in config}
def _scalar_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in metrics.items()
if key != "validation" and _is_scalar(value)
}
def _json_safe(value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, Mapping):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def _is_scalar(value: Any) -> bool:
return value is None or isinstance(value, (str, int, float, bool))
def _list_artifacts(run_dir: Path) -> list[dict[str, Any]]:
candidates: list[Path] = []
for relative in (Path("config.json"), Path("code/signal_engine.py")):
path = run_dir / relative
if path.exists() and path.is_file():
candidates.append(path)
artifacts_dir = run_dir / "artifacts"
if artifacts_dir.exists() and artifacts_dir.is_dir():
candidates.extend(path for path in artifacts_dir.rglob("*") if path.is_file())
artifacts = []
for path in sorted(candidates, key=lambda item: item.relative_to(run_dir).as_posix()):
artifacts.append(
{
"path": path.relative_to(run_dir).as_posix(),
"size_bytes": path.stat().st_size,
"sha256": _file_hash(path),
}
)
return artifacts
def _normalize_artifact_refs(artifact_refs: Sequence[Mapping[str, Any]] | None) -> list[dict[str, Any]]:
refs: list[dict[str, Any]] = []
for ref in artifact_refs or []:
if hasattr(ref, "model_dump"):
value = ref.model_dump(mode="json") # type: ignore[attr-defined]
else:
value = dict(ref)
refs.append(_json_safe(value))
return refs
def _render_markdown(card: Mapping[str, Any]) -> str:
lines = [
"# Backtest Run Card",
"",
f"Generated: {card['generated_at']}",
f"Run directory: `{card['run_dir']}`",
"",
"## Backtest Summary",
]
backtest = card.get("backtest", {})
if backtest:
lines.extend(f"- {key}: {value}" for key, value in backtest.items())
else:
lines.append("- No backtest summary fields provided.")
lines.extend(["", "## Reproducibility"])
reproducibility = card.get("reproducibility", {})
lines.append(f"- config_hash: `{reproducibility.get('config_hash', '')}`")
if "strategy_hash" in reproducibility:
lines.append(f"- strategy_hash: `{reproducibility['strategy_hash']}`")
lines.extend(["", "## Data Sources"])
data_sources = card.get("data_sources", [])
lines.extend(f"- {source}" for source in data_sources) if data_sources else lines.append("- None recorded.")
lines.extend(["", "## Metrics"])
metric_values = card.get("metrics", {})
lines.extend(f"- {key}: {value}" for key, value in metric_values.items()) if metric_values else lines.append("- No scalar metrics recorded.")
lines.extend(["", "## Validation"])
if "validation" in card:
validation = card["validation"]
if isinstance(validation, Mapping):
lines.extend(f"- {key}: {value}" for key, value in validation.items())
else:
lines.append(f"- {validation}")
else:
lines.append("- Not present.")
warnings = card.get("warnings", [])
if warnings:
lines.extend(["", "## Warnings"])
lines.extend(f"- {warning}" for warning in warnings)
lines.extend(["", "## Artifacts"])
artifacts = card.get("artifacts", [])
if artifacts:
lines.extend(
f"- `{artifact['path']}` ({artifact['size_bytes']} bytes, sha256 `{artifact['sha256']}`)"
for artifact in artifacts
)
else:
lines.append("- None found.")
artifact_refs = card.get("artifact_refs", [])
if artifact_refs:
lines.extend(["", "## IRR Artifact Refs"])
for ref in artifact_refs:
if isinstance(ref, Mapping):
label = ref.get("artifact_id", "")
artifact_type = ref.get("artifact_type", "")
digest = ref.get("sha256", "")
uri = ref.get("uri", "")
lines.append(f"- `{label}` ({artifact_type}, sha256 `{digest}`, uri `{uri}`)")
else:
lines.append(f"- {ref}")
return "\n".join(lines) + "\n"
+873 -147
View File
File diff suppressed because it is too large. Load diff
+132 -49
View File
@@ -5,12 +5,15 @@ Three independent tools:
- Bootstrap Sharpe CI: how stable is the risk-adjusted return?
- Walk-Forward analysis: is performance consistent across time windows?
Usage: called automatically by BaseEngine.run_backtest when config["validation"]
Usage: called automatically by BaseEngine.run_backtest when config[\"validation\"]
is present, or invoked directly on backtest outputs.
"""
from __future__ import annotations
import json
import math
from numbers import Integral, Real
from pathlib import Path
from typing import Any, Dict, List
@@ -44,6 +47,13 @@ def monte_carlo_test(
Dict with actual_sharpe, p_value_sharpe, actual_max_dd,
p_value_max_dd, simulated_sharpes (percentiles).
"""
if isinstance(n_simulations, bool) or not isinstance(n_simulations, Integral) or n_simulations < 1:
return {
"error": f"n_simulations must be >= 1, got {n_simulations}",
"p_value_sharpe": 1.0,
}
if isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0:
return {"error": f"seed must be >= 0, got {seed}", "p_value_sharpe": 1.0}
if len(trades) < 3:
return {"error": "need at least 3 trades", "p_value_sharpe": 1.0}
@@ -114,6 +124,18 @@ def bootstrap_sharpe_ci(
Dict with observed_sharpe, ci_lower, ci_upper, median_sharpe,
prob_positive (fraction of samples with Sharpe > 0).
"""
if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, Integral) or n_bootstrap < 1:
return {"error": f"n_bootstrap must be >= 1, got {n_bootstrap}"}
if (
isinstance(confidence, bool)
or not isinstance(confidence, Real)
or not math.isfinite(float(confidence))
or not 0.0 < confidence < 1.0
):
return {"error": f"confidence must be in (0, 1), got {confidence}"}
if isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0:
return {"error": f"seed must be >= 0, got {seed}"}
returns = equity_curve.pct_change().dropna().values
if len(returns) < 5:
return {"error": "need at least 5 return observations"}
@@ -170,6 +192,8 @@ def walk_forward_analysis(
Returns:
Dict with per_window stats, consistency metrics.
"""
if isinstance(n_windows, bool) or not isinstance(n_windows, Integral) or n_windows < 1:
return {"error": f"n_windows must be >= 1, got {n_windows}"}
if len(equity_curve) < n_windows * 2:
return {"error": f"need at least {n_windows * 2} bars for {n_windows} windows"}
@@ -185,10 +209,7 @@ def walk_forward_analysis(
win_end = indices[end_idx - 1]
# Per-window trades
win_trades = [
t for t in trades
if win_start <= t.entry_time <= win_end
]
win_trades = [t for t in trades if win_start <= t.entry_time <= win_end]
# Per-window metrics
ret = float(win_eq.iloc[-1] / win_eq.iloc[0] - 1) if win_eq.iloc[0] > 0 else 0.0
@@ -200,21 +221,20 @@ def walk_forward_analysis(
max_dd = float(dd.min())
win_pnls = [t.pnl for t in win_trades]
win_rate = (
len([p for p in win_pnls if p > 0]) / len(win_pnls)
if win_pnls else 0.0
)
win_rate = len([p for p in win_pnls if p > 0]) / len(win_pnls) if win_pnls else 0.0
windows.append({
"window": i + 1,
"start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start),
"end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end),
"return": round(ret, 6),
"sharpe": round(sharpe, 4),
"max_dd": round(max_dd, 6),
"trades": len(win_trades),
"win_rate": round(win_rate, 4),
})
windows.append(
{
"window": i + 1,
"start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start),
"end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end),
"return": round(ret, 6),
"sharpe": round(sharpe, 4),
"max_dd": round(max_dd, 6),
"trades": len(win_trades),
"win_rate": round(win_rate, 4),
}
)
# Consistency metrics
returns_list = [w["return"] for w in windows]
@@ -266,7 +286,8 @@ def run_validation(
if "monte_carlo" in v_cfg:
mc_cfg = v_cfg["monte_carlo"] if isinstance(v_cfg["monte_carlo"], dict) else {}
results["monte_carlo"] = monte_carlo_test(
trades, initial_capital,
trades,
initial_capital,
n_simulations=mc_cfg.get("n_simulations", 1000),
seed=mc_cfg.get("seed", 42),
)
@@ -274,7 +295,8 @@ def run_validation(
if "bootstrap" in v_cfg:
bs_cfg = v_cfg["bootstrap"] if isinstance(v_cfg["bootstrap"], dict) else {}
results["bootstrap"] = bootstrap_sharpe_ci(
equity_curve, bars_per_year=bars_per_year,
equity_curve,
bars_per_year=bars_per_year,
n_bootstrap=bs_cfg.get("n_bootstrap", 1000),
confidence=bs_cfg.get("confidence", 0.95),
seed=bs_cfg.get("seed", 42),
@@ -283,7 +305,8 @@ def run_validation(
if "walk_forward" in v_cfg:
wf_cfg = v_cfg["walk_forward"] if isinstance(v_cfg["walk_forward"], dict) else {}
results["walk_forward"] = walk_forward_analysis(
equity_curve, trades,
equity_curve,
trades,
n_windows=wf_cfg.get("n_windows", 5),
bars_per_year=bars_per_year,
)
@@ -298,7 +321,12 @@ def _load_equity(run_dir: Path) -> pd.Series:
"""Load equity curve from artifacts/equity.csv."""
path = run_dir / "artifacts" / "equity.csv"
df = pd.read_csv(path, index_col=0, parse_dates=True)
return df["equity"]
for col in ("equity", "nav", "value"):
if col in df.columns:
return df[col]
raise ValueError(
f"equity.csv must contain an equity/nav/value column; got {list(df.columns)}"
)
def _load_trades(run_dir: Path) -> List[TradeRecord]:
@@ -312,24 +340,86 @@ def _load_trades(run_dir: Path) -> List[TradeRecord]:
trades = []
exit_rows = df[df["pnl"] != 0].reset_index(drop=True)
for _, row in exit_rows.iterrows():
trades.append(TradeRecord(
symbol=str(row.get("code", "")),
direction=1 if row.get("side") == "sell" else -1,
entry_price=0.0,
exit_price=float(row.get("price", 0)),
entry_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
exit_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
size=float(row.get("qty", 0)),
leverage=1.0,
pnl=float(row.get("pnl", 0)),
pnl_pct=float(row.get("return_pct", 0)),
exit_reason=str(row.get("reason", "signal")),
holding_bars=int(row.get("holding_days", 0)),
commission=0.0,
))
trades.append(
TradeRecord(
symbol=str(row.get("code", "")),
direction=1 if row.get("side") == "sell" else -1,
entry_price=0.0,
exit_price=float(row.get("price", 0)),
entry_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
exit_time=pd.Timestamp(row.get("timestamp", "2000-01-01")),
size=float(row.get("qty", 0)),
leverage=1.0,
pnl=float(row.get("pnl", 0)),
pnl_pct=float(row.get("return_pct", 0)),
exit_reason=str(row.get("reason", "signal")),
holding_bars=int(row.get("holding_days", 0)),
commission=0.0,
)
)
return trades
def _parse_run_dir(argv: List[str]) -> Path:
"""Validate CLI input and return a usable run directory path."""
if len(argv) < 2:
raise SystemExit("Usage: python -m backtest.validation <run_dir>")
raw_run_dir = argv[1]
if not raw_run_dir.strip():
raise SystemExit("run_dir must be a non-empty path")
if "\0" in raw_run_dir:
raise SystemExit("Invalid run_dir path: embedded NUL byte")
try:
run_dir = Path(raw_run_dir).expanduser()
exists = run_dir.exists()
is_dir = run_dir.is_dir() if exists else False
except (OSError, RuntimeError, ValueError) as exc:
raise SystemExit(f"Invalid run_dir path: {exc}") from exc
if not exists:
raise SystemExit(f"run_dir does not exist: {run_dir}")
if not is_dir:
raise SystemExit(f"run_dir is not a directory: {run_dir}")
return run_dir
def _json_safe(value: Any) -> Any:
"""Return a JSON-strict copy of validation results."""
if isinstance(value, np.ndarray):
return [_json_safe(item) for item in value.tolist()]
if isinstance(value, np.generic):
return _json_safe(value.item())
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def write_validation_json(path: Path, results: Dict[str, Any]) -> Dict[str, Any]:
"""Write validation results to ``path`` as strict, RFC-8259 JSON.
A validation metric can be non-finite (e.g. a Sharpe computed from a path
whose equity touches zero), and ``json.dumps`` emits bare ``NaN`` /
``Infinity`` tokens for those by default (``allow_nan=True``) — tokens that
strict parsers reject. Sanitize with :func:`_json_safe` (non-finite → null)
and serialize with ``allow_nan=False`` so every writer of
``artifacts/validation.json`` produces the same valid JSON. Returns the
sanitized payload that was written.
"""
safe_results = _json_safe(results)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(safe_results, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
encoding="utf-8",
)
return safe_results
def main(run_dir: Path) -> Dict[str, Any]:
"""Run all three validations on existing backtest artifacts.
@@ -341,8 +431,6 @@ def main(run_dir: Path) -> Dict[str, Any]:
Returns:
Validation results dict.
"""
import json
# Load config for initial_cash
config_path = run_dir / "config.json"
if config_path.exists():
@@ -360,19 +448,14 @@ def main(run_dir: Path) -> Dict[str, Any]:
"walk_forward": walk_forward_analysis(equity, trades),
}
# Write results
out = run_dir / "artifacts" / "validation.json"
out.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
safe_results = write_validation_json(out, results)
print(json.dumps(results, indent=2))
return results
print(json.dumps(safe_results, indent=2, allow_nan=False))
return safe_results
if __name__ == "__main__":
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("Usage: python -m backtest.validation <run_dir>")
sys.exit(1)
main(Path(sys.argv[1]))
main(_parse_run_dir(sys.argv))
-1830
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
"""Vibe-Trading CLI package.
The legacy single-file CLI has been preserved verbatim as
``cli/_legacy.py`` and is the source of truth for non-interactive
subcommands (``serve``, ``run``, ``mcp``, ``sessions``, ``swarm`` ...).
The front door (:mod:`cli.main`) shows the banner, runs the
onboarding wizard when needed, then drives the interactive loop
built on :mod:`cli.input`, :mod:`cli.completer`, and
:mod:`cli.commands.*`. Non-interactive entries still pass through to
``_legacy.main``.
The console-script entry in ``pyproject.toml``
(``vibe-trading = "cli:main"``) points at the ``main`` callable exported
here.
Compatibility note: tests and downstream callers historically reached
into ``cli._INIT_ENV_PATH`` / ``cli.cmd_memory_list`` / ``cli.Confirm``
etc. To preserve that surface we re-export every public name from
``_legacy`` at package import time. New code should import the same
helpers from ``cli._legacy`` directly.
"""
from __future__ import annotations
from cli import _legacy as _legacy
from cli.main import main
# Re-export the legacy module's public surface (anything not prefixed
# with two underscores). This lets ``cli.cmd_memory_list`` /
# ``cli._INIT_ENV_PATH`` / ``cli.Prompt`` etc. keep working without us
# having to enumerate every name by hand.
for _name in dir(_legacy):
if _name.startswith("__"):
continue
globals().setdefault(_name, getattr(_legacy, _name))
del _name
# Symbols tests may monkeypatch on the ``cli`` package that legacy
# callables still read from their own module globals. Keep this list
# explicit so accidental package-level attributes don't bleed into
# ``_legacy``.
_LEGACY_SYNCED_GLOBALS: tuple[str, ...] = (
"_INIT_ENV_PATH",
"AGENT_DIR",
"RUNS_DIR",
"SWARM_DIR",
"SESSIONS_DIR",
"UPLOADS_DIR",
"_PROVIDER_CHOICES",
)
def _sync_legacy_test_overrides() -> None:
"""Mirror package-level monkeypatches onto ``_legacy``'s module globals.
Tests reach into ``cli.<NAME>`` to override constants, but legacy
callables read ``<NAME>`` from their own module namespace. This hook
copies any patched value back to ``_legacy`` for the allowlist below.
"""
pkg_globals = globals()
for name in _LEGACY_SYNCED_GLOBALS:
if name not in pkg_globals:
continue
new_value = pkg_globals[name]
if getattr(_legacy, name, None) is not new_value:
setattr(_legacy, name, new_value)
def cmd_init() -> int:
"""Compatibility wrapper for callers patching ``cli._INIT_ENV_PATH``."""
_sync_legacy_test_overrides()
return _legacy.cmd_init()
# Wrap every ``cmd_*`` callable re-exported from ``_legacy`` so a package-level
# monkeypatch of any symbol in ``_LEGACY_SYNCED_GLOBALS`` is propagated to
# ``_legacy`` before the call. Without this, ``patch.object(cli, "RUNS_DIR",
# tmp); cli.cmd_list()`` would silently read the unpatched ``_legacy.RUNS_DIR``.
import functools as _functools # noqa: E402
def _make_synced_legacy_wrapper(legacy_fn): # noqa: ANN001
"""Wrap ``legacy_fn`` so the package-level monkeypatch sync fires first."""
@_functools.wraps(legacy_fn)
def _wrapper(*args, **kwargs):
_sync_legacy_test_overrides()
# Re-read the (possibly just-synced) attribute from ``_legacy`` in
# case the test also patched the function itself.
return getattr(_legacy, legacy_fn.__name__)(*args, **kwargs)
_wrapper.__wrapped__ = legacy_fn
return _wrapper
for _cmd_name in [n for n in globals() if n.startswith("cmd_") and n != "cmd_init"]:
_cmd_obj = globals()[_cmd_name]
if callable(_cmd_obj) and getattr(_cmd_obj, "__module__", "") == "cli._legacy":
globals()[_cmd_name] = _make_synced_legacy_wrapper(_cmd_obj)
del _cmd_name, _cmd_obj
__all__ = ["main", *sorted(
name for name in globals()
if not name.startswith("_") and name != "main"
)]
+5
View File
@@ -0,0 +1,5 @@
"""Allow ``python -m cli`` to run the CLI entrypoint."""
from cli.main import _entrypoint
_entrypoint()
+5727
View File
File diff suppressed because it is too large. Load diff
+46
View File
@@ -0,0 +1,46 @@
"""Single source of truth for the CLI version string.
Reads ``vibe-trading-ai``'s installed package metadata when available
(``pip install -e .`` is enough). For an un-installed checkout (e.g. running
straight from a clone with ``PYTHONPATH=agent``) it falls back to reading the
version straight out of ``pyproject.toml`` — so ``pyproject.toml`` is the one
and only place the version is ever written. There is deliberately no hardcoded
version constant to drift out of sync on release (issue #156).
"""
from __future__ import annotations
from typing import Final
def _version_from_pyproject() -> str:
"""Read ``[project] version`` from the repo's ``pyproject.toml``.
Returns:
The declared version, or ``"unknown"`` if the file cannot be located
or parsed (only reachable for an un-installed checkout whose tree has
been moved away from its ``pyproject.toml``).
"""
import tomllib
from pathlib import Path
# agent/cli/_version.py -> parents[2] is the repo root holding pyproject.toml.
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
try:
return tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
except (OSError, KeyError, tomllib.TOMLDecodeError):
return "unknown"
try:
from importlib.metadata import PackageNotFoundError, version as _pkg_version
try:
__version__: Final[str] = _pkg_version("vibe-trading-ai")
except PackageNotFoundError:
__version__ = _version_from_pyproject()
except ImportError: # pragma: no cover — importlib.metadata is stdlib on 3.8+
__version__ = _version_from_pyproject()
__all__ = ["__version__"]
+18
View File
@@ -0,0 +1,18 @@
"""Slash command implementations.
Each command module exports a ``run(ctx, *args) -> int`` callable. Modules
that own multiple commands (``chat``, ``show``, ``session``) also expose
named ``cmd_<name>`` callables so the slash router can dispatch by
command keyword.
The :data:`SLASH_COMMANDS` registry lives in :mod:`.slash_router`.
"""
from .slash_router import SLASH_COMMANDS, Command, find_exact, match_commands
__all__ = [
"Command",
"SLASH_COMMANDS",
"find_exact",
"match_commands",
]
+256
View File
@@ -0,0 +1,256 @@
"""Chat-flow slash commands: ``/model``, ``/clear``, ``/journal``,
``/shadow``, ``/swarm``, ``/debug``, ``/quit``.
``/model`` renders the current configuration via the legacy
``_show_settings`` helper. ``/swarm`` dispatches to the legacy
``_handle_swarm_command`` so the existing presets keep working. ``/clear``
clears the screen and reprints the banner. ``/journal``, ``/shadow``, and
``/debug`` show a "Coming soon" placeholder pointing at the established
fallback workflows.
"""
from __future__ import annotations
from typing import Any, Optional
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from cli.theme import get_console
def _resolve_console(console: Optional[Console] = None) -> Console:
"""Return the shared CLI console, or a caller-supplied override."""
if console is not None:
return console
return get_console()
def _coming_soon(command: str, *, hint: str) -> int:
"""Render the placeholder panel for not-yet-wired commands."""
console = _resolve_console()
body = Text()
body.append(f"/{command} is not yet wired up to the interactive CLI.\n\n", style="dim")
body.append("Until then: ", style="dim")
body.append(hint, style="bold")
console.print(Panel(body, title=f"/{command}", border_style="dim", padding=(1, 2)))
return 0
# --- /model ----------------------------------------------------------------
def cmd_model(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unused
"""Print the current provider/model + how to re-run the wizard."""
console = _resolve_console()
try:
from cli._legacy import _show_settings
_show_settings()
except Exception as exc: # noqa: BLE001 — legacy may be absent on partial install
from src.config.accessor import get_env_config
cfg = get_env_config()
provider = cfg.llm.langchain_provider or "(not set)"
model = cfg.llm.langchain_model_name or "(not set)"
console.print(Text(f"Provider: {provider}", style="bold"))
console.print(Text(f"Model: {model}", style="bold"))
console.print(Text(f"(legacy _show_settings unavailable: {exc})", style="dim"))
console.print()
console.print(
Text(
"Run `vibe-trading init` to switch provider, model, or credentials.",
style="dim",
)
)
return 0
# --- /clear, /journal, /shadow, /swarm, /debug, /quit ----------------------
def cmd_clear(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Clear the screen and reprint the welcome banner."""
console = _resolve_console()
try:
console.clear()
except Exception: # noqa: BLE001 — clear can fail on dumb terminals
pass
# Best-effort: reprint the banner so the conversation appears fresh.
# Reuse the cached stats populated at startup so the redrawn banner
# shows the real skills / tools / sessions counts, not zeros.
try:
from cli.intro import print_banner
from cli.main import _collect_banner_stats
print_banner(console, **_collect_banner_stats())
except Exception: # noqa: BLE001
console.print(Text("Conversation cleared.", style="dim"))
# Caller is expected to also drop in-memory conversation history.
if ctx is not None and hasattr(ctx, "history"):
try:
ctx.history.clear()
except Exception: # noqa: BLE001
pass
return 0
def _queue_prompt(ctx: Any, prompt: str) -> bool:
"""Stash ``prompt`` on ``ctx.pending_prompt`` for the loop to consume.
Returns ``True`` if the context exposes a writable ``pending_prompt``
attribute, ``False`` otherwise (e.g. a tiny test stub) — the caller
falls back to printing the canonical instruction in that case.
"""
if ctx is None or not hasattr(ctx, "pending_prompt"):
return False
try:
ctx.pending_prompt = prompt
return True
except Exception: # noqa: BLE001 — never crash the slash dispatch
return False
def cmd_journal(ctx: Any = None, *args: str) -> int:
"""Queue an "analyze my trade journal at <path>" agent turn.
With a path: ``/journal trades.csv`` becomes the prompt
``Analyze my trade journal at trades.csv`` which is queued on
``ctx.pending_prompt`` and executed by the interactive loop on the
next tick. Without a path the command prints the canonical
instruction so the user can paste it themselves.
"""
console = _resolve_console()
path = " ".join(args).strip()
if not path:
body = Text()
body.append("Usage: ", style="dim")
body.append("/journal <path-to-csv>\n", style="bold")
body.append("Example: ", style="dim")
body.append('/journal ~/Downloads/journal.csv\n\n', style="bold")
body.append("Or type the prompt directly: ", style="dim")
body.append('"analyze my trade journal at <path>"', style="bold")
console.print(Panel(body, title="/journal", border_style="dim", padding=(1, 2)))
return 0
prompt = f"Analyze my trade journal at {path}"
if _queue_prompt(ctx, prompt):
console.print(Text(f"→ Running: {prompt}", style="dim"))
return 0
# Fallback when the context does not support queuing (legacy callers).
console.print(Text(f'Type: "{prompt}"', style="bold"))
return 0
def cmd_shadow(ctx: Any = None, *args: str) -> int:
"""Queue a Shadow Account agent turn.
``/shadow`` opens / inspects the shadow account. ``/shadow <path>``
trains a new shadow from a trade journal at that path. Both forms
queue the canonical natural-language prompt on ``ctx.pending_prompt``
so the ReAct loop picks the right tool (``extract_shadow_strategy``,
``run_shadow_backtest``, ``render_shadow_report``).
"""
console = _resolve_console()
path = " ".join(args).strip()
if path:
prompt = f"Train a shadow account from my trade journal at {path}"
else:
prompt = "Open the shadow account dashboard and show the latest report"
if _queue_prompt(ctx, prompt):
console.print(Text(f"→ Running: {prompt}", style="dim"))
return 0
console.print(Text(f'Type: "{prompt}"', style="bold"))
return 0
def cmd_swarm(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Dispatch to the legacy swarm handler."""
try:
from cli._legacy import _handle_swarm_command
_handle_swarm_command(" ".join(args))
return 0
except Exception as exc: # noqa: BLE001
console = _resolve_console()
console.print(Text(f"/swarm failed: {exc}", style="bold red"))
return 1
def cmd_debug(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Toggle the debug summary that prints after each agent turn.
When ON the interactive loop appends a single muted line after every
turn containing ``iter``, ``tools``, ``elapsed``, and an approximate
context-size estimate — see ``_print_debug_summary`` in
:mod:`cli.main`. When OFF the summary is suppressed.
"""
console = _resolve_console()
if ctx is not None and hasattr(ctx, "debug"):
try:
ctx.debug = not bool(getattr(ctx, "debug", False))
state = "ON" if ctx.debug else "OFF"
console.print(Text(f"Debug summary: {state}", style="bold"))
if ctx.debug:
console.print(
Text(
"After each turn a one-line summary will print: "
"iterations, tool count, elapsed, approx context tokens.",
style="dim",
)
)
return 0
except Exception as exc: # noqa: BLE001
console.print(Text(f"/debug toggle failed: {exc}", style="bold red"))
return 1
return _coming_soon(
"debug",
hint="set `VIBE_TRADING_DEBUG=1` and restart for verbose logging.",
)
def cmd_quit(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Signal exit by returning a distinct exit code.
The interactive loop interprets ``2`` as "user-requested quit" and
performs session persistence + final farewell before terminating.
"""
return 2
# --- Module-level dispatch table -----------------------------------------
_DISPATCH = {
"model": cmd_model,
"clear": cmd_clear,
"journal": cmd_journal,
"shadow": cmd_shadow,
"swarm": cmd_swarm,
"debug": cmd_debug,
"quit": cmd_quit,
}
def run(ctx: Any = None, command: str = "model", *args: str) -> int:
"""Dispatch ``run("model")`` / ``run("clear")`` / ... to the right handler."""
handler = _DISPATCH.get(command)
if handler is None:
console = _resolve_console()
console.print(Text(f"Unknown chat command: /{command}", style="bold red"))
return 1
return handler(ctx, *args)
__all__ = [
"run",
"cmd_model",
"cmd_clear",
"cmd_journal",
"cmd_shadow",
"cmd_swarm",
"cmd_debug",
"cmd_quit",
]
+402
View File
@@ -0,0 +1,402 @@
"""``/goal`` — manage the current finance research goal from the CLI."""
from __future__ import annotations
import os
from typing import Any
from rich import box
from rich.console import Console
from rich.console import Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from cli.theme import get_console
from src.goal.context import default_goal_criteria
_goal_store = None
def _resolve_console() -> Console:
"""Return the shared CLI console."""
return get_console()
def _get_goal_store():
"""Return the shared goal store, lazily initialized."""
global _goal_store
if _goal_store is None:
from src.goal import GoalStore
_goal_store = GoalStore()
return _goal_store
def _default_criteria() -> list[str]:
"""Return the MVP finance protocol checklist."""
return default_goal_criteria()
def _criterion_is_covered(criterion: dict, evidence: list[dict]) -> bool:
"""Return whether a criterion has completion status or attached evidence."""
status = str(criterion.get("status") or "").lower()
if status not in {"", "pending", "open", "unsatisfied"}:
return True
criterion_id = criterion.get("criterion_id")
return any(item.get("criterion_id") == criterion_id for item in evidence)
def _create_cli_session(ctx: Any, title: str) -> str | None:
"""Create a normal CLI session when /goal is used before the first turn."""
try:
from cli._legacy import SESSIONS_DIR
from src.session.models import Session, SessionStatus
from src.session.search import get_shared_index
from src.session.store import SessionStore
session = Session(
title=(title[:60] or "Goal research"),
status=SessionStatus.ACTIVE,
)
SessionStore(base_dir=SESSIONS_DIR).create_session(session)
get_shared_index().index_session(session.session_id, session.title)
if ctx is not None:
setattr(ctx, "session_id", session.session_id)
return session.session_id
except Exception: # noqa: BLE001
return None
def _session_id(ctx: Any, *, title: str = "Goal research", create: bool) -> str | None:
"""Return the active CLI session id, optionally creating one."""
existing = getattr(ctx, "session_id", None)
if existing:
return str(existing)
from src.config.accessor import get_env_config
env_session_id = get_env_config().paths.vibe_goal_session_id
if env_session_id:
return env_session_id
if not create:
return None
created = _create_cli_session(ctx, title)
if created:
return created
return "cli-default"
def _render_snapshot(snapshot: dict, *, title: str = "/goal") -> None:
"""Render a compact goal card."""
console = _resolve_console()
goal = snapshot["goal"]
criteria = snapshot.get("criteria") or []
evidence = snapshot.get("evidence") or []
evidence_count = int(snapshot.get("evidence_count", len(evidence)))
covered = sum(1 for item in criteria if _criterion_is_covered(item, evidence))
total = len(criteria)
meta = Table.grid(padding=(0, 2))
meta.add_column(style="dim", no_wrap=True)
meta.add_column(ratio=1)
meta.add_column(style="dim", no_wrap=True)
meta.add_column(no_wrap=True)
meta.add_row("goal", str(goal["objective"]), "status", f"[green]{goal['status']}[/green]")
meta.add_row("id", str(goal["goal_id"]), "progress", f"[cyan]{covered}/{total}[/cyan]")
meta.add_row("evidence", str(evidence_count), "protocol", str(goal.get("protocol", "thesis_review")))
criteria_table = Table(
box=box.SIMPLE_HEAVY,
show_lines=False,
expand=True,
padding=(0, 1),
)
criteria_table.add_column("#", style="dim", no_wrap=True, width=3)
criteria_table.add_column("Criterion", ratio=1)
criteria_table.add_column("Status", no_wrap=True)
criteria_table.add_column("Evidence", no_wrap=True)
for index, criterion in enumerate(criteria, start=1):
criterion_id = criterion["criterion_id"]
criterion_evidence = [item for item in evidence if item.get("criterion_id") == criterion_id]
is_covered = _criterion_is_covered(criterion, evidence)
status = "[green]covered[/green]" if is_covered else "[yellow]pending[/yellow]"
evidence_label = (
f"{len(criterion_evidence)} item{'s' if len(criterion_evidence) != 1 else ''}"
if criterion_evidence
else "[dim]needed[/dim]"
)
criteria_table.add_row(
str(index),
str(criterion["text"]),
status,
evidence_label,
)
if not criteria:
criteria_table.add_row("-", "[dim](none)[/dim]", "[dim]pending[/dim]", "[dim]needed[/dim]")
next_steps = Text()
next_steps.append("Next ", style="dim")
next_steps.append("/goal status", style="bold")
if criteria:
next_steps.append(" ")
next_steps.append("/goal evidence <#> <note>", style="bold")
console.print(
Panel(
Group(meta, criteria_table, next_steps),
title=title,
border_style="cyan",
padding=(1, 2),
)
)
def cmd_status(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Show the current goal snapshot."""
session_id = _session_id(ctx, create=False)
if session_id is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> to start one.", style="dim"))
return 0
snapshot = _get_goal_store().get_current_snapshot(session_id)
if snapshot is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> to start one.", style="dim"))
return 0
_render_snapshot(snapshot)
return 0
def cmd_start(ctx: Any = None, *args: str) -> int:
"""Start or replace the current research goal."""
objective = " ".join(args).strip()
if not objective:
_resolve_console().print(Text("Usage: /goal <research objective>", style="bold red"))
return 1
session_id = _session_id(ctx, title=objective, create=True)
if session_id is None:
_resolve_console().print(Text("Could not create or resolve a session for /goal.", style="bold red"))
return 1
try:
goal = _get_goal_store().replace_goal(
session_id=session_id,
objective=objective,
criteria=_default_criteria(),
source="cli",
protocol="thesis_review",
)
except ValueError as exc:
_resolve_console().print(Text(f"/goal failed: {exc}", style="bold red"))
return 1
snapshot = _get_goal_store().get_goal_snapshot(goal.goal_id)
if snapshot is None:
_resolve_console().print(Text("Goal created but could not be reloaded.", style="bold red"))
return 1
_render_snapshot(snapshot, title="/goal started")
return 0
def _resolve_criterion_id(snapshot: dict, token: str) -> str:
"""Resolve a criterion by 1-based index, exact id, or id prefix."""
criteria = snapshot.get("criteria") or []
if token.isdigit():
index = int(token)
if 1 <= index <= len(criteria):
return str(criteria[index - 1]["criterion_id"])
raise ValueError(f"criterion index out of range: {token}")
matches = [
item["criterion_id"]
for item in criteria
if item["criterion_id"] == token or str(item["criterion_id"]).startswith(token)
]
if len(matches) == 1:
return str(matches[0])
if not matches:
raise ValueError(f"unknown criterion: {token}")
raise ValueError(f"ambiguous criterion prefix: {token}")
def cmd_evidence(ctx: Any = None, *args: str) -> int:
"""Append a manual evidence note to the current goal."""
if len(args) < 2:
_resolve_console().print(Text("Usage: /goal evidence <criterion-index-or-id> <note>", style="bold red"))
return 1
session_id = _session_id(ctx, create=False)
if session_id is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="bold red"))
return 1
snapshot = _get_goal_store().get_current_snapshot(session_id)
if snapshot is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="bold red"))
return 1
criterion_token, *text_parts = args
text = " ".join(text_parts).strip()
if not text:
_resolve_console().print(Text("Evidence note cannot be empty.", style="bold red"))
return 1
try:
criterion_id = _resolve_criterion_id(snapshot, criterion_token)
from src.goal import EvidenceInput
_get_goal_store().append_evidence(
session_id=session_id,
goal_id=snapshot["goal"]["goal_id"],
expected_goal_id=snapshot["goal"]["goal_id"],
evidence=EvidenceInput(
criterion_id=criterion_id,
text=text,
source_provider="cli",
source_type="manual_note",
),
)
except Exception as exc: # noqa: BLE001
_resolve_console().print(Text(f"/goal evidence failed: {exc}", style="bold red"))
return 1
updated = _get_goal_store().get_goal_snapshot(snapshot["goal"]["goal_id"])
if updated is not None:
_render_snapshot(updated, title="/goal evidence added")
return 0
def cmd_cancel(ctx: Any = None, *args: str) -> int:
"""Cancel the current research goal."""
session_id = _session_id(ctx, create=False)
if session_id is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
return 0
snapshot = _get_goal_store().get_current_snapshot(session_id)
if snapshot is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
return 0
recap = " ".join(args).strip() or "Cancelled from CLI."
try:
from src.goal import GoalStatus
updated = _get_goal_store().update_status(
session_id=session_id,
goal_id=snapshot["goal"]["goal_id"],
expected_goal_id=snapshot["goal"]["goal_id"],
status=GoalStatus.CANCELLED,
recap=recap,
)
except Exception as exc: # noqa: BLE001
_resolve_console().print(Text(f"/goal cancel failed: {exc}", style="bold red"))
return 1
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
if terminal_snapshot is not None:
_render_snapshot(terminal_snapshot, title="/goal cancelled")
return 0
def cmd_complete(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Complete the current research goal after auditing verified evidence."""
session_id = _session_id(ctx, create=False)
if session_id is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
return 0
snapshot = _get_goal_store().get_current_snapshot(session_id)
if snapshot is None:
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
return 0
evidence = snapshot.get("evidence") or []
audit_rows = []
for criterion in snapshot.get("criteria") or []:
criterion_evidence = [
item
for item in evidence
if item.get("criterion_id") == criterion["criterion_id"]
and item.get("verification_status") == "verified"
]
if not criterion_evidence:
_resolve_console().print(
Text(
"Cannot complete: every criterion needs verified run/artifact evidence.",
style="bold red",
)
)
return 1
from src.goal import AuditRow
audit_rows.append(
AuditRow(
criterion_id=criterion["criterion_id"],
result="satisfied",
evidence_ids=[item["evidence_id"] for item in criterion_evidence],
notes="Verified evidence attached from CLI audit.",
)
)
try:
from src.goal import GoalStatus
updated = _get_goal_store().update_status(
session_id=session_id,
goal_id=snapshot["goal"]["goal_id"],
expected_goal_id=snapshot["goal"]["goal_id"],
status=GoalStatus.COMPLETE,
audit=audit_rows,
recap=" ".join(args).strip() or "Completed from CLI audit.",
)
except Exception as exc: # noqa: BLE001
_resolve_console().print(Text(f"/goal complete failed: {exc}", style="bold red"))
return 1
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
if terminal_snapshot is not None:
_render_snapshot(terminal_snapshot, title="/goal completed")
return 0
def cmd_help() -> int:
"""Render /goal usage."""
body = Text()
body.append("/goal <objective>", style="bold")
body.append(" start or replace the current research goal\n", style="dim")
body.append("/goal status", style="bold")
body.append(" show the current goal snapshot\n", style="dim")
body.append("/goal evidence <criterion-index-or-id> <note>", style="bold")
body.append(" append manual evidence\n", style="dim")
body.append("/goal complete [recap]", style="bold")
body.append(" complete after verified evidence audit\n", style="dim")
body.append("/goal cancel [recap]", style="bold")
body.append(" cancel the current goal\n", style="dim")
_resolve_console().print(Panel(body, title="/goal", border_style="dim", padding=(1, 2)))
return 0
def run(ctx: Any = None, *args: str) -> int:
"""Dispatch /goal subcommands."""
if not args:
return cmd_status(ctx)
command = args[0].lower()
if command in {"help", "-h", "--help"}:
return cmd_help()
if command in {"status", "show"}:
return cmd_status(ctx, *args[1:])
if command == "start":
return cmd_start(ctx, *args[1:])
if command == "evidence":
return cmd_evidence(ctx, *args[1:])
if command == "complete":
return cmd_complete(ctx, *args[1:])
if command in {"cancel", "cancelled"}:
return cmd_cancel(ctx, *args[1:])
return cmd_start(ctx, *args)
__all__ = [
"run",
"cmd_start",
"cmd_status",
"cmd_evidence",
"cmd_complete",
"cmd_cancel",
]
+64
View File
@@ -0,0 +1,64 @@
"""``/help`` — Show the slash command list and keyboard shortcuts.
Renders ``SLASH_COMMANDS`` as a two-column Rich table (name in primary
color, description muted) plus a short keyboard-shortcuts panel.
"""
from __future__ import annotations
from typing import Any
from rich.console import Console
from rich.table import Table
from rich.text import Text
from .slash_router import SLASH_COMMANDS
def _resolve_console() -> Console:
"""Return the shared CLI console."""
from cli.theme import get_console
return get_console()
_SHORTCUTS: tuple[tuple[str, str], ...] = (
("⏎", "Send"),
("Shift+⏎", "Newline"),
("Tab", "Accept completion"),
("↑/↓", "Browse history (or navigate completions)"),
("Ctrl+C", "Clear input · then exit hint"),
("Ctrl+D", "Exit (auto-saves session)"),
("/", "Open slash command typeahead"),
)
def run(ctx: Any = None, *args: str) -> int: # noqa: ARG001 — ctx unused here
"""Print the help screen. Always returns 0."""
console = _resolve_console()
commands_table = Table.grid(padding=(0, 2))
commands_table.add_column(style="bold #d97706", no_wrap=True)
commands_table.add_column(style="dim")
for cmd in SLASH_COMMANDS:
commands_table.add_row(f"/{cmd.name}", cmd.description)
console.print()
console.print(Text("Commands", style="bold"))
console.print(commands_table)
console.print()
shortcuts_table = Table.grid(padding=(0, 2))
shortcuts_table.add_column(style="bold", no_wrap=True)
shortcuts_table.add_column(style="dim")
for key, desc in _SHORTCUTS:
shortcuts_table.add_row(key, desc)
console.print(Text("Keyboard", style="bold"))
console.print(shortcuts_table)
console.print()
return 0
__all__ = ["run"]
+67
View File
@@ -0,0 +1,67 @@
"""``/memory`` — list / show persistent memory snippets.
Delegates to the legacy ``cmd_memory_list`` / ``cmd_memory_show`` /
``cmd_memory_search`` callables. ``/memory`` with no args lists; with
a single arg shows one; with ``search <q>`` runs FTS; with
``forget <name>`` deletes one.
"""
from __future__ import annotations
from typing import Any
from rich.console import Console
from rich.text import Text
from cli.theme import get_console
def _resolve_console() -> Console:
"""Return the shared CLI console."""
return get_console()
def cmd_memory(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Dispatch sub-actions:
* ``/memory`` → list
* ``/memory <name>`` → show
* ``/memory search <q>`` → full-text search
* ``/memory forget <name>``→ delete a snippet
"""
console = _resolve_console()
if not args:
try:
from cli._legacy import cmd_memory_list
return cmd_memory_list()
except Exception as exc: # noqa: BLE001
console.print(Text(f"/memory list failed: {exc}", style="bold red"))
return 1
sub = args[0]
rest = args[1:]
try:
if sub == "search" and rest:
from cli._legacy import cmd_memory_search
return cmd_memory_search(" ".join(rest))
if sub == "forget" and rest:
from cli._legacy import cmd_memory_forget
return cmd_memory_forget(rest[0], yes=False)
from cli._legacy import cmd_memory_show
return cmd_memory_show(sub)
except Exception as exc: # noqa: BLE001
console.print(Text(f"/memory failed: {exc}", style="bold red"))
return 1
def run(ctx: Any = None, *args: str) -> int:
"""Single-entrypoint wrapper for the slash router."""
return cmd_memory(ctx, *args)
__all__ = ["run", "cmd_memory"]
+98
View File
@@ -0,0 +1,98 @@
"""``/history``, ``/search``, ``/export`` — session lifecycle commands.
* ``/history`` lists recent sessions (legacy ``cmd_sessions``).
* ``/search <query>`` runs FTS5 across the session store via
:class:`src.session.search.SessionSearchIndex`.
* ``/export`` is a placeholder — the rich export lives in the Web bubble.
"""
from __future__ import annotations
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from cli.theme import get_console
def _resolve_console() -> Console:
"""Return the shared CLI console."""
return get_console()
def cmd_history(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""List recent sessions via legacy ``cmd_sessions``."""
console = _resolve_console()
try:
from cli._legacy import cmd_sessions
cmd_sessions()
return 0
except Exception as exc: # noqa: BLE001
console.print(Text(f"/history failed: {exc}", style="bold red"))
return 1
def cmd_search(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Full-text search across recorded sessions."""
console = _resolve_console()
if not args:
console.print(Text("Usage: /search <query>", style="bold red"))
return 1
query = " ".join(args)
try:
from src.session.search import get_shared_index
index = get_shared_index()
matches = index.search(query)
except Exception as exc: # noqa: BLE001
console.print(Text(f"/search failed: {exc}", style="bold red"))
return 1
if not matches:
console.print(Text(f"No matches for '{query}'.", style="dim"))
return 0
for hit in matches:
line = Text()
line.append(str(hit.session_id), style="bold")
line.append(" ")
line.append(hit.title, style="dim")
line.append(" ")
line.append(hit.snippet or "", style="dim")
console.print(line)
return 0
def cmd_export(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""Placeholder until the rich export lands."""
console = _resolve_console()
body = Text()
body.append("/export is not yet wired up to the interactive CLI.\n\n", style="dim")
body.append("Until then: ", style="dim")
body.append("the web UI exports md/json from the message footer.", style="bold")
console.print(Panel(body, title="/export", border_style="dim", padding=(1, 2)))
return 0
_DISPATCH = {
"history": cmd_history,
"search": cmd_search,
"export": cmd_export,
}
def run(ctx: Any = None, command: str = "history", *args: str) -> int:
"""Dispatch ``run("history")`` / ``run("search", ...)`` / ``run("export")``."""
handler = _DISPATCH.get(command)
if handler is None:
console = _resolve_console()
console.print(Text(f"Unknown session command: /{command}", style="bold red"))
return 1
return handler(ctx, *args)
__all__ = ["run", "cmd_history", "cmd_search", "cmd_export"]
+86
View File
@@ -0,0 +1,86 @@
"""``/show``, ``/skill``, ``/pine`` — read-only inspect commands.
All three already exist as fully-featured ``cmd_*`` callables in
``cli._legacy``; this module is a thin shim so the slash router can
dispatch by command name without importing the legacy module top-level
(which is heavy — it pulls in providers, swarm, etc.).
"""
from __future__ import annotations
from typing import Any
from rich.console import Console
from rich.text import Text
from cli.theme import get_console
def _resolve_console() -> Console:
"""Return the shared CLI console."""
return get_console()
def cmd_show(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""``/show <run_id>`` — replay a prior run via the legacy handler."""
console = _resolve_console()
if not args:
console.print(Text("Usage: /show <run_id>", style="bold red"))
return 1
try:
from cli._legacy import cmd_show as legacy_show
legacy_show(args[0])
return 0
except Exception as exc: # noqa: BLE001
console.print(Text(f"/show failed: {exc}", style="bold red"))
return 1
def cmd_skill(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""``/skill`` — list bundled + user skills (legacy ``cmd_skills``)."""
console = _resolve_console()
try:
from cli._legacy import cmd_skills
cmd_skills()
return 0
except Exception as exc: # noqa: BLE001
console.print(Text(f"/skill failed: {exc}", style="bold red"))
return 1
def cmd_pine(ctx: Any = None, *args: str) -> int: # noqa: ARG001
"""``/pine <run_id>`` — emit Pine Script for a prior backtest."""
console = _resolve_console()
if not args:
console.print(Text("Usage: /pine <run_id>", style="bold red"))
return 1
try:
from cli._legacy import cmd_pine as legacy_pine
legacy_pine(args[0])
return 0
except Exception as exc: # noqa: BLE001
console.print(Text(f"/pine failed: {exc}", style="bold red"))
return 1
_DISPATCH = {
"show": cmd_show,
"skill": cmd_skill,
"pine": cmd_pine,
}
def run(ctx: Any = None, command: str = "show", *args: str) -> int:
"""Dispatch ``run("show", run_id)`` / ``run("skill")`` etc."""
handler = _DISPATCH.get(command)
if handler is None:
console = _resolve_console()
console.print(Text(f"Unknown show command: /{command}", style="bold red"))
return 1
return handler(ctx, *args)
__all__ = ["run", "cmd_show", "cmd_skill", "cmd_pine"]
+156
View File
@@ -0,0 +1,156 @@
"""Slash command registry + fuzzy matcher.
Source of truth for the 15 user-facing slash commands defined in
``docs/2026-05-19_session02_uiux_design_proposal.md`` §3.4.
Each :class:`Command` is a frozen dataclass — registry entries are
immutable so callers can cache filtered slices without worrying about
mutation. ``match_commands`` powers the typeahead in
:mod:`agent.cli.completer`.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Command:
"""A single slash command entry.
Attributes:
name: The command keyword, without leading ``/``.
description: One-line muted description shown in the completion menu.
handler_module: Dotted path to the module exposing ``run(ctx, *args)``.
"""
name: str
description: str
handler_module: str
# Order here = order shown in bare ``/`` typeahead. Group by frequency so the
# most-used commands surface first (help/model/memory/history are top).
SLASH_COMMANDS: tuple[Command, ...] = (
Command("help", "Show keyboard shortcuts and command list", "cli.commands.help"),
Command("model", "Switch LLM provider and model", "cli.commands.chat"),
Command("memory", "Show / manage persistent memory", "cli.commands.memory"),
Command("history", "Browse and resume prior sessions", "cli.commands.session"),
Command("goal", "Start / inspect a finance research goal", "cli.commands.goal"),
Command("search", "Full-text search across all sessions", "cli.commands.session"),
Command("swarm", "Multi-agent presets (committee / quant / risk)", "cli.commands.chat"),
Command("skill", "List / load / unload skills", "cli.commands.show"),
Command("show", "Show prior run by id", "cli.commands.show"),
Command("clear", "Clear current conversation", "cli.commands.chat"),
Command("pine", "Export current strategy as Pine Script", "cli.commands.show"),
Command("journal", "Analyze trade journal CSV", "cli.commands.chat"),
Command("shadow", "Train / view shadow account", "cli.commands.chat"),
Command("export", "Export current session (md / json)", "cli.commands.session"),
Command("debug", "Toggle debug panel (token usage / latency)", "cli.commands.chat"),
Command("quit", "Exit (also: q, exit, :q)", "cli.commands.chat"),
)
# Aliases — same handler, different surface keyword. Keep separate from the
# main registry so typeahead does not duplicate rows.
_ALIASES: dict[str, str] = {
"q": "quit",
"exit": "quit",
":q": "quit",
"?": "help",
}
def _parse_token(input_text: str) -> str:
"""Strip the leading ``/`` and isolate the command token.
>>> _parse_token("/me arg")
'me'
>>> _parse_token("/")
''
>>> _parse_token("not a slash")
''
"""
text = input_text.lstrip()
if not text.startswith("/"):
return ""
# ``/foo bar`` → ``foo``. Use ``split(None, 1)`` to handle any whitespace.
parts = text[1:].split(None, 1)
return parts[0] if parts else ""
def _fuzzy_score(needle: str, name: str) -> int:
"""Return a heuristic score for ``needle`` matching ``name``.
Higher = better. ``0`` means no match.
Scoring order (boundary-friendly, like dexter's slash matcher):
prefix match → 100 + length bonus
substring match → 50 + length bonus
subsequence match (chars in order) → 10 + chars matched
otherwise → 0
"""
if not needle:
return 1 # bare ``/`` shows everything in registry order
needle_l = needle.lower()
name_l = name.lower()
if name_l.startswith(needle_l):
return 100 + len(needle_l)
if needle_l in name_l:
return 50 + len(needle_l)
# subsequence match: every char of needle appears in order inside name
j = 0
for ch in name_l:
if j < len(needle_l) and ch == needle_l[j]:
j += 1
if j == len(needle_l):
return 10 + j
return 0
def match_commands(input_text: str) -> list[Command]:
"""Return commands that fuzzy-match ``input_text``.
Only matches when the trimmed input starts with ``/``. The leading slash
and any trailing arguments are stripped before scoring; only the command
token participates in the match.
Result is ordered best-match first, ties broken by registry order.
Args:
input_text: Raw input line, possibly empty.
Returns:
A new list — callers may freely mutate without side effects.
"""
token = _parse_token(input_text)
if not input_text.lstrip().startswith("/"):
return []
# Resolve aliases up front so ``/q`` shows the ``quit`` row.
if token in _ALIASES:
token = _ALIASES[token]
scored: list[tuple[int, int, Command]] = []
for idx, cmd in enumerate(SLASH_COMMANDS):
score = _fuzzy_score(token, cmd.name)
if score > 0:
# Negative idx so ties prefer earlier registry position when
# sorted descending by score.
scored.append((score, -idx, cmd))
scored.sort(reverse=True)
return [cmd for _score, _idx, cmd in scored]
def find_exact(name: str) -> Command | None:
"""Resolve an exact command name or alias.
Returns ``None`` if no match — callers handle the "unknown command"
response themselves so error UX stays consistent.
"""
key = name.lstrip("/").strip()
key = _ALIASES.get(key, key)
for cmd in SLASH_COMMANDS:
if cmd.name == key:
return cmd
return None
+115
View File
@@ -0,0 +1,115 @@
"""FuzzyCompleter for slash commands.
Returns completions only when the line starts with ``/`` so normal prose
typing is not polluted by suggestions. Each completion advertises the
command description in muted style with the command name in the brand
primary color (sourced from :mod:`agent.cli.theme` when available; falls
back to ``ansibrightyellow`` so the file is importable before Parcel α
ships ``theme.py``).
"""
from __future__ import annotations
from typing import Iterable
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import FormattedText
from .commands.slash_router import SLASH_COMMANDS, Command, match_commands
def _primary_style() -> str:
"""Resolve the brand-orange ``style`` string for prompt_toolkit.
Rich and prompt_toolkit do not share a style grammar, so we read the
brand hex from :mod:`cli.theme` (the single source of truth) and
rewrite it for prompt_toolkit's ``fg:#xxxxxx`` syntax. NO_COLOR users
pick the no-op style so completions remain visible without color.
"""
try:
from cli.theme import Theme
brand = getattr(Theme, "brand_hex", None)
if isinstance(brand, str) and brand:
return f"fg:{brand} bold"
except Exception: # noqa: BLE001 — never block typeahead on theme import
pass
# Fallback approximates brand orange in 256-color terminals.
return "fg:#d97706 bold"
def _muted_style() -> str:
"""Resolve the muted ``style`` string for prompt_toolkit descriptions."""
return "fg:#9ca3af"
class SlashCompleter(Completer):
"""prompt_toolkit completer that fuzzy-matches the slash registry.
Behaviour mirrors dexter's slash typeahead: bare ``/`` lists everything,
``/m`` filters by prefix/substring/subsequence. Completions are emitted
only when the cursor sits on the first token of the line — once the user
types ``/help `` (with a trailing space) we get out of the way so they
can fill in arguments without the menu re-popping.
"""
def __init__(self, commands: Iterable[Command] = SLASH_COMMANDS) -> None:
# Snapshot the registry; commands is a tuple of frozen dataclasses
# so the snapshot itself is effectively immutable.
self._commands = tuple(commands)
# ----------------------------------------------------------------- API
def get_completions(
self,
document: Document,
complete_event: CompleteEvent,
) -> Iterable[Completion]:
text = document.text_before_cursor
stripped = text.lstrip()
# Bail unless the line starts with ``/`` AND we are still typing the
# command token (no space yet). This means ``/help <enter args>``
# stops triggering completions once the user moves past the keyword.
if not stripped.startswith("/"):
return
# Find the slash position (the leading whitespace is irrelevant once
# we have confirmed it leads with ``/``).
slash_idx = text.index("/")
token_zone = text[slash_idx + 1 :]
if " " in token_zone or "\t" in token_zone:
return
matches = match_commands(stripped)
if not matches:
return
# Compute the replacement window so the chosen completion overwrites
# whatever the user typed after ``/``.
start_position = -len(token_zone)
# Right-pad each command name so the description column lines up.
name_width = max((len(c.name) for c in matches), default=0) + 2
primary = _primary_style()
muted = _muted_style()
for cmd in matches:
padded_name = cmd.name.ljust(name_width)
display: FormattedText = FormattedText(
[
(primary, f"/{padded_name}"),
(muted, cmd.description),
]
)
# ``text`` is the value inserted on accept (no leading slash —
# the slash is already in the buffer at ``slash_idx``).
yield Completion(
text=cmd.name,
start_position=start_position,
display=display,
display_meta=cmd.description,
)
__all__ = ["SlashCompleter"]
+18
View File
@@ -0,0 +1,18 @@
"""Reusable CLI render components.
Each component returns a Rich renderable (Text / Panel / Table) so callers
can ``console.print`` or stuff it into a Live group.
"""
from .chat_log import render_history
from .hint_bar import render_hint_bar
from .tool_event import render_tool_event, render_tool_events
from .working_indicator import ThinkingSpinner
__all__ = [
"ThinkingSpinner",
"render_history",
"render_hint_bar",
"render_tool_event",
"render_tool_events",
]
+77
View File
@@ -0,0 +1,77 @@
"""Minimal chat-log renderer — stub for the demo.
Iterates a list of turn dicts and prints user / assistant lines with the
expected meta header. Defer the rich features (collapsible tool
timeline, markdown rendering, action footer) to post-demo work — they
belong in the Web bubble (Parcel C / D), not in the CLI replay.
Turn shape:
{
"role": "user" | "assistant",
"content": str,
"timestamp": str | None, # ISO or formatted clock
"meta": str | None, # "Vibe · 4.1s · 1.2k tokens · $0.003"
}
"""
from __future__ import annotations
from typing import Iterable, Mapping, Optional
from rich.console import Console
from rich.text import Text
def _resolve_console(console: Optional[Console]) -> Console:
"""Return ``console`` if given, else the shared CLI console."""
if console is not None:
return console
from cli.theme import get_console
return get_console()
def _render_turn(turn: Mapping[str, object]) -> Text:
"""Compose the meta header line for one turn."""
role = str(turn.get("role", "user"))
timestamp = turn.get("timestamp")
meta = turn.get("meta")
header = Text()
if role == "assistant":
# "Vibe" is the brand wordmark for assistant turns — primary color
header.append("Vibe", style="bold #d97706")
else:
header.append("you", style="bold")
if timestamp:
header.append(f" {timestamp}", style="dim")
if meta:
header.append(f" · {meta}", style="dim")
return header
def render_history(
turns: Iterable[Mapping[str, object]],
*,
console: Optional[Console] = None,
) -> None:
"""Print past turns to ``console``.
Args:
turns: Iterable of turn dicts (see module docstring for shape).
console: Override; defaults to the shared CLI console.
No return value — this is a side-effecting renderer.
"""
cons = _resolve_console(console)
for turn in turns:
cons.print(_render_turn(turn))
content = str(turn.get("content", ""))
if content:
# Body intentionally renders as plain text — markdown / katex
# / syntax highlighting live in the Web bubble. The CLI keeps
# the body monospaced for diff-friendly replay.
cons.print(content)
cons.print() # blank line between turns
__all__ = ["render_history"]
+67
View File
@@ -0,0 +1,67 @@
"""Bottom hint bar — left + right aligned.
Renders a single-line hint where the right-aligned segment hugs the
terminal edge regardless of left-segment width. Mirrors dexter's
``hint-bar.ts``.
Used to surface keyboard guidance ("Tab to complete · /help for
commands") and ephemeral state ("input cleared", "cancelled").
"""
from __future__ import annotations
import shutil
from rich.text import Text
def _terminal_width(default: int = 80) -> int:
"""Best-effort terminal width detection (falls back gracefully)."""
try:
size = shutil.get_terminal_size((default, 20))
return max(20, size.columns)
except (OSError, ValueError):
return default
def render_hint_bar(left: str, right: str = "", *, width: int | None = None) -> Text:
"""Return a Rich :class:`Text` row with ``left`` and right-aligned ``right``.
The bar always fits the terminal width:
* If ``left + right`` fits, pad with spaces between them
* Otherwise truncate the left segment with an ellipsis so the
right hint remains visible (right-side hint usually carries
the *current* Ctrl+C semantics — losing it is worse than
losing context)
Args:
left: Left segment (e.g. ``"↑/↓ navigate · Tab/⏎ select"``).
right: Right segment (e.g. ``"Esc to cancel"``); may be empty.
width: Override terminal width. Defaults to autodetect.
"""
cols = width if width is not None else _terminal_width()
if right == "":
# Only a left hint — clip and pad to width, dim style throughout.
text = Text(left[:cols], style="dim")
return text
# Reserve at least one space between left and right segments.
available_for_left = cols - len(right) - 1
if available_for_left < 1:
# Right segment alone is wider than the terminal — show right only.
return Text(right[: cols].rjust(cols), style="dim")
if len(left) > available_for_left:
left = left[: max(1, available_for_left - 1)].rstrip() + "…"
padding = cols - len(left) - len(right)
if padding < 1:
padding = 1
bar = Text()
bar.append(left, style="dim")
bar.append(" " * padding)
bar.append(right, style="dim")
return bar
__all__ = ["render_hint_bar"]
+218
View File
@@ -0,0 +1,218 @@
"""Single tool invocation render.
Layout (matches design_proposal §3.5):
● Get Financials ("AAPL", quarterly, last 8 quarters) 1.4s · 8 quarters
* ``●`` (U+25CF black circle) marker, color-coded by status:
running → ``--warning`` amber (pulse via Rich style ``blink`` when
the terminal advertises it, otherwise solid amber)
ok → ``--success`` green, solid
error → ``--danger`` red, solid
* Tool name pretty-printed (``get_financials`` → ``Get Financials``)
* Args summary prefers ``query`` then ``prompt``, else the first 2
``key=value`` pairs each truncated to 40 chars
* Duration formatted via :func:`agent.cli.utils.format.format_duration`
when Parcel α has shipped it; otherwise a local fallback is used
"""
from __future__ import annotations
from typing import Any, Iterable, Literal, Mapping
from rich.text import Text
Status = Literal["running", "ok", "error"]
# Status → (Rich style, marker glyph). The glyph stays the same; the style
# encodes color + decoration (bold/blink). dexter only changes brightness
# of the marker rather than swapping glyphs.
_STATUS_STYLE: dict[Status, str] = {
# ``blink`` is gracefully ignored by Rich when the terminal does not
# advertise the capability, so we always include it.
"running": "bold blink #f59e0b", # amber, F-002 warning
"ok": "bold #16a34a", # green, F-002 success
"error": "bold #dc2626", # red, F-002 danger
}
_ARG_VALUE_MAX = 40
# ---------------------------------------------------------------- helpers ----
def _pretty_tool_name(name: str) -> str:
"""Convert ``get_financials`` to ``Get Financials``.
Strips a leading ``get_`` prefix when present (dexter convention).
Underscores and hyphens become spaces; each whitespace-separated
token is title-cased. Acronyms preserved when fully upper.
"""
cleaned = name.strip()
if cleaned.lower().startswith("get_"):
cleaned = cleaned[4:]
tokens = cleaned.replace("-", " ").replace("_", " ").split()
out: list[str] = []
for tok in tokens:
if tok.isupper() and len(tok) <= 4:
out.append(tok)
else:
out.append(tok[:1].upper() + tok[1:].lower())
return " ".join(out) if out else cleaned
def _truncate(value: str, limit: int = _ARG_VALUE_MAX) -> str:
if len(value) <= limit:
return value
return value[: limit - 1].rstrip() + "…"
def _summarize_args(args: Mapping[str, Any] | None) -> str:
"""Produce a one-line args summary.
Rules:
* ``args is None`` or empty → empty string
* ``query`` field present → ``"<value>"`` (quoted)
* ``prompt`` field present → ``"<value>"`` (quoted)
* otherwise → first 2 ``key=value`` pairs, value truncated to 40 chars
Returns the bare summary string; callers wrap it in parens.
"""
if not args:
return ""
# Prefer the well-known semantic keys
for primary_key in ("query", "prompt"):
if primary_key in args:
return f'"{_truncate(str(args[primary_key]))}"'
pairs: list[str] = []
for key, value in list(args.items())[:2]:
rendered_value = _truncate(_render_value(value))
pairs.append(f"{key}={rendered_value}")
return ", ".join(pairs)
def _render_value(value: Any) -> str:
"""Compact ``repr``-ish formatting suitable for inline arg display."""
if isinstance(value, str):
return value
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (list, tuple)):
# Show length to avoid blowing up the line on huge arrays.
if len(value) <= 3:
return "[" + ", ".join(_render_value(v) for v in value) + "]"
return f"[{len(value)} items]"
if isinstance(value, Mapping):
return f"{{{len(value)} keys}}"
return str(value)
def _format_duration_local(ms: float) -> str:
"""Local fallback when ``agent.cli.utils.format.format_duration`` is absent.
Parcel α owns the canonical implementation; until it lands we keep a
minimal version here so the demo build is not blocked on cross-parcel
ordering. Numbers match the contract in design_proposal §3.5:
< 1000ms → "740ms"
< 60s → "1.4s"
≥ 60s → "1m12s"
"""
if ms < 1000:
return f"{int(round(ms))}ms"
seconds = ms / 1000
if seconds < 60:
return f"{seconds:.1f}s"
minutes = int(seconds // 60)
rem = int(seconds - minutes * 60)
return f"{minutes}m{rem:02d}s"
def _format_duration(ms: float) -> str:
"""Delegate to the shared formatter; fall back locally on import error."""
try:
from cli.utils.format import format_duration
return format_duration(ms)
except Exception: # noqa: BLE001 — never block render on helper issues
return _format_duration_local(ms)
# ---------------------------------------------------------------- public ----
def render_tool_event(
name: str,
args: Mapping[str, Any] | None = None,
status: Status = "running",
duration_ms: float | None = None,
*,
result_summary: str | None = None,
) -> Text:
"""Return a Rich :class:`Text` row describing a single tool call.
Args:
name: Raw tool name (``get_financials``); pretty-printed in output.
args: Argument mapping; summarised inline.
status: ``"running"`` | ``"ok"`` | ``"error"``.
duration_ms: When the call finished, total wall time in milliseconds.
result_summary: Optional one-line summary appended after the
duration (e.g. ``"8 quarters"``).
Returns:
A Rich ``Text`` object — caller decides whether to ``console.print``
it, stuff it into a ``Group``, or stream it as a transient row.
"""
if status not in _STATUS_STYLE:
# Be lenient — unknown statuses degrade to "running" instead of
# raising; CLI rendering should never crash the agent loop.
status = "running"
marker = Text("● ", style=_STATUS_STYLE[status])
line = Text()
line.append(marker)
line.append(_pretty_tool_name(name), style="bold")
summary = _summarize_args(args)
if summary:
line.append(f" ({summary})", style="dim")
suffix_parts: list[str] = []
if duration_ms is not None:
suffix_parts.append(_format_duration(duration_ms))
if result_summary:
suffix_parts.append(result_summary)
if suffix_parts:
line.append(" " + " · ".join(suffix_parts), style="dim")
return line
def render_tool_events(events: Iterable[Mapping[str, Any]]) -> list[Text]:
"""Convenience: render a list of event dicts.
Each event dict needs ``name`` plus any of ``args`` / ``status`` /
``duration_ms`` / ``result_summary``. Useful for replaying a stored
run via :mod:`agent.cli.commands.show`.
"""
rendered: list[Text] = []
for ev in events:
rendered.append(
render_tool_event(
name=str(ev.get("name", "tool")),
args=ev.get("args"),
status=ev.get("status", "ok"),
duration_ms=ev.get("duration_ms"),
result_summary=ev.get("result_summary"),
)
)
return rendered
__all__ = ["render_tool_event", "render_tool_events"]
+144
View File
@@ -0,0 +1,144 @@
"""Thinking spinner — picks a random verb on entry, hides on exit.
Wraps :class:`rich.live.Live` with ``transient=True`` so the spinner
erases itself once the context exits — leaving the answer print
uncluttered (the bug called out in the reference StreamRenderer
docstring: transient=True avoids the "ghost spinner" duplication).
If :mod:`agent.cli.utils.thinking_verbs` has not landed yet (Parcel α
ships it) we fall back to ``"Pondering…"`` so the demo build still
works.
"""
from __future__ import annotations
import random
from types import TracebackType
from typing import Optional
from rich.console import Console
from rich.live import Live
from rich.spinner import Spinner
from rich.text import Text
_FALLBACK_VERBS: tuple[str, ...] = (
"Pondering…",
"Analyzing…",
"Reasoning…",
"Thinking…",
"Investigating…",
)
def _pick_verb() -> str:
"""Pick a thinking verb via the shared helper, with a local fallback."""
try:
from cli.utils.thinking_verbs import pick_thinking_verb
verb = pick_thinking_verb()
if isinstance(verb, str) and verb:
return verb
except Exception: # noqa: BLE001 — never block the spinner on helper issues
pass
return random.choice(_FALLBACK_VERBS)
def _resolve_console() -> Console:
"""Return the shared CLI console."""
from cli.theme import get_console
return get_console()
class ThinkingSpinner:
"""Context manager that shows a Rich spinner + thinking verb.
Usage:
with ThinkingSpinner() as spinner:
spinner.update_verb("Backtesting…") # optional mid-run swap
... do agent work ...
The spinner is ``transient=True`` so it disappears when the block
exits. Use :meth:`pause` to temporarily hide the spinner while
printing auxiliary lines (e.g. a tool event row) — borrowed from
the reference StreamRenderer pattern.
"""
def __init__(
self,
verb: Optional[str] = None,
*,
console: Optional[Console] = None,
spinner_name: str = "dots",
) -> None:
self._verb = verb or _pick_verb()
self._console = console or _resolve_console()
self._spinner_name = spinner_name
self._live: Optional[Live] = None
# --------------------------------------------------------- lifecycle ----
def __enter__(self) -> "ThinkingSpinner":
self._live = Live(
self._renderable(),
console=self._console,
transient=True,
refresh_per_second=12,
)
self._live.__enter__()
return self
def __exit__(
self,
exc_type: Optional[type[BaseException]],
exc: Optional[BaseException],
tb: Optional[TracebackType],
) -> None:
live, self._live = self._live, None
if live is not None:
live.__exit__(exc_type, exc, tb)
# ------------------------------------------------------------- helpers --
def _renderable(self) -> Spinner:
# ``Spinner.text`` accepts Rich Text for styling — keep the verb in
# the dim style so the live region is unobtrusive.
return Spinner(self._spinner_name, text=Text(self._verb, style="dim"))
def update_verb(self, verb: str) -> None:
"""Swap the verb mid-run (e.g. ``"Backtesting…"`` once tools fire)."""
self._verb = verb
if self._live is not None:
self._live.update(self._renderable())
def pause(self) -> "_SpinnerPause":
"""Yield a context manager that hides the spinner inside its block.
Useful when callers need to ``console.print`` a tool-event row
without the spinner frame racing with the print to stdout.
"""
return _SpinnerPause(self)
class _SpinnerPause:
"""Inner context manager returned by :meth:`ThinkingSpinner.pause`."""
def __init__(self, parent: ThinkingSpinner) -> None:
self._parent = parent
self._saved_live: Optional[Live] = None
def __enter__(self) -> None:
# Stash the live so __exit__ on the outer spinner does nothing
# while we are paused; restart it on exit.
if self._parent._live is not None:
self._parent._live.__exit__(None, None, None)
self._saved_live = self._parent._live
self._parent._live = None
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
if self._saved_live is not None:
self._parent._live = self._saved_live
self._parent._live.__enter__()
self._saved_live = None
__all__ = ["ThinkingSpinner"]
+385
View File
@@ -0,0 +1,385 @@
"""prompt_toolkit input editor + ``SafeFileHistory``.
Wraps a :class:`PromptSession` with:
* The slash :class:`~cli.completer.SlashCompleter`
* Multi-line editing — Enter inserts a newline only when the buffer is
mid-bracket; Alt+Enter / Esc-Enter inserts a newline unconditionally;
a plain Enter on a balanced buffer submits.
* Ctrl+C with three-state semantics (clear buffer → exit hint → exit)
* A surrogate-safe :class:`FileHistory` subclass for Windows users
* UTF-8 stdout reconfigure on Windows so the brand glyph ``●`` and the
Rich box-drawing characters print without ``UnicodeEncodeError``
Cancel-during-generation lives outside the input loop — that is owned by
the agent runner in :mod:`cli.main`. Here we only handle the *input
editing* and *idle* states.
"""
from __future__ import annotations
import sys
import shutil
import time
from pathlib import Path
from typing import Optional
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout.containers import Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.styles import Style
# Sentinel raised by the Ctrl+C path so the caller can distinguish
# "user pressed Ctrl+C on an empty line" from real EOF. We reuse
# :class:`EOFError` so prompt_toolkit's existing plumbing keeps working;
# the caller decides between "show hint" and "exit" via timing.
_EXIT_HINT_GAP_SEC = 2.0
# ---------------------------------------------------------------- history ----
class SafeFileHistory(FileHistory):
""":class:`FileHistory` that strips invalid surrogate code points.
Background:
Windows terminals occasionally inject lone surrogate halves into
pasted Unicode (emoji, mixed-script CJK). prompt_toolkit's default
``store_string`` writes the line straight to disk using the
system encoding, which raises ``UnicodeEncodeError`` and corrupts
the history file. This subclass round-trips the string through
``utf-16-le`` with ``surrogatepass`` and then back to a sanitised
string before delegating to the parent implementation.
The cleanup is safe to run on every line — sanitised input that
contains only valid code points round-trips unchanged.
"""
def store_string(self, string: str) -> None: # type: ignore[override]
super().store_string(_strip_surrogates(string))
def _strip_surrogates(text: str) -> str:
"""Drop unpaired surrogate code points from ``text``."""
try:
round_tripped = text.encode("utf-16-le", "surrogatepass").decode(
"utf-16-le", "replace"
)
except UnicodeError:
return "".join(ch for ch in text if not 0xD800 <= ord(ch) <= 0xDFFF)
cleaned = round_tripped.encode("utf-8", "ignore").decode("utf-8", "ignore")
return cleaned.replace("�", "")
# ---------------------------------------------------------------- session ----
class _VibePromptSession(PromptSession):
"""PromptSession with a prompt-height that hugs the edited text."""
def _create_layout(self): # type: ignore[no-untyped-def]
layout = super()._create_layout()
# prompt_toolkit's bottom_toolbar is a screen-bottom status bar. Insert
# our divider directly after the input container so it hugs the prompt.
layout.container.children.insert(1, _prompt_divider_window())
return layout
def _get_default_buffer_control_height(self) -> Dimension: # type: ignore[override]
line_count = self.default_buffer.document.line_count
return Dimension.exact(max(1, line_count))
def _prompt_divider_window() -> Window:
return Window(
FormattedTextControl(
lambda: FormattedText([("class:prompt-border", _prompt_rule())])
),
height=1,
style="class:prompt-border",
dont_extend_height=True,
)
def _force_utf8_stdout() -> None:
"""Reconfigure stdout to UTF-8 on Windows so brand glyphs render."""
if sys.platform != "win32":
return
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
try:
reconfigure(encoding="utf-8")
except (OSError, ValueError):
# Best-effort — a redirected pipe may refuse encoding swaps.
pass
def _has_unbalanced_brackets(text: str) -> bool:
"""Return True if ``text`` contains unbalanced ``()``/``[]``/``{}`` pairs.
Used to decide whether a plain Enter should submit or insert a
newline. Strings inside ``"..."`` / ``'...'`` are skipped so a user
typing ``"hello (world)"`` does not get stuck in multi-line mode.
"""
stack: list[str] = []
pairs = {")": "(", "]": "[", "}": "{"}
openers = set(pairs.values())
in_str: Optional[str] = None
escape = False
for ch in text:
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if in_str is not None:
if ch == in_str:
in_str = None
continue
if ch in ("'", '"'):
in_str = ch
continue
if ch in openers:
stack.append(ch)
elif ch in pairs:
if not stack or stack[-1] != pairs[ch]:
return True
stack.pop()
return bool(stack) or in_str is not None
class _CtrlCState:
"""Track Ctrl+C presses so the outer loop can implement two-press exit.
Attributes:
previous_press_ts: Monotonic timestamp of the press *before* the
most recent one. ``0.0`` means "no prior press".
last_window_hit: Set by :meth:`record_press_and_check_window` —
``True`` iff the press that just landed was inside the
configured window relative to ``previous_press_ts``. Cached
so :func:`ctrl_c_within_window` does not re-read the clock
and decide independently of the keybinding.
The two-press semantics live here (not in the outer loop) because
the keybinding fires *before* the EOFError propagates, and we want
the outer loop's view of "are we inside the window?" to reflect the
decision made at the exact press moment — not whatever ``time.monotonic()``
reads a few microseconds later.
"""
__slots__ = ("previous_press_ts", "last_window_hit")
def __init__(self) -> None:
self.previous_press_ts: float = 0.0
self.last_window_hit: bool = False
def record_press_and_check_window(self, window_sec: float = _EXIT_HINT_GAP_SEC) -> bool:
"""Record the current press, return True iff inside the window.
Args:
window_sec: Two-press window in seconds.
Returns:
``False`` for the very first press (``previous_press_ts == 0``).
``True`` if the gap between the prior press and this one is
below ``window_sec``. Otherwise ``False`` (treated as a fresh
first press for the next round).
"""
now = time.monotonic()
prev = self.previous_press_ts
self.previous_press_ts = now
if prev == 0.0:
self.last_window_hit = False
return False
hit = (now - prev) < window_sec
self.last_window_hit = hit
return hit
def _build_keybindings(state: _CtrlCState) -> KeyBindings:
"""Wire Ctrl+C + multi-line submit semantics.
State machine (idle / typing):
Ctrl+C with text → clear the buffer and stay at the prompt
Ctrl+C empty (first) → exit with ``EOFError``; caller prints hint
and records the press timestamp.
Ctrl+C empty (twice) → caller sees the timestamp inside 2 s and
actually exits the loop.
Enter on balanced buffer → submit
Enter on unbalanced buffer → insert newline
Alt-Enter / Esc-Enter → insert newline unconditionally
"""
kb = KeyBindings()
@kb.add("c-c")
def _(event) -> None: # noqa: ANN001 — prompt_toolkit event
buf = event.app.current_buffer
if buf.text:
buf.reset()
event.app.invalidate()
return
# Empty buffer → record the press (this updates the state so the
# outer loop's two-press check has the right prior timestamp) and
# propagate EOF so the outer loop can decide whether to print the
# exit hint or actually exit.
state.record_press_and_check_window(_EXIT_HINT_GAP_SEC)
event.app.exit(exception=EOFError())
@kb.add("enter")
def _(event) -> None: # noqa: ANN001
buf = event.app.current_buffer
text = buf.text
if _has_unbalanced_brackets(text):
buf.insert_text("\n")
return
buf.validate_and_handle()
# Alt+Enter / Esc-Enter — unconditional newline.
@kb.add("escape", "enter")
def _(event) -> None: # noqa: ANN001
event.app.current_buffer.insert_text("\n")
return kb
def _default_history_path() -> Path:
"""Where ``~/.vibe-trading/history`` lives by default."""
home = Path.home() / ".vibe-trading"
return home / "history"
def make_session(history_path: Optional[Path] = None) -> PromptSession:
"""Construct a configured :class:`PromptSession`.
Args:
history_path: Override for the persistent history file. ``None``
uses ``~/.vibe-trading/history``.
Returns:
A ready-to-use ``PromptSession`` wired to the slash completer,
Ctrl+C bindings, multi-line editing, and a surrogate-safe
history file. The session exposes ``vibe_ctrl_c_state`` on the
returned object so callers can implement the two-press exit
confirmation.
"""
_force_utf8_stdout()
path = history_path or _default_history_path()
path.parent.mkdir(parents=True, exist_ok=True)
# Touch the file so FileHistory's first read does not fail on a fresh
# install.
if not path.exists():
path.touch(mode=0o600)
# Lazy import so unit tests can import this module without instantiating
# the slash registry side-effects.
from cli.completer import SlashCompleter
ctrl_c_state = _CtrlCState()
session = _VibePromptSession(
history=SafeFileHistory(str(path)),
completer=SlashCompleter(),
complete_while_typing=True,
key_bindings=_build_keybindings(ctrl_c_state),
enable_history_search=True,
mouse_support=False,
multiline=True,
reserve_space_for_menu=0,
style=Style.from_dict(
{
"prompt": "#258bff bold",
"prompt-border": "#4b5563",
}
),
)
# Expose the state so the outer loop can implement two-press exit.
setattr(session, "vibe_ctrl_c_state", ctrl_c_state)
return session
# ---------------------------------------------------------------- helpers ----
def get_user_input(
prompt_message: str = "❯ ",
*,
session: Optional[PromptSession] = None,
) -> str:
"""Prompt the user with the configured session and return the input.
Convenience for one-shot callers. Reuses ``session`` when supplied so
the persistent history and completer carry across calls — otherwise
a fresh session is built (and torn down).
Raises:
EOFError: When the user hits Ctrl+D, or Ctrl+C on an empty line.
"""
sess = session or make_session()
formatted = FormattedText(
[
("class:prompt-border", _prompt_rule() + "\n"),
("class:prompt", prompt_message),
]
)
return sess.prompt(formatted)
def _prompt_rule() -> str:
cols = shutil.get_terminal_size((88, 24)).columns
return "─" * max(10, cols)
def ctrl_c_within_window(session: PromptSession, window_sec: float = _EXIT_HINT_GAP_SEC) -> bool:
"""Return True if the most recent Ctrl+C press was a "second press".
A "second press" means the user pressed Ctrl+C twice within
``window_sec`` on an empty buffer — that's the signal to actually
exit. The decision is made at *press time* by
:meth:`_CtrlCState.record_press_and_check_window` and cached on the
state object; the outer loop reads the cached flag here.
Falls back to a timestamp comparison against ``previous_press_ts``
for two cases:
* ``SimpleNamespace`` test doubles that set ``last_press_ts`` directly
(legacy test fixtures predate the two-timestamp design).
* ``vibe_ctrl_c_state`` being absent entirely (defensive — returns
``False`` so the caller treats it as "no exit").
Args:
session: The active prompt_toolkit session (or a duck-typed
stand-in exposing ``vibe_ctrl_c_state``).
window_sec: Window length in seconds. Only used by the fallback
paths described above; the primary path trusts the cached
``last_window_hit`` already computed against the configured
window.
Returns:
``True`` iff the loop should now exit.
"""
state = getattr(session, "vibe_ctrl_c_state", None)
if state is None:
return False
# Primary path — the keybinding cached the press-time decision.
if hasattr(state, "last_window_hit"):
return bool(state.last_window_hit)
# Legacy fallback for test doubles that only set ``last_press_ts``.
last_ts = getattr(state, "last_press_ts", 0.0)
if last_ts <= 0.0:
return False
return (time.monotonic() - last_ts) < window_sec
__all__ = [
"SafeFileHistory",
"make_session",
"get_user_input",
"ctrl_c_within_window",
]
Loaded 100 of 1460 files, more files were not shown because too many files have changed in this diff. Show more