230 Commits
Author SHA1 Message Date
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
warren618 4dcb33034a fix(packaging): bundle swarm presets inside src.swarm (#55) + 0.1.6 release
Move 29 preset YAMLs from agent/config/swarm/ to agent/src/swarm/presets/
so they ship as package-data; loader resolves via
Path(__file__).parent / "presets". Identical behavior under editable
installs and built wheels — fixes "No presets available" reported in #55
after pip install / uv tool install vibe-trading-ai 0.1.5.

Root cause: preset YAMLs were declared via [tool.setuptools.data-files],
which lands them under <install-prefix>/.data/data/config/swarm/ rather
than at the <site-packages>/config/swarm/ path the loader expected.

Pinned by a 6-test packaging regression suite. Bump 0.1.5 -> 0.1.6.
SKILL.md and 5 READMEs synced to reality: 72 skills, 22 MCP tools
(adds 5 shadow-account / journal tool names), 6 data sources
(adds Futu). Adds 2026-04-28 v0.1.6 release news rollup.
2026-04-28 17:39:19 +08:00
warren618 aae5a7ec1a fix(loader): route ETFs and forex correctly in AKShare; harden registry fallback
Closes #50, closes #54.

akshare_loader._fetch_one previously dispatched every unrecognized symbol to
stock_zh_a_hist. Two consequences:
- Exchange-listed ETFs (518880.SH, 159915.SZ, ...) match the .SH/.SZ tail and
  are misrouted to the A-share endpoint, which can't price them (#50).
- Forex pairs (EURUSD, GBPUSD, ...) match no predicate and fall through to
  the same default branch (#54).

Add two predicates + endpoints:
- _is_etf_listed: 6-digit code with prefix 15/16/50/51/52/56/58 on .SH/.SZ
  -> fund_etf_hist_sina(symbol='sh518880')
- _is_forex: looks up akshare.forex.cons.symbol_market_map (190 pairs)
  -> forex_hist_em(symbol='EURUSD'), with .FX suffix stripped

ETF check runs before _is_a_share so 518880.SH takes the ETF branch.

registry.resolve_loader / get_loader_cls_with_fallback used to construct the
loader before checking is_available(). Tushare's __init__ calls ts.pro_api(token)
and raises 'api init error' on missing token, which broke the fallback chain
(secondary issue from #50). Construction is now wrapped in try/except so init
failures are treated as unavailable and the chain keeps walking.

Tests:
- 21 new unit tests in test_akshare_loader.py (predicates + routing via
  monkeypatched sys.modules['akshare']); no network, no API keys.
- 2 new tests in test_registry.py covering init-error fallback.
- Real-data E2E (manual): loader.fetch returned 242 rows for 518880.SH and
  262 rows for EURUSD over 2024; full runner.main produced metrics for both.

Credit:
- @LaierX for issue #50 root-cause + reference patch
- @yuzawaya for issue #54 root-cause + reference patch
2026-04-27 22:28:14 +08:00
warren618 300f5c2f4f docs: 2026-04-27 news — benchmark panel + upload safety
- Benchmark comparison panel in backtest output (#48, ticker / benchmark return / excess return / information ratio, yfinance-backed)
- /upload streaming + size enforcement (#53) pinned by 4-case regression suite

Across 5 READMEs (en/zh/ja/ko/ar). 04-20 reasoning+swarm entry rolled into 'Earlier news' to keep the 3-latest-visible convention.
2026-04-27 22:01:50 +08:00
warren618 05fc5cac22 test(api): add /upload streaming + size-limit regression tests
Pin PR #53 behavior with 4 cases:
- under-limit upload writes to UPLOADS_DIR
- exactly-at-limit upload succeeds
- over-limit returns 413 and removes the partial file
- blocked extension returns 400 with no file written

Tests shrink MAX_UPLOAD_SIZE to 4 KB via monkeypatch so the streaming
chunk loop and cleanup path run without allocating 50 MB.

Also gitignore AGENTS.md (local mirror of CLAUDE.md for non-Claude
agent tools, identical content, no need to track twice).
2026-04-27 21:53:50 +08:00
Brandy Goodandgenoshide cd999a8d5b fix: stream uploads while enforcing API size limit (#53)
Co-authored-by: genoshide <genoshide@users.noreply.github.com>
2026-04-27 21:37:59 +08:00
warren618 69a4dc69f9 docs: add star history chart to all 5 READMEs 2026-04-27 14:45:26 +08:00
MB-Ndhlovu ffa95a9f51 feat(cli): add benchmark comparison to backtest output
Adds benchmark.py with yfinance-backed ticker resolution (SPY, CSI 300, etc.), injects external benchmark via config['benchmark'] in engines/base.py, and renders a benchmark panel (ticker / benchmark return / excess return / information ratio) in cli.py.

Closes #48
2026-04-27 14:21:50 +08:00
warren618 29fd1f91fa docs: news entries for 0.1.6 hardening + Futu/vnpy integrations across 5 READMEs 2026-04-22 00:03:48 +08:00
warren618 0b9b4d8f64 perf(frontend): route-level lazy loading shrinks initial bundle 688KB → 262KB
Wrap each route in React.lazy() + Suspense so Home/Agent/RunDetail/Compare
download only on first visit. Most importantly, echarts (630KB) now loads
async when a chart-using route is visited instead of eagerly on first
paint. Home and Agent no longer pay the chart import cost.

Measurements (vite build):
  before: index 688KB + vendor-charts 631KB, both eager (~1.3MB blocking)
  after:  index 262KB + vendor-react 101KB + CSS 51KB, ~414KB blocking.
          vendor-charts (630KB) is async, loaded on /runs/:id or /compare.
2026-04-21 09:45:07 +08:00
warren618 5c9488d30b build: add MANIFEST.in so sdist ships .env.example, tests, and Docker files
Previously `pip install vibe-trading-ai --no-binary` missed
`agent/.env.example` and `agent/tests/`, so users who built from
sdist had no template env and no test suite to run. MANIFEST.in also
includes 4 localized READMEs and Docker configs.

Local debug scripts prefixed with `_` in agent/tests/ are explicitly
excluded so untracked files in the working tree never leak to the sdist.
2026-04-21 09:39:55 +08:00
warren618 0a38ba5ef0 fix(security): enforce path containment in safe_path + sandbox journal/shadow tools
- safe_path() now actually raises ValueError on escape / UNC paths
  (existing callers already expected this but never got it).
- New safe_user_path() validates user-supplied broker paths against a
  HOME+CWD envelope, blocking /etc/passwd-style LLM hijack attempts.
- Wire into analyze_trade_journal + 3 shadow account tools.
- 14 new tests in test_path_safety.py cover traversal, UNC, absolute
  escape, and tilde expansion on both helpers.
2026-04-21 09:36:31 +08:00
warren618 8fdf4f8014 chore(loaders): align futu.py with logger convention
Match the existing logger pattern used by akshare_loader, ccxt_loader,
and registry.py. Follow-up to #47 (@hamza-mobeen).
2026-04-21 09:15:56 +08:00
Hamza Mobeen bc62206ad6 feat: add Futu data loader for HK and A-share equities (#47)
Implements FutuLoader using the existing DataLoaderProtocol so users can pull HK and China A-share OHLCV data via Futu OpenAPI instead of yfinance (which frequently rate-limits on HK equity requests).

- agent/backtest/loaders/futu.py: FutuLoader class with symbol mapping (700.HK → HK.00700, 000001.SZ → SZ.000001), interval mapping (1D/1H/4H → KLType), is_available() env-var check, and fetch() that normalises request_history_kline() output to the standard OHLCV schema
- agent/tests/test_futu_loader.py: fully mocked tests — symbol mapping, interval mapping, OHLCV normalisation, is_available() with missing env vars, NoAvailableSourceError on connection failure
- agent/backtest/loaders/registry.py: register futu loader
- agent/.env.example: add FUTU_HOST and FUTU_PORT with comments

Closes #40
2026-04-21 09:13:29 +08:00
Hamza Mobeen 4b28a4d1b4 feat: add vnpy export skill for CtaTemplate strategies (#46)
Adds /vnpy export skill that translates Vibe-Trading backtest strategies into runnable vnpy CtaTemplate Python classes.

- agent/src/skills/vnpy-export/SKILL.md: skill guide with workflow, CtaTemplate structure, ArrayManager indicator mapping, signal→order mapping, multi-timeframe patterns, and quality checklist
- agent/src/skills/vnpy-export/scripts/cta_template.py: complete MA crossover reference implementation (BarGenerator + ArrayManager)
- agent/tests/test_vnpy_export.py: 18 tests covering SKILL.md integrity and template Python syntax/structure (no vnpy install required)

Closes #38
2026-04-21 09:13:16 +08:00
warren618 b76af14d00 docs: news entries for 0.1.5 follow-up patches across 5 READMEs
- 04-21: workspace boundary fix (#43) + README usage examples (#45)
- 04-20: reasoning_content preservation (#39) + swarm streaming (#42)
- 04-18 / 04-17 entries pushed into <details> (top-3 visible convention)
2026-04-21 08:42:26 +08:00
Mothilal M 63ca24867c fix(loop): normalize relative/empty run_dir to memory run_dir (#43)
Hardens against LLMs passing run_dir="." or "" after context compression, which previously resolved against the agent process CWD and exposed repo files (incl. .env) to read_file / write_file and silently mis-routed bash subprocess cwd.
2026-04-20 19:06:57 +08:00
Hamza Mobeen f9022a3ac4 docs: add usage examples to README (#45) 2026-04-20 18:46:00 +08:00
warren618 fc708d15c5 fix(swarm): unblock run_swarm retries and clean Ctrl+C shutdown (#42)
Two follow-ups to b769580 reported by @codewings:

1. "Blocked duplicate call: run_swarm" — loop.py dedups by tool name only,
   and SwarmTool defaulted to repeatable=False. A single successful
   run_swarm per session blocked every subsequent call (retry with a
   different preset, different market, etc). Mark SwarmTool.repeatable=True.

2. threading._shutdown noise on Ctrl+C — `with ThreadPoolExecutor` calls
   shutdown(wait=True) on exit, which blocks indefinitely while kimi
   workers are mid-stream. Switch to manual executor + try/finally with
   shutdown(wait=False, cancel_futures=True); catch KeyboardInterrupt at
   layer boundary to set cancel_event. CLI now returns immediately instead
   of deadlocking on exit.
2026-04-19 22:05:43 +08:00
warren618 a1040bbb71 fix(providers): re-inject reasoning_content on outbound requests (#39)
LangChain's ChatOpenAI strips custom fields across all three serialization
boundaries, but prior commits (73b179a + f866e5f) only patched the two
inbound paths. Assistant messages in context therefore lose reasoning_content
on every ReAct continuation — Moonshot's strict kimi snapshots
(reported as "kimi-k2.5") reject those turns.

Add _get_request_payload override to ChatOpenAIWithReasoning:
  - Copy reasoning_content from additional_kwargs back into the outbound
    messages dict so it echoes across turns.
  - Normalize content=None → "" for assistant messages (LangChain emits
    null when content='' and tool_calls are present, which kimi-k2.5
    rejects).
  - Default missing reasoning_content to "" so strict providers accept
    every assistant turn.

Inbound/outbound symmetry for reasoning_content now covers all three
LangChain serialization boundaries. Verified with real moonshot 2-turn
roundtrip and DeepSeek regression; 22/22 unit tests pass.
2026-04-19 22:05:21 +08:00
warren618 b769580389 fix(swarm): stream LLM calls in workers to unblock moonshot/kimi (#42)
Non-streaming invoke against moonshot is unreliable — kimi-k2 either stalls the
TCP connection (APIConnectionError after 3 min) or exceeds the 120s default
timeout, causing swarm workers to retry-loop forever. Swap worker.py to the
same stream_chat path the main Agent already uses — all other providers are
unaffected. As a bonus the dashboard now receives live worker_text deltas
during kimi's thinking window instead of silent minutes.

Also harden runtime.py: as_completed() now enforces a layer-level deadline
(timeout_seconds * (max_retries+1) + 60s) so any future stuck past the in-loop
check is cancelled cleanly instead of freezing the DAG.

Verified e2e with kimi-k2-0905-preview + equity_research_team:
  - 724 streaming deltas across 3 parallel workers in 8 min
  - 32 tool calls, 0 Request-timed-out retries
  - first dashboard update within 10s (was 10+ min)
2026-04-19 18:51:18 +08:00
warren618 e6ceb5a3b3 refactor(providers): collapse reasoning_content path helpers
Follow-up to f866e5f trimming fallbacks and single-use constants:

  * Collapse _REASONING_FIELDS + _preserve into ChatOpenAIWithReasoning._capture
    (walrus := + or-chained field normalization).
  * Inline extra_body branch in build_llm (kwargs-dict dance removed).
  * Rename _normalize_finish_reason → _dedupe_finish_reason, inline marker
    tuple, switch to endswith() for precision, drop `or "stop"` double default.
  * _parse_response: drop `or []` and `tc.get(..., default)` defenses —
    LangChain BaseMessage contract already guarantees these fields.

No behavior change. Net -44 lines, 64/64 tests green. Smoke re-verified on
Moonshot official and OpenRouter kimi-k2-thinking (effort=medium).
2026-04-19 14:58:06 +08:00
warren618 d4c730213f docs: document LANGCHAIN_REASONING_EFFORT in .env.example 2026-04-19 14:50:30 +08:00
warren618 f866e5fb14 fix(providers): cover streaming path and OpenRouter relay for reasoning_content (#39)
PR #41 only hooked _create_chat_result (non-streaming); swarm workers
use llm.stream() which goes through _convert_chunk_to_generation_chunk
and dropped reasoning_content silently. @codewings' breakpoint never
firing confirmed the miss.

Adds _convert_chunk_to_generation_chunk override. Also handles two
neighbouring quirks found during verification:

  * OpenRouter renames the field to "reasoning" (ships reasoning_details
    alongside). Normalize both variants to the canonical
    additional_kwargs["reasoning_content"].
  * OpenRouter emits finish_reason on every stream chunk;
    AIMessageChunk.__add__ concatenates them into "stopstop" /
    "tool_callstool_calls". Dedupe in _parse_response so ReAct equality
    checks survive.
  * LANGCHAIN_REASONING_EFFORT env forwarded as extra_body.reasoning.effort
    for relays (e.g. OpenRouter) that require explicit thinking activation.

Removed 3-way getattr fallback in _parse_response; downstream reads
single canonical key.

Verified end-to-end (tests/_smoke_reasoning.py + _smoke_swarm_roundtrip.py):
  * Moonshot kimi-k2-thinking (official) — stream + non-stream + 2-round ReAct
  * DeepSeek deepseek-reasoner (official) — stream + non-stream + 2-round ReAct
  * OpenRouter moonshotai/kimi-k2-thinking with effort=medium
  * OpenRouter qwen/qwen3-coder-flash — non-thinking path unaffected

Tests: 66 passed (12 new: 2 field normalization, 7 finish_reason dedupe,
3 extra_body passthrough).
2026-04-19 14:49:51 +08:00
warren618 b6adee5651 fix(tests): patch ChatOpenAIWithReasoning in minimax temperature tests
build_llm() now returns ChatOpenAIWithReasoning (introduced in 73b179a)
instead of ChatOpenAI. The minimax temperature clamping tests were still
patching ChatOpenAI directly, so the fake was never hit and the real
subclass was instantiated — causing KeyError: 'temperature' on assert.
2026-04-19 12:22:38 +08:00
warren618 73b179a28d fix(providers): subclass ChatOpenAI to preserve reasoning_content
PR #41 fixed the protocol layer (LLMResponse / format_assistant_tool_calls)
but langchain-openai 0.3.x's _convert_dict_to_message silently drops
unknown fields — reasoning_content never reached our _parse_response,
so the fix was a no-op end-to-end.

Add ChatOpenAIWithReasoning: subclass that overrides _create_chat_result
to re-read the raw API response and restore reasoning_content into
additional_kwargs. PR #41's 3-location fallback in _parse_response then
picks it up without changes.

Benefits all OpenAI-compat thinking providers: Moonshot K2.5, DeepSeek
reasoner, Qwen thinking (Qwen-QwQ, Qwen3-thinking).

Reported by @codewings on issue #39 after PR #41 shipped.
2026-04-19 12:13:54 +08:00
warren618 da2df110b1 docs: document reasoning_content param in format_assistant_tool_calls
Follow-up to #41: the new reasoning_content parameter was missing from
the docstring. Also clarified that content carries inlined thinking for
providers that stream reasoning via content chunks, while
reasoning_content is the dedicated field for Kimi K2.5 / DeepSeek
reasoner / Qwen thinking models.
2026-04-19 11:42:36 +08:00
yule153604and@yule153604 0efa443429 fix(kimi): preserve reasoning_content in thinking tool-call context (#41)
Fixes #39

Kimi K2.5 rejects follow-up requests if replayed assistant tool-call messages drop `reasoning_content`. This PR:
- Adds `reasoning_content` to `LLMResponse` with 3-location LangChain-compatible fallback (direct attr / additional_kwargs / response_metadata)
- Preserves it through `format_assistant_tool_calls` when present (guarded by `is not None`, so non-thinking providers are unaffected)
- Covers both the main agent loop (streaming, with `thinking_text` fallback) and the swarm worker (non-streaming)
- 4 regression tests

Also benefits DeepSeek reasoner and Qwen thinking providers that use the same field.

Co-authored-by: @yule153604
2026-04-19 11:41:18 +08:00
warren618 93db691583 test: isolate user_skills_dir in test_skills
4 tests were leaking into ~/.vibe-trading/skills/user/ by calling the
live SkillsLoader. Added empty_user_dir fixture via tmp_path monkeypatch
so each test gets a clean, isolated user-skills directory. No production
behavior changes.
2026-04-19 11:10:50 +08:00
warren618 a35e870aad docs: update skill counts + v0.1.5 news
- 5 READMEs (en/zh/ja/ko/ar): skill count 68/69 -> 71, tool count 27 -> 32,
  MCP tool count -> 22
- Add 2026-04-19 v0.1.5 news entry to all 5 READMEs
- Collapse 2026-04-16 Harness v2 entry into <details> (keeping top 3 visible
  per news convention)
2026-04-19 11:10:35 +08:00
warren618 2c80efcd77 chore: bump to 0.1.5 + CVE floor
- pyproject.toml 0.1.4 -> 0.1.5; python-multipart >=0.0.18 (CVE floor)
- agent/requirements.txt: parity with pyproject (9 deps: openpyxl,
  python-docx, python-pptx, pypdfium2, Pillow, ddgs, jinja2, matplotlib,
  weasyprint) so Docker build no longer misses runtime deps
- agent/cli.py: _VERSION 0.1.0 -> 0.1.5 (banner)
- agent/SKILL.md: version 0.1.5; 22 MCP tools; 71 skills
- agent/mcp_server.py: expose analyze_trade_journal + 4 shadow-account
  tools; fix pattern_recognition -> pattern registry name
- frontend/package.json, package-lock.json, Layout.tsx sidebar: 0.1.0 -> 0.1.5
- .github/ISSUE_TEMPLATE/bug_report.yml: version placeholder 0.1.3 -> 0.1.5
2026-04-19 11:10:20 +08:00
warren618 25380bac96 feat(ux): surface shadow account + trade journal in CLI, web, README
CLI (agent/cli.py):
- Welcome footer + help table natural-language hints for journal/shadow
- Tool preview highlights report_url for render_shadow_report;
  shadow_id for extract_shadow_strategy / run_shadow_backtest

Frontend:
- WelcomeScreen: Trade Journal (2) + Shadow Account (3) sample categories;
  capability chips refreshed (56→70 skills, 25→29 presets, 19→32 tools)
  + 4 new chips (Trade Journal / Shadow Account / Persistent Memory /
  Session Search)
- Home: 4th feature card "Strategy Replay" (grid: 4 cols at lg)
- RunCompleteCard: detects msg.shadowId, opens /shadow-reports/<id>
  in new tab; runId now optional so shadow-only runs render the card
- Agent.tsx: parses render_shadow_report preview for shadow_id; emits
  run_complete even when no backtest metrics if shadowId present
- AgentMessage: + shadowId optional field
- i18n: feat4/feat4d ("Strategy Replay")
- vite.config.ts: /shadow-reports proxy to :8899 (without this, the
  button 404s in dev because Vite doesn't forward unknown routes)

README news (en/zh/ja/ko/ar):
- 2026-04-18 Shadow Account entry
- 2026-04-15 Z.ai + MiniMax collapsed into <details> Earlier news

Repo hygiene:
- .gitignore: assets/*.mp4 (demo videos kept local);
  agent/tests/e2e_*.py + test_e2e_*.py (manual smoke tests requiring
  live API keys — would break CI if collected by pytest)
2026-04-18 23:15:59 +08:00
warren618 c090d1fce8 feat: shadow account — extract/backtest/render/scan + HTML report + skill
- 4 new tools: extract_shadow_strategy, run_shadow_backtest,
  render_shadow_report, scan_shadow_signals
- src/shadow_account/ package: clustering-based rule extractor, cached
  cross-market backtester, 8-section HTML/PDF reporter with attribution
  waterfall, signal scanner, Jinja2-rendered signal engine
- src/skills/shadow-account/ ships rules + methodology + attribution semantics
- API endpoint GET /shadow-reports/<id>?format=html|pdf
- Context routing: Shadow block mandates load_skill first
- Reused trade_journal_tool.pair_trades_fifo (promoted from private)
- 28 new unit tests
- Reporter fix: _chart_attribution_waterfall had 6 undefined refs
  (_ACCENT/_POS/_NEG/_BG/_GRID/_style_axes) — inlined hex + native mpl
- Deps: jinja2, matplotlib, weasyprint (optional; HTML fallback when GTK missing)
2026-04-18 23:15:06 +08:00
warren618 8e45d2c4ee docs: add 2026-04-17 news entry; collapse older entries behind <details>
News section now shows only the 3 latest entries inline; everything
older lives under a simple "Earlier news" collapse across all 5
READMEs. Today's entry covers the trade journal analyzer and the
universal file reader shipped in 01996ff and 2b0a775.
2026-04-17 22:02:46 +08:00
warren618 2b0a775db0 feat: trade journal analyzer — profile + behavior diagnostics
Users upload broker exports (CSV/Excel) and get a trading portrait
plus 4 bias diagnostics. Parses 同花顺/东方财富/富途/generic formats
with encoding fallback (utf-8/gbk/gb2312/big5).

Profile: holding days, win rate, PnL ratio, max drawdown, top
symbols, market/hourly distribution via FIFO lot matching.

Behavior: disposition effect, overtrading, chasing momentum,
anchoring — each with severity + numeric evidence derived from
the journal itself, no external market data pulls.

Also: gitignore agent/uploads/ so runtime user uploads never leak
into the repo; inline a tiny csv in test_doc_reader instead of
depending on a fixture file.
2026-04-17 21:46:13 +08:00
warren618 01996fff7c feat: universal file reader — pdf/docx/xlsx/pptx/images/text
read_document now dispatches by extension across PDF, Word, Excel,
PowerPoint, images (OCR), and 30+ plain-text formats (config, markup,
source code). Unified JSON envelope across formats so downstream
agent prompts don't need to branch on file type.

/upload flips from allowlist to blocklist — rejects only executables
and archives. Frontend accept attr widened to the same surface.
2026-04-17 21:28:10 +08:00
warren618 5709e20a20 docs: add recommended models section to all READMEs
Guides users to pick models with reliable tool-calling (best /
sweet-spot / avoid tiers) since Vibe-Trading is tool-heavy and
small models break the agent experience by fabricating answers
instead of loading skills or running backtests.
2026-04-17 16:11:25 +08:00
warren618 c0074da1d2 docs: update all READMEs for harness trading release 2026-04-17 00:17:52 +08:00
warren618 e2a4654edb feat: harness trading — persistent memory, session search, skill CRUD, 5-layer compression, tool batching 2026-04-16 23:59:06 +08:00
warren618 6caa1d523f docs: add 2026-04-15 news for Z.ai and MiniMax PRs 2026-04-15 21:31:52 +08:00
warren618 c74a059afb fix: update cli init test for Z.ai provider insertion
Ollama moved from position 11 to 12 after Z.ai was added.
Also add missing tushare token prompt return value.
2026-04-15 21:24:36 +08:00
jiakeboge 0bd40354bc feat: support Z.ai coding platform (#35)
* feat: support Z.ai coding platform (#34)

* fix: address PR feedback (unit tests, docs cleanup, news revert)
2026-04-15 21:19:11 +08:00
e8e86e1a1d fix: update MiniMax provider config and clamp temperature=0 to 0.01 (#33)
* fix: update MiniMax provider config and fix temperature handling

- Update .env.example: replace deprecated MiniMax-Text-01 with
  MiniMax-M2.7 and document MiniMax-M2.7-highspeed as the faster
  alternative; add note that temperature must be > 0
- Fix temperature clamping in build_llm(): MiniMax rejects
  temperature=0.0 (requires range (0.0, 1.0]); auto-clamp to 1.0
  when the default 0.0 is configured for the minimax provider
- Add unit tests: MiniMax env-var mapping, base URL propagation,
  temperature clamping from 0.0, and preservation of explicit values

* fix: clamp MiniMax temperature to 0.01 instead of 1.0

0.01 stays closest to the user's intent (deterministic output) while
satisfying the MiniMax API constraint of temperature > 0.

---------

Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: warren618 <haozhe_wu@connect.hku.hk>
2026-04-15 21:17:15 +08:00
warren618 5de1f82e27 docs: condense README news section and add 2026-04-14 entry 2026-04-14 21:48:12 +08:00
warren618 4e52ed7e77 fix: backtest MCP tool Connection closed on stdio transport (#32)
Three issues in Runner causing backtest to fail via MCP stdio:

1. Console(stderr=True): Rich console wrote to stdout, corrupting the
   JSON-RPC protocol stream — root cause of "Connection closed"
2. stdin=subprocess.DEVNULL: child process inherited MCP stdin pipe,
   causing deadlock between server and subprocess reads
3. Remove HOME/USERPROFILE override: forced yfinance/akshare cache miss,
   turning a 9s backtest into 170s+ (exceeding client timeout)
2026-04-14 11:49:02 +08:00
warren618 59fb021c58 fix: swarm template variable fallback and frontend timeout reset
1. worker.py: missing template variables ({market}, {target}, etc.)
   now auto-fallback with LLM inference hint instead of crashing
2. Agent.tsx: swarm flush() resets lastEventRef to prevent
   safety timeout from killing active swarm runs
2026-04-13 23:37:32 +08:00
warren618 1f8f4a005f feat: cross-market composite backtest engine with shared capital pool
- Add CompositeEngine for backtesting portfolios spanning multiple markets
  (e.g. A-shares + crypto) with a single shared capital pool
- Extract on_bar logic (funding fee, liquidation, swap) into _market_hooks.py
  as pure functions, called by both original engines and CompositeEngine
- Fix _align() signal shift bug: shift on each symbol's own trading calendar
  then ffill to unified dates (prevents Monday close-and-reopen for A-shares)
- Add calendar-day annualization for cross-market Sharpe calculation
- Add cross-market-strategy skill with vol-adjusted weighting example
- Update all READMEs (en/zh/ja/ko/ar): news, 69 skills, composite engine
- Update test imports for extracted _market_hooks functions
2026-04-13 22:46:49 +08:00
warren618 940624a2d6 docs: add CODE_OF_CONDUCT and SECURITY policy
Add Contributor Covenant v2.1 and security vulnerability reporting guide
to improve GitHub Community Health score.
2026-04-13 18:50:16 +08:00
warren618 e2de75c3b6 fix: add ddgs to dependencies for web_search tool 2026-04-12 23:36:44 +08:00
Haozhe Wu cb85af67b5 Merge pull request #24 from SoufianoDev/main
docs: add Arabic README translation
2026-04-12 23:32:18 +08:00
SoufianoDev e9d04a10e5 docs: add Arabic README translation and update language links 2026-04-12 14:50:03 +01:00
warren618 66cd96a303 feat: multi-platform indicator export (TradingView + TDX + MT5)
Upgrade pine-script skill from TradingView-only to multi-platform export.
/pine now generates indicator code for TradingView (Pine Script v6),
通达信/同花顺/东方财富 (TDX formula), and MetaTrader 5 (MQL5) in a single file.
Add 2026-04-12 news to all README translations.
2026-04-12 21:04:00 +08:00
warren618 ed47b26b5c docs: add 2026-04-11 news to all README translations 2026-04-12 00:00:31 +08:00
warren618 0aea8b0150 fix: inject current date and time into agent and swarm system prompts
Closes #22
2026-04-11 20:15:08 +08:00
warren618 ddeb371632 docs: reorganize .env.example and add missing variables
- Unified provider block order (PROVIDER → MODEL → KEY → URL)
- Grouped LLM params (temperature, timeout, retries) together
- Added API Server section (API_AUTH_KEY, CORS_ORIGINS, ENABLE_SESSION_RUNTIME)
- Added CCXT_EXCHANGE for crypto fallback config
- Renamed Swarm section to Agent Tuning
2026-04-11 16:37:41 +08:00
warren618 a8828c16f3 fix: add runtime fallback when primary data source returns empty
When a loader (e.g. OKX) is reachable but returns no data at runtime
(network block, SSL failure, regional restriction), the backtest runner
now walks the FALLBACK_CHAINS to try the next available source
(e.g. ccxt/Binance). Covers both explicit source and auto-detect paths.
2026-04-11 16:26:46 +08:00
warren618 2772ecb688 fix: harden backtest engine with data validation and error isolation
- ffill(limit=5) replaces unlimited ffill to prevent stale prices from
  masking long suspensions; all-NaN symbols auto-dropped with warning
- try/except per symbol in _execute_bars so one symbol failure doesn't
  crash the entire backtest
- pydantic BacktestConfigSchema validates config.json before execution
  (codes non-empty, date format, start<=end, valid interval/engine/source)
- validate_date_range() shared helper called from all 5 loaders
- 25 regression tests covering all four fixes
2026-04-11 13:39:36 +08:00
warren618 f09cae12b0 docs: add contributors section to all README translations 2026-04-11 11:17:03 +08:00
trinhchienandchientrinh fd02b6f4ac feat(cli): add interactive init env bootstrap (#19)
* feat(cli): add interactive init env bootstrap

* fix(cli): align init providers with env example

---------

Co-authored-by: chientrinh <trinhdachien1598@gmail.com>
2026-04-11 11:05:21 +08:00
warren618 e32b09e515 feat: startup preflight checks for LLM and data sources
Show connectivity status on startup (CLI + API server) so users
immediately see which services are reachable and which are not,
instead of getting cryptic errors mid-run.
2026-04-11 10:55:44 +08:00
Haozhe Wu d682621645 Merge pull request #21 from yule153604/docs/readme-translations
LGTM — translations look accurate, language switcher works correctly.
2026-04-11 10:13:31 +08:00
yule 37e7fd9946 fix: translate zh README core feature headings 2026-04-11 02:14:24 +08:00
yule 8257ffeb09 docs: add multi-language README translations (zh, ja, ko) 2026-04-11 02:01:58 +08:00
Matheus Nunes Dantas de Sena 1a40e53bdd feat: add docker-compose profile for frontend-only dev (#18)
* feat: add docker-compose profile for frontend-only dev

* fix: improve networking and use explicit service dependency
2026-04-11 00:19:17 +08:00
warren618 423ed52537 docs: add roadmap, consolidate good-first-issues
Moved most good-first-issues to a Roadmap section in README
to keep the issue tracker focused. 3 well-scoped issues remain
open for external contributors.
2026-04-10 23:49:49 +08:00
warren618 701d5269e4 docs: add PR template + 8 beginner-friendly issues 2026-04-10 23:13:13 +08:00
warren618 b0f59bd7f4 fix: Docker build + MCP/PyPI/ClawHub sync (v0.1.4)
- Fix #8: remove COPY data/ data/ from Dockerfile (dir doesn't exist)
- Add akshare/ccxt to requirements.txt and pyproject.toml dependencies
- Remove non-existent run_agent from py-modules
- MCP: rewrite _get_loader to use registry with fallback, add web_search tool (16→17)
- MCP: update docstrings (56→68 skills, add akshare/ccxt source docs)
- ClawHub SKILL.md: 64→68 skills, 3→5 sources, 16→17 tools, add version field
- docker-compose: remove dead fin-data volume, rename fin-→vibe- prefix
- .dockerignore: expand exclusions (tests, docs, assets, build artifacts)
- README: merge 4/10 news, update MCP 16→17, fix contributing (remove done items)
- Bump version 0.1.3→0.1.4, published to PyPI and ClawHub
2026-04-10 22:11:07 +08:00
warren618 1e3db39722 feat: backtest Wave 2 (multi-asset engines) + Wave 3 (statistical validation)
Wave 2 — 3 new engines + options v2:
- ChinaFuturesEngine: CFFEX/SHFE/DCE/ZCE/INE/GFEX, 50+ contracts with
  multiplier/margin/commission lookup tables, T+0, price limits
- GlobalFuturesEngine: CME/ICE/Eurex, 30+ contracts, per-contract commission,
  smart product code extraction (ESZ4→ES)
- ForexEngine: 24 pairs with spread/swap tables, pip-based slippage,
  micro-lot rounding, per-symbol daily swap (Wed 3x)
- OptionsEngine v2: American early exercise + IV smile model
- FuturesBaseEngine: decoupled contract_multiplier layer (only futures use it)
- Runner routing for futures/forex market detection

Wave 3 — statistical validation:
- Monte Carlo permutation test (p-value for Sharpe significance)
- Bootstrap Sharpe CI (confidence interval + P(Sharpe>0))
- Walk-Forward analysis (per-window consistency check)
- Trigger via config["validation"] or standalone CLI
- Frontend ValidationPanel with badges, CI bars, per-window table

Bug fixes:
- CryptoEngine funding fee: only applied once for daily bars (now per-date)
- Futures calc_commission: was using default rate, now uses _active_symbol
- Forex apply_slippage: JPY pairs used wrong pip value (0.0001→0.01)
- Forex swap: shared across symbols, now per-symbol tracking
- GlobalFutures leverage: cm*100/margin gave ~1.0, now defaults to 10.0
- pct_chg heuristic: small values (0-1%) misclassified, now prioritizes
  close/pre_close over pct_chg field

Tests: 364 passed (121 new), TypeScript zero errors.
2026-04-10 18:11:52 +08:00
warren618 b8d544d71d docs: add CONTRIBUTING.md, issue templates, and contribution roadmap
- CONTRIBUTING.md with dev setup, code standards, and difficulty-tiered roadmap
- GitHub issue templates (bug report + feature request) with YAML forms
- README: add Contributing section with contribution areas table
2026-04-10 16:17:19 +08:00
warren618 483de22b7d test: add 236 unit tests + CI pytest integration
- 13 test files covering: market detection, loader registry/fallback,
  engine market rules (ChinaA T+1/涨跌停, Crypto funding/liquidation,
  US/HK commission), signal alignment, metrics, optimizer, provider mapping
- CI: fix branch master→main, add pytest step (e2e excluded)
- pyproject.toml: add pytest config + dev dependencies
- .gitignore: expose unit tests, keep e2e local-only
2026-04-10 14:36:11 +08:00
warren618 6999af0e5f feat: multi-provider support (11 providers + Ollama) and performance tuning
- Expand _sync_provider_env() from 4 to 12 providers with independent env keys
- Add provider configs: OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope,
  Zhipu, Moonshot, MiniMax, MIMO, Ollama (local)
- LLM timeout 2400s→120s, retries 5→2 for faster failure recovery
- Decouple all timeout/concurrency params to env vars (9 configurable knobs)
- Update .env.example with all provider blocks and tuning params
- Update README: condensed News, multi-provider prerequisites, env var docs
2026-04-10 13:53:29 +08:00
warren618 140e9487af fix: harden ml-strategy skill template with input validation, div-by-zero guards, and output contract
Restructured ml-strategy SKILL.md from a skills-creator perspective:
- Added validate_data() input gate (columns, min rows, NaN ratio)
- Guarded RSI/Bollinger Band division-by-zero with .replace(0, nan)
- Added inf→NaN sanitization at end of build_features()
- Added sliding window option (window_type + sliding_size params)
- Added output contract: fillna(0).clip(-1, 1) guarantees clean signals
- Provided complete SignalEngine example so agent can copy-paste safely
- Reduced Common Pitfalls from 7 to 3 (4 now enforced by code)
- Zero changes to backtest engine — all fixes are skill-only
2026-04-10 01:50:04 +08:00
warren618 27354eb008 feat: data source expansion with auto-fallback + web search + skill categorization
- Add DataLoader Protocol + Registry with market-level fallback chains
- New data sources: AKShare (A-shares/US/HK/futures, free) and CCXT (100+ crypto exchanges)
- Auto-fallback: tushare unavailable -> akshare; OKX unavailable -> CCXT
- Zero-config: all markets work without any API keys
- New web_search tool (DuckDuckGo) for web information retrieval
- Skill categorization: 68 skills organized into 7 categories (data-source, strategy, analysis, asset-class, crypto, flow, tool)
- New data-routing meta-skill for intelligent data source selection
- Registry self-initializes via _ensure_registered() — no manual imports needed
- Pattern detection returns market type instead of source name (decoupled)
- runner.py loads .env via dotenv for reliable token access
2026-04-09 13:43:13 +08:00
warren618 dca916c1bd feat: Pine Script v6 export for TradingView integration
Add full-stack Pine Script export: backtest strategies can now be
converted to TradingView Pine Script v6 via natural language, CLI,
API, or the web UI.

- New `pine-script` skill with Python-to-Pine mapping table and v6 syntax guide
- CLI: `/pine <run_id>` slash command and `--pine` flag
- API: `GET /runs/{run_id}/pine` endpoint
- Frontend: Pine Script button on RunCompleteCard + modal code viewer with copy
- README: updated news, capabilities, skill count (65), CLI/API docs
2026-04-09 11:31:41 +08:00
warren618 8e6963f462 feat: multi-market backtest engines with per-market rules
Replace single vectorised daily_portfolio engine with bar-by-bar engines
that enforce real market rules:
- ChinaAEngine: T+1, no shorting, price limits, 100-lot, stamp tax
- CryptoEngine: 24/7, funding fees, liquidation, maker/taker, leverage
- GlobalEquityEngine: US zero-commission + fractional / HK stamp tax + lots

New shared foundation: models.py (Position/TradeRecord/EquitySnapshot),
metrics.py (extracted from daily_portfolio), base.py (BaseEngine ABC).
Runner routes by source to the correct engine automatically.
2026-04-09 01:17:28 +08:00
warren618 05f7627930 docs: add News section to README
Record initial release date (2026-04-01) and project milestones.
2026-04-08 23:47:30 +08:00
chaohuang-ai ad274c7101 Update README.md 2026-04-01 23:32:07 +08:00
warren618 95ba197591 fix: update video links 2026-04-01 19:08:21 +08:00
1042 changed files with 120382 additions and 4912 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"
}
}
}
}
+39 -17
View File
@@ -1,28 +1,50 @@
# Version control & CI
.git/
.github/
# IDE & editor
.claude/
.vscode/
.editorconfig
# Python artifacts
.venv/
agent/.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
node_modules/
frontend/dist/
frontend/tsconfig.tsbuildinfo
.env
agent/.env
agent/vibe_trading.egg-info/
agent/vibe_trading_ai.egg-info/
agent/__pycache__/
# Runtime data (generated at runtime, not in repo)
data/
agent/runs/
agent/sessions/
agent/.swarm/runs/
agent/.ui_runtime/
agent/.tasks/
agent/vibe_trading.egg-info/
.git/
.github/
.claude/
.vscode/
data/*.duckdb
data/*.duckdb.wal
data/.duckdb_tmp/
data/parquet/
data/backup/
data/minutes/
data/*.log
# Env files (mounted via docker-compose env_file)
.env
agent/.env
# Frontend build (built inside Docker, don't copy local)
frontend/dist/
frontend/node_modules/
frontend/tsconfig.tsbuildinfo
# Tests & docs (not needed in production image)
agent/tests/
docs/
assets/
build/
dist/
# Project docs
CLAUDE.md
CONTRIBUTING.md
*.md
!README.md
!LICENSE
+77
View File
@@ -0,0 +1,77 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Please fill out the sections below.
- type: textarea
id: description
attributes:
label: Description
description: What happened? What did you expect to happen?
placeholder: |
**What happened:**
The backtest command crashed with a KeyError.
**What I expected:**
The backtest should complete and show results.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: How can we reproduce this?
placeholder: |
1. Run `vibe-trading run -p "Backtest BTC-USDT MACD strategy"`
2. Wait for backtest to complete
3. See error
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs
description: Paste any error messages or stack traces
render: shell
- type: dropdown
id: interface
attributes:
label: Interface
description: How are you using Vibe-Trading?
options:
- CLI (vibe-trading)
- Web UI (vibe-trading serve)
- MCP Plugin
- Docker
- Python API
validations:
required: true
- type: input
id: provider
attributes:
label: LLM Provider
description: Which provider are you using?
placeholder: "e.g., openrouter, deepseek, ollama"
- type: input
id: version
attributes:
label: Version
description: "Run `pip show vibe-trading-ai` to check"
placeholder: "e.g., 0.1.5"
- type: input
id: environment
attributes:
label: Environment
description: OS and Python version
placeholder: "e.g., macOS 15 / Python 3.11"
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Discord Community
url: https://discord.gg/2vDYc2w5
about: Ask questions, share strategies, get help
- name: Documentation
url: https://github.com/HKUDS/Vibe-Trading#-quick-started
about: Check the README for setup and usage guides
@@ -0,0 +1,62 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for your idea! Please describe what you'd like.
- type: textarea
id: problem
attributes:
label: Problem or Motivation
description: What problem does this solve? Why do you need it?
placeholder: |
I often need to compare multiple strategies side by side,
but currently I have to run them one at a time.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: How would you like it to work?
placeholder: |
Add a `/compare` command that takes multiple run IDs
and shows a side-by-side performance table.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Which part of the project does this affect?
options:
- Skills (new finance skill)
- Backtest (engines, loaders, metrics)
- Swarm (new preset or workflow)
- CLI / TUI
- Web UI
- MCP Plugin
- Data Sources
- Documentation
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any other approaches you've thought about?
- type: checkboxes
id: contribution
attributes:
label: Contribution
options:
- label: I'm willing to submit a PR for this feature
+28
View File
@@ -0,0 +1,28 @@
## Summary
<!-- What does this PR do? 1-3 bullet points. -->
-
## Why
<!-- What problem does it solve? Link related issues with "Closes #123". -->
## Changes
<!-- List key changes. For new skills/presets, describe what they cover. -->
-
## Test Plan
- [ ] Existing tests pass (`pytest --ignore=agent/tests/e2e_backtest --tb=short -q`)
- [ ] New tests added (if applicable)
- [ ] Tested manually (describe below)
## Checklist
- [ ] No changes to protected areas (`src/agent/`, `src/session/`, `src/providers/`) without prior discussion
- [ ] No hardcoded values (API keys, file paths, magic numbers)
- [ ] Code follows [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines
- [ ] Documentation updated (if user-facing change)
+11 -5
View File
@@ -2,12 +2,12 @@ name: CI
on:
push:
branches: [master]
branches: [main]
pull_request:
branches: [master]
branches: [main]
jobs:
build:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -20,18 +20,24 @@ jobs:
cache: pip
- name: Install dependencies
run: pip install -e .
run: pip install -e ".[dev]"
- 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
python -m py_compile src/tools/__init__.py
python -m py_compile backtest/runner.py
- name: Run tests
# 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 --tb=short -q
- uses: actions/setup-node@v4
with:
node-version: "20"
+32
View File
@@ -0,0 +1,32 @@
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@v4
- name: Deploy wiki
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy wiki --project-name=vibetrading-wiki --branch=main
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
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@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: actions/setup-python@v5
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 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
! grep -R "localhost\\|nanobot" wiki --exclude=README.md
+44 -2
View File
@@ -1,4 +1,4 @@
.venv/
.venv/
agent/.venv/
__pycache__/
.pytest_cache/
@@ -16,10 +16,21 @@ agent/sessions/
agent/runs/
agent/.ui_runtime/
agent/.tasks/
agent/uploads/
agent/tests/*_result.json
.ruff_cache/
frontend/dist/
frontend/tsconfig.tsbuildinfo
wiki/node_modules/
wiki/dist/
wiki/.astro/
wiki/.vite/
wiki/.wrangler/
wiki/.cache/
.vibe-dev/
.vibe-record/
.cache/
.remotion/
# DuckDB data cache
data/*.duckdb
@@ -27,13 +38,44 @@ data/*.duckdb.wal
data/.duckdb_tmp/
data/parquet/
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
data/test_write.txt
agent/.pytest_tmp/
agent/tests/e2e_swarm_results.json
agent/vibe_trading.egg-info/
agent/vibe_trading_ai.egg-info/
agent/tests/e2e_backtest/
dist/
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.
agent/tests/e2e_*.py
agent/tests/test_e2e_*.py
# Local working notes
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/
+127
View File
@@ -0,0 +1,127 @@
# Changelog
All notable changes to Vibe-Trading are documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
### UI
- Redesigned the interactive CLI startup and run-state visuals with a large
figlet-style banner plus a Claude Code-style activity rail using `●`, `│`,
and `·` glyphs. Non-TTY prompt runs now fall back to plain text output.
### Added
### Changed
### Fixed
## [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.
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socioeconomic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement via
[GitHub Issues](https://github.com/HKUDS/Vibe-Trading/issues).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+152
View File
@@ -0,0 +1,152 @@
# Contributing to Vibe-Trading
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.
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.
## 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 commit -s -m "feat(factors): add gtja191 alpha 042"
```
This appends a trailer like:
```
Signed-off-by: Your Name <you@example.com>
```
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.
### DCO 1.1 (full text)
```
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.
```
## Alpha PR Reviewer Checklist
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.
- [ ] **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:`.
## Adding a New Alpha (Quickstart)
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
pytest agent/tests/factors/test_alpha_purity.py agent/tests/factors/test_lookahead.py -q
```
4. (Optional but recommended) Run a quick bench:
```bash
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.
## Code Style
- Format with `black`; lint with `ruff` (config in `pyproject.toml`).
- 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.
## Attribution
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.
By contributing, you agree that your contributions are licensed under the
project's MIT license (see `LICENSE`).
+13 -1
View File
@@ -14,6 +14,12 @@ RUN npm run build
# ============================================================================
FROM python:3.11-slim AS runtime
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.7" \
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
# System deps
@@ -28,7 +34,6 @@ RUN pip install --no-cache-dir -r agent/requirements.txt
# Copy project
COPY pyproject.toml LICENSE README.md ./
COPY agent/ agent/
COPY data/ data/
# Copy built frontend
COPY --from=frontend-build /app/frontend/dist frontend/dist
@@ -36,6 +41,13 @@ 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. Keep writable app data directories owned by
# the service user so named Docker volumes inherit usable permissions.
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs \
&& chown -R vibe:vibe /app
USER vibe
# Default port
EXPOSE 8899
+30
View File
@@ -0,0 +1,30 @@
include LICENSE
include NOTICE
include README.md
include README_zh.md
include README_ja.md
include README_ko.md
include README_ar.md
include pyproject.toml
include Dockerfile
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.
recursive-include agent/tests *.py
recursive-exclude agent/tests _*.py
# Strip generated / VCS / editor noise
global-exclude __pycache__
global-exclude *.py[cod]
global-exclude *.so
global-exclude .DS_Store
global-exclude .pytest_cache
global-exclude *.egg-info
+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.
+671 -123
View File
File diff suppressed because it is too large. Load diff
+947
View File
@@ -0,0 +1,947 @@
<p align="center">
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <b>العربية</b>
</p>
<p align="center">
<img src="assets/icon.png" width="120" alt="شعار Vibe-Trading"/>
</p>
<h1 align="center">Vibe-Trading: وكيل التداول الشخصي الخاص بك</h1>
<p align="center">
<b>أمر واحد يمنح وكيلك قدرات تداول شاملة</b>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
<br>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<p align="center">
<a href="https://vibetrading.wiki/">الموقع</a> &nbsp;&middot;&nbsp;
<a href="https://vibetrading.wiki/docs/">الوثائق</a> &nbsp;&middot;&nbsp;
<a href="#-الأخبار">الأخبار</a> &nbsp;&middot;&nbsp;
<a href="#-الميزات-الرئيسية">الميزات</a> &nbsp;&middot;&nbsp;
<a href="#-حساب-الظل">حساب الظل</a> &nbsp;&middot;&nbsp;
<a href="#-العرض-التوضيحي">العرض التوضيحي</a> &nbsp;&middot;&nbsp;
<a href="#-البدء-السريع">البدء السريع</a> &nbsp;&middot;&nbsp;
<a href="#-أمثلة">أمثلة</a> &nbsp;&middot;&nbsp;
<a href="#-خادم-api">API / MCP</a> &nbsp;&middot;&nbsp;
<a href="#-خارطة-الطريق">خارطة الطريق</a> &nbsp;&middot;&nbsp;
<a href="#المساهمة">المساهمة</a>
</p>
<p align="center">
<a href="#-البدء-السريع"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
</p>
---
## 📰 الأخبار
- **2026-05-31** 🔌 **بنية وسطاء تعتمد أولاً على connectors (IBKR + Robinhood)**: يبدأ الوصول إلى التداول الآن من connector profile قابل للاختيار، لا من مداخل منفصلة للوسيط أو live. أوامر `vibe-trading connector list/use/check/account/positions/orders/quote/history` وأدوات MCP `trading_*` تشترك في نفس profile المحدد، حيث تكون paper/live مجرد خاصية ضمن connector. يمكن استخدام IBKR فوراً عبر profile محلي للقراءة فقط من TWS / IB Gateway، بينما يُزرع مسار MCP الرسمي البعيد لـ IBKR كتحقق OAuth بنطاق `mcp.read` إلى أن تتوفر أسماء أدوات قراءة مستقرة. يظل Robinhood Agentic Trading هو connector MCP حيّاً ومحدوداً خلف OAuth وmandate مُلتزم وorder guard وسجل تدقيق وhalt فوري.
- **2026-05-30** 🧰 **جولة متانة — backtest وagent loop وsession**: تمرّ الآن signal engines المولّدة بواسطة LLM بتحقق مسبق من الواجهة قبل الإنشاء، فتلتقط مبكراً الأخطاء الشائعة مثل self-import الدائري، وغياب `generate()`، ووسائط `__init__` بلا قيم افتراضية، ونوع الإرجاع الخاطئ، وتُرجِع أخطاء JSON قابلة للتنفيذ بدل traceback خام ([#149](https://github.com/HKUDS/Vibe-Trading/pull/149))؛ ومتابعةٌ لاحقة توجّه أخطاء تحقق AST على مستوى المصدر عبر نفس مغلّف JSON النظيف. لم يعد agent loop يستنزف الخمسين تكراراً ليصل إلى حالة `failed` بلا أي مخرجات — فهو يحاكي أسلوب swarm worker المُجرَّب: يحقن wrap-up nudge عند 80% من ميزانية التكرار ويُسقط تعريفات الأدوات في التكرار الأخير لفرض إجابة نصية ([#148](https://github.com/HKUDS/Vibe-Trading/pull/148))، مع حارس يجعله يُطلَق في المنتصف فقط كي لا يزيح سياق research-goal. كتابة رسائل الجلسة تجري الآن `flush + fsync` بعد كل append حتى تنجو ردود الـ AI الباهظة من تعطّل أثناء الكتابة، ويتخطّى مسار القراءة أسطر JSONL التالفة (مع تسجيل أول 200 حرف للاسترداد) بدل إعطاء 500 لنقطة `/messages` كاملة ([#147](https://github.com/HKUDS/Vibe-Trading/pull/147)). كما أصلح محرّر الإدخال في الويب معالجة Enter مع IME بحيث لا يؤدي Enter لتأكيد التركيب إلى إرسال في منتصف الكلمة ([#146](https://github.com/HKUDS/Vibe-Trading/pull/146)).
- **2026-05-29** 🔐 **دعم Robinhood Agentic Trading (اختياري، استقلالية محدودة)**: أُضيف دعم Robinhood Agentic Trading (MCP عن بُعد، OAuth). مُعطَّل وللقراءة فقط افتراضياً؛ ويتداول الوكيل تلقائياً فقط ضمن mandate يلتزم به المستخدم (الرموز / حجم الأمر / التعرّض / الرافعة / الحد اليومي)، مع kill switch فوري على مستوى الملفات، وتصفية استباقية للمراكز، وانتهاء صلاحية تلقائي لـ mandate، وسجل تدقيق كامل، و runner مستقل دائم. لا حفظ للأموال ولا تشغيل لمنصة تداول — الوسيط يحتفظ بالأموال وينفّذ، ونحن ننقل النية فقط. تجريبي / الاستخدام على مسؤوليتك.
<details>
<summary>أخبار سابقة</summary>
- **2026-05-28** 🧪 **سلامة Swarm + بوّابة alpha صارمة + MCP لعمّال swarm**: يحجب Swarm DAG الآن المهام المتفرعة عندما تفشل المهمة الأعلى ([#145](https://github.com/HKUDS/Vibe-Trading/pull/145)). دالة `run_bench_strict()` الجديدة تضيف فوق بوّابة IC تحكماً عشوائياً بنفس universe + قسمة train/test OOS لاصطياد العوامل التي تتبع beta السوق فقط ([#143](https://github.com/HKUDS/Vibe-Trading/pull/143)، شكراً @Soli22de). يستطيع عمّال Swarm الآن استدعاء أدوات من خوادم MCP خارجية يضبطها المشغل، مع تثبيت حدود الثقة باختبارات مخصصة ([#142](https://github.com/HKUDS/Vibe-Trading/pull/142)، شكراً @shadowinlife).
- **2026-05-27** 📊 **مصدر بيانات A-share عبر mootdx + تحسين الإخراج**: محمّل `mootdx` الجديد يتحدث بروتوكول 通达信 TCP الأصلي لبيانات OHLCV لأسهم A (بدون مصادقة، بدون قيود معدل لكل IP، يومي + intraday مع pagination تراجع بـ 25 صفحة)، ويُدرج بين tushare وakshare في سلسلة fallback ([#107](https://github.com/HKUDS/Vibe-Trading/issues/107)). محمّل CCXT يقرأ الآن `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY` ليعمل جلب بيانات Binance/OKX العامة من الشبكات المقيدة ([#126](https://github.com/HKUDS/Vibe-Trading/pull/126)، شكراً @ruok808). عرض الإجابة النهائية أزال أيضاً فواصل `---` الأفقية القبيحة بعرض كامل على CLI وWeb: يحث system prompt الآن agent على استخدام جداول markdown وعناوين `##`، يجرّد CLI renderer أسطر HR المستقلة كدفاع متعمق، ويخفي chat bubble أي `<hr>` ينفذ عبر ([#139](https://github.com/HKUDS/Vibe-Trading/issues/139)، شكراً @sdwxm188).
- **2026-05-26** ✅ **إغلاق دورة حياة Research Goal**: أصبح Goal mode يعمل كمنفّذ مهام حقيقي: إنشاء goal من Web UI ينشئ الجلسة أو يربطها ويرسل kickoff turn فوراً؛ يمكن متابعة active goals وتعديلها وإلغاؤها وإكمالها عبر Web/API/CLI/MCP؛ ويتقدم agent loop من لقطة goal الحالية (criteria وevidence وclaims وopen items) بدلاً من الاعتماد على prompt الأصلي فقط. عندما تكون criteria covered لكن goal لا يزال active، ينتقل النظام إلى audit/status update بدلاً من التوقف الصامت، مع تغطية انحدارية عبر backend وCLI وMCP وfrontend events.
- **2026-05-25** 🧼 **واجهة Chat أنظف + سير composer**: أصبحت واجهة Web UI تترك التركيز للمدخل التالي: انتقلت أوضاع upload وswarm وresearch-goal إلى قائمة `+` في composer بدلاً من لوحات عائمة تقاطع المحادثة. يظهر السياق النشط فوق حقل الإدخال كشرائح compact، ولا تتوسع تفاصيل goal إلا inline عند النقر على الشريحة. أزيلت طبقة i18n المخصصة القديمة لصالح نصوص إنجليزية مباشرة، وتظهر بطاقة Full Report فقط للتشغيلات ذات تقرير فعلي، كما أصبح تشغيل التطوير المحلي وتقارير الحالة أكثر ثباتاً لاختبارات browser smoke.
- **2026-05-24** 🎯 **Research Goal runtime**: أضيفت طبقة Research Goal مرتبطة بالجلسة عبر backend وCLI وAPI/MCP وSSE وWeb UI. تحفظ الأهداف claims وacceptance criteria وevidence rows وbudgets وcompletion policy؛ تستطيع agent tools إنشاء الأهداف وإضافة evidence؛ أصبح `/goal` مدخل CLI؛ تعرض REST/MCP لقطات goal وكتابات evidence؛ وتحافظ SSE على حداثة حالة chat clients. أغلقت إصلاحات audit اللاحقة مسارات verified evidence، ومنعت live-trading risk tiers عبر agent tools، وربطت goals المنشأة من CLI بالمنعطفات اللاحقة، ونظفت goal ledger عند حذف الجلسة، ووصلت replay-all، وأصلحت race في frontend snapshot بين الجلسات.
- **2026-05-23** 🖥️ **تحديث CLI التفاعلي**: تفتح واجهة الطرفية الآن ببانر Vibe-Trading أكبر، وفاصل prompt أوضح، وملخص للدورة السابقة، وتوقيت بعد التشغيل، ومسار نشاط بأسلوب Claude Code لعمل الوكيل الحي. تُعرض استدعاءات الأدوات، وجلب الويب/البيانات، وأفعال نمط shell، وإجابات Markdown، وجداول pipe كسجل أكثر قابلية للقراءة، بينما تحافظ تشغيلات pipe أو non-TTY على إخراج نصي مناسب للأتمتة. أصبحت لقطات CLI المولدة artifacts محلية بدلاً من ملفات docs ملتزم بها، مما يبقي المستودع أخف.
- **2026-05-22** 🧭 **استعادة Swarm + إبقاء MCP حياً**: أصبحت حالة Swarm تُصالح من ملفات المهام الحية عند كل قراءة، لذلك تستعيد عروض API/MCP/SSE/list التشغيلات التي تعطلت أو صارت stale بدلاً من عرض لقطة `running` للأبد. يرسل `run_swarm` نبضات MCP progress أثناء polling، مع إطار أول ثابت `swarm_started run_id=<id>` كي يستطيع العملاء استعادة المقبض بعد سقوط النقل؛ كما يصدر worker نبضات خلال LLM streaming وgrounding fetch وتنفيذ الأدوات. يستخدم stale-run reaper عتبات خاصة بكل run ويستنتج الحالة النهائية من حالات المهام. لم يعد `SwarmTool` يلغي team ما زال يعمل لمجرد انتهاء wait budget، ويمكن لعملاء MCP استدعاء `reap_stale_runs()` للتنظيف الصريح. حدّثت دفعة DX اليوم أيضاً النماذج الافتراضية للمزودين، وواءمت فحص CI syntax مع حزمة `agent/cli/` الجديدة. تغطي 22 اختباراً انحدارياً جديداً hydration، واستعادة الحالات النهائية، وجمع التشغيلات stale، وإيقاع keepalive، وتحمل env parsing، وربط heartbeat؛ ومجموعة swarm/MCP الكاملة عند 169 passed و4 skipped.
- **2026-05-21** 🧱 **إعادة هيكلة حزمة CLI**: تقسيم `agent/cli.py` (3216 سطراً) إلى حزمة `agent/cli/` — واجهة تفاعلية، موجّه slash، مكوّنات Rich، وطبقة `_legacy.py` تحافظ على كل الأوامر الفرعية وتعيد تصدير كل الرموز العامة فتبقى `cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` كما هي. Middleware جديد في FastAPI يخدم قشرة SPA عند فتح `/runs/{id}` أو `/correlation` مباشرة من المتصفح، مع نفس التضييق في بروكسي Vite للتطوير. توحيد سلسلة الإصدار عبر `cli/_version.py` (إنهاء الانحراف بين `--version` والبانر)، استعادة `python -m cli` عبر `__main__.py`، وتضييق بوابة chat بحيث تصل `chat --help` / `chat extra` إلى argparse القديم بدلاً من ابتلاع REPL لها.
- **2026-05-20** 🔬 **Hypothesis Registry CLI**: استكمال جانب CLI لـ Hypothesis Registry الذي شُحن backend فقط في 2026-05-16. يُخرج `vibe-trading hypothesis list` جدول Rich أو JSON (مع فلتر `--status` و`--limit`)؛ يعرض `show <id>` لوحة تفاصيل تتضمن run cards المرتبطة؛ يقلب `invalidate <id> --note "..."` الحالة إلى `rejected` ويُبقي ملاحظات الإبطال السابقة عند حذف `--note`. متغير البيئة `VIBE_TRADING_HYPOTHESES_PATH` ما زال مدعوماً، مع إضافة `--path` لكل استدعاء. تغطي 22 اختباراً جديداً الربط، إخراج JSON، فلتر الحالة، الحد، أخطاء معرّف مفقود، وثبات الملاحظات.
- **2026-05-19** ✨ **تغذية راجعة حيّة للأدوات + إلغاء سلس**: لم تعد الأدوات الطويلة (backtests، PDF كبيرة، عمّال swarm) تبدو متجمدة. كل استدعاء أداة يُصدر الآن نبضة قلب كل 3 ثوانٍ، بالإضافة إلى تقدّم مرحلي مهيكل — يُظهر `run_backtest` علامات الأطوار (`validate` / `simulate` / `finalize`)، ويُحدّث `read_document` عدّاد كل صفحة على PDF أو كل ورقة على Excel، ويُعلِم `read_url` بمرحلتي `fetch` / `parse`. تعرض لوحة Rich Live في CLI دوّاراً Unicode وشريط تقدّم ASCII وETA، وتُكدّس حتى 3 أدوات متوازية مفهرسة بالاسم. تضيف الواجهة الأمامية مكوّن `ToolProgressIndicator` جديد مع تجميع rAF، وARIA `role="status"` + `<progress>` أصلي مخفي لقارئات الشاشة، وSVG `ProgressRing` حتمي عندما يكون المجموع معروفاً. أول `Ctrl+C` أثناء تشغيل CLI يستدعي الآن `agent.cancel()` للخروج السلس (تكتمل الخطوة الحالية وتُغلق التتبعات بنظافة)، والثاني خلال ثانيتين يفرض الإنهاء. تم استخراج عناصر أساسية قابلة لإعادة الاستخدام: `ProgressBar.tsx` و`lib/tools.ts` (تعيين i18n لأسماء الأدوات المشترك).
- **2026-05-18** 🧹 **تنظيف + إصلاح 3 أخطاء كامنة**: لم يعد `CompositeEngine` يوجّه رموز العقود الآجلة الصينية بدون لاحقة (مثل `RB2410`) إلى `GlobalFuturesEngine` بشكل خاطئ — انتقل `_is_china_futures` إلى وحدة `_market_hooks` المشتركة مع تطبيع حالة جدول المنتجات + حارس لبورصة غير صينية، وأُضيفت 9 حالات اختبار انحدار. تحفظ فهارس FTS5 للجلسات الآن الطوابع الزمنية، فيمكن لبحث الجلسات الفرز بالتاريخ، ونفس التغيير أصلح مسار إعادة الإدراج الذي كان يستبدل `started_at` بساعة الحائط في كل مرة. أُضيف `/alpha` المفقود إلى بروكسي تطوير Vite، فتُحلّ صفحة AlphaZoo الآن على `npm run dev`. تم تقييد `tests/test_e2e_harness_v2.py` (مجموعة e2e بـ LLM حقيقي) خلف `VIBE_TRADING_RUN_LIVE_E2E=1` كي لا تغيّر CI شكلها بناءً على وجود مفتاح البيئة. أُضيفت إلى ruff قاعدة `per-file-ignores` لمكتبة المعاملات (الضوضاء F401 من 3783 إلى 0)، وفُعِّلت `noUnusedLocals` / `noUnusedParameters` في tsconfig الواجهة كحواجز انحدار، وحُذف 76 سطراً من نموذج `vw = vwap(...)` غير المستخدم في ملفات `gtja191`. الصافي **-918 سطراً**.
- **2026-05-17** 🧬 **Alpha Zoo v1 (0.1.8)**: 452 ألفا كمّي جاهز عبر 4 zoos — `qlib158` (ميزات Alpha158 من Microsoft Qlib، إسناد Apache-2.0)، `alpha101` (إعادة تنفيذ "101 Formulaic Alphas" من Kakushadze بناءً على ورقة arXiv:1601.00991)، `gtja191` (تقرير بحث Guotai Junan 2014 لعوامل تداول قصيرة الأجل)، `academic` (Fama-French 5 + Carhart momentum كـ proxy قائم على الأسعار). سطر أوامر واحد للـ bench على أي universe: `vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`. تتضمن بوابة AST للنقاء، اختبار حماية lookahead، عزل الشبكة عبر `pytest-socket`، LICENSE.md لكل zoo، وسير عمل توقيع DCO لمساهمات المجتمع. تقديم Alpha Library تلقائياً على [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/)، مع منشور Research Lab [Which of the 191 GTJA alphas still work in 2026?](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html).
- **2026-05-16** 🧪 **تحديث عمود البحث**: أضيف backend Hypothesis Registry مع `create_hypothesis` و`update_hypothesis` و`link_backtest` و`search_hypotheses`. تضيف قارئات المحتوى الخارجي الآن `security_warnings` تحذيرية فقط، وانتقل ماسح Shadow Account من calendar-phase stub القديم إلى تقييم حتمي لميزات OHLCV.
- **2026-05-15** 🪪 تعرض صفحة تفاصيل الـ run الآن بطاقة Trust Layer run card إلى جانب المقاييس والمخرجات، لتكمل الجانب الواجهي من عمل `run_card.json` الذي هبط في 2026-05-12. كما تم تعزيز `PersistentMemory.add()` على مسارات الطول والأسماء الفارغة أو التي تحتوي على فراغات فقط وبايتات التحكم C0/C1 ضمن فرز #108/#109/#110 ([#112](https://github.com/HKUDS/Vibe-Trading/pull/112)، شكراً @Teerapat-Vatpitak).
- **2026-05-14** 🌐 أصبح الويكي العام متاحاً على [vibetrading.wiki](https://vibetrading.wiki/) مع أقسام docs وtutorials وResearch Lab وAlpha Library، ويُنشر عبر Cloudflare Pages. أصبحت الذاكرة الدائمة أيضاً قابلة للفحص من سطر الأوامر عبر `vibe-trading memory list/show/search/forget` ([#102](https://github.com/HKUDS/Vibe-Trading/pull/102)، شكراً @Teerapat-Vatpitak)، كما يدعم توليد الرموز وslugs للذاكرة الآن التايلاندية والعربية والعبرية والنص السيريلي ([#104](https://github.com/HKUDS/Vibe-Trading/pull/104)).
- **2026-05-13** 🧭 أصبحت تشغيلات السرب تؤسس عمل الوكلاء على بيانات سوق مجلوبة مسبقاً، مع تقارير محفوظة أنظف ([#93](https://github.com/HKUDS/Vibe-Trading/pull/93)، [#84](https://github.com/HKUDS/Vibe-Trading/pull/84)).
- **2026-05-12** 🧾 أصبحت الاختبارات الرجعية تنتج `run_card.json` و`run_card.md` إلى جانب المخرجات لدعم تشغيلات بحثية قابلة لإعادة الإنتاج.
- **2026-05-11** 🧭 **Memory slugs، ومحاسبة السرب، وفحص CLI المسبق**: أصبحت الذاكرة الدائمة تحفظ أحرف CJK عند توليد slugs للملفات، مما يمنع اصطدامات أسماء صامتة لملاحظات الصينية/اليابانية/الكورية ([#95](https://github.com/HKUDS/Vibe-Trading/pull/95)، شكراً @voidborne-d). تفضل مجاميع تشغيل السرب الآن استخدام استهلاك الرموز المبلغ من المزود مع الإبقاء على التقدير الاحتياطي الحالي ([#94](https://github.com/HKUDS/Vibe-Trading/pull/94)، شكراً @Teerapat-Vatpitak)، كما حصلت واجهة تشغيل CLI على فحص بدء مبكر للمشكلات البيئية الشائعة ([#96](https://github.com/HKUDS/Vibe-Trading/pull/96)، شكراً @ykykj).
- **2026-05-10** 🧱 **حواجز انحدار وبيانات تشغيل وصفية**: أصبح استدعاء الذاكرة يتعامل مع الشرطات السفلية كحدود رموز، لذلك تطابق ذكريات `snake_case` مثل `mcp_wiring_test` استعلامات طبيعية مثل "mcp wiring" ([#87](https://github.com/HKUDS/Vibe-Trading/pull/87)، شكراً @hp083625). يملك خادم MCP الآن اختبار smoke عبر subprocess يغطي initialize → `tools/list` → `tools/call` لحماية مسار التعطل في أول استدعاء ([#86](https://github.com/HKUDS/Vibe-Trading/pull/86))، كما وصلت تحسينات منخفضة المخاطر لاختبارات مسارات Windows، ومعالجة استثناءات API best-effort، والتحقق من allowed-root في `run_dir` للاختبار الرجعي، وبيانات provider/model في SwarmRun ([#88](https://github.com/HKUDS/Vibe-Trading/pull/88)، [#90](https://github.com/HKUDS/Vibe-Trading/pull/90)، [#91](https://github.com/HKUDS/Vibe-Trading/pull/91)، [#92](https://github.com/HKUDS/Vibe-Trading/pull/92)، شكراً @Teerapat-Vatpitak).
- **2026-05-09** 🛡️ **تعزيز مسارات API واستقرار خادم MCP**: تتحقق مسارات run/session في API الآن من معرفات المسار قبل البحث، وترفض المعاملات المشوهة التي تحتوي على أسطر جديدة مع تثبيت السلوك في مجموعة اختبارات auth/security ([#80](https://github.com/HKUDS/Vibe-Trading/pull/80)، شكراً @SJoon99). يسخن خادم MCP سجل الأدوات على الخيط الرئيسي قبل خدمة `tools/call` لتجنب تعطل أول استدعاء في اكتشاف الأدوات الكسول ([#85](https://github.com/HKUDS/Vibe-Trading/pull/85)، شكراً @Teerapat-Vatpitak). كما يحترم Vite dev proxy المتغير `VITE_API_URL` لأهداف الخلفية غير الافتراضية ([#82](https://github.com/HKUDS/Vibe-Trading/pull/82)، شكراً @voidborne-d).
- **2026-05-08** 🧾 **حقول قوائم Tushare داخل المرشحات**: تستطيع اختبارات أسهم A اليومية الآن طلب حقول قوائم مالية آمنة زمنياً عبر `fundamental_fields`، بحيث يمكن لمحركات الإشارات الفرز على أعمدة مثل `income_total_revenue` و`income_n_income` و`balancesheet_total_hldr_eqy_exc_min_int` و`fina_indicator_roe` بعد تواريخ الإعلان/الإفصاح ([#76](https://github.com/HKUDS/Vibe-Trading/pull/76)، شكراً @mrbob-git). ويجعل التعزيز اللاحق طلب حقول القوائم الصريح يفشل سريعاً إذا تعذر تشغيل إثراء Tushare، بدلاً من الرجوع بصمت إلى أشرطة الأسعار الخام ([#77](https://github.com/HKUDS/Vibe-Trading/pull/77)).
- **2026-05-07** 📈 **أساسيات Tushare وفرز المجتمع**: أضيف عقد `TushareFundamentalProvider` بنمط point-in-time لتدفقات البحث الأساسي، مع تغطية انحدار لمسار متغير البيئة `TUSHARE_TOKEN` في المشروع ([#74](https://github.com/HKUDS/Vibe-Trading/pull/74)). كما أوضح فرز المجتمع أن Vibe-Trading يركز حالياً على لغة واجهة واحدة لتسريع التكرار، ويتجنب تبعيات بحث زائدة ما دام `web_search` المدعوم من DuckDuckGo مضمناً، ويتعامل مع النشر المستضاف غير الرسمي كمكان غير موثوق لمفاتيح API أو رموز مصادر البيانات.
- **2026-05-06** 🚀 **إصدار v0.1.7** ([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7)، `pip install -U vibe-trading-ai`): نُشر تعزيز حدود الأمان على PyPI وClawHub، ويغطي افتراضات أكثر أماناً للـ API/القراءة/الرفع/الملفات/URL/الكود المولد/أدوات shell/Docker مع إبقاء تدفقات CLI/Web UI المحلية سهلة. تشمل الدورة أيضاً Web UI Settings، وخريطة ارتباط حرارية، وOpenAI Codex OAuth، ومرشح A-share pre-ST، وتحسين CLI التفاعلي، وفحص swarm presets، وتحليل التوزيعات، وصقل سير التطوير، ورفع حدود أمان تبعيات بناء الواجهة. شكراً لمساهمي 0.1.7 وlemi9090 (S2W) على التحقق الأمني المنسق.
- **2026-05-05** 🛡️ **متابعة حدود الأمان**: استكمال تعزيز الأمان حول CORS origins الصريحة، ومؤشرات بيانات الاعتماد في Settings، وقراءة عناوين الويب، وتوليد كود Shadow Account، مع اختبارات انحدار لكل مسار. تبقى تدفقات CLI/Web UI على localhost كما هي؛ وعلى عمليات النشر البعيدة استخدام `API_AUTH_KEY` وorigins موثوقة صريحة.
- **2026-05-04** 🖥️ **تجربة CLI تفاعلية وتنظيف CI**: يعرض الوضع التفاعلي الآن شريط حالة سفلياً مباشراً يبين provider/model ومدة الجلسة وكمون آخر تشغيل وإحصاءات استدعاءات الأدوات، مع تصفح سجل الأوامر وتحرير المؤشر بمفاتيح الأسهم عبر `prompt_toolkit` ([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). يعود CLI إلى Rich prompts عند غياب `prompt_toolkit` أو TTY. كما وُئمت توقعات مسارات CI مع صندوق استيراد الملفات المعزز وحل `/tmp` عبر المنصات، فعاد main إلى الأخضر ([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
- **2026-05-03** 🛡️ **تصحيح تعزيز الأمان**: يشدد مصادقة API الافتراضية للنشر غير المحلي، ويحمي قراءات run/session/swarm الحساسة، ويقيد حدود الرفع وقراءة الملفات المحلية، ويقيد أدوات shell بحسب نقطة الدخول، ويتحقق من تحميل الاستراتيجيات المولدة قبل الاستيراد، ويشغل صورة Docker كمستخدم غير root مع منفذ localhost فقط افتراضياً. تبقى تدفقات CLI وWeb UI المحلية سهلة؛ وعلى نشر API/Web البعيد ضبط `API_AUTH_KEY`.
- **2026-05-02** 🧭 **تحليل التوزيعات وخارطة طريق أوضح**: أضيفت مهارة `dividend-analysis` لأسهم الدخل، واستدامة التوزيعات، ونموها، وعائد المساهمين، وآليات ex-dividend، وفحص مصائد العائد، مع تثبيتها باختبارات انحدار للمهارات المضمنة. تركز خارطة الطريق العامة الآن على Research Autopilot وData Bridge وOptions Lab وPortfolio Studio وAlpha Zoo وResearch Delivery وTrust Layer ومشاركة Community.
- **2026-05-01** 🔥 **خريطة ارتباط حرارية وOpenAI Codex OAuth ومرشح A-share pre-ST**: لوحة/API ارتباط جديدة تحسب ارتباطات العوائد المتحركة وتعرض خريطة حرارية ECharts لتحليل المحافظ والرموز ([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). يدعم مزود OpenAI Codex الآن ChatGPT OAuth عبر `vibe-trading provider login openai-codex` مع بيانات Settings واختبارات انحدار للمحول ([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). أضيفت وعُززت مهارة `ashare-pre-st-filter` لفحص مخاطر ST/*ST في أسهم A، مع فلترة صلة عقوبات Sina حتى لا تضخم إشارات حسابات الأوراق المالية عدادات E2 ([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
- **2026-04-30** ⚙️ **Web UI Settings وتعزيز validation CLI**: صفحة Settings جديدة لمزود/نموذج LLM، وbase URL، وreasoning effort، وبيانات اعتماد مصادر البيانات، مدعومة بواجهات settings API محلية/محمية وببيانات مزودين قابلة للتكوين ([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). كما تعزز `python -m backtest.validation <run_dir>` حتى تفشل المدخلات الناقصة أو الفارغة أو المشوهة أو غير الموجودة أو غير الدليل برسائل واضحة قبل بدء التحقق ([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
- **2026-04-28** 🚀 **إصدار v0.1.6** (`pip install -U vibe-trading-ai`): إصلاح إرجاع `vibe-trading --swarm-presets` فارغاً بعد `pip install` / `uv tool install` ([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))، حيث أصبحت ملفات preset YAML مضمنة داخل حزمة `src.swarm` ومثبتة بستة اختبارات انحدار. كما أصبح محمل AKShare يوجه ETFs مثل `510300.SH` والفوركس مثل `USDCNH` إلى النقاط الصحيحة مع fallback registry معزز. يجمع الإصدار كل ما بعد v0.1.5: لوحة مقارنة معيارية، بث `/upload` وحدود الحجم، محمل Futu (HK + A-share)، مهارة تصدير vnpy، تعزيز أمني، وتحميل واجهة كسول من 688KB إلى 262KB.
- **2026-04-27** 📊 **لوحة مقارنة معيارية وأمان الرفع**: مخرجات الاختبار الرجعي تتضمن الآن لوحة مقارنة معيارية (ticker / benchmark return / excess return / information ratio) مع حل عبر yfinance لـ SPY وCSI 300 وغيرها ([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). كما تبث `/upload` جسم الطلب في أجزاء 1 MB وتتوقف بعد `MAX_UPLOAD_SIZE`، مما يحد الذاكرة تحت العملاء الضخمين/المشوهين ([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))، ومثبتة بأربعة اختبارات انحدار.
- **2026-04-22** 🛡️ **تعزيز وتكاملات جديدة**: فرض احتواء المسارات في `safe_path` وصندوق أدوات journal/shadow، وإرسال `.env.example` / الاختبارات / ملفات Docker في sdist عبر `MANIFEST.in`، وتصغير الحزمة الأولية للواجهة من 688KB إلى 262KB عبر التحميل الكسول على مستوى المسارات. إضافة محمل Futu لأسهم HK وA-share ([#47](https://github.com/HKUDS/Vibe-Trading/pull/47)) ومهارة تصدير vnpy CtaTemplate ([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)).
- **2026-04-21** 🛡️ **مساحة العمل والوثائق**: تطبيع `run_dir` النسبي إلى دليل التشغيل النشط ([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). أمثلة استخدام README ([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
- **2026-04-20** 🔌 **Reasoning وSwarm**: الحفاظ على `reasoning_content` عبر جميع مسارات `ChatOpenAI`، لتعمل أفكار Kimi / DeepSeek / Qwen من البداية للنهاية ([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). بث Swarm وإيقاف Ctrl+C نظيف ([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
- **2026-04-19** 📦 **v0.1.5**: النشر إلى PyPI وClawHub. رفع حد `python-multipart` لسد CVE، وربط 5 أدوات MCP جديدة (`analyze_trade_journal` + 4 أدوات shadow-account)، وإصلاح سجل `pattern_recognition` → `pattern`، ومطابقة تبعيات Docker، ومزامنة بيان SKILL (22 أداة MCP / 71 مهارة).
- **2026-04-18** 👥 **Shadow Account**: استخرج قواعد استراتيجيتك من سجل وسيط → اختبر الظل عبر الأسواق → تقرير HTML/PDF من 8 أقسام يوضح ما تتركه على الطاولة (خرق القواعد، الخروج المبكر، الإشارات الفائتة، الصفقات المضادة). 4 أدوات جديدة، ومهارة واحدة، و32 أداة إجمالاً. أمثلة Trade Journal + Shadow Account موجودة الآن في شاشة ترحيب Web UI.
- **2026-04-17** 📊 **محلل سجل التداول وقارئ ملفات شامل**: ارفع صادرات الوسطاء (同花顺/东财/富途/generic CSV) → ملف تداول تلقائي (أيام الاحتفاظ، معدل الربح، نسبة PnL، التراجع) + 4 تشخيصات سلوكية (disposition effect، الإفراط في التداول، مطاردة الزخم، anchoring). أصبح `read_document` يوجه PDF وWord وExcel وPowerPoint والصور (OCR) و40+ صيغة نصية خلف استدعاء موحد.
- **2026-04-16** 🧠 **Agent Harness**: ذاكرة دائمة عبر الجلسات، بحث جلسات FTS5، مهارات ذاتية التطور (CRUD كامل)، ضغط سياق بخمس طبقات، وتجميع أدوات القراءة/الكتابة. 27 أداة، و107 اختبارات جديدة.
- **2026-04-15** 🤖 **Z.ai + MiniMax**: مزود Z.ai ([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))، وإصلاح temperature في MiniMax وتحديث النموذج ([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). 13 مزوداً.
- **2026-04-14** 🔧 **استقرار MCP**: إصلاح خطأ `Connection closed` في أداة الاختبار الرجعي على نقل stdio ([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
- **2026-04-13** 🌐 **اختبار رجعي مركب عبر الأسواق**: محرك `CompositeEngine` جديد يختبر محافظ مختلطة الأسواق (مثل أسهم A + crypto) بمجمع رأس مال مشترك وقواعد لكل سوق. كما أصلح fallback لمتغيرات قالب السرب ومهلة الواجهة.
- **2026-04-12** 🌍 **تصدير متعدد المنصات**: يصدر `/pine` الاستراتيجيات إلى TradingView (Pine Script v6)، وTDX (通达信/同花顺/东方财富)، وMetaTrader 5 (MQL5) بأمر واحد.
- **2026-04-11** 🛡️ **الموثوقية وتجربة المطور**: إعداد `.env` عبر `vibe-trading init` ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))، وفحوصات مسبقة، وfallback لمصادر البيانات وقت التشغيل، ومحرك اختبار رجعي معزز. README متعدد اللغات ([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
- **2026-04-10** 📦 **v0.1.4**: إصلاح Docker ([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))، وأداة MCP `web_search`، و12 مزود LLM، وتبعيات `akshare`/`ccxt`. النشر إلى PyPI وClawHub.
- **2026-04-09** 📊 **الموجة الثانية للاختبار الرجعي**: محركات ChinaFutures وGlobalFutures وForex وOptions v2. تحقق Monte Carlo وBootstrap CI وWalk-Forward.
- **2026-04-08** 🔧 **اختبار رجعي متعدد الأسواق** مع قواعد لكل سوق، وتصدير Pine Script v6، و5 مصادر بيانات مع fallback تلقائي.
</details>
---
## ✨ الميزات الرئيسية
<div align="center">
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="وكيل تداول ذاتي التحسن"/><br>
<h3>🔍 وكيل تداول ذاتي التحسن</h3>
<div align="left">
• بحث سوقي باللغة الطبيعية<br>
• مسودات استراتيجيات وتحليل ملفات/ويب<br>
• تدفقات عمل مدعومة بالذاكرة
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="فرق تداول متعددة الوكلاء"/><br>
<h3>🐝 فرق تداول متعددة الوكلاء</h3>
<div align="left">
• فرق استثمار وكمّ وكريبتو ومخاطر<br>
• تقدم مباشر وتقارير محفوظة<br>
• وكلاء مؤسسون على بيانات سوق مجلوبة
</div>
</td>
</tr>
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="بيانات واختبارات رجعية عبر الأسواق"/><br>
<h3>📊 بيانات واختبارات رجعية عبر الأسواق</h3>
<div align="left">
• أسهم A/HK/US، وكريبتو، وعقود آجلة، وفوركس<br>
• fallback للبيانات واختبارات مركبة<br>
• بيانات PIT، وتحقيق، وبطاقات تشغيل
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
<h3>👥 Shadow Account</h3>
<div align="left">
• تشخيص سلوكي لسجلات الوسطاء<br>
• مقارنات Shadow Account قائمة على القواعد<br>
• تقارير تدقيق وكود استراتيجية قابلان للتصدير
</div>
</td>
</tr>
</table>
</div>
## 💡 ما هو Vibe-Trading؟
Vibe-Trading مساحة عمل بحثية مفتوحة المصدر تحول الأسئلة المالية إلى تحليل قابل للتشغيل. يربط المطالبات باللغة الطبيعية بمحملات بيانات السوق، وتوليد الاستراتيجيات، ومحركات الاختبار الرجعي، والتقارير، والتصدير، وذاكرة البحث الدائمة.
صُمم للبحث والمحاكاة والاختبار الرجعي — وعند اختيارك، يتيح أيضاً تداولاً مستقلاً عبر وسيط تُصرّح به بنفسك (مثل Robinhood Agentic Trading). لا يحتفظ بأي أموال، ولا يتداول أبداً خارج الحدود التي تضعها، ويمكنك إيقافه فوراً.
---
## ✨ ما الذي يمكنك فعله؟
| المهمة | الناتج |
|------|--------|
| **طرح سؤال تداول** | بحث سوقي باستخدام الأدوات والبيانات والمستندات وسياق جلسة قابل لإعادة الاستخدام. |
| **اختبار فكرة استراتيجية رجعياً** | كود استراتيجية، ومقاييس، وسياق معياري، ومخرجات تحقق، وبطاقات تشغيل. |
| **مراجعة صفقاتك الخاصة** | قراءة سجلات الوسطاء، وتشخيص السلوك، واستخراج القواعد، ومقارنات Shadow Account. |
| **تحسين الأبحاث المتكررة** | الذاكرة الدائمة والمهارات القابلة للتحرير تحول الروتينات المفيدة إلى تدفقات قابلة لإعادة الاستخدام. |
| **تشغيل فرق محللين** | مراجعات بحث متعددة الوكلاء لتدفقات الاستثمار والكم والكريبتو والماكرو والمخاطر. |
| **إنتاج مخرجات قابلة للاستخدام** | تقارير، وTradingView Pine Script، وTDX، وMetaTrader 5، وأدوات MCP، وجلسات بحث لاحقة. |
| **bench ألفا zoo جاهزة** | تشغيل IC + IR + تصنيف alive/reversed/dead عبر 452 ألفا (Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart) بسطر أوامر واحد على universe الخاص بك. |
---
## ⚡ مثال سريع
```bash
pip install vibe-trading-ai
# بحث بلغة طبيعية
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
# bench لـ alpha zoo جاهز بسطر واحد
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 👥 حساب الظل
ينطلق Shadow Account من سجلات تداولك أنت، لا من قالب استراتيجية عام.
ارفع تصديراً من وسيطك، ودع الوكيل يلخص سلوكك، ثم قارن مسار التداول الحقيقي باستراتيجية ظل قائمة على قواعد.
| الخطوة | ناتج الوكيل |
|------|--------------|
| **1. قراءة سجلك** | يقرأ صادرات الوسطاء من 同花顺 و东方财富 و富途 وصيغ CSV العامة. |
| **2. بناء ملف سلوكك** | أيام الاحتفاظ، ومعدل الربح، ونسبة PnL، والتراجع، وdisposition effect، والإفراط في التداول، ومطاردة الزخم، وفحوصات anchoring. |
| **3. استخراج قواعدك** | يحول أنماط الدخول/الخروج المتكررة إلى ملف استراتيجية صريح بدلاً من ملخص ضبابي. |
| **4. تشغيل الظل** | يختبر القواعد المستخرجة رجعياً ويبرز خرق القواعد، والخروج المبكر، والإشارات الفائتة، ومسارات التداول البديلة. |
| **5. تسليم التقرير** | ينتج تقرير HTML/PDF يمكن فحصه أو أرشفته أو تحسينه في جلسة لاحقة. |
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 🧪 سير البحث
تتبع أغلب التشغيلات مسار أدلة واحداً: توجيه الطلب، تحميل سياق السوق المناسب، تنفيذ الأدوات، التحقق من المخرجات، وإبقاء المخرجات قابلة للفحص.
| الطبقة | ما يحدث |
|-------|--------------|
| **Plan** | يختار المهارات المالية والأدوات ومصادر البيانات وإعداد السرب الملائمة عند الحاجة. |
| **Ground** | يجلب أسهم A، وأسهم HK/US، والكريبتو، والعقود الآجلة، والفوركس، والمستندات، أو سياق الويب عبر المحملات المتاحة. |
| **Execute** | يولد كود استراتيجية قابل للاختبار، ويشغل الأدوات، ويستخدم محرك الاختبار الرجعي أو سير التحليل المناسب. |
| **Validate** | يضيف المقاييس، والمقارنة المعيارية، وMonte Carlo، وBootstrap، وWalk-Forward، وبطاقات التشغيل، والتحذيرات عند اللزوم. |
| **Deliver** | يعيد التقارير والمخرجات وآثار الأدوات والتصديرات إلى TradingView وTDX وMetaTrader 5 وعملاء MCP أو جلسات لاحقة. |
---
## 🔩 القدرات التفصيلية
القوائم التفصيلية مطوية أدناه حتى يبقى README سهل القراءة. افتحها عندما تريد فحص اللبنات المتاحة.
<details>
<summary><b>مكتبة المهارات المالية</b> <sub>75 مهارة عبر 8 فئات</sub></summary>
- 📊 75 مهارة مالية متخصصة منظمة في 8 فئات
- 🌐 تغطية كاملة من الأسواق التقليدية إلى الكريبتو وDeFi
- 🔬 قدرات شاملة من مصادر البيانات إلى البحث الكمي
| الفئة | المهارات | أمثلة |
|----------|--------|----------|
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
</details>
<details>
<summary><b>فرق تداول جاهزة</b> <sub>29 إعداد سرب مسبق</sub></summary>
- 🏢 29 فريق وكلاء جاهزاً للاستخدام
- ⚡ تدفقات مالية مهيأة مسبقاً
- 🎯 إعدادات للاستثمار والتداول وإدارة المخاطر
| الإعداد | سير العمل |
|--------|----------|
| `investment_committee` | مناظرة صعود/هبوط → مراجعة مخاطر → قرار مدير المحفظة النهائي |
| `global_equities_desk` | باحث أسهم A + HK/US + كريبتو → استراتيجي عالمي |
| `crypto_trading_desk` | تمويل/أساس + تصفية + تدفق → مدير مخاطر |
| `earnings_research_desk` | أساسيات + مراجعات + خيارات → استراتيجي أرباح |
| `macro_rates_fx_desk` | أسعار فائدة + FX + سلع → مدير محفظة ماكرو |
| `quant_strategy_desk` | فرز + بحث عوامل → اختبار رجعي → تدقيق مخاطر |
| `technical_analysis_panel` | TA كلاسيكي + Ichimoku + harmonic + Elliott + SMC → إجماع |
| `risk_committee` | تراجع + مخاطر ذيل + مراجعة نظام → اعتماد |
| `global_allocation_committee` | أسهم A + كريبتو + HK/US → تخصيص عبر الأسواق |
<sub>بالإضافة إلى أكثر من 20 إعداداً متخصصاً آخر — شغل vibe-trading --swarm-presets لاستكشافها كلها.
</sub>
</details>
<details>
<summary><b>Alpha Zoo</b> <sub>452 ألفا كمّي جاهز عبر 4 zoos</sub></summary>
- 🧬 452 ألفا cross-sectional، مع منع lookahead على طبقة العوامل (operators)
- 📈 IC + IR + تصنيف alive/reversed/dead بأمر CLI واحد
- 🔬 بوابة نقاء AST + اختبار حماية lookahead بـ 300 صف + قاطع شبكة عبر `pytest-socket`
- 📦 إسناد Apache-2 لـ Qlib؛ ملف `LICENSE.md` لكل zoo يصرّح بأن الصيغ محتوى رياضي
- 🤝 سير عمل توقيع Developer Certificate of Origin (DCO) لمساهمات المجتمع
| Zoo | العدد | المصدر | الرخصة |
|-----|-------|--------|--------|
| **qlib158** | 154 | Microsoft Qlib `Alpha158` (Apache-2.0، مثبّت على commit) | Apache-2.0 |
| **alpha101** | 101 | Kakushadze (2015)، "101 Formulaic Alphas"، arXiv:1601.00991 | الصيغ محتوى رياضي |
| **gtja191** | 191 | Guotai Junan (2014)، "191 Short-period Trading Alpha Factors" | الصيغ محتوى رياضي |
| **academic** | 6 | Fama-French 5 + Carhart momentum (proxy قائم على الأسعار) | أدبيات أكاديمية عامة |
شغّل `vibe-trading alpha list` للتصفح، و`vibe-trading alpha show <id>` للحصول على الصيغ + المصدر، و`vibe-trading alpha bench --zoo X --universe Y --period Z` لتقييم zoo كاملة.
</details>
## 🎬 العرض التوضيحي
<div align="center">
<table>
<tr>
<td width="50%">
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
</td>
<td width="50%">
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
</td>
</tr>
<tr>
<td colspan="2" align="center"><sub>☝️ اختبار رجعي باللغة الطبيعية ومناظرة سرب متعدد الوكلاء — Web UI + CLI</sub></td>
</tr>
</table>
</div>
---
## 🚀 البدء السريع
### تثبيت بسطر واحد (PyPI)
```bash
pip install vibe-trading-ai
```
ثم شغل أول مهمة بحثية:
```bash
vibe-trading init
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
```
> **اسم الحزمة مقابل الأوامر:** حزمة PyPI هي `vibe-trading-ai`. بعد التثبيت تحصل على ثلاثة أوامر:
>
> | الأمر | الغرض |
> |---------|---------|
> | `vibe-trading` | CLI / TUI تفاعلي |
> | `vibe-trading serve` | تشغيل خادم ويب FastAPI |
> | `vibe-trading-mcp` | بدء خادم MCP (لـ Claude Desktop وOpenClaw وCursor وغيرها) |
```bash
vibe-trading init # interactive .env setup
vibe-trading # launch CLI
vibe-trading serve --port 8899 # launch web UI
vibe-trading-mcp # start MCP server (stdio)
```
### أو اختر مساراً
| المسار | الأنسب لـ | الوقت |
|------|----------|------|
| **A. Docker** | التجربة الآن، دون إعداد محلي | دقيقتان |
| **B. تثبيت محلي** | التطوير والوصول الكامل إلى CLI | 5 دقائق |
| **C. MCP plugin** | وصله بوكيلك الحالي | 3 دقائق |
| **D. ClawHub** | أمر واحد دون استنساخ | دقيقة واحدة |
### المتطلبات المسبقة
- **مفتاح API لنموذج LLM** من أي مزود مدعوم — أو التشغيل محلياً عبر **Ollama** (لا يحتاج مفتاحاً)
- **Python 3.11+** للمسار B
- **Docker** للمسار A
- يمكن استخدام OpenAI Codex أيضاً عبر ChatGPT OAuth: اضبط `LANGCHAIN_PROVIDER=openai-codex`، ثم شغل `vibe-trading provider login openai-codex`. هذا لا يستخدم `OPENAI_API_KEY`.
> **مزودو LLM المدعومون:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama (local). راجع `.env.example` للإعداد.
> **نصيحة:** تعمل كل الأسواق دون مفاتيح API بفضل fallback التلقائي. yfinance (HK/US)، وOKX (crypto)، وmootdx (أسهم A، اتصال TCP مباشر بدون قيود IP)، وAKShare (A-shares, US, HK, futures, forex) كلها مجانية. رمز Tushare اختياري — mootdx هو الـ fallback الموصى به لأسهم A بدون رمز، بينما يوفر AKShare احتياطياً أوسع تغطية.
### المسار A: Docker (دون إعداد)
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
cp agent/.env.example agent/.env
# Edit agent/.env — uncomment your LLM provider and set API key
docker compose up --build
```
افتح `http://localhost:8899`. الخلفية والواجهة الأمامية داخل حاوية واحدة.
ينشر Docker الخلفية على `127.0.0.1:8899` افتراضياً ويشغل التطبيق كمستخدم حاوية غير root. إذا كنت تقصد تعريض API خارج جهازك، فاضبط `API_AUTH_KEY` قوياً وأرسل `Authorization: Bearer <key>` من العملاء.
### المسار B: التثبيت المحلي
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
# Activate
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -e .
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
vibe-trading # Launch interactive TUI
```
<details>
<summary><b>تشغيل واجهة الويب (اختياري)</b></summary>
```bash
# Terminal 1: API server
vibe-trading serve --port 8899
# Terminal 2: Frontend dev server
cd frontend && npm install && npm run dev
```
افتح `http://localhost:5899`. تمرر الواجهة الأمامية استدعاءات API إلى `localhost:8899`.
**وضع الإنتاج (خادم واحد):**
```bash
cd frontend && npm run build && cd ..
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
```
</details>
### المسار C: MCP plugin
راجع قسم [MCP Plugin](#-mcp-plugin) أدناه.
### المسار D: ClawHub (أمر واحد)
```bash
npx clawhub@latest install vibe-trading --force
```
تُنزل المهارة وإعداد MCP إلى مجلد مهارات وكيلك. راجع [تثبيت ClawHub](#-mcp-plugin) للتفاصيل.
---
## 🧠 متغيرات البيئة
انسخ `agent/.env.example` إلى `agent/.env` وأزل التعليق عن كتلة المزود التي تريدها. يحتاج كل مزود إلى 3-4 متغيرات:
| المتغير | مطلوب | الوصف |
|----------|:--------:|-------------|
| `LANGCHAIN_PROVIDER` | نعم | اسم المزود (`openrouter`, `deepseek`, `groq`, `ollama`, إلخ) |
| `<PROVIDER>_API_KEY` | نعم* | مفتاح API (`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, إلخ) |
| `<PROVIDER>_BASE_URL` | نعم | عنوان URL لنقطة نهاية API |
| `LANGCHAIN_MODEL_NAME` | نعم | اسم النموذج (مثل `deepseek-v4-pro`) |
| `TUSHARE_TOKEN` | لا | رمز Tushare Pro لبيانات أسهم A (يرجع إلى AKShare عند الحاجة) |
| `TIMEOUT_SECONDS` | لا | مهلة استدعاء LLM، الافتراضي 120s |
| `API_AUTH_KEY` | موصى به للنشر الشبكي | Bearer token مطلوب عندما يكون API قابلاً للوصول من عملاء غير محليين |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | لا | تفعيل صريح للأدوات القادرة على shell في نشر API/MCP-SSE البعيد |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | لا | جذور إضافية مفصولة بفواصل لاستيراد المستندات وسجلات الوسطاء |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | لا | جذور إضافية مفصولة بفواصل لأدلة تشغيل الكود المولد |
<sub>* لا يحتاج Ollama إلى مفتاح API. يستخدم OpenAI Codex ChatGPT OAuth ويخزن الرموز عبر `oauth-cli-kit`، لا داخل `agent/.env`.</sub>
**بيانات مجانية (دون مفتاح):** أسهم A عبر AKShare، وأسهم HK/US عبر yfinance، والكريبتو عبر OKX، وأكثر من 100 بورصة كريبتو عبر CCXT. يختار النظام تلقائياً أفضل مصدر متاح لكل سوق.
### 🎯 النماذج الموصى بها
Vibe-Trading وكيل كثيف الأدوات؛ المهارات والاختبارات الرجعية والذاكرة والأسراب كلها تمر عبر استدعاءات أدوات. اختيار النموذج يحدد مباشرة هل سيستخدم الوكيل أدواته أم سيصطنع إجابات من بيانات التدريب.
| المستوى | أمثلة | متى تستخدمه |
|------|----------|-------------|
| **الأفضل** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | أسراب معقدة (3+ وكلاء)، جلسات بحث طويلة، تحليل بمستوى ورقة علمية |
| **النقطة المثلى** (افتراضي) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | الاستخدام اليومي، tool-calling موثوق بنحو عُشر التكلفة |
| **تجنبها لاستخدام الوكيل** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | tool-calling غير موثوق؛ سيبدو الوكيل وكأنه "يجيب من الذاكرة" بدلاً من تحميل المهارات أو تشغيل الاختبارات الرجعية |
يأتي `agent/.env.example` افتراضياً مع DeepSeek official API + `deepseek-v4-pro`; ويمكن لمستخدمي OpenRouter استخدام `deepseek/deepseek-v4-pro`.
---
## 🖥 مرجع CLI
```bash
vibe-trading # interactive TUI
vibe-trading run -p "..." # single run
vibe-trading serve # API server
vibe-trading alpha list # استعرض 452 ألفا جاهز؛ متاح show / bench / compare / export-manifest
```
<details>
<summary><b>أوامر الشرطة المائلة داخل TUI</b></summary>
| الأمر | الوصف |
|---------|-------------|
| `/help` | عرض كل الأوامر |
| `/skills` | عرض كل المهارات المالية الـ 77 |
| `/swarm` | عرض إعدادات فرق السرب الـ 29 |
| `/swarm run <preset> [vars_json]` | تشغيل فريق سرب مع بث مباشر |
| `/swarm list` | سجل تشغيلات السرب |
| `/swarm show <run_id>` | تفاصيل تشغيل السرب |
| `/swarm cancel <run_id>` | إلغاء سرب قيد التشغيل |
| `/list` | التشغيلات الأخيرة |
| `/show <run_id>` | تفاصيل التشغيل + المقاييس |
| `/code <run_id>` | كود الاستراتيجية المولدة |
| `/pine <run_id>` | تصدير المؤشرات (TradingView + TDX + MT5) |
| `/trace <run_id>` | إعادة تشغيل التنفيذ كاملة |
| `/continue <run_id> <prompt>` | متابعة تشغيل بتعليمات جديدة |
| `/sessions` | عرض جلسات الدردشة |
| `/settings` | عرض إعدادات التشغيل |
| `/clear` | مسح الشاشة |
| `/quit` | خروج |
</details>
<details>
<summary><b>تشغيل واحد والخيارات</b></summary>
```bash
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
vibe-trading run -p "Analyze AAPL momentum" --json
vibe-trading run -f strategy.txt
echo "Backtest 000001.SZ RSI" | vibe-trading run
```
```bash
vibe-trading -p "your prompt"
vibe-trading --skills
vibe-trading --swarm-presets
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
vibe-trading --list
vibe-trading --show <run_id>
vibe-trading --code <run_id>
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
```
```bash
vibe-trading alpha list --zoo gtja191 --limit 10
vibe-trading alpha show gtja191_171
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
</details>
---
## 💡 أمثلة
### الاستراتيجيات والاختبار الرجعي
```bash
# Moving average crossover on US equities
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
# RSI mean-reversion on crypto
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
# Multi-factor strategy on A-shares
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
# After backtesting, export to TradingView / TDX / MetaTrader 5
vibe-trading --pine <run_id>
```
**bench ألفا zoo جاهز بسطر واحد**:
```bash
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
**استعرض الكتالوج** وافحص ألفا مفردة:
```bash
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
vibe-trading alpha show gtja191_171
```
**ركّب إشارة متعدد العوامل** من ألفات zoo (Python):
```python
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
panel = ... # your wide OHLCV panel
signal = engine.compute_signal(panel)
```
### بحث السوق
```bash
# Equity deep-dive
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
# Macro analysis
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
# Crypto on-chain
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
```
### تدفقات السرب
```bash
# Bull/bear debate on a stock
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
# Quant strategy from screening to backtest
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
# Crypto desk: funding + liquidation + flow → risk manager
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
# Global macro portfolio allocation
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
```
### ذاكرة عبر الجلسات
```bash
# Save your preferences once
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
# The agent recalls them in future sessions automatically
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
```
### رفع المستندات وتحليلها
```bash
# Analyze a broker export or earnings report
vibe-trading --upload trades_export.csv
vibe-trading run -p "Profile my trading behavior and identify any biases"
vibe-trading --upload NVDA_Q1_earnings.pdf
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
```
---
## 🌐 خادم API
```bash
vibe-trading serve --port 8899
```
| الطريقة | نقطة النهاية | الوصف |
|--------|----------|-------------|
| `GET` | `/runs` | عرض التشغيلات |
| `GET` | `/runs/{run_id}` | تفاصيل التشغيل |
| `GET` | `/runs/{run_id}/pine` | تصدير مؤشرات متعدد المنصات |
| `POST` | `/sessions` | إنشاء جلسة |
| `POST` | `/sessions/{id}/messages` | إرسال رسالة |
| `GET` | `/sessions/{id}/events` | بث أحداث SSE |
| `POST` | `/upload` | رفع PDF/ملف |
| `GET` | `/swarm/presets` | عرض إعدادات السرب |
| `POST` | `/swarm/runs` | بدء تشغيل سرب |
| `GET` | `/swarm/runs/{id}/events` | بث SSE للسرب |
| `GET` | `/alpha/list` | قائمة ألفات مع تصفية حسب zoo/theme/universe |
| `GET` | `/alpha/{alpha_id}` | بيانات وصفية + الكود المصدري للألفا |
| `POST` | `/alpha/bench` | بدء مهمة bench (يعيد `job_id`) |
| `GET` | `/alpha/bench/{job_id}/stream` | تدفق تقدّم SSE |
| `GET` | `/settings/llm` | قراءة إعدادات LLM في Web UI |
| `PUT` | `/settings/llm` | تحديث إعدادات LLM المحلية |
| `GET` | `/settings/data-sources` | قراءة إعدادات مصادر البيانات المحلية |
| `PUT` | `/settings/data-sources` | تحديث إعدادات مصادر البيانات المحلية |
توثيق تفاعلي: `http://localhost:8899/docs`
### الإعدادات الأمنية الافتراضية
للتطوير على localhost، يبقي `vibe-trading serve` سير المتصفح بسيطاً. لأي عميل غير محلي، تتطلب نقاط API الحساسة `API_AUTH_KEY`؛ استخدم `Authorization: Bearer <key>` لطلبات JSON/الرفع. تتعامل Web UI مع تدفقات Browser EventSource بعد إدخال المفتاح نفسه مرة واحدة في Settings.
تتوفر الأدوات القادرة على shell للـ CLI المحلي وتدفقات localhost الموثوقة، لكنها لا تُعرض لجلسات API البعيدة ما لم تضبط صراحة `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`. قارئات المستندات والسجلات محدودة افتراضياً بجذور الرفع/الاستيراد؛ ضع الملفات تحت `agent/uploads` أو `agent/runs` أو `./uploads` أو `./data` أو `~/.vibe-trading/uploads` أو `~/.vibe-trading/imports`، أو أضف دليلاً مخصصاً عبر `VIBE_TRADING_ALLOWED_FILE_ROOTS`.
### إعدادات Web UI
تتيح صفحة Settings في Web UI للمستخدمين المحليين تحديث مزود/نموذج LLM، وbase URL، ومعلمات التوليد، وreasoning effort، وبيانات اعتماد السوق الاختيارية مثل رمز Tushare. تُحفظ الإعدادات في `agent/.env`؛ وتُحمّل قيم المزودين الافتراضية من `agent/src/providers/llm_providers.json`.
قراءات Settings بلا آثار جانبية: لا تنشئ `GET /settings/llm` ولا `GET /settings/data-sources` ملف `agent/.env`، ولا تعيدان إلا مسارات نسبية للمشروع. قد تكشف قراءات وكتابات Settings حالة بيانات الاعتماد أو تحدث بيانات الاعتماد/بيئة التشغيل، لذلك تتطلب `API_AUTH_KEY` عند ضبطه. إذا كان `API_AUTH_KEY` غير مضبوط في وضع التطوير، فلا يقبل الوصول إلى Settings إلا من عملاء loopback.
---
## 🔌 MCP Plugin
يعرض Vibe-Trading 22 أداة MCP لأي عميل متوافق مع MCP. يعمل كعملية stdio فرعية، دون إعداد خادم. **21 من أصل 22 أداة تعمل دون أي مفاتيح API** (HK/US/crypto). يحتاج `run_swarm` وحده إلى مفتاح LLM.
<details>
<summary><b>Claude Desktop</b></summary>
أضف إلى `claude_desktop_config.json`:
```json
{
"mcpServers": {
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
</details>
<details>
<summary><b>OpenClaw</b></summary>
أضف إلى `~/.openclaw/config.yaml`:
```yaml
skills:
- name: vibe-trading
command: vibe-trading-mcp
```
</details>
<details>
<summary><b>Cursor / Windsurf / عملاء MCP آخرون</b></summary>
```bash
vibe-trading-mcp # stdio (default)
vibe-trading-mcp --transport sse # SSE for web clients
```
</details>
**أدوات MCP المعروضة (22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
<details>
<summary><b>التثبيت من ClawHub (أمر واحد)</b></summary>
```bash
npx clawhub@latest install vibe-trading --force
```
> `--force` مطلوب لأن المهارة تشير إلى واجهات API خارجية، مما يطلق فحص VirusTotal الآلي. الكود مفتوح المصدر بالكامل وآمن للفحص.
ينزل هذا المهارة وإعداد MCP إلى مجلد مهارات وكيلك. لا حاجة للاستنساخ.
تصفح على ClawHub: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
</details>
<details>
<summary><b>OpenSpace — مهارات ذاتية التطور</b></summary>
كل المهارات المالية الـ 77 منشورة على [open-space.cloud](https://open-space.cloud) وتتطور ذاتياً عبر محرك التطور الذاتي في OpenSpace.
للاستخدام مع OpenSpace، أضف خادمي MCP إلى إعداد وكيلك:
```json
{
"mcpServers": {
"openspace": {
"command": "openspace-mcp",
"toolTimeout": 600,
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
}
},
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
سيكتشف OpenSpace كل المهارات الـ 77 تلقائياً، مما يتيح auto-fix وauto-improve والمشاركة المجتمعية. ابحث عن مهارات Vibe-Trading عبر `search_skills("finance backtest")` في أي وكيل متصل بـ OpenSpace.
</details>
---
## 📁 هيكل المشروع
<details>
<summary><b>انقر للتوسيع</b></summary>
```
Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # CLI package — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 35 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
│ │ │ ├── loop.py # 5-layer compression + read/write tool batching
│ │ │ ├── context.py # system prompt + auto-recall from persistent memory
│ │ │ ├── skills.py # skill loader (77 bundled + user-created via CRUD)
│ │ │ ├── tools.py # tool base class + registry
│ │ │ ├── memory.py # lightweight workspace state per run
│ │ │ ├── frontmatter.py # shared YAML frontmatter parser
│ │ │ └── trace.py # execution trace writer
│ │ │
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 31 auto-discovered agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
│ │ │ ├── session_search_tool.py # FTS5 cross-session search
│ │ │ ├── swarm_tool.py # launch swarm teams
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
│ │ │
│ │ ├── factors/ # Alpha Zoo — 452 ألفا عبر 4 zoos
│ │ │ ├── base.py # 19 عاملاً (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
│ │ │ ├── registry.py # تحميل بيانات وصفية AST فقط + حساب كسول + بوابات سلامة
│ │ │ ├── bench_runner.py # IC + تصنيف alive/reversed/dead
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
│ │ │
│ │ ├── api/ # وحدات مسارات FastAPI
│ │ │ └── alpha_routes.py # /alpha/list, /alpha/{id}, /alpha/bench, SSE stream
│ │ │
│ │ ├── skills/ # 77 finance skills in 8 categories (SKILL.md each)
│ │ ├── swarm/ # Swarm DAG execution engine
│ │ │ └── presets/ # 29 swarm preset YAML definitions
│ │ ├── session/ # Multi-turn chat + FTS5 session search
│ │ └── providers/ # LLM provider abstraction
│ │
│ └── backtest/ # Backtest engines
│ ├── engines/ # 7 engines + composite cross-market engine + options_portfolio
│ ├── loaders/ # 7 sources: tushare, okx, yfinance, akshare, mootdx, ccxt, futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # Registry + auto-fallback chains
│ └── optimizers/ # MVO, equal vol, max div, risk parity
│
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
│ └── src/
│ ├── pages/ # Home, Agent, AlphaZoo, RunDetail, Compare, Correlation, Settings
│ ├── components/ # chat, charts, layout
│ └── stores/ # Zustand state management
│
├── Dockerfile # Multi-stage build
├── docker-compose.yml # One-command deploy
├── pyproject.toml # Package config + CLI entrypoint
├── tools/ # Repo-level CI helpers
│ └── ci_grep_gates.sh # rejects yaml.load / trademark / per-stock-data leaks
└── LICENSE # MIT
```
</details>
---
## 🏛 النظام البيئي
Vibe-Trading جزء من نظام وكلاء **[HKUDS](https://github.com/HKUDS)**:
<table>
<tr>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
<sub>Ultra-Lightweight Personal AI Assistant</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
<sub>Agent-Native Signal &amp; Copy Trading Platform</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
<sub>Making All Software Agent-Native</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
<sub>Self-Evolving AI Agent Skills</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
<sub>Agent Swarm Intelligence</sub>
</td>
</tr>
</table>
---
## 🗺 خارطة الطريق
> نشحن على مراحل. تنتقل العناصر إلى [Issues](https://github.com/HKUDS/Vibe-Trading/issues) عندما يبدأ العمل.
| المرحلة | الميزة | الحالة |
|-------|---------|--------|
| **Trust Layer** | بطاقات تشغيل قابلة لإعادة الإنتاج تُنتج وتظهر في Run Detail؛ يضيف v1 آثار الأدوات والاستشهادات | v0 شُحن |
| **Hypothesis Registry** | فرضيات بحثية دائمة مع حالة lifecycle ومصادر بيانات ومهارات وروابط run-card وملاحظات إبطال | Backend MVP شُحن |
| **Research Autopilot** | حلقة بحث يدوية أولاً: فرضية → اختبار رجعي حتمي → تقرير أدلة | التالي |
| **Data Bridge** | أحضر بياناتك: موصلات CSV/Parquet/SQL محلية مع schema mapping | مخطط |
| **Options Lab** | سطح تقلب، ولوحة Greeks، ومستكشف payoff/scenario | مخطط |
| **Portfolio Studio** | أشعة مخاطر، وقيود، ومحسن يراعي الدوران، وملاحظات إعادة توازن | مخطط |
| **Alpha Zoo** | 452 ألفا كمّي جاهز عبر 4 zoos (Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)، سطر أوامر واحد للـ bench، تكامل agent، وواجهة Web | **تم الإطلاق 0.1.8** |
| **Research Delivery** | موجزات مجدولة إلى Slack / Telegram / قنوات شبيهة بالبريد | مخطط |
| **Community** | مهارات وإعدادات مسبقة وبطاقات استراتيجية قابلة للمشاركة | قيد الاستكشاف |
---
## المساهمة
نرحب بالمساهمات! راجع [CONTRIBUTING.md](CONTRIBUTING.md) للإرشادات.
**المشكلات الجيدة للمبتدئين** موسومة بـ [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — اختر واحدة وابدأ.
هل تريد المساهمة بشيء أكبر؟ راجع [خارطة الطريق](#-خارطة-الطريق) أعلاه وافتح issue للنقاش قبل البدء.
---
## المساهمون
شكراً لكل من ساهم في Vibe-Trading!
مساهمو واعتمادات دورة v0.1.8 الأخيرة:
- @GTC2080 / TaoMu — Web UI Settings وواجهات إعداد provider/data-source (#57)
- @BigNounce90 — تعزيز validation CLI لمدخل `run_dir` في الاختبار الرجعي (#60)
- @shadowinlife — مهارة A-share pre-ST filter (#63)
- @MB-Ndhlovu — لوحة خريطة الارتباط الحرارية وإصلاحات المراجعة (#64, #66)
- @ykykj — خيار مزود OpenAI Codex OAuth (#65)
- @RuifengFu — شريط حالة CLI التفاعلي وتحرير prompt (#69)
- @SiMinus — أمر فحص swarm preset (#73)
- @warren618 / Haozhe Wu — تعزيز الأمان، وتكامل الإصدار، والوثائق، وDocker، والتغليف، وسير التطوير المحلي
- lemi9090 (S2W) — بحث أمني منسق، وتحقق، ودعم الإفصاح
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
</a>
---
## إخلاء المسؤولية
Vibe-Trading برنامج للبحث والتداول. ليس نصيحة استثمارية، ولا يحتفظ بأي أموال، ولا يشغّل أي منصة تنفيذ. يحدث التداول فقط عبر قناة وسيط تُصرّح بها صراحةً (مثل Robinhood Agentic Trading)، ضمن الحدود التي تضعها، ويمكنك إيقافه في أي وقت. قدرة التداول عبر الوسيط هذه تجريبية ولم نتحقق منها على حساب وسيط حقيقي — استخدمها على مسؤوليتك. الأداء السابق لا يضمن النتائج المستقبلية.
## الرخصة
رخصة MIT — راجع [LICENSE](LICENSE)
---
## تاريخ النجوم
[![Star History Chart](https://api.star-history.com/svg?repos=HKUDS/Vibe-Trading&type=Date)](https://star-history.com/#HKUDS/Vibe-Trading&Date)
<p align="center" dir="rtl">
⭐ إذا ساعدك <b>Vibe-Trading</b> في بحثك، فإن منح نجمة يساعد المزيد على اكتشافه.
</p>
---
<p align="center">
شكراً لزيارة <b>Vibe-Trading</b> ✨
</p>
<p align="center">
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
</p>
+944
View File
@@ -0,0 +1,944 @@
<p align="center">
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <b>日本語</b> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
</p>
<p align="center">
<img src="assets/icon.png" width="120" alt="Vibe-Trading Logo"/>
</p>
<h1 align="center">Vibe-Trading: あなた専用のトレーディングエージェント</h1>
<p align="center">
<b>1つのコマンドで、包括的なトレーディング能力をエージェントに付与</b>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
<br>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<p align="center">
<a href="https://vibetrading.wiki/">公式サイト</a> &nbsp;&middot;&nbsp;
<a href="https://vibetrading.wiki/docs/">ドキュメント</a> &nbsp;&middot;&nbsp;
<a href="#-ニュース">ニュース</a> &nbsp;&middot;&nbsp;
<a href="#-主な機能">機能</a> &nbsp;&middot;&nbsp;
<a href="#-shadow-account">Shadow Account</a> &nbsp;&middot;&nbsp;
<a href="#-デモ">デモ</a> &nbsp;&middot;&nbsp;
<a href="#-クイックスタート">クイックスタート</a> &nbsp;&middot;&nbsp;
<a href="#-例">例</a> &nbsp;&middot;&nbsp;
<a href="#-api-サーバー">API / MCP</a> &nbsp;&middot;&nbsp;
<a href="#-ロードマップ">ロードマップ</a> &nbsp;&middot;&nbsp;
<a href="#contributing">Contributing</a>
</p>
<p align="center">
<a href="#-クイックスタート"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
</p>
---
## 📰 ニュース
- **2026-05-31** 🔌 **Connector-first ブローカーアーキテクチャ(IBKR + Robinhood)**: 取引アクセスは、個別のブローカー入口や live 入口ではなく、選択可能な connector profile から始まるようになりました。`vibe-trading connector list/use/check/account/positions/orders/quote/history` と MCP の `trading_*` ツールは同じ選択済み profile を共有し、paper/live は connector 配下の属性として扱われます。IBKR はローカル読み取り専用 TWS / IB Gateway profile ですぐ使え、公式 IBKR リモート MCP は安定した read tool 名が公開されるまで OAuth `mcp.read` probe として seed されています。Robinhood Agentic Trading は引き続き、OAuth、コミット済み mandate、order guard、audit ledger、instant halt の背後にある bounded live MCP connector です。
- **2026-05-30** 🧰 **堅牢性パス — backtest、agent loop、session**: LLM 生成の signal engine は、インスタンス化の前にインターフェース事前検証を通すようになりました。循環 self-import、`generate()` の欠落、デフォルト値のない `__init__` 引数、誤った戻り値型といった典型ミスを早期に捕捉し、生の traceback ではなく実行可能な JSON エラーで返します ([#149](https://github.com/HKUDS/Vibe-Trading/pull/149))。続くフォローアップで、ソースレベルの AST 検証エラーも同じクリーンな JSON エンベロープに乗せました。agent loop は 50 反復を使い切って出力のない `failed` 状態に陥らなくなりました——swarm worker の実績ある方式に倣い、反復予算の 80% で wrap-up nudge を注入し、最後の反復で tool 定義を外してテキスト回答を強制します ([#148](https://github.com/HKUDS/Vibe-Trading/pull/148))。途中でのみ発火するようガードしてあり、research-goal の文脈を押しのけることはありません。session のメッセージ書き込みは append ごとに `flush + fsync` するようになり、高価な AI 応答が書き込み途中のクラッシュでも残ります。読み取り側は壊れた JSONL 行をスキップし(復旧用に先頭 200 文字をログ)、`/messages` エンドポイント全体を 500 にしません ([#147](https://github.com/HKUDS/Vibe-Trading/pull/147))。Web の入力欄は IME の Enter 処理も修正し、変換確定の Enter で語の途中送信が起きないようにしました ([#146](https://github.com/HKUDS/Vibe-Trading/pull/146))。
- **2026-05-29** 🔐 **Robinhood Agentic Trading 対応(オプトイン・有界自律)**: Robinhood Agentic Trading に対応しました(リモート MCP、OAuth)。デフォルトでは無効かつ読み取り専用。エージェントはユーザーがコミットした mandate(銘柄/注文サイズ/エクスポージャー/レバレッジ/日次上限)の範囲内でのみ自律取引し、ファイルレベルの即時 kill switch、先制的なポジション手仕舞い、mandate の自動失効、完全な監査台帳、永続的な自律 runner を備えます。資金の保管なし・取引所運営なし——資金の保有と執行はブローカーが行い、こちらは意図を中継するだけです。実験的 / 自己責任でご利用ください。
<details>
<summary>過去のニュース</summary>
- **2026-05-28** 🧪 **Swarm の安全性 + 厳格 alpha gate + worker 側 MCP**: Swarm DAG は上流タスクが失敗したとき下流タスクをブロックするようになりました ([#145](https://github.com/HKUDS/Vibe-Trading/pull/145))。新規 `run_bench_strict()` は IC gate に同 universe のランダムコントロール + train/test OOS 分割を追加し、市場 beta を追っているだけの偽 factor を捕捉します ([#143](https://github.com/HKUDS/Vibe-Trading/pull/143), @Soli22de さんに感謝)。Swarm worker は operator が設定した外部 MCP server からツールを呼べるようになり、信頼境界は専用テストで固定されています ([#142](https://github.com/HKUDS/Vibe-Trading/pull/142), @shadowinlife さんに感謝)。
- **2026-05-27** 📊 **mootdx A 株データソース + 出力スタイル**: 新規 `mootdx` loader はネイティブ 通达信 TCP プロトコルで A 株 OHLCV を取得します(認証不要、IP 速度制限なし、日足 + 分足の 25 ページ walk-back ページング)。fallback chain では tushare と akshare の間に配置されます ([#107](https://github.com/HKUDS/Vibe-Trading/issues/107))。CCXT loader は `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY` を読み込み、制限されたネットワークから Binance/OKX の公開データを取得できるようになりました ([#126](https://github.com/HKUDS/Vibe-Trading/pull/126), @ruok808 さんに感謝)。最終回答のレンダリングからは CLI と Web の見苦しい全幅 `---` セパレータを削除しました: system prompt は markdown table と `##` heading を促し、CLI renderer は単独 HR を defense-in-depth として除去し、chat bubble はすり抜けた `<hr>` を隠します ([#139](https://github.com/HKUDS/Vibe-Trading/issues/139), @sdwxm188 さんに感謝)。
- **2026-05-26** ✅ **Research Goal ライフサイクルの閉ループ化**: Goal mode が実際のタスクランナーのように動くようになりました。Web UI で goal を作成すると session を作成または bind し、即座に kickoff turn を送ります。active goal は Web/API/CLI/MCP から continue/edit/cancel/complete でき、agent loop は最初の prompt だけでなく現在の goal snapshot(criteria、evidence、claims、open items)から前進します。criteria が covered でも goal が active のままなら silent stop ではなく audit/status update に入り、backend、CLI、MCP、frontend events の回帰で固定しました。
- **2026-05-25** 🧼 **よりクリーンな Chat UI + composer workflow**: Web UI は次の入力に集中できる形になりました。upload、swarm、research-goal mode は composer の `+` メニューにまとまり、floating panel で会話を邪魔しません。現在の context は input 上の compact chip として表示され、goal details は chip クリック時だけ inline 展開されます。旧 custom i18n layer も削除し、直接 English copy に統一。Full Report card は report-worthy run のみに表示され、local dev startup/status reporting もブラウザ smoke test 向けに安定化しました。
- **2026-05-24** 🎯 **Research Goal runtime**: backend、CLI、API/MCP、SSE、Web UI をまたぐ session-scoped Research Goal layer を追加しました。Goal は claim、acceptance criteria、evidence row、budget、completion policy を永続化します。agent tool は goal 作成と evidence 追加に対応し、`/goal` が CLI 入口になり、REST/MCP は goal snapshot と evidence write を公開し、SSE は chat client の状態を fresh に保ちます。後続 audit fixes では verified evidence をロックダウンし、agent tool からの live-trading risk tier をブロックし、CLI-created goal を後続 turn に接続し、session 削除時の goal ledger cleanup、replay-all 接続、frontend の cross-session snapshot race 修正を行いました。
- **2026-05-23** 🖥️ **インタラクティブ CLI の刷新**: ターミナル入口は大きな Vibe-Trading バナー、より見やすい prompt 区切り、前ターンの recap、実行後の所要時間、Claude Code 風の activity rail で live agent 作業を表示します。tool call、web/data fetch、shell 風 action、Markdown 回答、pipe table は読みやすい transcript として描画され、pipe や非 TTY 実行では自動化向けの plain-text 出力を維持します。生成 CLI スクリーンショットは committed docs ではなく local artifact として扱い、リポジトリを軽く保ちます。
- **2026-05-22** 🧭 **Swarm リカバリ + MCP keepalive**: Swarm の状態は読み取りのたびに live task ファイルから reconcile されるようになり、API/MCP/SSE/list ビューはクラッシュ済みまたは stale な run を復旧し、永遠に `running` のスナップショットを見せ続けません。`run_swarm` は polling 中に MCP progress heartbeat を送り、transport drop 後に再接続するクライアントでも handle を拾えるよう最初のフレームを `swarm_started run_id=<id>` に固定しました。worker も LLM streaming、grounding fetch、tool execution の各段階で heartbeat を出します。stale-run reaper は run ごとの閾値を使い、task 状態から終端状態を導出します。`SwarmTool` は待機予算が尽きても進行中の team をキャンセルせず、MCP クライアントは `reap_stale_runs()` で明示的に cleanup できます。今日の DX pass では provider の既定モデルも更新し、CI syntax check を新しい `agent/cli/` パッケージに合わせました。hydrate、終端復旧、stale reap、keepalive cadence、env parsing、heartbeat wiring を 22 件の新規回帰テストでカバーし、swarm/MCP 全体スイートは 169 passed、4 skipped です。
- **2026-05-21** 🧱 **CLI パッケージリファクタ**: `agent/cli.py`(3216 LOC)を `agent/cli/` パッケージへ分割 — インタラクティブな入口、slash ルーター、Rich コンポーネント、そしてすべてのサブコマンドを保ち `cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` などの公開シンボルを再エクスポートする `_legacy.py` shim。新しい FastAPI ミドルウェアはブラウザが `/runs/{id}` または `/correlation` を直接開いた際に SPA シェルを返し、同じ絞り込みを Vite dev プロキシにも反映。バージョン文字列は `cli/_version.py` で一本化(`--version` とバナーのドリフト解消)、`python -m cli` を `__main__.py` で復活、chat ゲートを絞り `chat --help` / `chat extra` は REPL に飲み込まれずレガシー argparse に届きます。
- **2026-05-20** 🔬 **Hypothesis Registry CLI**: 2026-05-16 にバックエンドのみで公開された Hypothesis Registry の CLI 側を完成させました。`vibe-trading hypothesis list` は Rich テーブルまたは JSON を出力(`--status` フィルタと `--limit` をサポート)、`show <id>` はリンクされた run card を含む詳細パネルを描画、`invalidate <id> --note "..."` はステータスを `rejected` に切り替え、`--note` を省略すると既存の invalidation notes を保持します。既存の `VIBE_TRADING_HYPOTHESES_PATH` 環境変数オーバーライドに加え、呼び出し単位の `--path` も使えます。配線、JSON 出力、ステータスフィルタ、limit、ID 不在エラー、ノート永続化を 22 のテストでカバー。
- **2026-05-19** ✨ **ツールのライブフィードバック + グレースフルキャンセル**: 長時間実行されるツール(バックテスト、大きい PDF、swarm worker)が固まったように見えなくなりました。各ツール呼び出しは 3 秒ごとのハートビートに加え、構造化された段階進捗を発行します — `run_backtest` はフェーズマーカー(`validate` / `simulate` / `finalize`)、`read_document` は PDF ではページ単位、Excel ではシート単位、`read_url` は `fetch` / `parse` をマーク。CLI の Rich Live ダッシュボードは Unicode スピナー、ASCII プログレスバー、ETA を描画し、ツール名でキー付けして最大 3 つの並列ツールをスタック表示します。フロントエンドのチャットには新規 `ToolProgressIndicator` を追加し、rAF コアレッシング、ARIA `role="status"` + スクリーンリーダー向けの非表示 `<progress>`、合計が既知の場合は determinate な `ProgressRing` SVG を備えます。CLI 実行中の最初の `Ctrl+C` は `agent.cancel()` を呼んでグレースフル終了(現在のステップが完了し、trace がクリーンに閉じる)し、2 秒以内に 2 度目を押すと強制終了します。再利用可能なプリミティブ `ProgressBar.tsx` と `lib/tools.ts`(共有ツール名 i18n マッピング)も抽出。
- **2026-05-18** 🧹 **クリーンアップ + 3 つの潜在バグ修正**: `CompositeEngine` が取引所サフィックスのない中国先物コード(`RB2410` 等)を `GlobalFuturesEngine` に誤ルーティングしていた問題を修正。`_is_china_futures` を共有の `_market_hooks` モジュールに移し、製品コード表を大小文字正規化 + 非中国取引所のガードを追加、回帰ケース 9 件を新設しました。session FTS5 インデックスがタイムスタンプを永続化するようになり、クロスセッション検索を日付ソートできるようになりました。同じ修正で、re-upsert 経路が `started_at` を wall-clock で上書きしていた副次バグも解消しました。Vite 開発プロキシに `/alpha` を追加し、AlphaZoo ページが `npm run dev` で解決されるようになりました。`tests/test_e2e_harness_v2.py`(実 LLM の e2e スイート)は `VIBE_TRADING_RUN_LIVE_E2E=1` でゲート化し、CI が環境変数の有無で形を変えないようにしました。ruff に factor zoo 用の `per-file-ignores` を追加(F401 ノイズ 3783 → 0)、フロントエンド tsconfig は `noUnusedLocals` / `noUnusedParameters` を有効化して回帰ガードとし、`gtja191` alpha の未使用 `vw = vwap(...)` 雛形 76 件も削除しました。正味 **-918 行**。
- **2026-05-17** 🧬 **Alpha Zoo v1(0.1.8)**: 4 つの zoo にまたがる 452 個の事前構築 quant alpha を同梱しました — `qlib158`(Microsoft Qlib の Alpha158 特徴量、Apache-2.0 出処明示)、`alpha101`(Kakushadze の "101 Formulaic Alphas" を arXiv:1601.00991 から論文ベースで書き直し)、`gtja191`(国泰君安 2014 年の短期取引型 alpha レポート)、`academic`(Fama-French 5 + Carhart 動量の価格ベース proxy 実装)。任意の universe で 1 行 CLI: `vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`。AST 純関数ゲート、look-ahead ガードテスト、`pytest-socket` ネットワーク遮断、各 zoo ごとの LICENSE.md、コミュニティ PR 用の DCO 署名フローも同梱。Alpha Library 自動レンダリングは [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/)、Research Lab には [Which of the 191 GTJA alphas still work in 2026?](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html) を公開。
- **2026-05-16** 🧪 **リサーチ基盤アップデート**: backend Hypothesis Registry を追加し、`create_hypothesis`、`update_hypothesis`、`link_backtest`、`search_hypotheses` を提供します。外部コンテンツ reader は warning-only の `security_warnings` を付与し、Shadow Account scanner は旧 calendar-phase stub から決定的な OHLCV feature evaluation に移行しました。
- **2026-05-15** 🪪 Run 詳細ページが metrics と artifacts の隣に Trust Layer の run card を描画するようになり、2026-05-12 に入った `run_card.json` 側の UI 半分が揃いました。`PersistentMemory.add()` も #108/#109/#110 の triage を受け、長さ、空・空白だけの name、C0/C1 制御バイトの各経路で強化されました([#112](https://github.com/HKUDS/Vibe-Trading/pull/112)、@Teerapat-Vatpitak に感謝)。
- **2026-05-14** 🌐 公開 Wiki が [vibetrading.wiki](https://vibetrading.wiki/) で公開され、docs、tutorials、Research Lab、Alpha Library セクションを Cloudflare Pages から配信します。永続メモリも CLI から `vibe-trading memory list/show/search/forget` で確認できるようになり([#102](https://github.com/HKUDS/Vibe-Trading/pull/102)、@Teerapat-Vatpitak に感謝)、メモリの tokenization/slug はタイ語、アラビア語、ヘブライ語、キリル文字にも対応しました([#104](https://github.com/HKUDS/Vibe-Trading/pull/104))。
- **2026-05-13** 🧭 Swarm 実行では、取得済みの市場データでワーカーを grounding し、永続化レポートもより整理されました([#93](https://github.com/HKUDS/Vibe-Trading/pull/93)、[#84](https://github.com/HKUDS/Vibe-Trading/pull/84))。
- **2026-05-12** 🧾 バックテストは、再現可能なリサーチ実行のために artifacts と並んで `run_card.json` と `run_card.md` を出力するようになりました。
- **2026-05-11** 🧭 **メモリ slug、swarm 集計、CLI プリフライト**: 永続メモリのファイル slug 生成で CJK 文字を保持するようになり、中国語/日本語/韓国語ノートの静かなファイル名衝突を防ぎます([#95](https://github.com/HKUDS/Vibe-Trading/pull/95)、@voidborne-d に感謝)。Swarm run の合計は provider が返す token usage を優先し、従来の推定フォールバックも維持します([#94](https://github.com/HKUDS/Vibe-Trading/pull/94)、@Teerapat-Vatpitak に感謝)。CLI run UI には一般的な環境問題を早めに見つける起動時プリフライトチェックも入りました([#96](https://github.com/HKUDS/Vibe-Trading/pull/96)、@ykykj に感謝)。
- **2026-05-10** 🧱 **回帰ガードレール + run メタデータ**: Memory recall はアンダースコアを token 境界として扱うようになり、`mcp_wiring_test` のような snake_case の保存メモリが "mcp wiring" のような自然言語クエリに一致します([#87](https://github.com/HKUDS/Vibe-Trading/pull/87)、@hp083625 に感謝)。MCP server には initialize → `tools/list` → `tools/call` を通す subprocess smoke test を追加し、初回呼び出し deadlock 経路の回帰を防ぎます([#86](https://github.com/HKUDS/Vibe-Trading/pull/86))。さらに Windows のパス依存テスト、API の best-effort 例外処理、backtest `run_dir` allowed-root 検証、SwarmRun provider/model メタデータの低リスク強化も入りました([#88](https://github.com/HKUDS/Vibe-Trading/pull/88)、[#90](https://github.com/HKUDS/Vibe-Trading/pull/90)、[#91](https://github.com/HKUDS/Vibe-Trading/pull/91)、[#92](https://github.com/HKUDS/Vibe-Trading/pull/92)、@Teerapat-Vatpitak に感謝)。
- **2026-05-09** 🛡️ **API パス強化 + MCP server 安定化**: API の run/session ルートは参照前にパス ID を検証し、改行を含む不正なパラメータを拒否し、その挙動を auth/security 回帰テストで固定しました([#80](https://github.com/HKUDS/Vibe-Trading/pull/80)、@SJoon99 に感謝)。MCP server は `tools/call` を処理する前にメインスレッドでツールレジストリを事前ウォームアップし、lazy tool discovery の初回呼び出しデッドロックを回避します([#85](https://github.com/HKUDS/Vibe-Trading/pull/85)、@Teerapat-Vatpitak に感謝)。Vite dev proxy も `VITE_API_URL` を尊重し、非デフォルトのバックエンドターゲットを使えるようになりました([#82](https://github.com/HKUDS/Vibe-Trading/pull/82)、@voidborne-d に感謝)。
- **2026-05-08** 🧾 **Tushare 財務諸表フィールドをフィルターへ**: A 株の日次バックテストで `fundamental_fields` から PIT-safe な財務諸表フィールドを要求できるようになり、signal engine は公告/開示日以降に `income_total_revenue`、`income_n_income`、`balancesheet_total_hldr_eqy_exc_min_int`、`fina_indicator_roe` など表名プレフィックス付き列でスクリーニングできます([#76](https://github.com/HKUDS/Vibe-Trading/pull/76)、@mrbob-git に感謝)。後続の強化により、明示的な財務諸表フィールド要求で Tushare enrichment が失敗した場合は、価格バーだけに静かに戻るのではなく即時失敗します([#77](https://github.com/HKUDS/Vibe-Trading/pull/77))。
- **2026-05-07** 📈 **Tushare fundamentals + コミュニティ整理**: ファンダメンタル調査ワークフロー向けに point-in-time の `TushareFundamentalProvider` 契約を追加し、プロジェクトの `TUSHARE_TOKEN` 環境変数パスを回帰テストでカバーしました([#74](https://github.com/HKUDS/Vibe-Trading/pull/74))。コミュニティ整理では、Vibe-Trading は当面 UI を単一言語に絞って高速反復すること、DuckDuckGo ベースの `web_search` が既に同梱されているため重複する検索依存を追加しないこと、非公式ホスト先は API key やデータソース token を入力する信頼済み場所として扱わないことも明確にしました。
- **2026-05-06** 🚀 **v0.1.7 リリース**([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7)、`pip install -U vibe-trading-ai`): セキュリティ境界強化版を PyPI と ClawHub に公開しました。API/読み取り/アップロード/ファイル/URL/生成コード/shell ツール/Docker の既定境界をより安全にしつつ、localhost の CLI/Web UI ワークフローは低摩擦のままです。このサイクルには Web UI Settings、相関ヒートマップ、OpenAI Codex OAuth、A 株 pre-ST フィルター、対話型 CLI UX、swarm preset inspection、配当分析、開発ワークフロー改善、frontend build-dependency floor の監査も含まれます。0.1.7 のコントリビューターと、協調的なセキュリティ検証を行った lemi9090 (S2W) に感謝します。
- **2026-05-05** 🛡️ **セキュリティ境界の追加強化**: 明示的な CORS origins、Settings の認証情報表示、Web URL 読み取り、Shadow Account コード生成まわりの残りのセキュリティ境界を補強し、それぞれに回帰テストを追加しました。通常の localhost CLI/Web UI ワークフローは従来どおりです。リモートデプロイでは引き続き `API_AUTH_KEY` と明示的な信頼済み origins を設定してください。
- **2026-05-04** 🖥️ **インタラクティブ CLI UX + CI 整理**: インタラクティブモードに、provider/model、セッション時間、直近実行時間、累計ツール呼び出し統計を表示するライブ下部ステータスバーを追加。さらに `prompt_toolkit` により上下キーの履歴移動と左右キーのカーソル編集に対応しました([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。`prompt_toolkit` または TTY が利用できない場合は、従来どおり Rich prompt にフォールバックします。CI のパス期待値も強化済みファイル import サンドボックスとクロスプラットフォームな `/tmp` 解決に合わせ、main はグリーンに戻りました([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
- **2026-05-03** 🛡️ **セキュリティハードニングパッチ**: 非ローカルデプロイ向けの既定 API 認証を強化し、機密性の高い run/session/swarm 読み取りを保護、アップロードとローカルファイル読み取り境界を制限、shell 系ツールをエントリーポイント別に制御、生成戦略を import 前に検証し、Docker イメージは既定で非 root ユーザーかつ localhost 限定ポート公開で動作します。ローカル CLI と localhost Web UI は低摩擦のままです。リモート API/Web デプロイでは `API_AUTH_KEY` を設定してください。
- **2026-05-02** 🧭 **配当分析 + ロードマップ刷新**: インカム株、配当の持続性、増配、株主還元利回り、権利落ちメカニクス、利回りの罠チェックに対応する `dividend-analysis` skill を追加し、bundled-skill 回帰テストで固定しました。公開ロードマップは Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer、Community 共有に絞りました。
- **2026-05-01** 🔥 **相関ヒートマップ + OpenAI Codex OAuth + A 株 pre-ST フィルター**: 新しい相関ダッシュボード/APIでローリングリターン相関を計算し、ポートフォリオや銘柄分析向けに ECharts ヒートマップで可視化します([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider は `vibe-trading provider login openai-codex` による ChatGPT OAuth に対応し、Settings メタデータとアダプター回帰テストも追加([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。A 株の ST/*ST リスクスクリーニング用 `ashare-pre-st-filter` skill を追加・強化し、Sina 処分公告の関連性フィルターにより証券口座リスト内の言及が E2 回数を水増ししないようにしました([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
- **2026-04-30** ⚙️ **Web UI Settings + validation CLI 強化**: LLM provider/model、Base URL、reasoning effort、データソース認証情報をローカルで設定できる Settings ページを追加。settings API は local/auth で保護され、provider メタデータもデータ駆動設定に移行しました([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。さらに `python -m backtest.validation <run_dir>` を強化し、引数なし・空パス・不正パス・存在しないパス・ディレクトリでないパスを検証開始前に分かりやすく失敗させます([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
- **2026-04-28** 🚀 **v0.1.6 リリース**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 後に `vibe-trading --swarm-presets` が空を返す問題を修正([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))。プリセット YAML は `src.swarm` パッケージ内に同梱され、6 件の回帰テストで固定されています。加えて AKShare loader が ETF(`510300.SH`)と forex(`USDCNH`)を正しい endpoint にルーティングし、registry fallback も強化しました。v0.1.5 以降の更新を集約: benchmark comparison panel、`/upload` streaming + size limits、Futu loader(HK + A 株)、vnpy export skill、security hardening、frontend lazy loading(688KB → 262KB)。
- **2026-04-27** 📊 **ベンチマーク比較パネル + アップロード安全性**: バックテスト出力に benchmark comparison panel(ticker / benchmark return / excess return / information ratio)を追加し、yfinance 経由で SPY、CSI 300 などを解決します([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。加えて `/upload` は request body を 1 MB chunks で stream し、`MAX_UPLOAD_SIZE` 超過時に中断するため、過大/不正な client の下でもメモリを抑えます([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))。4 ケースの回帰テストで固定されています。
- **2026-04-22** 🛡️ **ハードニング + 新規連携**: `safe_path` でパス封じ込めを強制し、journal/shadow tool sandbox、`MANIFEST.in` による `.env.example` / tests / Docker files の sdist 同梱、route-level lazy loading による frontend 初期 bundle 688KB → 262KB を実施。さらに Futu data loader for HK & A-share equities([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))と vnpy CtaTemplate export skill([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))も追加しました。
- **2026-04-21** 🛡️ **Workspace + docs**: 相対 `run_dir` を active run dir に正規化しました([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README usage examples も追加しました([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
- **2026-04-20** 🔌 **Reasoning + Swarm**: `reasoning_content` をすべての `ChatOpenAI` path で保持し、Kimi / DeepSeek / Qwen thinking が end-to-end で動作します([#39](https://github.com/HKUDS/Vibe-Trading/issues/39))。Swarm streaming と clean Ctrl+C も入りました([#42](https://github.com/HKUDS/Vibe-Trading/issues/42))。
- **2026-04-19** 📦 **v0.1.5**: PyPI と ClawHub に公開。`python-multipart` CVE floor bump、新規 MCP tools 5 つ接続(`analyze_trade_journal` + shadow-account tools 4 つ)、`pattern_recognition` → `pattern` registry fix、Docker dep parity、SKILL manifest sync(22 MCP tools / 71 skills)。
- **2026-04-18** 👥 **Shadow Account**: broker journal から strategy rules を抽出 → market 横断で shadow を backtest → 8-section HTML/PDF report で取りこぼし(rule violations、early exits、missed signals、counterfactual trades)を正確に可視化。新規 tools 4 つ、skill 1 つ、合計 32 tools。Trade Journal + Shadow Account samples も Web UI welcome screen に追加されました。
- **2026-04-17** 📊 **Trade Journal Analyzer + Universal File Reader**: broker exports(同花順/東財/富途/generic CSV)を upload → auto trading profile(holding days、win rate、PnL ratio、drawdown)+ 4 bias diagnostics(disposition effect、overtrading、chasing momentum、anchoring)。`read_document` は PDF、Word、Excel、PowerPoint、images(OCR)、40+ text formats を 1 つの unified call に dispatch します。
- **2026-04-16** 🧠 **Agent Harness**: Persistent cross-session memory、FTS5 session search、self-evolving skills(full CRUD)、5-layer context compression、read/write tool batching。27 tools、107 new tests。
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai provider([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))、MiniMax temperature fix + model update([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。13 providers。
- **2026-04-14** 🔧 **MCP Stability**: stdio transport 上の backtest tool `Connection closed` error を修正しました([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
- **2026-04-13** 🌐 **Cross-Market Composite Backtest**: 新しい `CompositeEngine` が mixed-market portfolios(例: A-shares + crypto)を shared capital pool と per-market rules で backtest します。swarm template variable fallback と frontend timeout も修正しました。
- **2026-04-12** 🌍 **Multi-Platform Export**: `/pine` が strategies を TradingView(Pine Script v6)、TDX(通达信/同花顺/东方财富)、MetaTrader 5(MQL5)へ 1 コマンドで export します。
- **2026-04-11** 🛡️ **Reliability & DX**: `vibe-trading init` .env bootstrap([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))、preflight checks、runtime data-source fallback、hardened backtest engine。Multi-language README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21))。
- **2026-04-10** 📦 **v0.1.4**: Docker fix([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCP tool、12 LLM providers、`akshare`/`ccxt` deps。PyPI と ClawHub に公開。
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures、GlobalFutures、Forex、Options v2 engines。Monte Carlo、Bootstrap CI、Walk-Forward validation。
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules、Pine Script v6 export、5 data sources with auto-fallback。
</details>
---
## ✨ 主な機能
<div align="center">
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="Self-improving trading agent"/><br>
<h3>🔍 自己改善型トレーディングエージェント</h3>
<div align="left">
• 自然言語による市場リサーチ<br>
• 戦略ドラフトとファイル/Web 分析<br>
• メモリに支えられたワークフロー
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="Multi-agent trading teams"/><br>
<h3>🐝 マルチエージェント・トレーディングチーム</h3>
<div align="left">
• 投資、クオンツ、暗号資産、リスクの各チーム<br>
• 進捗ストリーミングと永続化レポート<br>
• 取得済み市場データで grounding されたワーカー
</div>
</td>
</tr>
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="Cross-market data and backtesting"/><br>
<h3>📊 クロスマーケットデータ & バックテスト</h3>
<div align="left">
• A/HK/US 株式、暗号資産、先物、FX<br>
• データフォールバックと複合バックテスト<br>
• PIT データ、検証、run cards
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
<h3>👥 Shadow Account</h3>
<div align="left">
• ブローカー取引日誌の行動診断<br>
• ルールベースの Shadow Account 比較<br>
• エクスポート可能な監査レポートと戦略コード
</div>
</td>
</tr>
</table>
</div>
## 💡 Vibe-Trading とは?
Vibe-Trading は、金融に関する問いを実行可能な分析へ変換するためのオープンソースのリサーチワークスペースです。自然言語プロンプトを、市場データ loader、戦略生成、バックテストエンジン、レポート、エクスポート、永続リサーチメモリへ接続します。
研究、シミュレーション、バックテストのために設計されています——さらに、お望みであれば、ご自身が認可したブローカー(例: Robinhood Agentic Trading)を通じた自律取引も可能です。資金は一切保管せず、設定した制限を超える取引は決して行わず、いつでも即座に停止できます。
---
## ✨ できること
| タスク | 出力 |
|------|--------|
| **トレーディングの問いを投げる** | ツール、データ、ドキュメント、再利用可能なセッション文脈を使った市場リサーチ。 |
| **戦略アイデアをバックテストする** | 戦略コード、指標、ベンチマーク文脈、検証 artifacts、run cards。 |
| **自分の取引をレビューする** | ブローカー取引日誌の解析、行動診断、ルール抽出、Shadow Account 比較。 |
| **反復リサーチを改善する** | 永続メモリと編集可能な skills により、有用な手順を再利用可能なワークフローへ変換。 |
| **アナリストチームを走らせる** | 投資、クオンツ、暗号資産、マクロ、リスクのワークフロー向けマルチエージェント・リサーチレビュー。 |
| **使える artifacts を出力する** | レポート、TradingView Pine Script、TDX、MetaTrader 5、MCP tools、後続リサーチセッション。 |
| **事前構築 alpha zoo をベンチ** | 452 個の alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)に対し、1 行 CLI で IC + IR + alive/reversed/dead 分類を実行 |
---
## ⚡ クイック例
```bash
pip install vibe-trading-ai
# 自然言語リサーチ
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
# 事前構築 alpha zoo を 1 行でベンチ
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 👥 Shadow Account
Shadow Account は、汎用的な戦略テンプレートではなく、あなた自身の取引記録から始めます。
ブローカー export をアップロードし、エージェントに行動を要約させたうえで、実際の取引経路をルールベースの shadow strategy と比較します。
| ステップ | エージェントの出力 |
|------|--------------|
| **1. 取引日誌を読む** | 同花順、东方财富、富途、generic CSV 形式のブローカー export を解析します。 |
| **2. 行動をプロファイルする** | 保有日数、勝率、PnL ratio、drawdown、disposition effect、overtrading、momentum chasing、anchoring checks。 |
| **3. ルールを抽出する** | 繰り返し現れる entries/exits を、曖昧な要約ではなく明示的な strategy profile に変換します。 |
| **4. shadow を実行する** | 抽出したルールをバックテストし、rule breaks、early exits、missed signals、alternative trade paths を強調します。 |
| **5. レポートを届ける** | 後から確認、アーカイブ、または次回セッションで改善できる HTML/PDF report を生成します。 |
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 🧪 リサーチワークフロー
多くの実行は、同じ evidence path をたどります。リクエストを routing し、適切な市場文脈を読み込み、ツールを実行し、出力を検証し、artifacts を確認可能な形で残します。
| レイヤー | 何が起きるか |
|-------|--------------|
| **Plan** | 必要な finance skills、tools、data sources、必要に応じて swarm preset を選びます。 |
| **Ground** | A 株、HK/US 株式、暗号資産、先物、FX、documents、Web context を利用可能な loaders から取得します。 |
| **Execute** | テスト可能な strategy code を生成し、tools を実行し、対応する backtest engine または analysis workflow を使います。 |
| **Validate** | metrics、benchmark comparison、Monte Carlo、Bootstrap、Walk-Forward、run cards、必要な warnings を追加します。 |
| **Deliver** | TradingView、TDX、MetaTrader 5、MCP clients、後続セッション向けの reports、artifacts、tool traces、exports を返します。 |
---
## 🔩 詳細な機能
メイン README を読みやすく保つため、詳細な一覧は以下に折りたたんでいます。利用できる構成要素を確認したいときに開いてください。
<details>
<summary><b>Finance Skill Library</b> <sub>8カテゴリにわたる77 skills</sub></summary>
- 📊 77 の金融特化 skills を 8 カテゴリに整理
- 🌐 伝統的市場から crypto & DeFi まで完全カバー
- 🔬 データ取得からクオンツリサーチまでを横断する包括的能力
| Category | Skills | Examples |
|----------|--------|----------|
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
</details>
<details>
<summary><b>Preset Trading Teams</b> <sub>29 swarm presets</sub></summary>
- 🏢 すぐ使える 29 の agent teams
- ⚡ 事前構成済みの finance workflows
- 🎯 投資、トレーディング、リスク管理向け presets
| Preset | Workflow |
|--------|----------|
| `investment_committee` | Bull/bear debate → risk review → PM final call |
| `global_equities_desk` | A-share + HK/US + crypto researcher → global strategist |
| `crypto_trading_desk` | Funding/basis + liquidation + flow → risk manager |
| `earnings_research_desk` | Fundamental + revision + options → earnings strategist |
| `macro_rates_fx_desk` | Rates + FX + commodity → macro PM |
| `quant_strategy_desk` | Screening + factor research → backtest → risk audit |
| `technical_analysis_panel` | Classic TA + Ichimoku + harmonic + Elliott + SMC → consensus |
| `risk_committee` | Drawdown + tail risk + regime review → sign-off |
| `global_allocation_committee` | A-shares + crypto + HK/US → cross-market allocation |
<sub>さらに 20 以上の specialist presets があります。すべて確認するには vibe-trading --swarm-presets を実行してください。
</sub>
</details>
<details>
<summary><b>Alpha Zoo</b> <sub>4 つの zoo に渡る 452 個の事前構築 quant alpha</sub></summary>
- 🧬 452 個のクロスセクショナル alpha、オペレーター層でルックアヘッドを禁止
- 📈 IC + IR + alive/reversed/dead 分類を 1 つの CLI コマンドで
- 🔬 AST 純関数ゲート + 300 行のルックアヘッド sentinel テスト + `pytest-socket` によるネットワーク遮断
- 📦 Qlib には Apache-2 帰属表示、各 zoo ごとに `LICENSE.md` で formula を数学的内容として宣言
- 🤝 コミュニティ PR 向け Developer Certificate of Origin (DCO) 署名フロー
| Zoo | 件数 | 出典 | ライセンス |
|-----|-------|--------|---------|
| **qlib158** | 154 | Microsoft Qlib `Alpha158`(Apache-2.0、コミット固定) | Apache-2.0 |
| **alpha101** | 101 | Kakushadze (2015)、"101 Formulaic Alphas"、arXiv:1601.00991 | Formula は数学的内容 |
| **gtja191** | 191 | 国泰君安 (2014)、「191 短周期取引型 alpha 因子」 | Formula は数学的内容 |
| **academic** | 6 | Fama-French 5 + Carhart momentum(価格ベースの proxy) | 公開された学術文献 |
`vibe-trading alpha list` で閲覧、`vibe-trading alpha show <id>` で formula + ソース、`vibe-trading alpha bench --zoo X --universe Y --period Z` で zoo 全体をスコアリングできます。
</details>
## 🎬 デモ
<div align="center">
<table>
<tr>
<td width="50%">
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
</td>
<td width="50%">
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
</td>
</tr>
<tr>
<td colspan="2" align="center"><sub>☝️ 自然言語バックテスト & マルチエージェント swarm debate — Web UI + CLI</sub></td>
</tr>
</table>
</div>
---
## 🚀 クイックスタート
### 1行インストール(PyPI)
```bash
pip install vibe-trading-ai
```
最初のリサーチタスクを実行します。
```bash
vibe-trading init
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
```
> **Package name vs commands:** PyPI package は `vibe-trading-ai` です。インストール後、3 つのコマンドが使えます。
>
> | Command | Purpose |
> |---------|---------|
> | `vibe-trading` | Interactive CLI / TUI |
> | `vibe-trading serve` | FastAPI web server を起動 |
> | `vibe-trading-mcp` | MCP server を起動(Claude Desktop、OpenClaw、Cursor など向け) |
```bash
vibe-trading init # interactive .env setup
vibe-trading # launch CLI
vibe-trading serve --port 8899 # launch web UI
vibe-trading-mcp # start MCP server (stdio)
```
### または利用経路を選ぶ
| Path | Best for | Time |
|------|----------|------|
| **A. Docker** | すぐ試す、ローカル設定ゼロ | 2 min |
| **B. Local install** | 開発、CLI へのフルアクセス | 5 min |
| **C. MCP plugin** | 既存 agent へ接続 | 3 min |
| **D. ClawHub** | clone 不要、1 コマンド | 1 min |
### 前提条件
- 対応 provider の **LLM API key**、または **Ollama** によるローカル実行(key 不要)
- Path B では **Python 3.11+**
- Path A では **Docker**
- OpenAI Codex は ChatGPT OAuth でも利用できます。`LANGCHAIN_PROVIDER=openai-codex` を設定し、`vibe-trading provider login openai-codex` を実行してください。`OPENAI_API_KEY` は使いません。
> **Supported LLM providers:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、Zhipu、Moonshot/Kimi、MiniMax、Xiaomi MIMO、Z.ai、Ollama(local)。設定は `.env.example` を参照してください。
> **Tip:** 自動フォールバックにより、すべての市場は API key なしで利用できます。yfinance(HK/US)、OKX(crypto)、mootdx(A 株、TCP 直結で IP 制限なし)、AKShare(A-shares、US、HK、futures、forex)はすべて無料です。Tushare token は任意で、A 株は mootdx が推奨の no-token fallback、AKShare がより広いカバレッジのバックアップになります。
### Path A: Docker(設定ゼロ)
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
cp agent/.env.example agent/.env
# Edit agent/.env — uncomment your LLM provider and set API key
docker compose up --build
```
`http://localhost:8899` を開きます。Backend + frontend が 1 つの container で動作します。
Docker は既定で backend を `127.0.0.1:8899` に公開し、app を non-root container user として実行します。意図して API を自分の machine 外へ公開する場合は、強い `API_AUTH_KEY` を設定し、client から `Authorization: Bearer <key>` を送ってください。
### Path B: Local install
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
# Activate
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -e .
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
vibe-trading # Launch interactive TUI
```
<details>
<summary><b>Web UI を起動(任意)</b></summary>
```bash
# Terminal 1: API server
vibe-trading serve --port 8899
# Terminal 2: Frontend dev server
cd frontend && npm install && npm run dev
```
`http://localhost:5899` を開きます。frontend は API calls を `localhost:8899` へ proxy します。
**Production mode(single server):**
```bash
cd frontend && npm run build && cd ..
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
```
</details>
### Path C: MCP plugin
下の [MCP Plugin](#-mcp-plugin) セクションを参照してください。
### Path D: ClawHub(1 コマンド)
```bash
npx clawhub@latest install vibe-trading --force
```
skill + MCP config が agent の skills directory にダウンロードされます。詳細は [ClawHub install](#-mcp-plugin) を参照してください。
---
## 🧠 環境変数
`agent/.env.example` を `agent/.env` にコピーし、使いたい provider block のコメントを外してください。各 provider には 3-4 個の変数が必要です。
| Variable | Required | Description |
|----------|:--------:|-------------|
| `LANGCHAIN_PROVIDER` | Yes | Provider name(`openrouter`, `deepseek`, `groq`, `ollama` など) |
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` など) |
| `<PROVIDER>_BASE_URL` | Yes | API endpoint URL |
| `LANGCHAIN_MODEL_NAME` | Yes | Model name(例: `deepseek-v4-pro`) |
| `TUSHARE_TOKEN` | No | A-share data 用 Tushare Pro token(AKShare に fallback) |
| `TIMEOUT_SECONDS` | No | LLM call timeout、既定 120s |
| `API_AUTH_KEY` | Recommended for network deployments | API が非ローカル client から到達可能な場合に必要な Bearer token |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | remote API/MCP-SSE style deployments で shell-capable tools を明示 opt-in |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | document と broker-journal imports 用の追加 comma-separated roots |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | generated-code run directories 用の追加 comma-separated roots |
<sub>* Ollama は API key 不要です。OpenAI Codex は ChatGPT OAuth を使い、tokens は `agent/.env` ではなく `oauth-cli-kit` 経由で保存します。</sub>
**無料データ(key 不要):** AKShare による A-shares、yfinance による HK/US equities、OKX による crypto、CCXT による 100+ crypto exchanges。システムは各市場に最適な利用可能 source を自動選択します。
### 🎯 推奨モデル
Vibe-Trading は tool-heavy agent です。skills、backtests、memory、swarms はすべて tool calls を通じて流れます。モデル選択は、agent が実際に *tools を使う* か、training data から作り話をするかを直接左右します。
| Tier | Examples | When to use |
|------|----------|-------------|
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | 複雑な swarms(3+ agents)、長い research sessions、paper-grade analysis |
| **Sweet spot** (default) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | 日常使い。信頼できる tool-calling を約 1/10 の cost で |
| **Avoid for agent use** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | Tool-calling が不安定です。agent は skills 読み込みや backtests 実行ではなく「記憶から答えている」ように見えます |
既定の `agent/.env.example` は DeepSeek official API + `deepseek-v4-pro` で出荷されています。OpenRouter users は `deepseek/deepseek-v4-pro` を利用できます。
---
## 🖥 CLI リファレンス
```bash
vibe-trading # interactive TUI
vibe-trading run -p "..." # single run
vibe-trading serve # API server
vibe-trading alpha list # 452 個の事前構築 alpha を閲覧;show / bench / compare / export-manifest サブコマンド利用可
```
<details>
<summary><b>TUI 内の slash commands</b></summary>
| Command | Description |
|---------|-------------|
| `/help` | 全コマンドを表示 |
| `/skills` | 77 finance skills を一覧表示 |
| `/swarm` | 29 swarm team presets を一覧表示 |
| `/swarm run <preset> [vars_json]` | live streaming で swarm team を実行 |
| `/swarm list` | Swarm run history |
| `/swarm show <run_id>` | Swarm run details |
| `/swarm cancel <run_id>` | 実行中の swarm をキャンセル |
| `/list` | Recent runs |
| `/show <run_id>` | Run details + metrics |
| `/code <run_id>` | 生成された strategy code |
| `/pine <run_id>` | indicators を export(TradingView + TDX + MT5) |
| `/trace <run_id>` | Full execution replay |
| `/continue <run_id> <prompt>` | 新しい指示で run を継続 |
| `/sessions` | Chat sessions を一覧表示 |
| `/settings` | Runtime config を表示 |
| `/clear` | 画面をクリア |
| `/quit` | 終了 |
</details>
<details>
<summary><b>Single run & flags</b></summary>
```bash
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
vibe-trading run -p "Analyze AAPL momentum" --json
vibe-trading run -f strategy.txt
echo "Backtest 000001.SZ RSI" | vibe-trading run
```
```bash
vibe-trading -p "your prompt"
vibe-trading --skills
vibe-trading --swarm-presets
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
vibe-trading --list
vibe-trading --show <run_id>
vibe-trading --code <run_id>
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
vibe-trading alpha list --zoo gtja191 --limit 10
vibe-trading alpha show gtja191_171
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
</details>
---
## 💡 例
### Strategy & Backtesting
```bash
# Moving average crossover on US equities
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
# RSI mean-reversion on crypto
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
# Multi-factor strategy on A-shares
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
# After backtesting, export to TradingView / TDX / MetaTrader 5
vibe-trading --pine <run_id>
```
**事前構築 alpha zoo を 1 行でベンチ**:
```bash
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
**カタログを閲覧**して個別の alpha を確認:
```bash
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
vibe-trading alpha show gtja191_171
```
**zoo からマルチファクターシグナルを構成**(Python):
```python
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
panel = ... # your wide OHLCV panel
signal = engine.compute_signal(panel)
```
### Market Research
```bash
# Equity deep-dive
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
# Macro analysis
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
# Crypto on-chain
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
```
### Swarm Workflows
```bash
# Bull/bear debate on a stock
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
# Quant strategy from screening to backtest
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
# Crypto desk: funding + liquidation + flow → risk manager
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
# Global macro portfolio allocation
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
```
### Cross-Session Memory
```bash
# Save your preferences once
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
# The agent recalls them in future sessions automatically
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
```
### Upload & Analyze Documents
```bash
# Analyze a broker export or earnings report
vibe-trading --upload trades_export.csv
vibe-trading run -p "Profile my trading behavior and identify any biases"
vibe-trading --upload NVDA_Q1_earnings.pdf
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
```
---
## 🌐 API サーバー
```bash
vibe-trading serve --port 8899
```
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/runs` | runs を一覧表示 |
| `GET` | `/runs/{run_id}` | run details |
| `GET` | `/runs/{run_id}/pine` | Multi-platform indicator export |
| `POST` | `/sessions` | session を作成 |
| `POST` | `/sessions/{id}/messages` | message を送信 |
| `GET` | `/sessions/{id}/events` | SSE event stream |
| `POST` | `/upload` | PDF/file をアップロード |
| `GET` | `/swarm/presets` | swarm presets を一覧表示 |
| `POST` | `/swarm/runs` | swarm run を開始 |
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
| `GET` | `/alpha/list` | zoo/theme/universe でフィルタした alpha リスト |
| `GET` | `/alpha/{alpha_id}` | Alpha のメタデータ + ソースコード |
| `POST` | `/alpha/bench` | Bench ジョブを開始(`job_id` を返す) |
| `GET` | `/alpha/bench/{job_id}/stream` | SSE 進捗ストリーム |
| `GET` | `/settings/llm` | Web UI LLM settings を読み取り |
| `PUT` | `/settings/llm` | local LLM settings を更新 |
| `GET` | `/settings/data-sources` | local data source settings を読み取り |
| `PUT` | `/settings/data-sources` | local data source settings を更新 |
Interactive docs: `http://localhost:8899/docs`
### Security defaults
localhost 開発では、`vibe-trading serve` は browser workflow を簡単に保ちます。非ローカル client では、sensitive API endpoints に `API_AUTH_KEY` が必要です。JSON/upload requests には `Authorization: Bearer <key>` を使ってください。Browser EventSource streams は、Settings で同じ key を一度入力した後、Web UI が処理します。
Shell-capable tools は local CLI と trusted localhost workflows で利用できますが、`VIBE_TRADING_ENABLE_SHELL_TOOLS=1` を明示的に設定しない限り remote API sessions には公開されません。Document と journal readers は既定で upload/import roots に制限されます。ファイルは `agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads`、`~/.vibe-trading/imports` の下に置くか、`VIBE_TRADING_ALLOWED_FILE_ROOTS` で専用 directory を追加してください。
### Web UI Settings
Web UI Settings page では、local users が LLM provider/model、base URL、generation parameters、reasoning effort、Tushare token など任意の market data credentials を更新できます。Settings は `agent/.env` に永続化され、provider defaults は `agent/src/providers/llm_providers.json` から読み込まれます。
Settings reads は side-effect free です。`GET /settings/llm` と `GET /settings/data-sources` は `agent/.env` を作成せず、project-relative paths だけを返します。Settings の読み書きは credential state の公開や credentials/runtime environment の更新を伴うため、設定済みの場合は `API_AUTH_KEY` が必要です。dev mode で `API_AUTH_KEY` が未設定の場合、settings access は loopback clients からのみ受け付けます。
---
## 🔌 MCP Plugin
Vibe-Trading は MCP-compatible client 向けに 35 MCP tools を公開します。stdio subprocess として動作し、server setup は不要です。Core research tools は HK/US/crypto で API key なしに動作し、trading connector tools は選択中の connector profile を使います。LLM key が必要なのは `run_swarm` のみです。
<details>
<summary><b>Claude Desktop</b></summary>
`claude_desktop_config.json` に追加:
```json
{
"mcpServers": {
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
</details>
<details>
<summary><b>OpenClaw</b></summary>
`~/.openclaw/config.yaml` に追加:
```yaml
skills:
- name: vibe-trading
command: vibe-trading-mcp
```
</details>
<details>
<summary><b>Cursor / Windsurf / other MCP clients</b></summary>
```bash
vibe-trading-mcp # stdio (default)
vibe-trading-mcp --transport sse # SSE for web clients
```
</details>
**公開される MCP tools(35):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`.
<details>
<summary><b>ClawHub からインストール(1 コマンド)</b></summary>
```bash
npx clawhub@latest install vibe-trading --force
```
> `--force` が必要なのは、skill が external APIs を参照し、VirusTotal の automated scan が起動するためです。コードは完全に open-source で、自由に確認できます。
これにより skill + MCP config が agent の skills directory にダウンロードされます。clone は不要です。
ClawHub で見る: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
</details>
<details>
<summary><b>OpenSpace — self-evolving skills</b></summary>
77 の finance skills はすべて [open-space.cloud](https://open-space.cloud) に公開され、OpenSpace の self-evolution engine を通じて自律的に進化します。
OpenSpace と使うには、agent config に両方の MCP servers を追加してください。
```json
{
"mcpServers": {
"openspace": {
"command": "openspace-mcp",
"toolTimeout": 600,
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
}
},
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
OpenSpace は 77 skills を自動検出し、auto-fix、auto-improve、community sharing を可能にします。OpenSpace-connected agent では `search_skills("finance backtest")` から Vibe-Trading skills を検索できます。
</details>
---
## 📁 プロジェクト構成
<details>
<summary><b>クリックして展開</b></summary>
```
Vibe-Trading/
├── agent/ # バックエンド (Python)
│ ├── cli/ # CLI パッケージ — インタラクティブ TUI + サブコマンド
│ ├── api_server.py # FastAPI サーバー — runs、sessions、upload、swarm、SSE
│ ├── mcp_server.py # MCP サーバー — OpenClaw / Claude Desktop 向け 35 tools
│ │
│ ├── src/
│ │ ├── agent/ # ReAct エージェントコア
│ │ │ ├── loop.py # 5 層コンテキスト圧縮 + read/write ツールバッチング
│ │ │ ├── context.py # システムプロンプト + 永続メモリからの自動 recall
│ │ │ ├── skills.py # skill ローダー(77 個同梱 + CRUD でユーザー作成)
│ │ │ ├── tools.py # ツール基底クラス + レジストリ
│ │ │ ├── memory.py # run ごとの軽量ワークスペース状態
│ │ │ ├── frontmatter.py # 共有 YAML frontmatter パーサー
│ │ │ └── trace.py # 実行トレースライター
│ │ │
│ │ ├── memory/ # クロスセッション永続メモリ
│ │ │ └── persistent.py # ファイルベースメモリ (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 31 個の自動検出エージェントツール
│ │ │ ├── backtest_tool.py # バックテスト実行
│ │ │ ├── remember_tool.py # クロスセッションメモリ (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
│ │ │ ├── session_search_tool.py # FTS5 クロスセッション検索
│ │ │ ├── swarm_tool.py # swarm チームを起動
│ │ │ ├── web_search_tool.py # DuckDuckGo Web 検索
│ │ │ └── ... # bash、file I/O、factor analysis、options、alpha browser + bench など
│ │ │
│ │ ├── factors/ # Alpha Zoo — 4 つの zoo にまたがる 452 個の alpha
│ │ │ ├── base.py # 19 個のオペレーター (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
│ │ │ ├── registry.py # AST 限定のメタデータ読み込み + 遅延計算 + sanity gate
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 分類
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
│ │ │
│ │ ├── api/ # FastAPI ルートモジュール
│ │ │ └── alpha_routes.py # /alpha/list、/alpha/{id}、/alpha/bench、SSE ストリーム
│ │ │
│ │ ├── skills/ # 8 カテゴリ 77 個の finance skills(各 SKILL.md)
│ │ ├── swarm/ # Swarm DAG 実行エンジン
│ │ │ └── presets/ # 29 個の swarm preset YAML 定義
│ │ ├── session/ # マルチターンチャット + FTS5 セッション検索
│ │ └── providers/ # LLM プロバイダー抽象化
│ │
│ └── backtest/ # バックテストエンジン
│ ├── engines/ # 7 エンジン + クロスマーケット composite engine + options_portfolio
│ ├── loaders/ # 7 ソース: tushare、okx、yfinance、akshare、mootdx、ccxt、futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # Registry + 自動フォールバックチェーン
│ └── optimizers/ # MVO、equal vol、max div、risk parity
│
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
│ └── src/
│ ├── pages/ # Home、Agent、AlphaZoo、RunDetail、Compare、Correlation、Settings
│ ├── components/ # chat、charts、layout
│ └── stores/ # Zustand 状態管理
│
├── Dockerfile # マルチステージビルド
├── docker-compose.yml # 1 コマンドデプロイ
├── pyproject.toml # パッケージ設定 + CLI エントリポイント
├── tools/ # リポジトリレベルの CI ヘルパー
│ └── ci_grep_gates.sh # yaml.load / 商標 / 銘柄データ漏洩を拒否
└── LICENSE # MIT
```
</details>
---
## 🏛 エコシステム
Vibe-Trading は **[HKUDS](https://github.com/HKUDS)** agent ecosystem の一部です。
<table>
<tr>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
<sub>Ultra-Lightweight Personal AI Assistant</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
<sub>Agent-Native Signal &amp; Copy Trading Platform</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
<sub>Making All Software Agent-Native</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
<sub>Self-Evolving AI Agent Skills</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
<sub>Agent Swarm Intelligence</sub>
</td>
</tr>
</table>
---
## 🗺 ロードマップ
> 段階的に出荷します。作業が始まった項目は [Issues](https://github.com/HKUDS/Vibe-Trading/issues) に移動します。
| Phase | Feature | Status |
|-------|---------|--------|
| **Trust Layer** | 再現可能な run cards は出力・Run Detail 表示まで完了。v1 では tool traces と citations を追加 | v0 出荷済み |
| **Hypothesis Registry** | lifecycle status、data sources、skills、run-card links、invalidation notes を持つ永続リサーチ仮説 | Backend MVP 出荷済み |
| **Research Autopilot** | 手動実行から始める research loop: hypothesis → deterministic backtest → evidence report | 次 |
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Planned |
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Planned |
| **Alpha Zoo** | 452 個の事前構築 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)、1 行 CLI でベンチ、agent 統合、Web UI | **0.1.8 でリリース済み** |
| **Research Delivery** | Scheduled briefs to Slack / Telegram / email-style channels | Planned |
| **Community** | Shareable skills, presets, and strategy cards | Exploring |
---
## Contributing
Contributions を歓迎します。ガイドラインは [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
**Good first issues** は [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) でタグ付けされています。気になるものから始めてください。
より大きな貢献を検討している場合は、上の [Roadmap](#-ロードマップ) を確認し、着手前に issue を開いて相談してください。
---
## Contributors
Vibe-Trading に貢献してくださった皆さまに感謝します。
最近の v0.1.8 cycle contributors and credits:
- @GTC2080 / TaoMu — Web UI Settings and provider/data-source configuration APIs (#57)
- @BigNounce90 — validation CLI hardening for backtest `run_dir` input (#60)
- @shadowinlife — A-share pre-ST filter skill (#63)
- @MB-Ndhlovu — correlation heatmap dashboard and review fixes (#64, #66)
- @ykykj — OpenAI Codex OAuth provider option (#65)
- @RuifengFu — interactive CLI live status bar and prompt editing (#69)
- @SiMinus — swarm preset inspection command (#73)
- @warren618 / Haozhe Wu — security hardening, release integration, docs, Docker, packaging, and local dev workflow
- lemi9090 (S2W) — coordinated security research, validation, and disclosure support
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
</a>
---
## Disclaimer
Vibe-Trading は研究・取引ソフトウェアです。投資助言ではなく、資金を一切保管せず、取引所も運営しません。取引はご自身が明示的に認可したブローカーチャネル(例: Robinhood Agentic Trading)を通じてのみ行われ、設定した制限の範囲内で、いつでも停止できます。このブローカー取引機能は実験的であり、当方が実際のブローカー口座で検証したものではありません——自己責任でご利用ください。過去の成績は将来の結果を保証しません。
## License
MIT License — see [LICENSE](LICENSE)
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=HKUDS/Vibe-Trading&type=Date)](https://star-history.com/#HKUDS/Vibe-Trading&Date)
<p align="center">
⭐ <b>Vibe-Trading</b> が研究の役に立ったら、Star を付けると他の人にも見つけてもらえます。
</p>
---
<p align="center">
<b>Vibe-Trading</b> をご覧いただきありがとうございます ✨
</p>
<p align="center">
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
</p>
+947
View File
@@ -0,0 +1,947 @@
<p align="center">
<a href="README.md">English</a> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <b>한국어</b> | <a href="README_ar.md">العربية</a>
</p>
<p align="center">
<img src="assets/icon.png" width="120" alt="Vibe-Trading 로고"/>
</p>
<h1 align="center">Vibe-Trading: 당신의 개인 트레이딩 에이전트</h1>
<p align="center">
<b>한 번의 명령으로 에이전트에 종합적인 트레이딩 역량을 더하세요</b>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
<br>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<p align="center">
<a href="https://vibetrading.wiki/">웹사이트</a> &nbsp;&middot;&nbsp;
<a href="https://vibetrading.wiki/docs/">문서</a> &nbsp;&middot;&nbsp;
<a href="#-뉴스">뉴스</a> &nbsp;&middot;&nbsp;
<a href="#-주요-기능">기능</a> &nbsp;&middot;&nbsp;
<a href="#-섀도우-계정">섀도우 계정</a> &nbsp;&middot;&nbsp;
<a href="#-데모">데모</a> &nbsp;&middot;&nbsp;
<a href="#-빠른-시작">빠른 시작</a> &nbsp;&middot;&nbsp;
<a href="#-예제">예제</a> &nbsp;&middot;&nbsp;
<a href="#-api-서버">API / MCP</a> &nbsp;&middot;&nbsp;
<a href="#-로드맵">로드맵</a> &nbsp;&middot;&nbsp;
<a href="#기여하기">기여하기</a>
</p>
<p align="center">
<a href="#-빠른-시작"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
</p>
---
## 📰 뉴스
- **2026-05-31** 🔌 **Connector-first 브로커 아키텍처(IBKR + Robinhood)**: 거래 접근은 이제 별도의 브로커/live 진입점이 아니라 선택 가능한 connector profile에서 시작합니다. `vibe-trading connector list/use/check/account/positions/orders/quote/history`와 MCP `trading_*` 도구는 동일한 선택 profile을 공유하며, paper/live는 connector의 속성으로 다룹니다. IBKR은 로컬 읽기 전용 TWS / IB Gateway profile로 즉시 사용할 수 있고, 공식 IBKR 원격 MCP 경로는 안정적인 read tool 이름이 제공될 때까지 OAuth `mcp.read` probe로 seed되어 있습니다. Robinhood Agentic Trading은 계속 OAuth, 커밋된 mandate, order guard, audit ledger, instant halt 뒤에 있는 bounded live MCP connector입니다.
- **2026-05-30** 🧰 **견고성 패스 — backtest, agent loop, session**: LLM이 생성한 signal engine은 이제 인스턴스화 전에 인터페이스 사전 검증을 거칩니다. 순환 self-import, 누락된 `generate()`, 기본값 없는 `__init__` 인자, 잘못된 반환 타입 같은 흔한 실수를 조기에 잡아 원시 traceback 대신 실행 가능한 JSON 오류로 반환합니다 ([#149](https://github.com/HKUDS/Vibe-Trading/pull/149)). 후속 작업으로 소스 수준 AST 검증 오류도 동일한 깔끔한 JSON 봉투에 실었습니다. agent loop는 더 이상 50회 반복을 모두 소진하고 출력 없는 `failed` 상태로 끝나지 않습니다 — swarm worker의 검증된 방식을 따라 반복 예산의 80%에서 wrap-up nudge를 주입하고 마지막 반복에서 tool 정의를 제거해 텍스트 답변을 강제합니다 ([#148](https://github.com/HKUDS/Vibe-Trading/pull/148)). 중간에만 발동하도록 가드되어 research-goal 컨텍스트를 밀어내지 않습니다. session 메시지 쓰기는 이제 append마다 `flush + fsync`하여 비싼 AI 응답이 쓰기 도중 크래시에도 살아남고, 읽기 경로는 손상된 JSONL 줄을 건너뛰며(복구용으로 앞 200자 로깅) `/messages` 엔드포인트 전체를 500으로 만들지 않습니다 ([#147](https://github.com/HKUDS/Vibe-Trading/pull/147)). Web 입력창은 IME Enter 처리도 수정해 조합 확정 Enter가 단어 도중에 전송되지 않도록 했습니다 ([#146](https://github.com/HKUDS/Vibe-Trading/pull/146)).
- **2026-05-29** 🔐 **Robinhood Agentic Trading 지원(옵트인, 제한된 자율성)**: Robinhood Agentic Trading을 지원합니다(원격 MCP, OAuth). 기본적으로 비활성·읽기 전용이며, 에이전트는 사용자가 커밋한 mandate(종목/주문 규모/익스포저/레버리지/일일 한도) 범위 안에서만 자율 거래합니다. 파일 수준의 즉시 kill switch, 선제적 포지션 청산, mandate 자동 만료, 완전한 감사 원장, 영속 자율 runner를 갖췄습니다. 수탁 없음·거래소 없음 — 자금 보유와 체결은 브로커가 하고, 우리는 의도만 중계합니다. 실험적 / 사용에 따른 책임은 본인에게 있습니다.
<details>
<summary>이전 뉴스</summary>
- **2026-05-28** 🧪 **Swarm 안전성 + 엄격 alpha 게이트 + worker MCP**: Swarm DAG가 상위 태스크 실패 시 하위 태스크를 차단합니다 ([#145](https://github.com/HKUDS/Vibe-Trading/pull/145)). 새 `run_bench_strict()`는 IC 게이트 위에 동일 universe 랜덤 컨트롤 + 학습/테스트 OOS 분할을 추가해 시장 beta만 따라가는 가짜 factor를 잡아냅니다 ([#143](https://github.com/HKUDS/Vibe-Trading/pull/143), @Soli22de 감사). Swarm worker는 이제 operator가 설정한 외부 MCP server를 호출할 수 있으며 신뢰 경계는 전용 테스트로 고정되어 있습니다 ([#142](https://github.com/HKUDS/Vibe-Trading/pull/142), @shadowinlife 감사).
- **2026-05-27** 📊 **mootdx A주 데이터 소스 + 출력 정리**: 새 `mootdx` loader는 네이티브 通达信 TCP 프로토콜로 A주 OHLCV를 가져옵니다(인증 불필요, IP 속도 제한 없음, 일봉 + 분봉의 25 페이지 walk-back 페이지네이션). fallback chain에서 tushare와 akshare 사이에 위치합니다 ([#107](https://github.com/HKUDS/Vibe-Trading/issues/107)). CCXT loader는 이제 `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY`를 읽어 제한된 네트워크에서 Binance/OKX 공개 데이터를 가져올 수 있습니다 ([#126](https://github.com/HKUDS/Vibe-Trading/pull/126), @ruok808 감사). 최종 답변 렌더링에서도 CLI와 Web의 보기 흉한 전체 너비 `---` 구분자를 제거했습니다: system prompt는 markdown 테이블과 `##` 헤딩 사용을 유도하고, CLI 렌더러는 독립 HR을 defense-in-depth로 제거하며, 채팅 버블은 빠져나온 `<hr>`을 숨깁니다 ([#139](https://github.com/HKUDS/Vibe-Trading/issues/139), @sdwxm188 감사).
- **2026-05-26** ✅ **Research Goal lifecycle 폐쇄 루프**: Goal mode가 실제 task runner처럼 동작합니다. Web UI에서 goal을 만들면 session을 생성하거나 바인딩하고 즉시 kickoff turn을 보냅니다. active goal은 Web/API/CLI/MCP에서 continue/edit/cancel/complete할 수 있으며, agent loop는 최초 prompt만이 아니라 현재 goal snapshot(criteria, evidence, claims, open items)을 기준으로 진행합니다. criteria가 covered였지만 goal이 active로 남아 있으면 조용히 멈추지 않고 audit/status update로 들어가며, backend, CLI, MCP, frontend events 회귀로 고정했습니다.
- **2026-05-25** 🧼 **더 깔끔한 Chat UI + composer 워크플로**: Web UI는 이제 다음 입력에 집중하도록 정리되었습니다. upload, swarm, research-goal 모드는 composer의 `+` 메뉴 뒤로 모이고, floating panel로 채팅을 방해하지 않습니다. 현재 context는 입력창 위 compact chip으로 표시되며, goal 세부 정보는 chip을 클릭할 때만 inline으로 펼쳐집니다. 기존 custom i18n layer도 제거하고 직접 English copy로 통일했습니다. Full Report card는 report-worthy run에만 표시되며, 로컬 dev startup/status reporting도 브라우저 smoke test에 맞게 안정화했습니다.
- **2026-05-24** 🎯 **Research Goal runtime**: backend, CLI, API/MCP, SSE, Web UI 전반에 session-scoped Research Goal layer를 추가했습니다. Goal은 claim, acceptance criteria, evidence row, budget, completion policy를 영속화합니다. agent tool은 goal 생성과 evidence 추가를 지원하고, `/goal`은 CLI 진입점이 되었으며, REST/MCP는 goal snapshot과 evidence write를 노출하고, SSE는 chat client 상태를 최신으로 유지합니다. 후속 audit fixes에서는 verified evidence 경계를 잠그고, agent tool의 live-trading risk tier 입력을 차단하며, CLI-created goal을 이후 turn에 연결하고, session 삭제 시 goal ledger를 정리하고, replay-all을 연결하고, frontend cross-session snapshot race를 수정했습니다.
- **2026-05-23** 🖥️ **대화형 CLI 새 단장**: 터미널 진입점은 더 큰 Vibe-Trading 배너, 더 깔끔한 prompt 구분선, 이전 턴 요약, 실행 후 소요 시간, Claude Code 스타일 activity rail로 live agent 작업을 보여줍니다. 도구 호출, 웹/데이터 fetch, shell 스타일 동작, Markdown 답변, pipe table은 더 읽기 쉬운 transcript로 렌더링되며, pipe 또는 non-TTY 실행은 자동화에 적합한 plain-text 출력을 유지합니다. 생성된 CLI 스크린샷은 커밋되는 docs 파일이 아니라 local artifact로 처리되어 저장소를 가볍게 유지합니다.
- **2026-05-22** 🧭 **Swarm 복구 + MCP keepalive**: Swarm 상태는 이제 읽을 때마다 live task 파일에서 reconcile되므로 API/MCP/SSE/list 뷰가 크래시되었거나 오래된 run을 복구하고 영구 `running` 스냅샷을 보여주지 않습니다. `run_swarm`는 polling 중 MCP progress heartbeat를 보내며, transport drop 이후 재연결한 클라이언트가 handle을 회수할 수 있도록 첫 프레임을 `swarm_started run_id=<id>`로 고정했습니다. worker도 LLM streaming, grounding fetch, tool execution 전 과정에서 heartbeat를 냅니다. stale-run reaper는 run별 임계값을 사용하고 task 상태에서 최종 상태를 도출합니다. `SwarmTool`은 wait budget이 끝났다는 이유만으로 진행 중인 team을 취소하지 않으며, MCP 클라이언트는 `reap_stale_runs()`로 명시적 cleanup을 실행할 수 있습니다. 오늘의 DX pass에서는 provider 기본 모델도 갱신하고 CI syntax check를 새 `agent/cli/` 패키지에 맞췄습니다. 22개의 새 회귀 테스트가 hydrate, 최종 상태 복구, stale reap, keepalive cadence, env parsing, heartbeat wiring을 다루며, 전체 swarm/MCP 스위트는 169 passed, 4 skipped입니다.
- **2026-05-21** 🧱 **CLI 패키지 리팩토링**: `agent/cli.py`(3216 LOC)를 `agent/cli/` 패키지로 분할 — 대화형 진입점, 슬래시 라우터, Rich 컴포넌트, 그리고 모든 서브커맨드를 보존하고 `cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` 등 공개 심볼을 재내보내는 `_legacy.py` shim. 새 FastAPI 미들웨어는 브라우저가 `/runs/{id}` 또는 `/correlation`에 직접 접근할 때 SPA 셸을 반환하며, 동일한 좁힘을 Vite dev 프록시에도 반영했습니다. 버전 문자열은 `cli/_version.py` 단일 소스로 통합(`--version`과 배너 드리프트 해결), `python -m cli`는 `__main__.py`로 복원, chat 게이트를 좁혀 `chat --help` / `chat extra`가 REPL에 삼켜지지 않고 레거시 argparse에 도달합니다.
- **2026-05-20** 🔬 **Hypothesis Registry CLI**: 2026-05-16에 백엔드만 출시된 Hypothesis Registry의 CLI 측을 완성했습니다. `vibe-trading hypothesis list`는 Rich 테이블 또는 JSON을 출력합니다(`--status` 필터, `--limit` 지원). `show <id>`는 링크된 run card를 포함한 상세 패널을 렌더링합니다. `invalidate <id> --note "..."`는 상태를 `rejected`로 전환하며, `--note`를 생략하면 기존 invalidation notes를 유지합니다. 기존 `VIBE_TRADING_HYPOTHESES_PATH` 환경변수 오버라이드와 호출별 `--path`를 모두 지원합니다. 22개의 새 테스트가 와이어링, JSON 출력, 상태 필터, limit, ID 누락 오류, 노트 영속성을 다룹니다.
- **2026-05-19** ✨ **도구 라이브 피드백 + 우아한 취소**: 오래 걸리는 도구(백테스트, 큰 PDF, swarm worker)가 멈춘 것처럼 보이지 않게 되었습니다. 모든 도구 호출은 이제 3초 간격의 하트비트와 구조화된 단계별 진행 상황을 발행합니다 — `run_backtest`는 단계 마커(`validate` / `simulate` / `finalize`), `read_document`는 PDF에서는 페이지 단위, Excel에서는 시트 단위, `read_url`은 `fetch` / `parse`를 표시합니다. CLI의 Rich Live 대시보드는 유니코드 스피너, ASCII 진행 표시줄, ETA를 렌더링하고 도구 이름으로 키된 최대 3개의 병렬 도구를 스택 표시합니다. 프런트엔드 채팅에는 새로운 `ToolProgressIndicator`를 추가했으며, rAF 코얼레싱, ARIA `role="status"` + 스크린 리더용 숨겨진 네이티브 `<progress>`, 총량을 알 때 결정적 `ProgressRing` SVG로 전환합니다. CLI 실행 중 첫 번째 `Ctrl+C`는 이제 `agent.cancel()`을 호출해 우아하게 종료(현재 단계가 끝나고 trace가 깨끗하게 닫힘)하고, 2초 이내 두 번째는 강제 종료합니다. 재사용 가능한 기본 요소도 추출했습니다: `ProgressBar.tsx`와 `lib/tools.ts`(공유 도구 이름 i18n).
- **2026-05-18** 🧹 **정리 + 3개의 잠재 버그 수정**: `CompositeEngine`이 거래소 접미사가 없는 중국 선물 코드(`RB2410` 등)를 `GlobalFuturesEngine`으로 잘못 라우팅하던 문제를 수정했습니다. `_is_china_futures`를 공유 `_market_hooks` 모듈로 옮기고, 상품 코드 테이블에 대소문자 정규화 + 비중국 거래소 가드를 추가했으며, 회귀 케이스 9개를 새로 작성했습니다. session FTS5 인덱스가 타임스탬프를 영구 저장하게 되어 크로스 세션 검색에서 날짜 정렬이 가능해졌으며, 동일 변경으로 re-upsert 경로가 `started_at`을 wall-clock으로 덮어쓰던 부수 버그도 해결했습니다. Vite 개발 프록시에 누락되었던 `/alpha`를 추가하여 AlphaZoo 페이지가 `npm run dev`에서 정상 해석됩니다. `tests/test_e2e_harness_v2.py`(실 LLM e2e 스위트)는 `VIBE_TRADING_RUN_LIVE_E2E=1`로 게이트하여 CI가 환경변수 유무에 따라 형태를 바꾸지 않도록 했습니다. ruff에 factor zoo용 `per-file-ignores`를 추가(F401 잡음 3783 → 0)하고, 프런트엔드 tsconfig에 `noUnusedLocals` / `noUnusedParameters`를 활성화해 회귀 가드로 두었으며, `gtja191` alpha 파일들의 사용되지 않는 `vw = vwap(...)` 보일러플레이트 76개도 삭제했습니다. 순 **-918줄**.
- **2026-05-17** 🧬 **Alpha Zoo v1 (0.1.8)**: 4개 zoo에 걸친 452개의 사전 빌드된 quant alpha를 번들로 제공 — `qlib158`(Microsoft Qlib의 Alpha158 특성, Apache-2.0 출처 표기), `alpha101`(Kakushadze의 "101 Formulaic Alphas"를 arXiv:1601.00991 논문 부록에서 재구현), `gtja191`(국태군안 2014 단기 거래형 alpha 리서치 보고서), `academic`(Fama-French 5 + Carhart 모멘텀의 가격 기반 proxy 구현). 한 줄 CLI로 임의 universe에서 벤치: `vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`. AST 순수 함수 게이트, look-ahead 가드 테스트, `pytest-socket` 네트워크 차단, zoo별 LICENSE.md, 커뮤니티 PR용 DCO 서명 워크플로우 포함. Alpha Library 자동 렌더링: [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/), Research Lab 글: [Which of the 191 GTJA alphas still work in 2026?](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html).
- **2026-05-16** 🧪 **리서치 기반 업데이트**: backend Hypothesis Registry를 추가해 `create_hypothesis`, `update_hypothesis`, `link_backtest`, `search_hypotheses`를 제공합니다. 외부 콘텐츠 reader는 warning-only `security_warnings`를 붙이고, Shadow Account scanner는 기존 calendar-phase stub 대신 deterministic OHLCV feature evaluation을 사용합니다.
- **2026-05-15** 🪪 Run 상세 페이지에서 metrics와 artifacts 옆에 Trust Layer run card를 렌더링해, 2026-05-12에 들어간 `run_card.json` 작업의 UI 측을 마무리합니다. `PersistentMemory.add()`도 #108/#109/#110 triage에 따라 길이, 빈 문자열 또는 공백만으로 이루어진 name, C0/C1 제어 바이트 경로에서 강화되었습니다([#112](https://github.com/HKUDS/Vibe-Trading/pull/112), @Teerapat-Vatpitak 감사합니다).
- **2026-05-14** 🌐 공개 Wiki가 [vibetrading.wiki](https://vibetrading.wiki/)에 열렸고, docs, tutorials, Research Lab, Alpha Library 섹션을 Cloudflare Pages로 배포합니다. 영구 메모리도 이제 `vibe-trading memory list/show/search/forget`으로 CLI에서 확인할 수 있으며([#102](https://github.com/HKUDS/Vibe-Trading/pull/102), @Teerapat-Vatpitak 감사합니다), memory tokenization/slug는 태국어, 아랍어, 히브리어, 키릴 문자도 지원합니다([#104](https://github.com/HKUDS/Vibe-Trading/pull/104)).
- **2026-05-13** 🧭 Swarm 실행은 이제 가져온 시장 데이터로 worker를 grounding하고, 더 깔끔한 영구 리포트를 남깁니다([#93](https://github.com/HKUDS/Vibe-Trading/pull/93), [#84](https://github.com/HKUDS/Vibe-Trading/pull/84)).
- **2026-05-12** 🧾 백테스트는 이제 재현 가능한 리서치 실행을 위해 artifacts와 함께 `run_card.json` 및 `run_card.md`를 생성합니다.
- **2026-05-11** 🧭 **Memory slug, swarm 집계, CLI 프리플라이트**: 영구 메모리는 파일 slug를 생성할 때 CJK 문자를 보존하여 중국어/일본어/한국어 노트에서 조용한 파일명 충돌이 발생하지 않도록 합니다([#95](https://github.com/HKUDS/Vibe-Trading/pull/95), @voidborne-d 감사합니다). Swarm 실행 합계는 이제 provider가 보고한 token usage를 우선 사용하고 기존 추정 fallback도 유지합니다([#94](https://github.com/HKUDS/Vibe-Trading/pull/94), @Teerapat-Vatpitak 감사합니다). CLI 실행 UI에는 일반적인 환경 문제를 확인하는 시작 프리플라이트 체크도 추가되었습니다([#96](https://github.com/HKUDS/Vibe-Trading/pull/96), @ykykj 감사합니다).
- **2026-05-10** 🧱 **회귀 가드레일 + run 메타데이터**: Memory recall은 이제 밑줄을 token 경계로 취급하므로 `mcp_wiring_test` 같은 snake_case 저장 메모리가 "mcp wiring" 같은 자연어 쿼리와 매칭됩니다([#87](https://github.com/HKUDS/Vibe-Trading/pull/87), @hp083625 감사합니다). MCP server에는 initialize → `tools/list` → `tools/call` 경로를 실제 subprocess로 검증하는 smoke test가 추가되어 첫 호출 deadlock 경로를 방지합니다([#86](https://github.com/HKUDS/Vibe-Trading/pull/86)). Windows 경로 민감 테스트, API best-effort 예외 처리, backtest `run_dir` 허용 루트 검증, SwarmRun provider/model 메타데이터에 대한 저위험 강화도 반영되었습니다([#88](https://github.com/HKUDS/Vibe-Trading/pull/88), [#90](https://github.com/HKUDS/Vibe-Trading/pull/90), [#91](https://github.com/HKUDS/Vibe-Trading/pull/91), [#92](https://github.com/HKUDS/Vibe-Trading/pull/92), @Teerapat-Vatpitak 감사합니다).
- **2026-05-09** 🛡️ **API 경로 강화 + MCP server 안정성**: API run/session 라우트는 조회 전에 path ID를 검증하여 개행이 포함된 잘못된 파라미터를 거부하고, 이 동작을 auth/security 회귀 테스트에 고정했습니다([#80](https://github.com/HKUDS/Vibe-Trading/pull/80), @SJoon99 감사합니다). MCP server는 `tools/call`을 제공하기 전에 메인 스레드에서 도구 레지스트리를 미리 워밍업하여 lazy tool discovery의 첫 호출 deadlock을 피합니다([#85](https://github.com/HKUDS/Vibe-Trading/pull/85), @Teerapat-Vatpitak 감사합니다). Vite dev proxy도 기본값이 아닌 백엔드 타깃을 위해 `VITE_API_URL`을 존중합니다([#82](https://github.com/HKUDS/Vibe-Trading/pull/82), @voidborne-d 감사합니다).
- **2026-05-08** 🧾 **Tushare 재무제표 필드를 필터에 연결**: A주 일간 백테스트에서 `fundamental_fields`를 통해 PIT-safe 재무제표 필드를 요청할 수 있으므로 signal engine은 공시/공개일 이후 `income_total_revenue`, `income_n_income`, `balancesheet_total_hldr_eqy_exc_min_int`, `fina_indicator_roe` 등 테이블 접두사 컬럼으로 선별할 수 있습니다([#76](https://github.com/HKUDS/Vibe-Trading/pull/76), @mrbob-git 감사합니다). 후속 강화로 명시적 재무제표 필드 요청 시 Tushare enrichment가 실행되지 않으면 원시 가격 bar로 조용히 fallback하지 않고 즉시 실패합니다([#77](https://github.com/HKUDS/Vibe-Trading/pull/77)).
- **2026-05-07** 📈 **Tushare fundamentals + 커뮤니티 정리**: 펀더멘털 리서치 워크플로를 위해 point-in-time `TushareFundamentalProvider` 계약을 추가하고, 프로젝트 `TUSHARE_TOKEN` 환경 경로를 회귀 테스트로 고정했습니다([#74](https://github.com/HKUDS/Vibe-Trading/pull/74)). 커뮤니티 정리에서는 Vibe-Trading이 당분간 빠른 반복을 위해 하나의 UI 언어에 집중하고, DuckDuckGo 기반 `web_search`가 이미 번들되어 있으므로 중복 검색 의존성을 추가하지 않으며, 비공식 호스팅 배포를 API 키나 데이터 소스 토큰을 입력할 수 있는 신뢰 지점으로 보지 않는다는 점도 명확히 했습니다.
- **2026-05-06** 🚀 **v0.1.7 릴리스**([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7), `pip install -U vibe-trading-ai`): 보안 경계 강화가 PyPI와 ClawHub에 게시되었습니다. API/read/upload/file/URL/generated-code/shell-tool/Docker 기본값을 더 안전하게 하면서 localhost CLI/Web UI 워크플로는 낮은 마찰을 유지합니다. 이번 사이클에는 Web UI Settings, 상관관계 히트맵, OpenAI Codex OAuth, A주 pre-ST 필터, 대화형 CLI UX, swarm preset inspection, 배당 분석, 개발 워크플로 개선, 감사된 frontend build dependency 하한도 포함됩니다. 0.1.7 기여자들과 조율된 보안 검증을 도와준 lemi9090 (S2W)에게 감사드립니다.
- **2026-05-05** 🛡️ **보안 경계 후속 조치**: 명시적 CORS origin, Settings credential indicator, web URL reading, Shadow Account code generation 주변의 남은 보안 경계 강화를 완료하고 각 경로에 회귀 테스트를 추가했습니다. 일반적인 localhost CLI/Web UI 워크플로는 그대로 유지되며, 원격 배포는 계속 `API_AUTH_KEY`와 명시적인 trusted origin을 사용해야 합니다.
- **2026-05-04** 🖥️ **대화형 CLI UX + CI 정리**: 대화형 모드에 provider/model, 세션 시간, 직전 실행 latency, 누적 tool-call 통계를 보여주는 live bottom status bar가 추가되었고, `prompt_toolkit`을 통해 방향키 기반 prompt history 탐색과 cursor editing을 지원합니다([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). `prompt_toolkit` 또는 TTY를 사용할 수 없으면 CLI는 여전히 Rich prompt로 fallback합니다. 강화된 file-import sandbox와 cross-platform `/tmp` 해석에 맞춰 CI path expectation도 정렬되어 main이 다시 green 상태가 되었습니다([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
- **2026-05-03** 🛡️ **보안 강화 패치**: 비로컬 배포의 기본 API 인증을 강화하고, 민감한 run/session/swarm read를 보호하며, upload와 local file-reading 경계를 제한하고, shell-capable tool을 entry point별로 제어하며, 생성 전략을 import 전에 검증하고, Docker image를 기본적으로 non-root 사용자와 localhost-only published port로 실행합니다. Local CLI와 localhost Web UI 워크플로는 낮은 마찰을 유지하며, 원격 API/Web 배포는 `API_AUTH_KEY`를 설정해야 합니다.
- **2026-05-02** 🧭 **배당 분석 + 더 선명한 로드맵**: income stock, payout sustainability, dividend growth, shareholder yield, ex-dividend mechanics, yield-trap check를 위한 `dividend-analysis` 스킬을 추가하고 bundled-skill 회귀 테스트로 고정했습니다. 공개 로드맵은 Research Autopilot, Data Bridge, Options Lab, Portfolio Studio, Alpha Zoo, Research Delivery, Trust Layer, Community sharing에 집중하도록 정리되었습니다.
- **2026-05-01** 🔥 **상관관계 히트맵 + OpenAI Codex OAuth + A주 pre-ST 필터**: 새 correlation dashboard/API가 rolling return correlation을 계산하고 포트폴리오 및 종목 분석용 ECharts heatmap을 렌더링합니다([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). OpenAI Codex provider support는 이제 `vibe-trading provider login openai-codex`를 통해 ChatGPT OAuth를 사용하며, Settings metadata와 adapter regression test가 포함됩니다([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). A주 ST/*ST 리스크 스크리닝용 `ashare-pre-st-filter` 스킬도 추가 및 강화되었고, Sina penalty relevance filtering으로 securities-account 언급이 E2 count를 부풀리지 않도록 했습니다([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
- **2026-04-30** ⚙️ **Web UI Settings + validation CLI 강화**: LLM provider/model, base URL, reasoning effort, data source credential을 위한 새 Settings page가 추가되었고, local/auth-protected settings API와 data-driven provider metadata가 이를 뒷받침합니다([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). 또한 `python -m backtest.validation <run_dir>`가 missing, blank, malformed, non-existent, non-directory input을 validation 시작 전에 operator-facing message로 명확히 실패하도록 강화했습니다([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
- **2026-04-28** 🚀 **v0.1.6 릴리스**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 이후 `vibe-trading --swarm-presets`가 비어 있던 문제를 수정했습니다([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)). preset YAML은 이제 `src.swarm` 패키지 내부에 번들되며 6개 테스트 회귀 suite로 고정됩니다. AKShare loader도 ETF(`510300.SH`)와 forex(`USDCNH`)를 올바른 endpoint로 routing하고 registry fallback을 강화했습니다. v0.1.5 이후의 benchmark comparison panel, `/upload` streaming + size limit, Futu loader(HK + A주), vnpy export skill, security hardening, frontend lazy loading(688KB → 262KB)을 모두 포함합니다.
- **2026-04-27** 📊 **벤치마크 패널 + 업로드 안전성**: 백테스트 출력에 yfinance 기반 SPY, CSI 300 등 resolution을 사용하는 benchmark comparison panel(ticker / benchmark return / excess return / information ratio)이 포함됩니다([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). 또한 `/upload`는 request body를 1MB chunk로 streaming하고 `MAX_UPLOAD_SIZE` 초과 시 중단하여 oversized/malformed client에서도 메모리를 제한합니다([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)). 4-case regression suite로 고정되었습니다.
- **2026-04-22** 🛡️ **하드닝 + 신규 통합**: `safe_path`와 journal/shadow tool sandbox에서 path containment를 강제하고, `MANIFEST.in`이 sdist에 `.env.example` / tests / Docker files를 포함하며, route-level lazy loading으로 frontend initial bundle을 688KB → 262KB로 줄였습니다. Futu data loader for HK & A-share equities([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))와 vnpy CtaTemplate export skill([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))도 추가되었습니다.
- **2026-04-21** 🛡️ **워크스페이스 + 문서**: 상대 `run_dir`이 active run dir로 정규화되었습니다([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). README usage example도 추가되었습니다([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
- **2026-04-20** 🔌 **Reasoning + Swarm**: 모든 `ChatOpenAI` 경로에서 `reasoning_content`가 보존되어 Kimi / DeepSeek / Qwen thinking이 end-to-end로 작동합니다([#39](https://github.com/HKUDS/Vibe-Trading/issues/39)). Swarm streaming과 깔끔한 Ctrl+C 처리도 반영되었습니다([#42](https://github.com/HKUDS/Vibe-Trading/issues/42)).
- **2026-04-19** 📦 **v0.1.5**: PyPI와 ClawHub에 게시되었습니다. `python-multipart` CVE floor bump, 신규 MCP tools 5개 연결(`analyze_trade_journal` + 4 shadow-account tools), `pattern_recognition` → `pattern` registry fix, Docker dependency parity, SKILL manifest sync(22 MCP tools / 71 skills)가 포함됩니다.
- **2026-04-18** 👥 **Shadow Account**: broker journal에서 전략 규칙 추출 → 여러 시장에서 shadow backtest → 규칙 위반, 조기 청산, 놓친 signal, counterfactual trade를 통해 정확히 얼마를 놓치는지 보여주는 8-section HTML/PDF report. 신규 tools 4개, skill 1개, 총 tools 32개. Trade Journal + Shadow Account sample은 이제 web UI welcome screen에 있습니다.
- **2026-04-17** 📊 **Trade Journal Analyzer + Universal File Reader**: broker export(同花顺/东财/富途/generic CSV) 업로드 → auto trading profile(holding days, win rate, PnL ratio, drawdown) + 4가지 bias diagnostics(disposition effect, overtrading, chasing momentum, anchoring). `read_document`는 이제 PDF, Word, Excel, PowerPoint, image(OCR), 40+ text format을 하나의 unified call로 dispatch합니다.
- **2026-04-16** 🧠 **Agent Harness**: persistent cross-session memory, FTS5 session search, self-evolving skills(full CRUD), 5-layer context compression, read/write tool batching. tools 27개, 신규 tests 107개.
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai provider([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)), MiniMax temperature fix + model update([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). providers 13개.
- **2026-04-14** 🔧 **MCP 안정성**: stdio transport에서 backtest tool `Connection closed` error를 수정했습니다([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
- **2026-04-13** 🌐 **Cross-Market Composite Backtest**: 새 `CompositeEngine`이 A주 + crypto 같은 mixed-market portfolio를 shared capital pool과 per-market rule로 backtest합니다. swarm template variable fallback과 frontend timeout도 수정되었습니다.
- **2026-04-12** 🌍 **Multi-Platform Export**: `/pine`은 TradingView(Pine Script v6), TDX(通达信/同花顺/东方财富), MetaTrader 5(MQL5)로 전략을 한 번에 내보냅니다.
- **2026-04-11** 🛡️ **Reliability & DX**: `vibe-trading init` .env bootstrap([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)), preflight checks, runtime data-source fallback, hardened backtest engine. Multi-language README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21)).
- **2026-04-10** 📦 **v0.1.4**: Docker fix([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), `web_search` MCP tool, LLM providers 12개, `akshare`/`ccxt` dependencies. PyPI와 ClawHub에 게시되었습니다.
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures, GlobalFutures, Forex, Options v2 engines. Monte Carlo, Bootstrap CI, Walk-Forward validation.
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules, Pine Script v6 export, 5 data sources with auto-fallback.
</details>
---
## ✨ 주요 기능
<div align="center">
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="자가 개선 트레이딩 에이전트"/><br>
<h3>🔍 자가 개선 트레이딩 에이전트</h3>
<div align="left">
• 자연어 기반 시장 리서치<br>
• 전략 초안 작성 및 파일/웹 분석<br>
• 메모리 기반 워크플로
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="멀티 에이전트 트레이딩 팀"/><br>
<h3>🐝 멀티 에이전트 트레이딩 팀</h3>
<div align="left">
• 투자, 퀀트, 크립토, 리스크 팀<br>
• 스트리밍 진행 상황과 영구 저장 리포트<br>
• 가져온 시장 데이터로 grounding된 worker
</div>
</td>
</tr>
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="크로스마켓 데이터와 백테스팅"/><br>
<h3>📊 크로스마켓 데이터 & 백테스팅</h3>
<div align="left">
• A/HK/US 주식, 크립토, 선물, 외환<br>
• 데이터 fallback과 composite backtest<br>
• PIT 데이터, 검증, run card
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
<h3>👥 Shadow Account</h3>
<div align="left">
• 브로커 거래 일지 행동 진단<br>
• 규칙 기반 Shadow Account 비교<br>
• 내보낼 수 있는 감사 리포트와 전략 코드
</div>
</td>
</tr>
</table>
</div>
## 💡 Vibe-Trading이란?
Vibe-Trading은 금융 질문을 실행 가능한 분석으로 바꾸는 오픈소스 리서치 워크스페이스입니다. 자연어 프롬프트를 시장 데이터 로더, 전략 생성, 백테스트 엔진, 리포트, 내보내기, 영구 리서치 메모리와 연결합니다.
리서치, 시뮬레이션, 백테스팅을 위해 설계되었습니다 — 그리고 원하신다면, 사용자가 직접 인가한 브로커(예: Robinhood Agentic Trading)를 통한 자율 거래도 가능합니다. 자금을 일절 보유하지 않고, 사용자가 설정한 한도를 결코 넘지 않으며, 언제든 즉시 중단할 수 있습니다.
---
## ✨ 무엇을 할 수 있나요?
| 작업 | 출력 |
|------|------|
| **트레이딩 질문하기** | 도구, 데이터, 문서, 재사용 가능한 세션 컨텍스트를 활용한 시장 리서치. |
| **전략 아이디어 백테스트** | 전략 코드, 지표, 벤치마크 컨텍스트, 검증 artifacts, run cards. |
| **내 거래 검토하기** | 브로커 일지 파싱, 행동 진단, 규칙 추출, Shadow Account 비교. |
| **반복 리서치 개선하기** | 영구 메모리와 편집 가능한 스킬로 유용한 루틴을 재사용 가능한 워크플로로 전환. |
| **애널리스트 팀 실행하기** | 투자, 퀀트, 크립토, 매크로, 리스크 워크플로를 위한 멀티 에이전트 리서치 리뷰. |
| **사용 가능한 artifacts 만들기** | 리포트, TradingView Pine Script, TDX, MetaTrader 5, MCP tools, 이후 리서치 세션. |
| **사전 빌드된 alpha zoo 벤치** | 452개의 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart)에 대해 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행 |
---
## ⚡ 빠른 예제
```bash
pip install vibe-trading-ai
# 자연어 리서치
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
# 한 줄로 사전 빌드된 alpha zoo 벤치
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 👥 섀도우 계정
Shadow Account는 일반적인 전략 템플릿이 아니라 사용자의 실제 거래 기록에서 시작합니다.
브로커 export를 업로드하고 에이전트가 행동을 요약하게 한 뒤, 실제 거래 경로를 규칙 기반 shadow strategy와 비교합니다.
| 단계 | 에이전트 출력 |
|------|--------------|
| **1. 일지 읽기** | 同花顺, 东方财富, 富途, generic CSV 형식의 브로커 export를 파싱합니다. |
| **2. 행동 프로파일링** | 보유 일수, 승률, 손익비, drawdown, disposition effect, overtrading, momentum chasing, anchoring 점검. |
| **3. 규칙 추출** | 반복되는 진입/청산을 모호한 요약이 아닌 명시적인 strategy profile로 변환합니다. |
| **4. Shadow 실행** | 추출된 규칙을 백테스트하고 규칙 위반, 조기 청산, 놓친 signal, 대안 거래 경로를 강조합니다. |
| **5. 리포트 제공** | 나중에 점검, 보관, 개선할 수 있는 HTML/PDF 리포트를 생성합니다. |
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 🧪 리서치 워크플로
대부분의 실행은 같은 evidence path를 따릅니다. 요청을 라우팅하고, 적절한 시장 컨텍스트를 로드하고, 도구를 실행하고, 출력을 검증하며, artifacts를 점검 가능한 상태로 유지합니다.
| 계층 | 수행 내용 |
|------|-----------|
| **Plan** | 유용한 경우 관련 finance skills, tools, data sources, swarm preset을 선택합니다. |
| **Ground** | 사용 가능한 loader로 A주, HK/US 주식, 크립토, 선물, 외환, 문서, 웹 컨텍스트를 가져옵니다. |
| **Execute** | 테스트 가능한 전략 코드를 생성하고, 도구를 실행하며, 적절한 backtest engine 또는 analysis workflow를 사용합니다. |
| **Validate** | 지표, benchmark comparison, Monte Carlo, Bootstrap, Walk-Forward, run cards, 관련 warning을 추가합니다. |
| **Deliver** | TradingView, TDX, MetaTrader 5, MCP client, 이후 세션을 위한 리포트, artifacts, tool traces, exports를 반환합니다. |
---
## 🔩 상세 기능
메인 README를 읽기 쉽게 유지하기 위해 상세 목록은 아래에 접어 두었습니다. 사용 가능한 구성 요소를 확인하고 싶을 때 열어보세요.
<details>
<summary><b>금융 스킬 라이브러리</b> <sub>8개 카테고리 77개 스킬</sub></summary>
- 📊 8개 카테고리로 구성된 77개 전문 금융 스킬
- 🌐 전통 시장부터 크립토 & DeFi까지 완전한 커버리지
- 🔬 데이터 sourcing부터 정량 리서치까지 포괄하는 기능
| 카테고리 | 스킬 | 예시 |
|----------|------|------|
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
</details>
<details>
<summary><b>프리셋 트레이딩 팀</b> <sub>29개 swarm preset</sub></summary>
- 🏢 바로 사용할 수 있는 29개 에이전트 팀
- ⚡ 사전 구성된 금융 워크플로
- 🎯 투자, 트레이딩, 리스크 관리 preset
| 프리셋 | 워크플로 |
|--------|----------|
| `investment_committee` | bull/bear 토론 → 리스크 리뷰 → PM 최종 판단 |
| `global_equities_desk` | A주 + HK/US + 크립토 리서처 → 글로벌 전략가 |
| `crypto_trading_desk` | funding/basis + liquidation + flow → 리스크 매니저 |
| `earnings_research_desk` | 펀더멘털 + revision + options → 실적 전략가 |
| `macro_rates_fx_desk` | rates + FX + commodity → macro PM |
| `quant_strategy_desk` | screening + factor research → backtest → risk audit |
| `technical_analysis_panel` | classic TA + Ichimoku + harmonic + Elliott + SMC → consensus |
| `risk_committee` | drawdown + tail risk + regime review → sign-off |
| `global_allocation_committee` | A주 + 크립토 + HK/US → cross-market allocation |
<sub>추가로 20개 이상의 전문 preset이 있습니다. 전체 목록은 vibe-trading --swarm-presets로 확인하세요.
</sub>
</details>
<details>
<summary><b>Alpha Zoo</b> <sub>4개 zoo에 걸친 452개 사전 빌드된 quant alpha</sub></summary>
- 🧬 operator 계층에서 lookahead가 금지된 452개 cross-sectional alpha
- 📈 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행
- 🔬 AST 순수성 게이트 + 300-row lookahead sentinel 테스트 + `pytest-socket` 네트워크 kill-switch
- 📦 Qlib에 대한 Apache-2 출처 표기, zoo별 `LICENSE.md`에서 수식을 수학적 콘텐츠로 명시
- 🤝 커뮤니티 PR을 위한 Developer Certificate of Origin (DCO) 서명 워크플로
| Zoo | Count | Source | License |
|-----|-------|--------|---------|
| **qlib158** | 154 | Microsoft Qlib `Alpha158` (Apache-2.0, 커밋 고정) | Apache-2.0 |
| **alpha101** | 101 | Kakushadze (2015), "101 Formulaic Alphas", arXiv:1601.00991 | 수식은 수학적 콘텐츠 |
| **gtja191** | 191 | 국태군안 (2014), "191 Short-period Trading Alpha Factors" | 수식은 수학적 콘텐츠 |
| **academic** | 6 | Fama-French 5 + Carhart 모멘텀 (가격 기반 proxy) | 공개 학술 문헌 |
`vibe-trading alpha list`로 카탈로그를 탐색하고, `vibe-trading alpha show <id>`로 수식과 소스 코드를 확인하며, `vibe-trading alpha bench --zoo X --universe Y --period Z`로 zoo 전체를 점수화하세요.
</details>
## 🎬 데모
<div align="center">
<table>
<tr>
<td width="50%">
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
</td>
<td width="50%">
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
</td>
</tr>
<tr>
<td colspan="2" align="center"><sub>☝️ 자연어 백테스트 & 멀티 에이전트 swarm 토론 — Web UI + CLI</sub></td>
</tr>
</table>
</div>
---
## 🚀 빠른 시작
### 한 줄 설치 (PyPI)
```bash
pip install vibe-trading-ai
```
첫 리서치 작업을 실행하세요:
```bash
vibe-trading init
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
```
> **패키지 이름 vs 명령:** PyPI 패키지는 `vibe-trading-ai`입니다. 설치하면 세 가지 명령을 사용할 수 있습니다:
>
> | Command | Purpose |
> |---------|---------|
> | `vibe-trading` | 대화형 CLI / TUI |
> | `vibe-trading serve` | FastAPI 웹 서버 실행 |
> | `vibe-trading-mcp` | MCP 서버 시작(Claude Desktop, OpenClaw, Cursor 등) |
```bash
vibe-trading init # interactive .env setup
vibe-trading # launch CLI
vibe-trading serve --port 8899 # launch web UI
vibe-trading-mcp # start MCP server (stdio)
```
### 또는 경로 선택
| 경로 | 적합한 용도 | 시간 |
|------|-------------|------|
| **A. Docker** | 즉시 체험, 로컬 설정 없음 | 2분 |
| **B. Local install** | 개발, 전체 CLI 접근 | 5분 |
| **C. MCP plugin** | 기존 에이전트에 연결 | 3분 |
| **D. ClawHub** | 한 번의 명령, clone 불필요 | 1분 |
### 사전 요구사항
- 지원 provider 중 하나의 **LLM API key** 또는 **Ollama** 로컬 실행(key 불필요)
- 경로 B용 **Python 3.11+**
- 경로 A용 **Docker**
- OpenAI Codex도 ChatGPT OAuth로 사용할 수 있습니다. `LANGCHAIN_PROVIDER=openai-codex`를 설정한 뒤 `vibe-trading provider login openai-codex`를 실행하세요. 이 방식은 `OPENAI_API_KEY`를 사용하지 않습니다.
> **지원 LLM provider:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama(local). 설정은 `.env.example`을 참고하세요.
> **팁:** 자동 fallback 덕분에 모든 시장은 API key 없이도 작동합니다. yfinance(HK/US), OKX(crypto), mootdx(A주, TCP 직결, IP 제한 없음), AKShare(A주, US, HK, futures, forex)는 모두 무료입니다. Tushare token은 선택 사항이며, mootdx가 권장 no-token A주 fallback이고 AKShare는 더 넓은 커버리지의 백업입니다.
### 경로 A: Docker (설정 불필요)
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
cp agent/.env.example agent/.env
# Edit agent/.env — uncomment your LLM provider and set API key
docker compose up --build
```
`http://localhost:8899`를 여세요. Backend + frontend가 하나의 container에 들어 있습니다.
Docker는 기본적으로 backend를 `127.0.0.1:8899`에 게시하고 앱을 non-root container user로 실행합니다. API를 자신의 머신 밖으로 의도적으로 노출하는 경우 강력한 `API_AUTH_KEY`를 설정하고 client에서 `Authorization: Bearer <key>`를 보내세요.
### 경로 B: Local install
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
# Activate
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -e .
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
vibe-trading # Launch interactive TUI
```
<details>
<summary><b>웹 UI 시작(선택 사항)</b></summary>
```bash
# Terminal 1: API server
vibe-trading serve --port 8899
# Terminal 2: Frontend dev server
cd frontend && npm install && npm run dev
```
`http://localhost:5899`를 여세요. Frontend는 API 호출을 `localhost:8899`로 proxy합니다.
**Production mode(single server):**
```bash
cd frontend && npm run build && cd ..
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
```
</details>
### 경로 C: MCP plugin
아래 [MCP Plugin](#-mcp-plugin) 섹션을 참고하세요.
### 경로 D: ClawHub (한 번의 명령)
```bash
npx clawhub@latest install vibe-trading --force
```
skill + MCP config가 agent의 skills directory에 다운로드됩니다. 자세한 내용은 [ClawHub install](#-mcp-plugin)을 참고하세요.
---
## 🧠 환경 변수
`agent/.env.example`을 `agent/.env`로 복사하고 사용할 provider block의 주석을 해제하세요. 각 provider에는 3~4개의 변수가 필요합니다:
| 변수 | 필수 | 설명 |
|------|:----:|------|
| `LANGCHAIN_PROVIDER` | Yes | Provider name(`openrouter`, `deepseek`, `groq`, `ollama` 등) |
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` 등) |
| `<PROVIDER>_BASE_URL` | Yes | API endpoint URL |
| `LANGCHAIN_MODEL_NAME` | Yes | Model name(예: `deepseek-v4-pro`) |
| `TUSHARE_TOKEN` | No | A주 data용 Tushare Pro token(AKShare로 fallback) |
| `TIMEOUT_SECONDS` | No | LLM call timeout, 기본 120s |
| `API_AUTH_KEY` | 네트워크 배포 권장 | API가 non-local client에서 접근 가능할 때 필요한 Bearer token |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | remote API/MCP-SSE 형태 배포에서 shell-capable tools 명시적 opt-in |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | document와 broker-journal import용 추가 comma-separated roots |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | generated-code run directory용 추가 comma-separated roots |
<sub>* Ollama는 API key가 필요 없습니다. OpenAI Codex는 ChatGPT OAuth를 사용하며 token을 `agent/.env`가 아니라 `oauth-cli-kit`을 통해 저장합니다.</sub>
**무료 데이터(key 불필요):** AKShare를 통한 A주, yfinance를 통한 HK/US equities, OKX를 통한 crypto, CCXT를 통한 100개 이상 crypto exchanges. 시스템은 시장별로 가장 적합한 source를 자동 선택합니다.
### 🎯 권장 모델
Vibe-Trading은 tool-heavy agent입니다. skills, backtests, memory, swarms가 모두 tool call을 통해 흐릅니다. 모델 선택은 에이전트가 실제로 *도구를 사용하는지*, 아니면 학습 데이터에서 답을 만들어내는지를 직접 결정합니다.
| 등급 | 예시 | 사용 시점 |
|------|------|-----------|
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | 복잡한 swarms(3+ agents), 긴 리서치 세션, 논문급 분석 |
| **Sweet spot**(기본값) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | Daily driver — 약 1/10 비용으로 안정적인 tool-calling |
| **Agent 사용 시 피할 것** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | tool-calling이 불안정합니다. agent가 skills를 로드하거나 backtest를 실행하는 대신 "기억에서 답하는" 것처럼 보일 수 있습니다. |
기본 `agent/.env.example`은 DeepSeek official API + `deepseek-v4-pro`를 포함합니다. OpenRouter 사용자는 `deepseek/deepseek-v4-pro`를 사용할 수 있습니다.
---
## 🖥 CLI 참조
```bash
vibe-trading # interactive TUI
vibe-trading run -p "..." # single run
vibe-trading serve # API server
vibe-trading alpha list # 사전 빌드된 452개 alpha 탐색; show / bench / compare / export-manifest 서브커맨드 사용 가능
```
<details>
<summary><b>TUI 내 slash commands</b></summary>
| Command | Description |
|---------|-------------|
| `/help` | 모든 명령 표시 |
| `/skills` | 77개 finance skills 목록 |
| `/swarm` | 29개 swarm team presets 목록 |
| `/swarm run <preset> [vars_json]` | live streaming으로 swarm team 실행 |
| `/swarm list` | Swarm run history |
| `/swarm show <run_id>` | Swarm run details |
| `/swarm cancel <run_id>` | 실행 중인 swarm 취소 |
| `/list` | Recent runs |
| `/show <run_id>` | Run details + metrics |
| `/code <run_id>` | Generated strategy code |
| `/pine <run_id>` | indicators export(TradingView + TDX + MT5) |
| `/trace <run_id>` | Full execution replay |
| `/continue <run_id> <prompt>` | 새 instruction으로 run 계속 |
| `/sessions` | Chat sessions 목록 |
| `/settings` | Runtime config 표시 |
| `/clear` | 화면 지우기 |
| `/quit` | 종료 |
</details>
<details>
<summary><b>Single run & flags</b></summary>
```bash
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
vibe-trading run -p "Analyze AAPL momentum" --json
vibe-trading run -f strategy.txt
echo "Backtest 000001.SZ RSI" | vibe-trading run
```
```bash
vibe-trading -p "your prompt"
vibe-trading --skills
vibe-trading --swarm-presets
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
vibe-trading --list
vibe-trading --show <run_id>
vibe-trading --code <run_id>
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
```
```bash
vibe-trading alpha list --zoo gtja191 --limit 10
vibe-trading alpha show gtja191_171
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
</details>
---
## 💡 예제
### 전략 & 백테스팅
```bash
# Moving average crossover on US equities
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
# RSI mean-reversion on crypto
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
# Multi-factor strategy on A-shares
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
# After backtesting, export to TradingView / TDX / MetaTrader 5
vibe-trading --pine <run_id>
```
**한 줄로 사전 빌드된 alpha zoo 벤치하기**:
```bash
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
**카탈로그 탐색** 후 단일 alpha 확인:
```bash
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
vibe-trading alpha show gtja191_171
```
**zoo 인자들로 다인자 신호 구성**(Python):
```python
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
panel = ... # your wide OHLCV panel
signal = engine.compute_signal(panel)
```
### 시장 리서치
```bash
# Equity deep-dive
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
# Macro analysis
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
# Crypto on-chain
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
```
### Swarm 워크플로
```bash
# Bull/bear debate on a stock
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
# Quant strategy from screening to backtest
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
# Crypto desk: funding + liquidation + flow → risk manager
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
# Global macro portfolio allocation
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
```
### 크로스세션 메모리
```bash
# Save your preferences once
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
# The agent recalls them in future sessions automatically
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
```
### 문서 업로드 & 분석
```bash
# Analyze a broker export or earnings report
vibe-trading --upload trades_export.csv
vibe-trading run -p "Profile my trading behavior and identify any biases"
vibe-trading --upload NVDA_Q1_earnings.pdf
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
```
---
## 🌐 API 서버
```bash
vibe-trading serve --port 8899
```
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/runs` | runs 목록 |
| `GET` | `/runs/{run_id}` | run details |
| `GET` | `/runs/{run_id}/pine` | multi-platform indicator export |
| `POST` | `/sessions` | session 생성 |
| `POST` | `/sessions/{id}/messages` | message 전송 |
| `GET` | `/sessions/{id}/events` | SSE event stream |
| `POST` | `/upload` | PDF/file 업로드 |
| `GET` | `/swarm/presets` | swarm presets 목록 |
| `POST` | `/swarm/runs` | swarm run 시작 |
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
| `GET` | `/alpha/list` | zoo/theme/universe로 alpha 목록 필터링 |
| `GET` | `/alpha/{alpha_id}` | Alpha 메타데이터 + 소스 코드 |
| `POST` | `/alpha/bench` | Bench 작업 시작 (`job_id` 반환) |
| `GET` | `/alpha/bench/{job_id}/stream` | SSE 진행 스트림 |
| `GET` | `/settings/llm` | Web UI LLM settings 읽기 |
| `PUT` | `/settings/llm` | local LLM settings 업데이트 |
| `GET` | `/settings/data-sources` | local data source settings 읽기 |
| `PUT` | `/settings/data-sources` | local data source settings 업데이트 |
Interactive docs: `http://localhost:8899/docs`
### 보안 기본값
localhost 개발에서 `vibe-trading serve`는 browser workflow를 단순하게 유지합니다. non-local client에서는 민감한 API endpoint에 `API_AUTH_KEY`가 필요합니다. JSON/upload request에는 `Authorization: Bearer <key>`를 사용하세요. Browser EventSource stream은 Web UI Settings에 같은 key를 한 번 입력하면 Web UI가 처리합니다.
Shell-capable tools는 local CLI와 trusted localhost workflow에서 사용할 수 있지만, `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`을 명시적으로 설정하지 않는 한 remote API session에는 노출되지 않습니다. Document와 journal reader는 기본적으로 upload/import roots로 제한됩니다. 파일은 `agent/uploads`, `agent/runs`, `./uploads`, `./data`, `~/.vibe-trading/uploads`, `~/.vibe-trading/imports` 아래에 두거나, `VIBE_TRADING_ALLOWED_FILE_ROOTS`로 전용 directory를 추가하세요.
### Web UI Settings
Web UI Settings page에서는 local user가 LLM provider/model, base URL, generation parameters, reasoning effort, Tushare token 같은 선택적 market data credentials를 업데이트할 수 있습니다. Settings는 `agent/.env`에 저장되며 provider defaults는 `agent/src/providers/llm_providers.json`에서 로드됩니다.
Settings read는 side effect가 없습니다. `GET /settings/llm`과 `GET /settings/data-sources`는 `agent/.env`를 만들지 않으며 project-relative path만 반환합니다. Settings read/write는 credential state를 노출하거나 credential/runtime environment를 업데이트할 수 있으므로 `API_AUTH_KEY`가 설정되어 있으면 인증이 필요합니다. dev mode에서 `API_AUTH_KEY`가 설정되지 않은 경우 settings access는 loopback client에서만 허용됩니다.
---
## 🔌 MCP Plugin
Vibe-Trading은 모든 MCP-compatible client를 위해 22개 MCP tools를 제공합니다. stdio subprocess로 실행되므로 server setup이 필요 없습니다. **22개 중 21개 tools는 API key 없이 작동합니다**(HK/US/crypto). `run_swarm`만 LLM key가 필요합니다.
<details>
<summary><b>Claude Desktop</b></summary>
`claude_desktop_config.json`에 추가:
```json
{
"mcpServers": {
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
</details>
<details>
<summary><b>OpenClaw</b></summary>
`~/.openclaw/config.yaml`에 추가:
```yaml
skills:
- name: vibe-trading
command: vibe-trading-mcp
```
</details>
<details>
<summary><b>Cursor / Windsurf / 기타 MCP clients</b></summary>
```bash
vibe-trading-mcp # stdio (default)
vibe-trading-mcp --transport sse # SSE for web clients
```
</details>
**노출되는 MCP tools(22):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
<details>
<summary><b>ClawHub에서 설치(한 번의 명령)</b></summary>
```bash
npx clawhub@latest install vibe-trading --force
```
> skill이 외부 API를 참조하여 VirusTotal 자동 스캔이 트리거되므로 `--force`가 필요합니다. 코드는 완전한 오픈소스이며 검토할 수 있습니다.
이 명령은 skill + MCP config를 agent의 skills directory에 다운로드합니다. clone은 필요 없습니다.
ClawHub에서 보기: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
</details>
<details>
<summary><b>OpenSpace — 자가 진화 스킬</b></summary>
77개 finance skills는 모두 [open-space.cloud](https://open-space.cloud)에 게시되어 있으며 OpenSpace의 self-evolution engine을 통해 자율적으로 발전합니다.
OpenSpace와 함께 사용하려면 두 MCP server를 agent config에 추가하세요:
```json
{
"mcpServers": {
"openspace": {
"command": "openspace-mcp",
"toolTimeout": 600,
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
}
},
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
OpenSpace는 77개 skills를 모두 자동 발견하여 auto-fix, auto-improve, community sharing을 활성화합니다. OpenSpace-connected agent에서 `search_skills("finance backtest")`로 Vibe-Trading skills를 검색하세요.
</details>
---
## 📁 프로젝트 구조
<details>
<summary><b>펼쳐 보기</b></summary>
```
Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # CLI package — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 35 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
│ │ │ ├── loop.py # 5-layer compression + read/write tool batching
│ │ │ ├── context.py # system prompt + auto-recall from persistent memory
│ │ │ ├── skills.py # skill loader (77 bundled + user-created via CRUD)
│ │ │ ├── tools.py # tool base class + registry
│ │ │ ├── memory.py # lightweight workspace state per run
│ │ │ ├── frontmatter.py # shared YAML frontmatter parser
│ │ │ └── trace.py # execution trace writer
│ │ │
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 31 auto-discovered agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
│ │ │ ├── session_search_tool.py # FTS5 cross-session search
│ │ │ ├── swarm_tool.py # launch swarm teams
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
│ │ │
│ │ ├── factors/ # Alpha Zoo — 4개 zoo에 걸친 452개 alpha
│ │ │ ├── base.py # 19개 operator (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
│ │ │ ├── registry.py # AST-only 메타데이터 로딩 + lazy compute + sanity gate
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 분류
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
│ │ │
│ │ ├── api/ # FastAPI 라우트 모듈
│ │ │ └── alpha_routes.py # /alpha/list, /alpha/{id}, /alpha/bench, SSE stream
│ │ │
│ │ ├── skills/ # 77 finance skills in 8 categories (SKILL.md each)
│ │ ├── swarm/ # Swarm DAG execution engine
│ │ │ └── presets/ # 29 swarm preset YAML definitions
│ │ ├── session/ # Multi-turn chat + FTS5 session search
│ │ └── providers/ # LLM provider abstraction
│ │
│ └── backtest/ # Backtest engines
│ ├── engines/ # 7 engines + composite cross-market engine + options_portfolio
│ ├── loaders/ # 7 sources: tushare, okx, yfinance, akshare, mootdx, ccxt, futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # Registry + auto-fallback chains
│ └── optimizers/ # MVO, equal vol, max div, risk parity
│
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
│ └── src/
│ ├── pages/ # Home, Agent, AlphaZoo, RunDetail, Compare, Correlation, Settings
│ ├── components/ # chat, charts, layout
│ └── stores/ # Zustand state management
│
├── Dockerfile # Multi-stage build
├── docker-compose.yml # One-command deploy
├── pyproject.toml # Package config + CLI entrypoint
├── tools/ # 레포 단위 CI helper
│ └── ci_grep_gates.sh # yaml.load / 트레이드마크 / 종목별 데이터 누출 차단
└── LICENSE # MIT
```
</details>
---
## 🏛 생태계
Vibe-Trading은 **[HKUDS](https://github.com/HKUDS)** agent ecosystem의 일부입니다:
<table>
<tr>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
<sub>초경량 개인 AI 어시스턴트</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
<sub>Agent-Native Signal &amp; Copy Trading Platform</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
<sub>모든 소프트웨어를 agent-native로</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
<sub>자가 진화 AI agent skills</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
<sub>Agent Swarm Intelligence</sub>
</td>
</tr>
</table>
---
## 🗺 로드맵
> 단계적으로 배포합니다. 작업이 시작되면 항목은 [Issues](https://github.com/HKUDS/Vibe-Trading/issues)로 이동합니다.
| Phase | Feature | Status |
|-------|---------|--------|
| **Trust Layer** | 재현 가능한 run cards는 생성 및 Run Detail 표시까지 완료. v1은 tool traces와 citations 추가 | v0 출시 |
| **Hypothesis Registry** | lifecycle status, data sources, skills, run-card links, invalidation notes를 가진 durable research hypotheses | Backend MVP 출시 |
| **Research Autopilot** | 수동 실행 우선 research loop: hypothesis → deterministic backtest → evidence report | 다음 |
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Planned |
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Planned |
| **Alpha Zoo** | 4개 zoo에 걸친 452개의 사전 빌드된 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart), 한 줄 CLI 벤치, agent 통합, Web UI | **0.1.8 출시 완료** |
| **Research Delivery** | Slack / Telegram / email-style channels로 예약 brief 전달 | Planned |
| **Community** | 공유 가능한 skills, presets, strategy cards | Exploring |
---
## 기여하기
기여를 환영합니다! 가이드는 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
**Good first issues**는 [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 라벨이 붙어 있습니다. 하나를 골라 시작해 보세요.
더 큰 기여를 하고 싶나요? 위 [로드맵](#-로드맵)을 확인하고 시작 전에 issue를 열어 논의해 주세요.
---
## 기여자
Vibe-Trading에 기여해 주신 모든 분께 감사드립니다!
최근 v0.1.8 cycle contributors and credits:
- @GTC2080 / TaoMu — Web UI Settings and provider/data-source configuration APIs (#57)
- @BigNounce90 — validation CLI hardening for backtest `run_dir` input (#60)
- @shadowinlife — A-share pre-ST filter skill (#63)
- @MB-Ndhlovu — correlation heatmap dashboard and review fixes (#64, #66)
- @ykykj — OpenAI Codex OAuth provider option (#65)
- @RuifengFu — interactive CLI live status bar and prompt editing (#69)
- @SiMinus — swarm preset inspection command (#73)
- @warren618 / Haozhe Wu — security hardening, release integration, docs, Docker, packaging, and local dev workflow
- lemi9090 (S2W) — coordinated security research, validation, and disclosure support
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
</a>
---
## 면책조항
Vibe-Trading은 리서치 및 거래 소프트웨어입니다. 투자 조언이 아니며, 자금을 보유하지 않고, 거래소를 운영하지 않습니다. 거래는 사용자가 명시적으로 인가한 브로커 채널(예: Robinhood Agentic Trading)을 통해서만, 사용자가 설정한 한도 내에서 이루어지며 언제든 중단할 수 있습니다. 이 브로커 거래 기능은 실험적이며 당사가 실제 브로커 계정으로 검증하지 않았습니다 — 사용에 따른 책임은 본인에게 있습니다. 과거 성과가 미래 수익을 보장하지 않습니다.
## 라이선스
MIT License — [LICENSE](LICENSE) 참조
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=HKUDS/Vibe-Trading&type=Date)](https://star-history.com/#HKUDS/Vibe-Trading&Date)
<p align="center">
⭐ <b>Vibe-Trading</b>이 연구에 도움이 되었다면, Star를 눌러 더 많은 분들이 찾을 수 있도록 도와주세요.
</p>
---
<p align="center">
<b>Vibe-Trading</b>에 방문해 주셔서 감사합니다 ✨
</p>
<p align="center">
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
</p>
+943
View File
@@ -0,0 +1,943 @@
<p align="center">
<a href="README.md">English</a> | <b>中文</b> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
</p>
<p align="center">
<img src="assets/icon.png" width="120" alt="Vibe-Trading Logo"/>
</p>
<h1 align="center">Vibe-Trading:你的个人交易智能体</h1>
<p align="center">
<b>一条命令,让你的智能体具备完整交易研究能力</b>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat" alt="FastAPI">
<img src="https://img.shields.io/badge/Frontend-React%2019-61DAFB?style=flat&logo=react&logoColor=white" alt="React">
<a href="https://pypi.org/project/vibe-trading-ai/"><img src="https://img.shields.io/pypi/v/vibe-trading-ai?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
<br>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu"></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat"></a>
<a href="https://discord.gg/2vDYc2w5"><img src="https://img.shields.io/badge/Discord-Join-7289DA?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<p align="center">
<a href="https://vibetrading.wiki/">官网</a> &nbsp;&middot;&nbsp;
<a href="https://vibetrading.wiki/docs/">文档</a> &nbsp;&middot;&nbsp;
<a href="#-news">News</a> &nbsp;&middot;&nbsp;
<a href="#-key-features">Features</a> &nbsp;&middot;&nbsp;
<a href="#-shadow-account">Shadow Account</a> &nbsp;&middot;&nbsp;
<a href="#-demo">Demo</a> &nbsp;&middot;&nbsp;
<a href="#-quick-start">Quick Start</a> &nbsp;&middot;&nbsp;
<a href="#-examples">Examples</a> &nbsp;&middot;&nbsp;
<a href="#-api-server">API / MCP</a> &nbsp;&middot;&nbsp;
<a href="#-roadmap">Roadmap</a> &nbsp;&middot;&nbsp;
<a href="#-contributing">Contributing</a>
</p>
<p align="center">
<a href="#-quick-start"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
</p>
---
## 📰 News
- **2026-05-31** 🔌 **Connector-first 券商架构(IBKR + Robinhood)**:交易接入现在从可选择的 connector profile 开始,不再拆成分散的券商入口和 live 入口。`vibe-trading connector list/use/check/account/positions/orders/quote/history` 与 MCP `trading_*` 工具共享同一个选中的 profile;paper/live 只是该 connector 下的属性。IBKR 可立即通过本地只读 TWS / IB Gateway profile 使用;官方 IBKR 远程 MCP 先作为 OAuth `mcp.read` 探测种子,等待稳定 read 工具名后再映射。Robinhood Agentic Trading 仍是有界 live MCP connector,必须经过 OAuth、已提交 mandate、order guard、审计账本和即时 halt。
- **2026-05-30** 🧰 **健壮性专项 — backtest、agent loop、session**:LLM 生成的 signal engine 现在会在实例化前先过接口预检,提前抓出循环 self-import、缺失 `generate()`、`__init__` 参数没有默认值、返回类型错误等常见问题,并给出可操作的 JSON 报错而非原始 traceback([#149](https://github.com/HKUDS/Vibe-Trading/pull/149));后续一并把源码级 AST 校验的报错也走同一套干净的 JSON 信封。agent loop 不再把 50 次迭代全烧光后留下一个没有任何输出的 `failed` 状态——它复用 swarm worker 已验证的做法:在迭代预算 80% 处注入 wrap-up nudge,并在最后一次迭代丢掉 tool 定义以强制产出文本答案([#148](https://github.com/HKUDS/Vibe-Trading/pull/148)),且只在中途触发,绝不挤掉 research-goal 上下文。session 消息写入现在每次 append 后 `flush + fsync`,让昂贵的 AI 回复能在写到一半崩溃时存活;读取端则跳过损坏的 JSONL 行(记录前 200 字符以便人工恢复),而不是让整个 `/messages` 端点 500([#147](https://github.com/HKUDS/Vibe-Trading/pull/147))。Web 输入框也修了 IME 回车处理,让中日韩输入法的确认上屏回车不再误触发提交([#146](https://github.com/HKUDS/Vibe-Trading/pull/146))。
- **2026-05-29** 🔐 **支持 Robinhood Agentic Trading(可选开启、有界自主)**:新增对 Robinhood Agentic Trading 的支持(远程 MCP,OAuth)。默认关闭且只读;仅在用户提交的 mandate(标的/单量/敞口/杠杆/每日笔数)内自主交易,配文件级即时 kill switch、抢占式平仓、mandate 自动过期、完整审计账本,以及一个持久自主 runner。无托管、无场所——券商持有资金并执行,我们只中继意图。实验性 / 风险自负。
<details>
<summary>更早的更新</summary>
- **2026-05-28** 🧪 **Swarm 安全 + 严格 alpha 门 + worker 端 MCP**:Swarm DAG 在上游任务失败时阻断下游任务([#145](https://github.com/HKUDS/Vibe-Trading/pull/145))。新增 `run_bench_strict()` 在 IC 门之上加入同 universe 随机控制 + 训练/测试 OOS 切分,识别只是跟随市场 beta 的伪因子([#143](https://github.com/HKUDS/Vibe-Trading/pull/143),感谢 @Soli22de)。Swarm worker 现在可以调用 operator 配置的外部 MCP server,信任边界由专项测试固定([#142](https://github.com/HKUDS/Vibe-Trading/pull/142),感谢 @shadowinlife)。
- **2026-05-27** 📊 **mootdx A 股数据源 + 输出排版**:新增 `mootdx` loader,走原生通达信 TCP 协议拉 A 股 OHLCV(无需 token,无 IP 速率限制,日线 + 分钟线 25 页 walk-back 分页),在 fallback chain 中位于 tushare 和 akshare 之间([#107](https://github.com/HKUDS/Vibe-Trading/issues/107))。CCXT loader 现在会读取 `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY`,使 Binance/OKX 公开数据可在受限网络下拉取([#126](https://github.com/HKUDS/Vibe-Trading/pull/126),感谢 @ruok808)。最终回答的渲染也去掉了 CLI 和 Web 上丑陋的全宽 `---` 分隔符:系统提示鼓励 agent 用 markdown 表格和 `##` 标题,CLI 渲染端兜底 strip 孤立 HR,前端 chat 气泡隐藏任何漏过去的 `<hr>`([#139](https://github.com/HKUDS/Vibe-Trading/issues/139),感谢 @sdwxm188)。
- **2026-05-26** ✅ **Research Goal 生命周期闭环**:Goal 模式现在像真正的任务运行器:Web UI 创建 goal 会创建或绑定 session,并立刻发出 kickoff turn;active goal 可在 Web/API/CLI/MCP 中继续、编辑、取消和完成;agent loop 会按当前 goal snapshot(criteria、evidence、claims、open items)推进,而不是只按最初 prompt。criteria 已 covered 但 goal 仍 active 时,会进入 audit/status 更新,不再静默停住,并用 backend、CLI、MCP 与 frontend events 回归覆盖固定。
- **2026-05-25** 🧼 **更干净的 Chat UI + composer 工作流**:Web UI 现在把注意力留给下一步输入:upload、swarm 和 research-goal 模式都收进 composer 的 `+` 菜单,不再用漂浮面板打断聊天。当前上下文会以紧凑 chip 附在输入框上方,goal 详情只在点击 chip 时原地展开。UI 也移除了旧的自定义 i18n 层,改用直接英文文案;Full Report card 只在真正有报告价值的 run 出现;本地 dev 启动与状态报告也加固,方便稳定做浏览器 smoke test。
- **2026-05-24** 🎯 **Research Goal runtime**:新增 session 级 Research Goal 层,贯通 backend、CLI、API/MCP、SSE 和 Web UI。Goal 会持久化 claim、acceptance criteria、evidence row、budget 与 completion policy;agent tools 可以创建 goal 并追加 evidence;`/goal` 成为 CLI 入口;REST/MCP 暴露 goal snapshot 和 evidence 写入;SSE 保持 chat client 状态新鲜。后续审计修复锁紧 verified evidence,阻断 agent tool 写入 live-trading 风险层,串起 CLI 创建的 goal 与后续 turn,删除 session 时清理 goal ledger,接上 replay-all,并修复前端跨 session snapshot race。
- **2026-05-23** 🖥️ **交互式 CLI 刷新**:终端入口现在使用更大的 Vibe-Trading banner、更清晰的 prompt 分隔线、上一轮摘要、运行后耗时,以及 Claude Code 风格的活动轨来展示实时 agent 工作。工具调用、网页/数据抓取、shell 风格动作、Markdown 回答和管道表格都会以更易读的 transcript 渲染;pipe 或非 TTY 运行仍保留适合自动化的纯文本输出。生成的 CLI 截图现在作为本地 artifact 处理,不再提交进 docs,让仓库更轻。
- **2026-05-22** 🧭 **Swarm 恢复 + MCP keepalive**:Swarm 状态现在每次读取都会从实时 task 文件 reconcile,API/MCP/SSE/list 视图可以自动恢复 crash 或过期 run,不再永久停在 `running` 快照。`run_swarm` 在 MCP polling 期间持续发送 progress heartbeat,首帧固定为 `swarm_started run_id=<id>`,方便 transport 掉线后的客户端找回句柄;worker 的 LLM streaming、grounding fetch、tool execution 也都包上了 heartbeat。stale-run reaper 按每个 run 的阈值判断,并从 task 状态推导终态;`SwarmTool` wait budget 用尽后不再取消仍在跑的 team,MCP 客户端也可以调用 `reap_stale_runs()` 显式清理。今天的 DX pass 还同步刷新 provider 默认模型,并把 CI syntax check 对齐到新的 `agent/cli/` 包。22 条新回归覆盖 hydrate、终态恢复、stale 回收、keepalive cadence、env 容错和 heartbeat wiring;完整 swarm/MCP 套件 169 passed、4 skipped。
- **2026-05-21** 🧱 **CLI 包重构**:`agent/cli.py`(3216 行)拆成 `agent/cli/` 包 —— 交互入口、slash 路由、Rich 组件,加 `_legacy.py` shim 保留所有子命令并 re-export 所有公共符号,`cli.cmd_*` / `cli._INIT_ENV_PATH` / `cli.Confirm` 不变。新增 FastAPI middleware:浏览器直开 `/runs/{id}` 或 `/correlation` 时返回 SPA shell;Vite dev proxy 同步收窄到相同 regex。版本号通过 `cli/_version.py` 单一来源(`--version` 与 banner 不再 drift),`python -m cli` 通过 `__main__.py` 恢复,chat-gate 收窄使 `chat --help` / `chat extra` 正确走 legacy argparse 而不被新 REPL 吞掉。
- **2026-05-20** 🔬 **Hypothesis Registry CLI**:补齐了 5-16 上线但只有后端的 Hypothesis Registry 的 CLI 侧。`vibe-trading hypothesis list` 输出 Rich 表格或 JSON(支持 `--status` 过滤、`--limit`);`show <id>` 渲染详情面板,包含已 link 的 run card;`invalidate <id> --note "..."` 把 status 翻成 `rejected`,省略 `--note` 时保留原有 invalidation notes。沿用 `VIBE_TRADING_HYPOTHESES_PATH` 环境变量,并新增按调用覆盖的 `--path`。22 个新单测覆盖 wiring、JSON 输出、状态过滤、limit、缺 id 报错、备注持久化。
- **2026-05-19** ✨ **工具实时反馈 + 优雅取消**:长时间运行的工具(回测、大 PDF、swarm worker)不再看起来卡死。每个工具调用现在会发出 3 秒一次的心跳,以及结构化的阶段进度 —— `run_backtest` 输出阶段标记(`validate` / `simulate` / `finalize`),`read_document` 在 PDF 上按页打点 / Excel 上按工作表打点,`read_url` 标记 `fetch` / `parse`。CLI 的 Rich Live 面板渲染 Unicode 转轮、ASCII 进度条、ETA,按工具名最多堆叠 3 个并行工具;前端 chat 新增 `ToolProgressIndicator`,rAF 合并刷新、ARIA `role="status"` + 隐藏的原生 `<progress>` 供屏幕阅读器使用,已知总数时切换为 determinate 的 `ProgressRing` SVG。CLI 中第一次 `Ctrl+C` 现在会调 `agent.cancel()` 优雅退出(当前步骤跑完、trace 干净关闭);2 秒内第二次 `Ctrl+C` 强制退出。顺手抽出可复用基础件:`ProgressBar.tsx` 和 `lib/tools.ts`(共享工具名 i18n 映射)。
- **2026-05-18** 🧹 **清理一次 + 3 个潜伏 bug 修复**:`CompositeEngine` 不再把无交易所后缀的中国期货代码(如 `RB2410`)错误路由到 `GlobalFuturesEngine` —— `_is_china_futures` 移到共享的 `_market_hooks` 模块,产品代码表做了大小写归一并加入非中国交易所守卫,新增 9 条回归用例。session FTS5 索引现在会持久化时间戳,跨 session 搜索可按日期排序;同一改动也修复了 re-upsert 路径每次都用 wall-clock 覆盖 `started_at` 的副作用 bug。前端 Vite dev proxy 补上漏配的 `/alpha`,AlphaZoo 页在 `npm run dev` 下不再 404。`tests/test_e2e_harness_v2.py`(真 LLM 的 e2e 套件)现在用 `VIBE_TRADING_RUN_LIVE_E2E=1` 做环境门控,CI 不再因为有无 LLM key 而静默切状态。Ruff 为 factor zoo 添加 `per-file-ignores`(3783 → 0 F401 噪音),前端 tsconfig 打开 `noUnusedLocals` / `noUnusedParameters` 做回归护栏,并删掉了 76 个 `gtja191` alpha 文件里没用上的 `vw = vwap(...)` 残留。净 **-918 行**。
- **2026-05-17** 🧬 **Alpha Zoo v1(0.1.8)**:内置 452 个量化 alpha,覆盖 4 个 zoo —— `qlib158`(Microsoft Qlib 的 Alpha158 特征,Apache-2.0 出处声明)、`alpha101`(Kakushadze 的 "101 Formulaic Alphas",从 arXiv:1601.00991 论文公式重写)、`gtja191`(国君证券 2014 短周期交易型因子研报)、`academic`(Fama-French 5 因子 + Carhart 动量的价格代理实现)。一行 CLI 就能在自己的 universe 上跑横评:`vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025`。配套设施包括 AST 纯函数门禁、lookahead 防护测试、`pytest-socket` 网络隔离、每个 zoo 一份 LICENSE.md、社区贡献用的 DCO 签名流程;Alpha Library 自动渲染上线 [vibetrading.wiki/alpha-library/](https://vibetrading.wiki/alpha-library/);Research Lab 同步发布 [《191 个 GTJA alpha 哪些在 2026 还能用》](https://vibetrading.wiki/research-lab/posts/alpha-191-in-2026.html)。
- **2026-05-16** 🧪 **研究主干更新**:新增后端 Hypothesis Registry,提供 `create_hypothesis`、`update_hypothesis`、`link_backtest`、`search_hypotheses`;外部内容读取工具现在会附加 warning-only 的 `security_warnings`;Shadow Account 扫描也从旧的日历 phase stub 升级为确定性的 OHLCV 特征评估。
- **2026-05-15** 🪪 Run 详情页现在会在 metrics 和 artifacts 旁边渲染 Trust Layer 的 run card,把 2026-05-12 已落地的 `run_card.json` 工作补齐到 UI 一侧。`PersistentMemory.add()` 也根据 #108/#109/#110 的 triage,在长度限制、空 / 纯空白 name、以及 C0/C1 控制字节三条路径上做了加固([#112](https://github.com/HKUDS/Vibe-Trading/pull/112),感谢 @Teerapat-Vatpitak)。
- **2026-05-14** 🌐 公开 Wiki 已上线 [vibetrading.wiki](https://vibetrading.wiki/),包含 docs、tutorials、Research Lab 和 Alpha Library,并通过 Cloudflare Pages 部署。持久记忆也可以通过 CLI 使用 `vibe-trading memory list/show/search/forget` 检查([#102](https://github.com/HKUDS/Vibe-Trading/pull/102),感谢 @Teerapat-Vatpitak);记忆 tokenizer/slug 现在支持泰语、阿拉伯语、希伯来语和西里尔文字([#104](https://github.com/HKUDS/Vibe-Trading/pull/104))。
- **2026-05-13** 🧭 Swarm 运行现在会用已获取的市场数据为 worker 提供依据,并生成更清晰的持久化报告([#93](https://github.com/HKUDS/Vibe-Trading/pull/93),[#84](https://github.com/HKUDS/Vibe-Trading/pull/84))。
- **2026-05-12** 🧾 回测现在会随 artifacts 一起输出 `run_card.json` 和 `run_card.md`,便于复现实验研究。
- **2026-05-11** 🧭 **记忆 slug、swarm 统计与 CLI 预检**:持久记忆在生成文件 slug 时会保留 CJK 字符,避免中文/日文/韩文笔记发生静默文件名冲突([#95](https://github.com/HKUDS/Vibe-Trading/pull/95),感谢 @voidborne-d)。Swarm 运行总量现在优先采用 provider 返回的 token 用量,并保留原有估算作为 fallback([#94](https://github.com/HKUDS/Vibe-Trading/pull/94),感谢 @Teerapat-Vatpitak)。CLI 运行界面也新增了启动预检,用于发现常见环境问题([#96](https://github.com/HKUDS/Vibe-Trading/pull/96),感谢 @ykykj)。
- **2026-05-10** 🧱 **回归护栏与运行元数据**:记忆召回现在将下划线视为 token 边界,因此 `mcp_wiring_test` 这类 snake_case 记忆可以匹配 "mcp wiring" 等自然语言查询([#87](https://github.com/HKUDS/Vibe-Trading/pull/87),感谢 @hp083625)。MCP server 增加了覆盖 initialize → `tools/list` → `tools/call` 的 subprocess smoke test,以防止首次调用死锁路径回归([#86](https://github.com/HKUDS/Vibe-Trading/pull/86))。同时还完成了多项低风险加固:Windows 路径敏感测试、API best-effort 异常处理、backtest `run_dir` allowed-root 校验,以及 SwarmRun provider/model 元数据([#88](https://github.com/HKUDS/Vibe-Trading/pull/88),[#90](https://github.com/HKUDS/Vibe-Trading/pull/90),[#91](https://github.com/HKUDS/Vibe-Trading/pull/91),[#92](https://github.com/HKUDS/Vibe-Trading/pull/92),感谢 @Teerapat-Vatpitak)。
- **2026-05-09** 🛡️ **API 路径加固与 MCP server 稳定性**:API run/session 路由现在会在查询前校验 path ID,拒绝包含换行等异常字符的参数,并将该行为纳入 auth/security 回归测试([#80](https://github.com/HKUDS/Vibe-Trading/pull/80),感谢 @SJoon99)。MCP server 现在会在主线程预热工具注册表再处理 `tools/call`,避免懒加载工具发现中的首次调用死锁([#85](https://github.com/HKUDS/Vibe-Trading/pull/85),感谢 @Teerapat-Vatpitak)。Vite dev proxy 也会为非默认后端目标遵循 `VITE_API_URL`([#82](https://github.com/HKUDS/Vibe-Trading/pull/82),感谢 @voidborne-d)。
- **2026-05-08** 🧾 **筛选器支持 Tushare 财报字段**:A 股日线回测现在可以通过 `fundamental_fields` 请求 PIT-safe 财务报表字段,使信号引擎能够在公告/披露日之后筛选 `income_total_revenue`、`income_n_income`、`balancesheet_total_hldr_eqy_exc_min_int`、`fina_indicator_roe` 等带表名前缀的字段([#76](https://github.com/HKUDS/Vibe-Trading/pull/76),感谢 @mrbob-git)。后续加固让显式财报字段请求在 Tushare enrich 无法运行时快速失败,而不是静默回退到原始价格 bar([#77](https://github.com/HKUDS/Vibe-Trading/pull/77))。
- **2026-05-07** 📈 **Tushare fundamentals 与社区 triage**:新增面向基本面研究工作流的 point-in-time `TushareFundamentalProvider` contract,并为项目 `TUSHARE_TOKEN` 环境路径加入回归覆盖([#74](https://github.com/HKUDS/Vibe-Trading/pull/74))。社区 triage 也明确了:Vibe-Trading 目前会将快速迭代聚焦在单一 UI 语言;在已内置 DuckDuckGo 支持的 `web_search` 时避免添加冗余搜索依赖;非官方托管部署不应被视为 API key 或数据源 token 的可信存放位置。
- **2026-05-06** 🚀 **v0.1.7 发布**([Release notes](https://github.com/HKUDS/Vibe-Trading/releases/tag/v0.1.7),`pip install -U vibe-trading-ai`):安全边界加固已发布到 PyPI 和 ClawHub,覆盖更安全的 API/read/upload/file/URL/generated-code/shell-tool/Docker 默认行为,同时保持 localhost CLI/Web UI 工作流低摩擦。本周期还包含 Web UI Settings、相关性热力图、OpenAI Codex OAuth、A 股 pre-ST 筛选、交互式 CLI UX、swarm preset 检查、股息分析、开发工作流打磨,以及经审计的前端构建依赖下限。感谢 0.1.7 贡献者,也感谢 lemi9090 (S2W) 的协同安全验证。
- **2026-05-05** 🛡️ **安全边界后续加固**:完成围绕显式 CORS origins、Settings 凭据指示、Web URL 读取和 Shadow Account 代码生成的剩余安全边界加固,并为每条路径加入回归测试。普通 localhost CLI/Web UI 工作流保持不变;远程部署应继续使用 `API_AUTH_KEY` 和显式可信 origins。
- **2026-05-04** 🖥️ **交互式 CLI UX 与 CI 清理**:交互模式现在拥有实时底部状态栏,可显示 provider/model、session 时长、最近一次运行延迟和累计工具调用统计;并通过 `prompt_toolkit` 支持 prompt 历史导航和方向键光标编辑([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。当 `prompt_toolkit` 或 TTY 不可用时,CLI 仍会回退到 Rich prompts。CI 路径期望也已与加固后的 file-import sandbox 和跨平台 `/tmp` 解析对齐,使 main 恢复绿色([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
- **2026-05-03** 🛡️ **安全加固补丁**:收紧非本地部署的默认 API 认证,保护敏感 run/session/swarm 读取,限制上传与本地文件读取边界,按入口限制 shell-capable 工具,导入前校验生成策略加载,并让 Docker 镜像默认以非 root 用户和 localhost-only 端口发布运行。本地 CLI 和 localhost Web UI 工作流仍保持低摩擦;远程 API/Web 部署应设置 `API_AUTH_KEY`。
- **2026-05-02** 🧭 **股息分析与更清晰路线图**:新增 `dividend-analysis` skill,用于收入型股票、派息可持续性、股息增长、股东收益、除息机制和收益率陷阱检查,并由 bundled-skill 回归测试固定。公开路线图现在聚焦即将开展的工作:Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer 和 Community sharing。
- **2026-05-01** 🔥 **相关性热力图、OpenAI Codex OAuth 与 A 股 pre-ST 筛选**:新的相关性 dashboard/API 会计算滚动收益相关性,并为组合与标的分析渲染 ECharts 热力图([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider 现在通过 `vibe-trading provider login openai-codex` 使用 ChatGPT OAuth,并加入 Settings 元数据和 adapter 回归测试([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。新增并加固 `ashare-pre-st-filter` skill,用于 A 股 ST/*ST 风险筛查,包括 Sina 处罚相关性过滤,避免证券账户提及错误抬高 E2 计数([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
- **2026-04-30** ⚙️ **Web UI Settings 与 validation CLI 加固**:新增 Settings 页面,用于配置 LLM provider/model、base URL、reasoning effort 和数据源凭据,由本地/认证保护的 settings API 与数据驱动的 provider metadata 支撑([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。同时加固 `python -m backtest.validation <run_dir>`,让缺失、空白、格式错误、不存在和非目录输入在 validation 开始前以清晰的面向操作者的信息失败([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
- **2026-04-28** 🚀 **v0.1.6 发布**(`pip install -U vibe-trading-ai`):修复 `pip install` / `uv tool install` 后 `vibe-trading --swarm-presets` 返回空的问题([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))—— preset YAML 现在打包在 `src.swarm` 包内,并由 6 个回归测试固定。同时 AKShare loader 会将 ETF(`510300.SH`)和外汇(`USDCNH`)正确路由到对应 endpoint,并强化 registry fallback。汇总 v0.1.5 以来的所有内容:benchmark comparison panel、`/upload` streaming + size limits、Futu loader(港股 + A 股)、vnpy export skill、安全加固、前端懒加载(688KB → 262KB)。
- **2026-04-27** 📊 **Benchmark panel 与上传安全**:回测输出现在包含 benchmark comparison panel(ticker / benchmark return / excess return / information ratio),并通过 yfinance 支持 SPY、沪深 300 等解析([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。此外 `/upload` 会以 1 MB chunk 流式读取请求体,并在超过 `MAX_UPLOAD_SIZE` 时中止,在超大/畸形客户端场景下限制内存使用([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))——由 4 个回归用例固定。
- **2026-04-22** 🛡️ **加固与新集成**:`safe_path` + journal/shadow tool sandbox 强制路径 containment,`MANIFEST.in` 在 sdist 中包含 `.env.example` / tests / Docker files,route-level lazy loading 将前端初始 bundle 从 688KB 降到 262KB。另有面向港股与 A 股 equities 的 Futu data loader([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))和 vnpy CtaTemplate export skill([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))。
- **2026-04-21** 🛡️ **Workspace 与文档**:相对 `run_dir` 会规范化到 active run dir([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README 使用示例([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
- **2026-04-20** 🔌 **Reasoning 与 Swarm**:所有 `ChatOpenAI` 路径都会保留 `reasoning_content`,Kimi / DeepSeek / Qwen thinking 全链路可用([#39](https://github.com/HKUDS/Vibe-Trading/issues/39))。Swarm streaming 与干净的 Ctrl+C([#42](https://github.com/HKUDS/Vibe-Trading/issues/42))。
- **2026-04-19** 📦 **v0.1.5**:发布到 PyPI 与 ClawHub。`python-multipart` CVE 下限升级,接入 5 个新 MCP tools(`analyze_trade_journal` + 4 个 shadow-account tools),修复 `pattern_recognition` → `pattern` registry,Docker 依赖对齐,SKILL manifest 同步(22 MCP tools / 71 skills)。
- **2026-04-18** 👥 **Shadow Account**:从券商流水中提取你的策略规则 → 跨市场回测 shadow → 生成 8 节 HTML/PDF 报告,明确展示你错过了多少机会(规则违背、过早离场、错过信号、反事实交易)。新增 4 个工具、1 个 skill,总计 32 tools。Trade Journal + Shadow Account 示例现在已在 Web UI 欢迎页中提供。
- **2026-04-17** 📊 **Trade Journal Analyzer 与 Universal File Reader**:上传券商导出(同花顺/东财/富途/generic CSV)→ 自动生成交易画像(持仓天数、胜率、盈亏比、回撤)+ 4 类行为偏差诊断(处置效应、过度交易、追涨、锚定)。`read_document` 现在以统一调用分发 PDF、Word、Excel、PowerPoint、图片(OCR)和 40+ 文本格式。
- **2026-04-16** 🧠 **Agent Harness**:跨 session 持久记忆、FTS5 session search、自进化 skills(完整 CRUD)、5 层上下文压缩、read/write tool batching。27 tools,107 个新增测试。
- **2026-04-15** 🤖 **Z.ai 与 MiniMax**:Z.ai provider([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)),MiniMax temperature 修复与模型更新([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。13 个 providers。
- **2026-04-14** 🔧 **MCP 稳定性**:修复 stdio transport 下 backtest tool 的 `Connection closed` 错误([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
- **2026-04-13** 🌐 **跨市场组合回测**:新的 `CompositeEngine` 可用共享资金池和分市场规则回测混合市场组合(例如 A 股 + crypto)。同时修复 swarm template variable fallback 和前端 timeout。
- **2026-04-12** 🌍 **多平台导出**:`/pine` 可一条命令将策略导出到 TradingView(Pine Script v6)、TDX(通达信/同花顺/东方财富)和 MetaTrader 5(MQL5)。
- **2026-04-11** 🛡️ **可靠性与 DX**:`vibe-trading init` .env bootstrap([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))、预检、运行时数据源 fallback、加固的回测引擎。多语言 README([#21](https://github.com/HKUDS/Vibe-Trading/pull/21))。
- **2026-04-10** 📦 **v0.1.4**:Docker 修复([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCP tool、12 个 LLM providers、`akshare`/`ccxt` 依赖。发布到 PyPI 与 ClawHub。
- **2026-04-09** 📊 **Backtest Wave 2**:ChinaFutures、GlobalFutures、Forex、Options v2 engines。Monte Carlo、Bootstrap CI、Walk-Forward validation。
- **2026-04-08** 🔧 **多市场回测**,支持分市场规则、Pine Script v6 导出、5 个数据源自动 fallback。
</details>
---
## ✨ Key Features
<div align="center">
<table align="center" width="94%" style="width:94%; margin-left:auto; margin-right:auto;">
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-self-improving-trading-agent.png" height="130" alt="Self-improving trading agent"/><br>
<h3>🔍 自我改进的交易智能体</h3>
<div align="left">
• 自然语言市场研究<br>
• 策略草稿与文件/网页分析<br>
• 由记忆驱动的研究工作流
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-multi-agent-trading-teams.png" height="130" alt="Multi-agent trading teams"/><br>
<h3>🐝 多智能体交易团队</h3>
<div align="left">
• 投资、量化、加密与风控团队<br>
• 流式进度与持久化报告<br>
• Worker 基于已获取的市场数据展开分析
</div>
</td>
</tr>
<tr>
<td align="center" width="50%" valign="top">
<img src="assets/feature-cross-market-data-backtesting.png" height="130" alt="Cross-market data and backtesting"/><br>
<h3>📊 跨市场数据与回测</h3>
<div align="left">
• A 股、港股、美股、加密、期货与外汇<br>
• 数据 fallback 与组合回测<br>
• PIT 数据、验证与 run cards
</div>
</td>
<td align="center" width="50%" valign="top">
<img src="assets/feature-shadow-account.png" height="130" alt="Shadow Account"/><br>
<h3>👥 Shadow Account</h3>
<div align="left">
• 券商交易日志行为诊断<br>
• 基于规则的 Shadow Account 对比<br>
• 可导出的审计报告与策略代码
</div>
</td>
</tr>
</table>
</div>
## 💡 What Is Vibe-Trading?
Vibe-Trading 是一个开源研究工作台,用于把金融问题转化为可运行的分析。它将自然语言提示连接到市场数据加载器、策略生成、回测引擎、报告、导出和持久研究记忆。
它面向研究、模拟和回测——并且在你选择时,可通过你自己授权的券商(如 Robinhood Agentic Trading)进行自主交易。它不托管任何资金,绝不超出你设定的限额交易,且你可随时一键停止。
---
## ✨ What You Can Do
| 任务 | 输出 |
|------|------|
| **提出交易问题** | 结合工具、数据、文档和可复用 session 上下文的市场研究。 |
| **回测策略想法** | 策略代码、指标、benchmark 上下文、验证 artifacts 和 run cards。 |
| **复盘自己的交易** | 券商日志解析、行为诊断、规则提取和 Shadow Account 对比。 |
| **改进重复研究** | 持久记忆和可编辑 skills 将有用流程变成可复用工作流。 |
| **运行分析师团队** | 面向投资、量化、加密、宏观和风控工作流的多智能体研究评审。 |
| **交付可用成果** | 报告、TradingView Pine Script、TDX、MetaTrader 5、MCP tools,以及可延续的研究 sessions。 |
| **跑预置 alpha zoo 横评** | 452 个 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart),一行 CLI 在你选的 universe 上算 IC + IR + alive/reversed/dead 分类 |
---
## ⚡ Quick Example
```bash
pip install vibe-trading-ai
# 自然语言研究
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024, summarize return and drawdown, then export the report"
# 一行 CLI 跑预置 alpha zoo 横评
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 👥 Shadow Account
Shadow Account 从你自己的交易记录出发,而不是从通用策略模板出发。
上传券商导出,让智能体总结你的交易行为,然后将真实交易路径与基于规则的 shadow strategy 进行对比。
| 步骤 | 智能体输出 |
|------|------------|
| **1. 读取交易日志** | 解析来自同花顺、东方财富、富途和 generic CSV 格式的券商导出。 |
| **2. 生成行为画像** | 持仓天数、胜率、盈亏比、回撤、处置效应、过度交易、追涨和锚定检查。 |
| **3. 提取你的规则** | 将反复出现的入场/出场行为转化为明确策略画像,而不是空泛总结。 |
| **4. 运行 shadow** | 回测提取出的规则,并高亮规则违背、过早离场、错过信号和替代交易路径。 |
| **5. 交付报告** | 生成可检查、可归档或在后续 session 中继续精修的 HTML/PDF 报告。 |
```bash
vibe-trading --upload trades_export.csv
vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, and compare it with my actual trades"
```
---
## 🧪 Research Workflow
多数运行都会遵循同一条证据路径:路由请求、加载正确的市场上下文、执行工具、验证输出,并保持 artifacts 可检查。
| 层 | 发生什么 |
|----|----------|
| **Plan** | 选择相关金融 skills、tools、数据源,以及在有帮助时选择 swarm preset。 |
| **Ground** | 通过可用 loader 拉取 A 股、港股/美股、加密、期货、外汇、文档或网页上下文。 |
| **Execute** | 生成可测试的策略代码,运行工具,并使用匹配的回测引擎或分析工作流。 |
| **Validate** | 在适用时加入指标、benchmark comparison、Monte Carlo、Bootstrap、Walk-Forward、run cards 和 warnings。 |
| **Deliver** | 返回报告、artifacts、tool traces,以及面向 TradingView、TDX、MetaTrader 5、MCP clients 或后续 sessions 的导出。 |
---
## 🔩 Detailed Capabilities
为保持主 README 易读,详细清单折叠在下方。需要检查可用构件时可展开查看。
<details>
<summary><b>Finance Skill Library</b> <sub>8 个类别中的 77 个 skills</sub></summary>
- 📊 77 个专业金融 skills,分布在 8 个类别中
- 🌐 覆盖传统市场、加密与 DeFi
- 🔬 从数据源到量化研究的完整能力链路
| 类别 | Skills | 示例 |
|------|--------|------|
| Data Source | 7 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `mootdx`, `ccxt` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 11 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export`, `alpha-zoo` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
</details>
<details>
<summary><b>Preset Trading Teams</b> <sub>29 个 swarm presets</sub></summary>
- 🏢 29 个开箱即用的智能体团队
- ⚡ 预配置金融工作流
- 🎯 投资、交易与风险管理 presets
| Preset | 工作流 |
|--------|--------|
| `investment_committee` | 多空辩论 → 风险审查 → PM 最终决策 |
| `global_equities_desk` | A 股 + 港/美股 + 加密研究员 → 全球策略师 |
| `crypto_trading_desk` | Funding/basis + liquidation + flow → 风险经理 |
| `earnings_research_desk` | 基本面 + 预期修正 + options → 财报策略师 |
| `macro_rates_fx_desk` | 利率 + 外汇 + 商品 → 宏观 PM |
| `quant_strategy_desk` | 筛选 + 因子研究 → 回测 → 风险审计 |
| `technical_analysis_panel` | 经典 TA + Ichimoku + harmonic + Elliott + SMC → 共识 |
| `risk_committee` | 回撤 + 尾部风险 + regime review → 审批 |
| `global_allocation_committee` | A 股 + 加密 + 港/美股 → 跨市场配置 |
<sub>另有 20+ 专业 presets,可运行 vibe-trading --swarm-presets 查看全部。
</sub>
</details>
<details>
<summary><b>Alpha Zoo</b> <sub>452 个预置 alpha,覆盖 4 个 zoo</sub></summary>
- 🧬 452 个横截面 alpha,算子层即禁用 lookahead
- 📈 一条 CLI 命令完成 IC + IR + alive/reversed/dead 分类
- 🔬 AST 纯函数门禁 + 300 行 lookahead 哨兵测试 + `pytest-socket` 网络阻断
- 📦 Qlib 部分附 Apache-2 出处声明;每个 zoo 一份 `LICENSE.md`,声明公式属于数学内容
- 🤝 社区 PR 走 Developer Certificate of Origin (DCO) 签名流程
| Zoo | 数量 | 来源 | 许可 |
|-----|------|------|------|
| **qlib158** | 154 | Microsoft Qlib `Alpha158`(Apache-2.0,锁定 commit) | Apache-2.0 |
| **alpha101** | 101 | Kakushadze (2015), "101 Formulaic Alphas", arXiv:1601.00991 | 公式属于数学内容 |
| **gtja191** | 191 | 国君证券 (2014)《191 个短周期交易型 alpha 因子》研报 | 公式属于数学内容 |
| **academic** | 6 | Fama-French 5 因子 + Carhart 动量(基于价格的代理实现) | 公开学术文献 |
运行 `vibe-trading alpha list` 浏览全部因子,`vibe-trading alpha show <id>` 查看公式与源码,`vibe-trading alpha bench --zoo X --universe Y --period Z` 给一整个 zoo 打分。
</details>
## 🎬 Demo
<div align="center">
<table>
<tr>
<td width="50%">
https://github.com/user-attachments/assets/4e4dcb80-7358-4b9a-92f0-1e29612e6e86
</td>
<td width="50%">
https://github.com/user-attachments/assets/3754a414-c3ee-464f-b1e8-78e1a74fbd30
</td>
</tr>
<tr>
<td colspan="2" align="center"><sub>☝️ 自然语言回测与多智能体 swarm 辩论 — Web UI + CLI</sub></td>
</tr>
</table>
</div>
---
## 🚀 Quick Start
### 一行安装(PyPI)
```bash
pip install vibe-trading-ai
```
然后运行第一个研究任务:
```bash
vibe-trading init
vibe-trading run -p "Backtest a BTC-USDT 20/50 moving-average strategy for 2024 and summarize return and drawdown"
```
> **包名与命令:** PyPI 包名是 `vibe-trading-ai`。安装后会获得三个命令:
>
> | 命令 | 用途 |
> |------|------|
> | `vibe-trading` | 交互式 CLI / TUI |
> | `vibe-trading serve` | 启动 FastAPI web server |
> | `vibe-trading-mcp` | 启动 MCP server(用于 Claude Desktop、OpenClaw、Cursor 等) |
```bash
vibe-trading init # interactive .env setup
vibe-trading # launch CLI
vibe-trading serve --port 8899 # launch web UI
vibe-trading-mcp # start MCP server (stdio)
```
### 或选择一种路径
| 路径 | 最适合 | 时间 |
|------|--------|------|
| **A. Docker** | 立即试用,零本地配置 | 2 min |
| **B. Local install** | 开发,完整 CLI 访问 | 5 min |
| **C. MCP plugin** | 接入你现有的智能体 | 3 min |
| **D. ClawHub** | 一条命令,无需 clone | 1 min |
### 前置条件
- 任意受支持 provider 的 **LLM API key**,或使用 **Ollama** 本地运行(无需 key)
- 路径 B 需要 **Python 3.11+**
- 路径 A 需要 **Docker**
- OpenAI Codex 也可通过 ChatGPT OAuth 使用:设置 `LANGCHAIN_PROVIDER=openai-codex`,然后运行 `vibe-trading provider login openai-codex`。它不使用 `OPENAI_API_KEY`。
> **支持的 LLM providers:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、Zhipu、Moonshot/Kimi、MiniMax、Xiaomi MIMO、Z.ai、Ollama(本地)。配置见 `.env.example`。
> **提示:** 由于自动 fallback,所有市场都可以在没有任何 API key 的情况下工作。yfinance(港/美股)、OKX(加密)、mootdx(A 股,TCP 直连不封 IP)和 AKShare(A 股、美股、港股、期货、外汇)都是免费的。Tushare token 是可选项 —— mootdx 是首选的免 token A 股 fallback,AKShare 作为覆盖更广的兜底。
### Path A: Docker(零配置)
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
cp agent/.env.example agent/.env
# Edit agent/.env — uncomment your LLM provider and set API key
docker compose up --build
```
打开 `http://localhost:8899`。后端 + 前端在同一个容器中运行。
Docker 默认将后端发布在 `127.0.0.1:8899`,并以非 root 容器用户运行应用。如果你有意将 API 暴露到本机之外,请设置强 `API_AUTH_KEY`,并让客户端发送 `Authorization: Bearer <key>`。
### Path B: Local install
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
# Activate
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -e .
cp agent/.env.example agent/.env # Edit — set your LLM provider API key
vibe-trading # Launch interactive TUI
```
<details>
<summary><b>启动 Web UI(可选)</b></summary>
```bash
# Terminal 1: API server
vibe-trading serve --port 8899
# Terminal 2: Frontend dev server
cd frontend && npm install && npm run dev
```
打开 `http://localhost:5899`。前端会将 API 调用代理到 `localhost:8899`。
**生产模式(单 server):**
```bash
cd frontend && npm run build && cd ..
vibe-trading serve --port 8899 # FastAPI serves dist/ as static files
```
</details>
### Path C: MCP plugin
见下方 [MCP Plugin](#-mcp-plugin) 章节。
### Path D: ClawHub(一条命令)
```bash
npx clawhub@latest install vibe-trading --force
```
skill + MCP config 会下载到你的智能体 skills 目录。详情见 [ClawHub install](#-mcp-plugin)。
---
## 🧠 Environment Variables
将 `agent/.env.example` 复制为 `agent/.env`,并取消注释你想使用的 provider block。每个 provider 需要 3-4 个变量:
| 变量 | 必需 | 说明 |
|------|:----:|------|
| `LANGCHAIN_PROVIDER` | Yes | Provider 名称(`openrouter`, `deepseek`, `groq`, `ollama` 等) |
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` 等) |
| `<PROVIDER>_BASE_URL` | Yes | API endpoint URL |
| `LANGCHAIN_MODEL_NAME` | Yes | 模型名称(例如 `deepseek-v4-pro`) |
| `TUSHARE_TOKEN` | No | A 股数据的 Tushare Pro token(会 fallback 到 AKShare) |
| `TIMEOUT_SECONDS` | No | LLM 调用超时,默认 120s |
| `API_AUTH_KEY` | 网络部署推荐 | API 可被非本地客户端访问时要求的 Bearer token |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | 在远程 API/MCP-SSE 风格部署中显式启用 shell-capable tools |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 文档和券商日志导入额外允许的逗号分隔 roots |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 生成代码 run directories 额外允许的逗号分隔 roots |
<sub>* Ollama 不需要 API key。OpenAI Codex 使用 ChatGPT OAuth,并通过 `oauth-cli-kit` 存储 token,不写入 `agent/.env`。</sub>
**免费数据(无需 key):** A 股通过 AKShare,港/美股通过 yfinance,加密通过 OKX,100+ 加密交易所通过 CCXT。系统会为每个市场自动选择最佳可用数据源。
### 🎯 Recommended Models
Vibe-Trading 是高度依赖工具的智能体:skills、backtests、memory 和 swarms 都会通过工具调用流转。模型选择会直接决定智能体是实际使用工具,还是从训练数据中编造答案。
| 档位 | 示例 | 使用场景 |
|------|------|----------|
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5-pro`, `google/gemini-3.5-flash` | 复杂 swarms(3+ agents)、长研究 sessions、论文级分析 |
| **Sweet spot**(默认) | `deepseek-v4-pro`, `deepseek/deepseek-v4-pro`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6`, `qwen/qwen3-max-thinking` | 日常主力,约 1/10 成本下具备可靠工具调用 |
| **避免用于 agent** | `*-nano`, `*-flash-lite`, `*-coder-next`, 小型 / 蒸馏变体 | 工具调用不可靠,智能体会看起来像是在“凭记忆回答”,而不是加载 skills 或运行回测 |
默认 `agent/.env.example` 使用 DeepSeek 官方 API + `deepseek-v4-pro`;OpenRouter 用户可以使用 `deepseek/deepseek-v4-pro`。
---
## 🖥 CLI Reference
```bash
vibe-trading # interactive TUI
vibe-trading run -p "..." # single run
vibe-trading serve # API server
vibe-trading alpha list # 浏览 452 个预置 alpha;支持 show / bench / compare / export-manifest 子命令
```
<details>
<summary><b>TUI 内 slash commands</b></summary>
| 命令 | 说明 |
|------|------|
| `/help` | 显示所有命令 |
| `/skills` | 列出全部 77 个 finance skills |
| `/swarm` | 列出 29 个 swarm team presets |
| `/swarm run <preset> [vars_json]` | 运行一个 swarm team,并实时流式展示 |
| `/swarm list` | Swarm 运行历史 |
| `/swarm show <run_id>` | Swarm 运行详情 |
| `/swarm cancel <run_id>` | 取消运行中的 swarm |
| `/list` | 最近 runs |
| `/show <run_id>` | Run 详情 + 指标 |
| `/code <run_id>` | 生成的策略代码 |
| `/pine <run_id>` | 导出指标(TradingView + TDX + MT5) |
| `/trace <run_id>` | 完整执行回放 |
| `/continue <run_id> <prompt>` | 用新指令继续一个 run |
| `/sessions` | 列出 chat sessions |
| `/settings` | 显示运行时配置 |
| `/clear` | 清屏 |
| `/quit` | 退出 |
</details>
<details>
<summary><b>Single run 与 flags</b></summary>
```bash
vibe-trading run -p "Backtest BTC-USDT MACD strategy, last 30 days"
vibe-trading run -p "Analyze AAPL momentum" --json
vibe-trading run -f strategy.txt
echo "Backtest 000001.SZ RSI" | vibe-trading run
```
```bash
vibe-trading -p "your prompt"
vibe-trading --skills
vibe-trading --swarm-presets
vibe-trading --swarm-run investment_committee '{"topic":"BTC outlook"}'
vibe-trading --list
vibe-trading --show <run_id>
vibe-trading --code <run_id>
vibe-trading --pine <run_id> # Export indicators (TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
vibe-trading alpha list --zoo gtja191 --limit 10
vibe-trading alpha show gtja191_171
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
</details>
---
## 💡 Examples
### Strategy & Backtesting
```bash
# Moving average crossover on US equities
vibe-trading run -p "Backtest a 20/50-day moving average crossover on AAPL for the past year, show Sharpe ratio and max drawdown"
# RSI mean-reversion on crypto
vibe-trading run -p "Test RSI(14) mean-reversion on BTC-USDT: buy below 30, sell above 70, last 6 months"
# Multi-factor strategy on A-shares
vibe-trading run -p "Backtest a momentum + value + quality multi-factor strategy on CSI 300 constituents over 2 years"
# After backtesting, export to TradingView / TDX / MetaTrader 5
vibe-trading --pine <run_id>
```
**一行命令横评预置 alpha zoo**:
```bash
vibe-trading alpha bench --zoo gtja191 --universe csi300 --period 2018-2025 --top 20
```
**浏览目录** + 查看单个 alpha:
```bash
vibe-trading alpha list --zoo gtja191 --theme reversal --limit 10
vibe-trading alpha show gtja191_171
```
**用 zoo 因子组合多因子信号**(Python):
```python
from src.skills.multi_factor.zoo_signal_engine import ZooSignalEngine
engine = ZooSignalEngine.from_zoo(["gtja191_171", "gtja191_111", "gtja191_163"])
panel = ... # your wide OHLCV panel
signal = engine.compute_signal(panel)
```
### Market Research
```bash
# Equity deep-dive
vibe-trading run -p "Research NVDA: earnings trend, analyst consensus, option flow, and key risks for next quarter"
# Macro analysis
vibe-trading run -p "Analyze the current Fed rate path, USD strength, and impact on EM equities and gold"
# Crypto on-chain
vibe-trading run -p "Deep dive BTC on-chain: whale flows, exchange balances, miner activity, and funding rates"
```
### Swarm Workflows
```bash
# Bull/bear debate on a stock
vibe-trading --swarm-run investment_committee '{"topic": "Is TSLA a buy at current levels?"}'
# Quant strategy from screening to backtest
vibe-trading --swarm-run quant_strategy_desk '{"universe": "S&P 500", "horizon": "3 months"}'
# Crypto desk: funding + liquidation + flow → risk manager
vibe-trading --swarm-run crypto_trading_desk '{"asset": "ETH-USDT", "timeframe": "1w"}'
# Global macro portfolio allocation
vibe-trading --swarm-run macro_rates_fx_desk '{"focus": "Fed pivot impact on EM bonds"}'
```
### Cross-Session Memory
```bash
# Save your preferences once
vibe-trading run -p "Remember: I prefer RSI-based strategies, max 10% drawdown, hold period 5–20 days"
# The agent recalls them in future sessions automatically
vibe-trading run -p "Build a crypto strategy that fits my risk profile"
```
### Upload & Analyze Documents
```bash
# Analyze a broker export or earnings report
vibe-trading --upload trades_export.csv
vibe-trading run -p "Profile my trading behavior and identify any biases"
vibe-trading --upload NVDA_Q1_earnings.pdf
vibe-trading run -p "Summarize the key risks and beats/misses from this earnings report"
```
---
## 🌐 API Server
```bash
vibe-trading serve --port 8899
```
| Method | Endpoint | 说明 |
|--------|----------|------|
| `GET` | `/runs` | 列出 runs |
| `GET` | `/runs/{run_id}` | Run 详情 |
| `GET` | `/runs/{run_id}/pine` | 多平台指标导出 |
| `POST` | `/sessions` | 创建 session |
| `POST` | `/sessions/{id}/messages` | 发送消息 |
| `GET` | `/sessions/{id}/events` | SSE event stream |
| `POST` | `/upload` | 上传 PDF/file |
| `GET` | `/swarm/presets` | 列出 swarm presets |
| `POST` | `/swarm/runs` | 启动 swarm run |
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
| `GET` | `/alpha/list` | 按 zoo/theme/universe 过滤列出 alpha |
| `GET` | `/alpha/{alpha_id}` | Alpha 元数据 + 源代码 |
| `POST` | `/alpha/bench` | 启动一个 bench job(返回 `job_id`) |
| `GET` | `/alpha/bench/{job_id}/stream` | SSE 进度流 |
| `GET` | `/settings/llm` | 读取 Web UI LLM settings |
| `PUT` | `/settings/llm` | 更新本地 LLM settings |
| `GET` | `/settings/data-sources` | 读取本地数据源 settings |
| `PUT` | `/settings/data-sources` | 更新本地数据源 settings |
交互式文档:`http://localhost:8899/docs`
### Security defaults
对于 localhost 开发,`vibe-trading serve` 会保持浏览器工作流简单。对任何非本地客户端,敏感 API endpoints 都要求 `API_AUTH_KEY`;JSON/upload 请求请使用 `Authorization: Bearer <key>`。浏览器 EventSource streams 会在你于 Settings 中输入同一个 key 后由 Web UI 处理。
Shell-capable tools 可用于本地 CLI 与可信 localhost 工作流,但不会暴露给远程 API sessions,除非你显式设置 `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`。文档和日志读取器默认限制在 upload/import roots 内;请将文件放在 `agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads` 或 `~/.vibe-trading/imports` 下,或通过 `VIBE_TRADING_ALLOWED_FILE_ROOTS` 添加专用目录。
### Web UI Settings
Web UI Settings 页面允许本地用户更新 LLM provider/model、base URL、generation parameters、reasoning effort,以及 Tushare token 等可选市场数据凭据。Settings 会持久化到 `agent/.env`;provider defaults 从 `agent/src/providers/llm_providers.json` 加载。
Settings 读取无副作用:`GET /settings/llm` 和 `GET /settings/data-sources` 永远不会创建 `agent/.env`,并且只返回项目相对路径。Settings 读写可能暴露凭据状态或更新凭据/运行时环境,因此在配置了 `API_AUTH_KEY` 时会要求认证。如果 dev mode 下未设置 `API_AUTH_KEY`,settings 访问只接受 loopback clients。
---
## 🔌 MCP Plugin
Vibe-Trading 为任何 MCP-compatible client 暴露 35 个 MCP tools。它作为 stdio subprocess 运行,无需 server setup。核心 research tools 对港股/美股/加密零 API key 可用;trading connector tools 使用当前选择的 connector profile;只有 `run_swarm` 需要 LLM key。
<details>
<summary><b>Claude Desktop</b></summary>
添加到 `claude_desktop_config.json`:
```json
{
"mcpServers": {
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
</details>
<details>
<summary><b>OpenClaw</b></summary>
添加到 `~/.openclaw/config.yaml`:
```yaml
skills:
- name: vibe-trading
command: vibe-trading-mcp
```
</details>
<details>
<summary><b>Cursor / Windsurf / other MCP clients</b></summary>
```bash
vibe-trading-mcp # stdio (default)
vibe-trading-mcp --transport sse # SSE for web clients
```
</details>
**暴露的 MCP tools(35):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`.
<details>
<summary><b>从 ClawHub 安装(一条命令)</b></summary>
```bash
npx clawhub@latest install vibe-trading --force
```
> 由于该 skill 引用了外部 API,会触发 VirusTotal 自动扫描,因此需要 `--force`。代码完全开源,可自行检查。
这会将 skill + MCP config 下载到你的智能体 skills 目录。无需 clone。
在 ClawHub 浏览:[clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
</details>
<details>
<summary><b>OpenSpace — 自进化 skills</b></summary>
全部 77 个 finance skills 都发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自主演进。
要配合 OpenSpace 使用,请将两个 MCP servers 都加入你的 agent config:
```json
{
"mcpServers": {
"openspace": {
"command": "openspace-mcp",
"toolTimeout": 600,
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/vibe-trading/agent/src/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace"
}
},
"vibe-trading": {
"command": "vibe-trading-mcp"
}
}
}
```
OpenSpace 会自动发现全部 77 个 skills,启用 auto-fix、auto-improve 和社区分享。在任意已连接 OpenSpace 的智能体中,可通过 `search_skills("finance backtest")` 搜索 Vibe-Trading skills。
</details>
---
## 📁 Project Structure
<details>
<summary><b>点击展开</b></summary>
```
Vibe-Trading/
├── agent/ # 后端(Python)
│ ├── cli/ # CLI 包 —— 交互式 TUI + 子命令
│ ├── api_server.py # FastAPI server —— runs、sessions、upload、swarm、SSE
│ ├── mcp_server.py # MCP server —— 35 个工具,面向 OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent 内核
│ │ │ ├── loop.py # 5 层上下文压缩 + 读/写工具批处理
│ │ │ ├── context.py # system prompt + 持久记忆自动召回
│ │ │ ├── skills.py # skill loader(77 个内置 + 通过 CRUD 创建的用户 skill)
│ │ │ ├── tools.py # tool 基类 + 注册表
│ │ │ ├── memory.py # 每个 run 的轻量 workspace 状态
│ │ │ ├── frontmatter.py # 共享的 YAML frontmatter 解析器
│ │ │ └── trace.py # 执行 trace 写入器
│ │ │
│ │ ├── memory/ # 跨 session 持久记忆
│ │ │ └── persistent.py # 基于文件的记忆(~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 31 个自动发现的 agent 工具
│ │ │ ├── backtest_tool.py # 运行回测
│ │ │ ├── remember_tool.py # 跨 session 记忆(save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD(save/patch/delete/file)
│ │ │ ├── session_search_tool.py # FTS5 跨 session 搜索
│ │ │ ├── swarm_tool.py # 启动 swarm team
│ │ │ ├── web_search_tool.py # DuckDuckGo 网络搜索
│ │ │ └── ... # bash、文件 I/O、因子分析、期权、alpha 浏览 + 横评等
│ │ │
│ │ ├── factors/ # Alpha Zoo —— 4 个 zoo 共 452 个 alpha
│ │ │ ├── base.py # 19 个算子(rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
│ │ │ ├── registry.py # 纯 AST 元数据加载 + 惰性计算 + sanity 校验
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 分类
│ │ │ └── zoo/ # qlib158 (154) + alpha101 (101) + gtja191 (191) + academic (6)
│ │ │
│ │ ├── api/ # FastAPI 路由模块
│ │ │ └── alpha_routes.py # /alpha/list、/alpha/{id}、/alpha/bench、SSE 流
│ │ │
│ │ ├── skills/ # 8 个类别共 77 个 finance skills(每个一份 SKILL.md)
│ │ ├── swarm/ # Swarm DAG 执行引擎
│ │ │ └── presets/ # 29 个 swarm preset YAML 定义
│ │ ├── session/ # 多轮对话 + FTS5 session 搜索
│ │ └── providers/ # LLM provider 抽象层
│ │
│ └── backtest/ # 回测引擎
│ ├── engines/ # 7 个引擎 + 跨市场 composite 引擎 + options_portfolio
│ ├── loaders/ # 7 个数据源:tushare、okx、yfinance、akshare、mootdx、ccxt、futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # Registry + 自动 fallback 链路
│ └── optimizers/ # MVO、equal vol、max div、risk parity
│
├── frontend/ # Web UI(React 19 + Vite + TypeScript)
│ └── src/
│ ├── pages/ # Home、Agent、AlphaZoo、RunDetail、Compare、Correlation、Settings
│ ├── components/ # chat、charts、layout
│ └── stores/ # Zustand 状态管理
│
├── Dockerfile # 多阶段构建
├── docker-compose.yml # 一条命令部署
├── pyproject.toml # 包配置 + CLI entrypoint
├── tools/ # 仓库级 CI 辅助脚本
│ └── ci_grep_gates.sh # 拦截 yaml.load / 商标 / 个股数据泄露
└── LICENSE # MIT
```
</details>
---
## 🏛 Ecosystem
Vibe-Trading 是 **[HKUDS](https://github.com/HKUDS)** 智能体生态的一部分:
<table>
<tr>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
<sub>Ultra-Lightweight Personal AI Assistant</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/AI-Trader"><b>AI-Trader</b></a><br>
<sub>Agent-Native Signal &amp; Copy Trading Platform</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
<sub>Making All Software Agent-Native</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
<sub>Self-Evolving AI Agent Skills</sub>
</td>
<td align="center" width="20%">
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
<sub>Agent Swarm Intelligence</sub>
</td>
</tr>
</table>
---
## 🗺 Roadmap
> 我们按阶段交付。工作开始时,条目会移动到 [Issues](https://github.com/HKUDS/Vibe-Trading/issues)。
| 阶段 | 功能 | 状态 |
|------|------|------|
| **Trust Layer** | 可复现 run cards 已输出并展示在 Run Detail;v1 会补充 tool traces 与 citations | v0 已发布 |
| **Hypothesis Registry** | 持久化研究假设:lifecycle status、data sources、skills、run-card links 与 invalidation notes | Backend MVP 已发布 |
| **Research Autopilot** | 手动触发优先的研究循环:hypothesis → deterministic backtest → evidence report | 下一步 |
| **Data Bridge** | 自带数据:本地 CSV/Parquet/SQL connectors 与 schema mapping | Planned |
| **Options Lab** | Vol surface、Greeks dashboard、payoff/scenario explorer | Planned |
| **Portfolio Studio** | Risk x-ray、constraints、turnover-aware optimizer、rebalance notes | Planned |
| **Alpha Zoo** | 452 个预置 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + FF5 + Carhart),一行 CLI 跑横评,agent 集成,Web UI 浏览 | **已发布 0.1.8** |
| **Research Delivery** | 定时 briefs 到 Slack / Telegram / email-style channels | Planned |
| **Community** | 可分享的 skills、presets 和 strategy cards | Exploring |
---
## Contributing
欢迎贡献!请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解指南。
**Good first issues** 使用 [`good first issue`](https://github.com/HKUDS/Vibe-Trading/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 标记,可选择一个开始。
想贡献更大的内容?请查看上方 [Roadmap](#-roadmap),并在开始前先开 issue 讨论。
---
## Contributors
感谢所有为 Vibe-Trading 做出贡献的人!
近期 v0.1.8 周期贡献者与致谢:
- @GTC2080 / TaoMu — Web UI Settings 和 provider/data-source configuration APIs (#57)
- @BigNounce90 — backtest `run_dir` 输入的 validation CLI 加固 (#60)
- @shadowinlife — A 股 pre-ST filter skill (#63)
- @MB-Ndhlovu — correlation heatmap dashboard 和 review fixes (#64, #66)
- @ykykj — OpenAI Codex OAuth provider option (#65)
- @RuifengFu — 交互式 CLI live status bar 和 prompt editing (#69)
- @SiMinus — swarm preset inspection command (#73)
- @warren618 / Haozhe Wu — 安全加固、release integration、文档、Docker、packaging 和本地开发工作流
- lemi9090 (S2W) — 协同安全研究、验证和披露支持
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
</a>
---
## Disclaimer
Vibe-Trading 是研究与交易软件。它不是投资建议,不托管任何资金,也不运营执行场所。仅通过你自己明确授权的券商通道(如 Robinhood Agentic Trading)进行交易,且只在你设定的限额内、你可随时停止。该券商交易能力为实验性,未经我们对接真实券商账户验证——风险自负。历史表现不代表未来结果。
## License
MIT License — see [LICENSE](LICENSE)
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=HKUDS/Vibe-Trading&type=Date)](https://star-history.com/#HKUDS/Vibe-Trading&Date)
<p align="center">
⭐ 如果 <b>Vibe-Trading</b> 对你的研究有帮助,点个 Star 让更多人看到它。
</p>
---
<p align="center">
感谢访问 <b>Vibe-Trading</b> ✨
</p>
<p align="center">
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
</p>
+26
View File
@@ -0,0 +1,26 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|:---------:|
| latest | ✅ |
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly:
1. **Do NOT open a public issue.**
2. Use the [GitHub Security Advisory](https://github.com/HKUDS/Vibe-Trading/security/advisories/new) to report privately.
3. Include steps to reproduce, potential impact, and any suggested fixes.
We will acknowledge your report within **5 business days** and work with you to resolve the issue.
## Scope
This policy applies to the [HKUDS/Vibe-Trading](https://github.com/HKUDS/Vibe-Trading) repository.
## Disclosure
- Please do not publicly disclose the vulnerability until we have released a fix.
- We will credit reporters in the release notes (unless you prefer anonymity).
+133 -11
View File
@@ -1,22 +1,144 @@
# ============================================================================
# LLM Provider
# LLM Provider — uncomment ONE provider block below
# ============================================================================
# --- OpenRouter (recommended, multi-model gateway) ---
LANGCHAIN_PROVIDER=openrouter
OPENAI_API_KEY=sk-or-v1-your-key-here
OPENAI_BASE_URL=https://openrouter.ai/api/v1
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v4-pro
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# --- OpenAI ---
# LANGCHAIN_PROVIDER=openai
# LANGCHAIN_MODEL_NAME=gpt-5.5-instant
# OPENAI_API_KEY=sk-xxx
# OPENAI_BASE_URL=https://api.openai.com/v1
# --- 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.3-codex
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
# --- DeepSeek ---
# LANGCHAIN_PROVIDER=deepseek
# LANGCHAIN_MODEL_NAME=deepseek-v4-pro
# DEEPSEEK_API_KEY=sk-xxx
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# --- Gemini ---
# LANGCHAIN_PROVIDER=gemini
# 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=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-latest
# DASHSCOPE_API_KEY=sk-xxx
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# --- Zhipu ---
# LANGCHAIN_PROVIDER=zhipu
# LANGCHAIN_MODEL_NAME=glm-5.1
# ZHIPU_API_KEY=xxx
# ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# --- Moonshot / Kimi ---
# LANGCHAIN_PROVIDER=moonshot
# LANGCHAIN_MODEL_NAME=kimi-k2.6
# MOONSHOT_API_KEY=sk-xxx
# MOONSHOT_BASE_URL=https://api.moonshot.ai/v1
# --- MiniMax ---
# LANGCHAIN_PROVIDER=minimax
# LANGCHAIN_MODEL_NAME=MiniMax-M2.7 # or MiniMax-M2.7-highspeed (faster)
# MINIMAX_API_KEY=xxx
# MINIMAX_BASE_URL=https://api.minimax.io/v1
# Note: MiniMax requires temperature > 0. Set LANGCHAIN_TEMPERATURE=1.0 (default when using MiniMax)
# --- Xiaomi MIMO ---
# LANGCHAIN_PROVIDER=mimo
# LANGCHAIN_MODEL_NAME=MiMo-72B-A27B
# MIMO_API_KEY=xxx
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1
# --- Z.ai (for Coding Plan) ---
# LANGCHAIN_PROVIDER=zai
# LANGCHAIN_MODEL_NAME=glm-5.1
# ZAI_API_KEY=xxx
# ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4
# --- Ollama (local) ---
# LANGCHAIN_PROVIDER=ollama
# LANGCHAIN_MODEL_NAME=qwen2.5:32b
# OLLAMA_BASE_URL=http://localhost:11434
# LLM parameters
LANGCHAIN_TEMPERATURE=0.0
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 / max.
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
# LANGCHAIN_REASONING_EFFORT=medium
# ============================================================================
# Data Providers
# Data Sources
# ============================================================================
# A-shares: Tushare Pro (requires token, https://tushare.pro)
# A-shares: Tushare Pro token (https://tushare.pro)
TUSHARE_TOKEN=your-tushare-token
# HK/US equities: yfinance (free, no key needed)
# Crypto: OKX public API (free, no key needed)
# HK/US equities: yfinance (free, no config needed)
# Crypto: OKX public API (free, no config needed)
# Crypto fallback exchange (default: binance). Change if OKX is blocked:
# CCXT_EXCHANGE=binance
# HK / A-share equities via Futu OpenAPI (optional, requires FutuOpenD running locally)
# Download FutuOpenD: https://www.futunn.com/download/openAPI
# FUTU_HOST=127.0.0.1
# FUTU_PORT=11111
# ============================================================================
# Runtime
# API Server (optional)
# ============================================================================
TIMEOUT_SECONDS=2400
MAX_RETRIES=5
# 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)
# ============================================================================
# SWARM_WORKER_TIMEOUT=300
# SWARM_WORKER_MAX_ITER=50
# SWARM_MAX_WORKERS=4
# SWARM_TIMEOUT=1800
# SUBAGENT_TIMEOUT=300
# SUBAGENT_MAX_ITER=25
# TOKEN_THRESHOLD=40000
# 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
+200 -18
View File
@@ -1,6 +1,7 @@
---
name: vibe-trading
description: Professional finance research toolkit — backtesting, factor analysis, options pricing, 64 finance skills, and 29 multi-agent swarm teams across 3 markets (A-shares, crypto, HK/US equities).
version: 0.1.8
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, Alpha Zoo (452 pre-built alphas across qlib158/alpha101/gtja191/academic), options pricing, 77 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 7 data sources (tushare, yfinance, okx, akshare, mootdx, ccxt, futu).
dependencies:
python: ">=3.11"
pip:
@@ -10,10 +11,10 @@ env:
description: "Tushare API token for China A-share data (optional — HK/US/crypto work without any key)"
required: false
- name: OPENAI_API_KEY
description: "OpenAI-compatible API key — only needed for run_swarm (multi-agent teams). All other 15 tools work without it."
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
@@ -22,7 +23,7 @@ mcp:
# Vibe-Trading
Professional finance research toolkit with AI-powered backtesting, multi-agent teams, and 64 specialized skills.
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 77 specialized skills, the **Alpha Zoo** (452 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic 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
@@ -52,7 +53,7 @@ Add to your agent's MCP config:
### API Key Requirements
**15 of 16 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, and all 64 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 (452 pre-built alphas), and all 77 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
| Feature | Key needed | When |
|---------|-----------|------|
@@ -62,11 +63,22 @@ Add to your agent's MCP config:
## What You Can Do
### Shadow Account — flagship loop
Feed a CSV broker export (同花顺 / 东财 / 富途 / generic), and the agent will:
1. `analyze_trade_journal` — profile your behavior (holding period, win rate, disposition effect, chasing, overtrading, anchoring).
2. `extract_shadow_strategy` — distill 3-5 if-then rules that describe your profitable roundtrips.
3. `run_shadow_backtest` — backtest those rules across A/HK/US/crypto and compute delta-PnL vs your realized trades.
4. `render_shadow_report` — produce an HTML/PDF report (8 sections + charts) with today's matching signals.
5. `scan_shadow_signals` — list today's symbols that match your shadow's entry cadence (research only).
### Backtesting
Create and run quantitative strategies across 3 markets:
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 7 data sources:
- **HK/US equities** via yfinance (free, no API key)
- **Cryptocurrency** via OKX (free, no API key)
- **China A-shares** via Tushare (requires TUSHARE_TOKEN)
- **Cryptocurrency** via OKX or CCXT/100+ exchanges (free, no API key)
- **China A-shares** via Tushare (token) or AKShare (free fallback)
- **Futures, forex, macro** via AKShare (free, no API key)
- **HK & A-share equities** via Futu (broker login required, optional)
Example workflow:
1. Use `list_skills()` to discover strategy patterns
@@ -87,40 +99,69 @@ Example workflow:
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
### Finance Skills (64)
### Alpha Zoo (452 pre-built alphas)
One-line cross-sectional IC / IR / alive-reversed-dead categorisation across four 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** (6 factors) — Fama-French 5 + Carhart momentum (honest price-based proxies).
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 (77)
Comprehensive knowledge base covering:
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC)
- Quantitative methods (factor research, ML strategy, pair trading)
- Technical analysis (candlestick, Elliott wave, Ichimoku, SMC, harmonic, chanlun)
- Quantitative methods (factor research, ML strategy, pair trading, multi-factor)
- Risk management (VaR/CVaR, stress testing, hedging)
- Options (Black-Scholes, Greeks, multi-leg strategies)
- Options (Black-Scholes, Greeks, multi-leg strategies, payoff diagrams)
- HK/US equities (SEC filings, earnings revisions, ETF flows, ADR/H-share arbitrage)
- Crypto trading desk (funding rates, liquidation heatmaps, stablecoin flows, token unlocks, DeFi yields)
- Behavioral finance, trade journal diagnostics, shadow account
- Macro analysis, credit research, sector rotation, and more
Use `load_skill(name)` to access full methodology docs with code templates.
## Available MCP Tools (16)
## Available MCP Tools (35)
| Tool | Description | API Key |
|------|-------------|---------|
| `list_skills` | List all 64 finance skills | None |
| `list_skills` | List all 77 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 3 markets (auto-detect source) | None* |
| `get_market_data` | Fetch OHLCV data across 6 sources (auto-detect + fallback) | None* |
| `web_search` | Search the web via DuckDuckGo | None |
| `read_url` | Fetch web page as Markdown | None |
| `read_document` | Extract text from PDF (with OCR) | None |
| `read_document` | Extract text from PDF/DOCX/XLSX/PPTX/images | None |
| `write_file` | Write files (config, strategy code) | None |
| `read_file` | Read file contents | None |
| `analyze_trade_journal` | Parse broker CSV → profile + behavior diagnostics | None |
| `extract_shadow_strategy` | Distill 3-5 if-then rules from profitable roundtrips | None |
| `run_shadow_backtest` | Multi-market backtest + delta-PnL attribution | None* |
| `render_shadow_report` | HTML/PDF shadow report (8 sections + charts) | None |
| `scan_shadow_signals` | Today's symbols matching the shadow's cadence | None |
| `list_swarm_presets` | List multi-agent team presets | None |
| `run_swarm` | Execute a multi-agent research team | LLM key |
| `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 |
| `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
@@ -128,13 +169,154 @@ 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`, and all 64 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 77 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
**Backtest a MACD strategy on Apple:**
> Backtest AAPL with MACD crossover strategy (fast=12, slow=26, signal=9) for 2024
**Analyze my trade journal and build a Shadow Account:**
> Call analyze_trade_journal on ~/Downloads/tonghuashun.csv, then extract_shadow_strategy with min_support=3, then run_shadow_backtest for the last year, then render_shadow_report.
**Run an investment committee review:**
> Use run_swarm with investment_committee preset to evaluate NVDA. Variables: target=NVDA.US, market=US
+2201 -92
View File
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
"""Benchmark ticker resolution and fetch for backtest comparison.
Provides a lightweight, zero-dependency way to fetch benchmark reference
data given a set of strategy codes and a data source.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pandas as pd
from backtest.loaders.yfinance_loader import DataLoader as YfinanceLoader
# -------------------------------------------------------------------
# Benchmark map: market type → default ticker
# -------------------------------------------------------------------
MARKET_BENCHMARKS: dict[str, Optional[str]] = {
"us_equity": "SPY",
"hk_equity": "HK.03100", # Hang Seng China Enterprises ETF
"a_share": "000300.SH", # CSI 300 (China A-share core index)
"crypto": "BTC-USDT",
"futures": "ES.CME", # E-mini S&P 500 futures
"forex": None, # no universal benchmark
}
@dataclass
class BenchmarkResult:
ticker: str
ret_series: pd.Series # per-bar returns, index = timestamps
total_ret: float # total return over the period
def resolve_benchmark(
strategy_codes: list[str],
source: str,
start_date: str,
end_date: str,
interval: str = "1D",
explicit: Optional[str] = None,
) -> Optional[BenchmarkResult]:
"""Resolve the appropriate benchmark ticker and fetch its return series.
Args:
strategy_codes: Instruments being backtested (used for market inference).
source: Data source name (tushare / yfinance / okx / akshare / ccxt).
start_date: Backtest start date.
end_date: Backtest end date.
interval: Bar interval (1m / 5m / 15m / 30m / 1H / 4H / 1D).
explicit: Override ticker (e.g. "SPY" passed via config).
Returns:
BenchmarkResult with return series and total return, or None if no
benchmark applies (forex, or fetch failure).
"""
ticker = _resolve_ticker(strategy_codes, source, explicit)
if ticker is None:
return None
try:
bench_df = _fetch_benchmark(ticker, start_date, end_date, interval)
except Exception:
return None
if bench_df.empty or "close" not in bench_df.columns:
return None
close = bench_df["close"].dropna()
if len(close) < 2:
return None
ret_series = close.pct_change().fillna(0.0)
total_ret = float((1 + ret_series).prod() - 1)
return BenchmarkResult(ticker=ticker, ret_series=ret_series, total_ret=total_ret)
# -------------------------------------------------------------------
# Internal helpers
# -------------------------------------------------------------------
def _resolve_ticker(
codes: list[str],
source: str,
explicit: Optional[str],
) -> Optional[str]:
"""Pick the benchmark ticker to use."""
if explicit:
return explicit
# Infer market from source + first code pattern
market = _infer_market(codes, source)
ticker = MARKET_BENCHMARKS.get(market)
# yfinance is the universal fallback for benchmark fetch
# but it only works for us_equity / hk_equity market types
if ticker and market not in {"us_equity", "hk_equity"}:
# Only use benchmark if we can actually fetch it
pass
return ticker
def _infer_market(codes: list[str], source: str) -> str:
"""Rough market inference from symbol patterns and source."""
if not codes:
return "us_equity"
first = codes[0].upper()
if source in ("okx", "ccxt") or "-" in first or "/" in first:
return "crypto"
if first.endswith(".US"):
return "us_equity"
if first.endswith(".HK"):
return "hk_equity"
if source in ("tushare", "akshare"):
if first.isdigit() and len(first) == 6:
return "a_share"
if first.startswith(("IF", "IC", "IH", "IM", "T", "TF")):
return "futures"
return "a_share"
return "us_equity"
def _fetch_benchmark(
ticker: str,
start_date: str,
end_date: str,
interval: str,
) -> pd.DataFrame:
"""Fetch benchmark OHLCV data via yfinance (single symbol, no auth)."""
loader = YfinanceLoader()
result = loader.fetch([ticker], start_date, end_date, interval=interval)
if isinstance(result, dict):
df = result.get(ticker)
elif isinstance(result, pd.DataFrame):
df = result
else:
return pd.DataFrame()
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
return pd.DataFrame()
return df
+187
View File
@@ -0,0 +1,187 @@
"""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
from typing import Dict, Literal
import pandas as pd
import numpy as np
from scipy.stats import spearmanr
def infer_market(code: str) -> str:
"""Infer market key from a ticker symbol."""
code_upper = code.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"
# Check .HK suffix FIRST so leading-zero tickers like 0700.HK / 0005.HK
# are correctly classified before the A-share prefix checks
if code_upper.endswith(".HK"):
return "hk_equity"
if code_upper.startswith(("6", "000", "001", "002")):
return "a_share"
if code_upper.startswith(("0", "399")):
return "a_share"
if code_upper.startswith(("0", "1", "2", "3", "4")):
return "hk_equity"
return "us_equity"
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():
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 both column-based and index-based trade_date
if "trade_date" in df.index.names and "trade_date" not in df.columns:
ts = df["close"]
else:
ts = df.set_index("trade_date")["close"]
closes[code] = ts.sort_index()
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 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")
# Import here to avoid circular
from backtest.loaders.registry import resolve_loader
price_series: Dict[str, pd.DataFrame] = {}
for code in codes:
market = infer_market(code)
try:
loader = resolve_loader(market)
except Exception:
# Fall back to yfinance for us_equity / hk_equity
try:
from backtest.loaders.registry import LOADER_REGISTRY
if "yfinance" in LOADER_REGISTRY:
loader = LOADER_REGISTRY["yfinance"]()
else:
continue
except Exception:
continue
try:
result = loader.fetch(
codes=[code],
start_date=start_date,
end_date=end_date,
interval="1D",
fields=["trade_date", "open", "high", "low", "close", "volume"],
)
if code in result and not result[code].empty:
price_series[code] = result[code]
except Exception:
continue
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,
}
+30
View File
@@ -0,0 +1,30 @@
"""Backtest engines.
Wave 1 (v1):
- BaseEngine: ABC for bar-by-bar execution with market rules
- ChinaAEngine: A-share (T+1, no short, price limits)
- GlobalEquityEngine: US / HK equities
- CryptoEngine: Crypto perpetuals (funding fees, liquidation)
- options_portfolio: European/American options (Black-Scholes, v2 with IV smile)
Wave 2:
- FuturesBaseEngine: intermediate layer adding contract-multiplier logic
- ChinaFuturesEngine: China commodity/financial futures (CFFEX/SHFE/DCE/ZCE/INE)
- GlobalFuturesEngine: International futures (CME/ICE/Eurex)
- ForexEngine: FX spot/CFD (spread, swap, high leverage)
Wave 3:
- CompositeEngine: Cross-market engine with shared capital pool
- _market_hooks: Extracted on_bar logic (funding, liquidation, swap)
Inheritance:
BaseEngine
├── ChinaAEngine
├── GlobalEquityEngine
├── CryptoEngine
├── ForexEngine
├── CompositeEngine (delegates to sub-engines as rule providers)
└── FuturesBaseEngine
├── ChinaFuturesEngine
└── GlobalFuturesEngine
"""
+291
View File
@@ -0,0 +1,291 @@
"""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
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"),
(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 = [
(100_000, 0.004),
(500_000, 0.006),
(1_000_000, 0.01),
(5_000_000, 0.02),
(10_000_000, 0.05),
(float("inf"), 0.10),
]
FUNDING_HOURS = {0, 8, 16}
def _maintenance_rate(notional_usd: float) -> float:
"""Look up tiered maintenance margin rate."""
for tier_max, rate in _TIER_TABLE:
if notional_usd <= tier_max:
return rate
return _TIER_TABLE[-1][1]
def calc_crypto_funding_fee(
symbol: str,
bar: pd.Series,
timestamp: pd.Timestamp,
positions: Dict[str, Position],
funding_rate: float,
applied_set: set,
daily_done_set: set,
) -> float:
"""Calculate crypto funding fee for one symbol.
Args:
symbol: Instrument code.
bar: Current bar data.
timestamp: Bar timestamp.
positions: Shared positions dict.
funding_rate: Fixed rate per settlement.
applied_set: (symbol, date, hour) dedup set — mutated.
daily_done_set: (symbol, date) dedup set — mutated.
Returns:
Fee amount (positive = longs pay, negative = longs receive).
"""
if not hasattr(timestamp, "date"):
return 0.0
current_date = timestamp.date()
hour = timestamp.hour if hasattr(timestamp, "hour") else 0
if hour in FUNDING_HOURS:
key = (symbol, current_date, hour)
if key in applied_set:
return 0.0
applied_set.add(key)
else:
day_key = (symbol, current_date)
if day_key in daily_done_set:
return 0.0
daily_done_set.add(day_key)
pos = positions.get(symbol)
if pos is None:
return 0.0
mark_price = float(bar.get("close", pos.entry_price))
notional = pos.size * mark_price
return notional * funding_rate * pos.direction
def check_crypto_liquidation(
symbol: str,
bar: pd.Series,
positions: Dict[str, Position],
) -> bool:
"""Check if a crypto position should be liquidated.
Args:
symbol: Instrument code.
bar: Current bar data.
positions: Shared positions dict.
Returns:
True if liquidation should be triggered.
Does NOT execute the liquidation -- caller handles that.
"""
pos = positions.get(symbol)
if pos is None or pos.leverage <= 1.0:
return False
mark_price = float(bar.get("close", pos.entry_price))
margin = pos.size * pos.entry_price / pos.leverage
unrealized = pos.direction * pos.size * (mark_price - pos.entry_price)
notional = pos.size * mark_price
maint_rate = _maintenance_rate(notional)
maint_margin = notional * maint_rate
return (margin + unrealized) <= maint_margin
# ── Forex: swap tables ──
_SWAP_LONG: dict[str, float] = {
"EUR/USD": -6.5, "GBP/USD": -3.0, "USD/JPY": 8.0, "USD/CHF": 4.0,
"AUD/USD": -2.0, "USD/CAD": 2.0, "NZD/USD": -1.5,
}
_SWAP_SHORT: dict[str, float] = {
"EUR/USD": 3.5, "GBP/USD": -1.0, "USD/JPY": -12.0, "USD/CHF": -8.0,
"AUD/USD": -1.0, "USD/CAD": -5.0, "NZD/USD": -2.0,
}
def _normalize_symbol(symbol: str) -> str:
"""Normalize forex symbol to 'XXX/YYY' format."""
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()
def calc_forex_swap(
symbol: str,
timestamp: pd.Timestamp,
positions: Dict[str, Position],
lot_size: float,
last_swap_dates: dict,
) -> float:
"""Calculate forex swap for one symbol.
Args:
symbol: Forex pair.
timestamp: Bar timestamp.
positions: Shared positions dict.
lot_size: Standard lot size (e.g. 100_000).
last_swap_dates: Per-symbol date tracking dict -- mutated.
Returns:
Swap amount (positive = credit, negative = debit).
"""
if not hasattr(timestamp, "date"):
return 0.0
current_date = timestamp.date()
if last_swap_dates.get(symbol) == current_date:
return 0.0
last_swap_dates[symbol] = current_date
pos = positions.get(symbol)
if pos is None:
return 0.0
pair = _normalize_symbol(symbol)
lots = pos.size / lot_size
if pos.direction == 1:
swap_per_lot = _SWAP_LONG.get(pair, -1.0)
else:
swap_per_lot = _SWAP_SHORT.get(pair, -1.0)
# Wednesday = triple swap (covers Sat+Sun)
multiplier = 3.0 if timestamp.weekday() == 2 else 1.0
return lots * swap_per_lot * multiplier
+719
View File
@@ -0,0 +1,719 @@
"""Base backtest engine with shared bar-by-bar execution loop.
All market engines inherit from BaseEngine and override market-rule methods.
The shared run_backtest() handles: data loading → signal generation →
pre-compute target weights (with optimizer) → bar-by-bar execution with
market rule enforcement → metrics → artifacts.
"""
from __future__ import annotations
import importlib
import json
import logging
import re as _re
import sys
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import pandas as pd
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,
)
from backtest.models import EquitySnapshot, Position, TradeRecord
logger = logging.getLogger(__name__)
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) ───
_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$")
def _detect_market_for_align(code: str) -> str:
"""Lightweight market detection for ffill_limit calculation."""
if _CRYPTO_RE.match(code):
return "crypto"
if _FOREX_RE.match(code):
return "forex"
return "equity"
# ─── Signal alignment (reused from daily_portfolio logic) ───
def _align(
data_map: Dict[str, pd.DataFrame],
signal_map: Dict[str, pd.Series],
codes: List[str],
optimizer: Optional[Callable] = None,
) -> tuple:
"""Build aligned date index, close matrix, target-position matrix, return matrix.
Signal is shifted by 1 bar (next-bar-open semantics) then normalised so
``sum(abs(weights)) <= 1.0``.
Args:
data_map: code -> OHLCV DataFrame.
signal_map: code -> signal Series.
codes: Valid instrument codes.
optimizer: Optional weight optimiser ``(ret, pos, dates) -> pos``.
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))
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
for c in codes:
close[c] = data_map[c]["close"].reindex(dates)
# 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)
# 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()]
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]
if not codes:
raise ValueError("All symbols have no data in the requested date range")
close = close[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)
ret = close.pct_change().fillna(0.0)
if optimizer is not None:
pos = optimizer(ret, pos, dates)
scale = pos.abs().sum(axis=1).clip(lower=1.0)
pos = pos.div(scale, axis=0)
return dates, close, pos, ret
def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
"""Dynamically load an optimizer function from config.
Args:
config: Backtest configuration.
Returns:
Optimizer callable, or None.
"""
opt_name = config.get("optimizer")
if not opt_name:
return None
opt_params = config.get("optimizer_params") or {}
try:
mod = importlib.import_module(f"backtest.optimizers.{opt_name}")
return lambda ret, pos, dates: mod.optimize(ret, pos, dates, **opt_params)
except (ImportError, AttributeError) as e:
print(f"[WARN] Failed to load optimizer '{opt_name}': {e}, falling back to equal weight")
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
# ─── Base Engine ───
class BaseEngine(ABC):
"""Abstract base for all market engines.
Subclasses override market-rule methods:
- can_execute: whether a trade is allowed by market rules
- round_size: lot-size rounding
- calc_commission: fee structure
- apply_slippage: slippage model
- on_bar: per-bar hooks (funding fees, liquidation, etc.)
"""
def __init__(self, config: dict):
self.config = config
self.initial_capital: float = config.get("initial_cash", 1_000_000)
self.default_leverage: float = config.get("leverage", 1.0)
self.capital: float = self.initial_capital
self.positions: Dict[str, Position] = {}
self.trades: List[TradeRecord] = []
self.equity_snapshots: List[EquitySnapshot] = []
self._bar_idx: int = 0
self._active_symbol: str = "" # set by _rebalance/_close_position for subclass use
# ── Market rule interface (subclass must implement) ──
@abstractmethod
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Whether market rules allow this trade.
Args:
symbol: Instrument identifier.
direction: 1 (long), -1 (short), 0 (close).
bar: Current bar data (OHLCV + extras).
Returns:
True if allowed.
"""
@abstractmethod
def round_size(self, raw_size: float, price: float) -> float:
"""Round position size per market lot rules.
Args:
raw_size: Desired size.
price: Current price.
Returns:
Rounded size.
"""
@abstractmethod
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""Calculate commission for a trade.
Args:
size: Trade size.
price: Execution price.
direction: 1 or -1.
is_open: True for opening, False for closing.
Returns:
Commission amount.
"""
@abstractmethod
def apply_slippage(self, price: float, direction: int) -> float:
"""Apply slippage to execution price.
Args:
price: Raw price.
direction: 1 (buying / covering short) or -1 (selling / shorting).
Returns:
Slipped price.
"""
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
"""Per-bar market-rule hook (funding fees, liquidation, etc.).
Default: no-op. Override in subclass as needed.
"""
# ── PnL / margin calculation hooks ──
# Override in FuturesBaseEngine to inject contract multiplier.
def _calc_pnl(
self, symbol: str, direction: int, size: float,
entry_price: float, exit_price: float,
) -> float:
"""Realised PnL for a closed position."""
return direction * size * (exit_price - entry_price)
def _calc_margin(
self, symbol: str, size: float, price: float, leverage: float,
) -> float:
"""Margin (collateral) required for a position."""
return size * price / leverage
def _calc_raw_size(
self, symbol: str, target_notional: float, price: float,
) -> float:
"""Convert target notional exposure to number of units/contracts."""
return target_notional / price
# ── Main entry ──
def run_backtest(
self,
config: Dict[str, Any],
loader: Any,
signal_engine: Any,
run_dir: Path,
bars_per_year: int = 252,
) -> Dict[str, Any]:
"""Full backtest pipeline.
Signature matches ``daily_portfolio.run_backtest`` for drop-in replacement.
Args:
config: Backtest configuration dict.
loader: DataLoader with ``fetch()`` method.
signal_engine: SignalEngine with ``generate()`` method.
run_dir: Artifacts output directory.
bars_per_year: Annualisation factor.
Returns:
Metrics dictionary.
"""
codes = config.get("codes", [])
interval = config.get("interval", "1D")
extra_fields = config.get("extra_fields") or None
# 1. Load data
data_map = loader.fetch(
codes,
config.get("start_date", ""),
config.get("end_date", ""),
fields=extra_fields,
interval=interval,
)
if not data_map:
print(json.dumps({"error": "No data fetched"}))
sys.exit(1)
data_map = _maybe_enrich_fundamentals(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"}))
sys.exit(1)
# 3. Pre-compute target weights (with optimizer)
opt_fn = _load_optimizer(config)
dates, close_df, target_pos, ret_df = _align(
data_map, signal_map, valid_codes, optimizer=opt_fn,
)
# Sync codes after _align may have dropped all-NaN symbols
valid_codes = [c for c in valid_codes if c in target_pos.columns]
# 4. Bar-by-bar execution
self._execute_bars(dates, data_map, close_df, target_pos, valid_codes)
# 5. Build output series
equity_series = pd.Series(
[s.equity for s in self.equity_snapshots],
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_result = resolve_benchmark(
strategy_codes=codes,
source=config.get("source", "yfinance"),
start_date=config.get("start_date", ""),
end_date=config.get("end_date", ""),
interval=interval,
explicit=bench_ticker,
)
if bench_result is not None:
bench_ret = bench_result.ret_series.reindex(dates).fillna(0.0)
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)
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
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")
# 8. Artifacts
self._write_artifacts(
run_dir, data_map, dates, equity_series, bench_equity, bench_ret,
target_pos, m, valid_codes,
)
# 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",
)
# 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))
return m
# ── Execution loop ──
def _execute_bars(
self,
dates: pd.DatetimeIndex,
data_map: Dict[str, pd.DataFrame],
close_df: pd.DataFrame,
target_pos: pd.DataFrame,
codes: List[str],
) -> None:
"""Bar-by-bar execution with market rule enforcement."""
for i, ts in enumerate(dates):
self._bar_idx = i
# a. Per-bar hooks (funding fees, liquidation checks)
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
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)
self.equity_snapshots.append(EquitySnapshot(
timestamp=ts,
capital=self.capital,
unrealized=total_unrealized,
equity=snap_equity,
positions=len(self.positions),
))
# d. Force close all remaining positions
if len(dates) > 0:
last_ts = 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")
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)
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
return equity
def _rebalance(
self,
symbol: str,
target_weight: float,
df: Optional[pd.DataFrame],
ts: pd.Timestamp,
equity: float,
) -> None:
"""Adjust position for *symbol* toward *target_weight*."""
self._active_symbol = symbol
target_dir = 1 if target_weight > 1e-9 else (-1 if target_weight < -1e-9 else 0)
current_pos = self.positions.get(symbol)
# Nothing to do
if current_pos is None and target_dir == 0:
return
if df is None or ts not in df.index:
return
bar = df.loc[ts]
# Close if target is flat or direction changed
if current_pos is not None:
need_close = target_dir == 0 or target_dir != current_pos.direction
if need_close:
if self.can_execute(symbol, 0, bar):
open_price = float(bar.get("open", bar.get("close", 0)))
price = self.apply_slippage(open_price, -current_pos.direction)
self._close_position(symbol, price, ts, "signal")
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)
open_price = float(bar.get("open", bar.get("close", 0)))
if open_price <= 0:
return
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 _close_position(
self,
symbol: str,
exit_price: float,
exit_time: pd.Timestamp,
reason: str,
) -> None:
"""Close position, record trade, return capital."""
self._active_symbol = symbol
pos = self.positions.pop(symbol, None)
if pos is None:
return
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)
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)
self.capital += margin + pnl - exit_comm
holding_bars = max(self._bar_idx - pos.entry_bar_idx, 0)
self.trades.append(TradeRecord(
symbol=symbol,
direction=pos.direction,
entry_price=pos.entry_price,
exit_price=exit_price,
entry_time=pos.entry_time,
exit_time=exit_time,
size=pos.size,
leverage=pos.leverage,
pnl=pnl,
pnl_pct=pnl_pct,
exit_reason=reason,
holding_bars=holding_bars,
commission=pos.entry_commission + exit_comm,
))
# ── Artifacts ──
def _write_artifacts(
self,
run_dir: Path,
data_map: Dict[str, pd.DataFrame],
dates: pd.DatetimeIndex,
equity_series: pd.Series,
bench_equity: pd.Series,
bench_ret: pd.Series,
target_pos: pd.DataFrame,
metrics: dict,
codes: List[str],
) -> None:
"""Write CSV artifacts compatible with daily_portfolio format."""
out = run_dir / "artifacts"
out.mkdir(parents=True, exist_ok=True)
# OHLCV per symbol
for code, df in data_map.items():
df.to_csv(out / f"ohlcv_{code}.csv")
# Equity curve
port_ret = equity_series.pct_change().fillna(0.0)
peak = equity_series.cummax()
dd = (equity_series - peak) / peak.replace(0, 1)
eq_df = pd.DataFrame({
"ret": port_ret,
"equity": equity_series,
"drawdown": dd,
"benchmark_equity": bench_equity.reindex(dates),
"active_ret": port_ret - bench_ret.reindex(dates).fillna(0.0),
}, index=dates)
eq_df.index.name = "timestamp"
eq_df.to_csv(out / "equity.csv")
# Position weights (target, for compatibility)
target_pos.index.name = "timestamp"
target_pos.to_csv(out / "positions.csv")
# Trades (compatible format)
trade_rows = []
for t in self.trades:
# Entry event
trade_rows.append({
"timestamp": str(t.entry_time.date()) if hasattr(t.entry_time, "date") else str(t.entry_time),
"code": t.symbol,
"side": "buy" if t.direction == 1 else "sell",
"price": round(t.entry_price, 4),
"qty": round(t.size, 6),
"reason": "signal",
"pnl": 0.0,
"holding_days": 0,
"return_pct": 0.0,
})
# Exit event
try:
hold_days = (t.exit_time - t.entry_time).days
except Exception:
hold_days = 0
trade_rows.append({
"timestamp": str(t.exit_time.date()) if hasattr(t.exit_time, "date") else str(t.exit_time),
"code": t.symbol,
"side": "sell" if t.direction == 1 else "buy",
"price": round(t.exit_price, 4),
"qty": round(t.size, 6),
"reason": t.exit_reason,
"pnl": round(t.pnl, 4),
"holding_days": hold_days,
"return_pct": round(t.pnl_pct, 2),
})
trade_cols = ["timestamp", "code", "side", "price", "qty", "reason", "pnl", "holding_days", "return_pct"]
pd.DataFrame(trade_rows or [], columns=trade_cols).to_csv(out / "trades.csv", index=False)
# Metrics
flat_metrics = {k: v for k, v in metrics.items() if not isinstance(v, dict)}
pd.DataFrame([flat_metrics]).to_csv(out / "metrics.csv", index=False)
# ── Helpers ──
@staticmethod
def _safe_price(
close_df: pd.DataFrame,
ts: pd.Timestamp,
symbol: str,
fallback: float,
) -> float:
"""Get close price with fallback."""
if ts in close_df.index and symbol in close_df.columns:
val = close_df.at[ts, symbol]
if pd.notna(val):
return float(val)
return fallback
+155
View File
@@ -0,0 +1,155 @@
"""A-share (China mainland) backtest engine.
Market rules:
- T+1: cannot sell shares bought today
- No short selling for retail investors
- Price limits: ±10% main board, ±20% ChiNext/STAR, ±5% ST
- Minimum lot: 100 shares (odd lots can only be sold, not bought)
- Commission: ¥5 minimum, 0.025% bilateral
- Stamp tax: 0.05% sell-side only
- Transfer fee: 0.001% bilateral
"""
from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
class ChinaAEngine(BaseEngine):
"""A-share market engine.
Config keys:
- commission_rate: default 0.00025 (万2.5)
- commission_min: default 5.0 (RMB)
- stamp_tax: default 0.0005 (万5, sell-only)
- transfer_fee: default 0.00001 (万0.1)
- slippage: default 0.001
"""
def __init__(self, config: dict):
config = {**config, "leverage": 1.0} # A-shares: no leverage
super().__init__(config)
self.commission_rate: float = config.get("commission_rate", 0.00025)
self.commission_min: float = config.get("commission_min", 5.0)
self.stamp_tax: float = config.get("stamp_tax", 0.0005)
self.transfer_fee: float = config.get("transfer_fee", 0.00001)
self.slippage_rate: float = config.get("slippage", 0.001)
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""A-share execution rules.
Args:
symbol: Stock code (e.g. 000001.SZ).
direction: 1 (buy), -1 (short — always blocked), 0 (sell/close).
bar: Current bar (needs 'close', 'pre_close' or 'pct_chg').
Returns:
True if the trade is allowed.
"""
# 1. No short selling
if direction == -1:
return False
# 2. T+1: can't sell shares bought today
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. Price limits
pct_chg = _calc_pct_change(bar)
if pct_chg is not None:
limit = _price_limit(symbol)
if direction == 1 and pct_chg >= limit - 0.001:
return False # limit-up: can't buy
if direction == 0 and pct_chg <= -limit + 0.001:
return False # limit-down: can't sell
return True
def round_size(self, raw_size: float, price: float) -> float:
"""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.
``_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)
# Transfer fee: 万0.1 bilateral
comm += notional * self.transfer_fee
# Stamp tax: 万5 sell-only
if not is_open:
comm += notional * self.stamp_tax
return comm
def apply_slippage(self, price: float, direction: int) -> float:
"""A-share slippage (relatively small due to tick size)."""
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
# Fall back to bar name (index timestamp)
if hasattr(bar, "name") and hasattr(bar.name, "date"):
return bar.name.date()
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:
val = bar["pct_chg"]
if pd.notna(val):
return float(val) / 100.0 # tushare pct_chg is in percentage points
close = bar.get("close")
pre_close = bar.get("pre_close")
if close is not None and pre_close is not None and pre_close > 0:
return (float(close) - float(pre_close)) / float(pre_close)
return None
def _price_limit(symbol: str) -> float:
"""Determine price limit based on board.
Args:
symbol: Stock code (e.g. 300001.SZ, 688001.SH, 000001.SZ).
Returns:
Limit as fraction (0.10, 0.20, or 0.05).
"""
code = symbol.split(".")[0] if "." in symbol else symbol
# ChiNext (300xxx) / STAR (688xxx): ±20%
if code.startswith("300") or code.startswith("688"):
return 0.20
# ST stocks: ±5% (heuristic: can't fully detect from code alone)
# Beijing exchange (8xxxxx): ±30% — simplified to 0.30
if code.startswith("8") and len(code) == 6:
return 0.30
# Main board: ±10%
return 0.10
+278
View File
@@ -0,0 +1,278 @@
"""China futures backtest engine.
Market rules (exchange-level, CFFEX / SHFE / DCE / ZCE / INE / GFEX):
- T+0: can open and close same day (intraday trading allowed)
- Margin trading: 5%~15% by product (exchange-set minimum)
- Price limits: stock-index +-10%, bonds +-2%, commodities +-3%~8%
- Commission: per-lot fixed or per-notional rate (varies by product)
- Contract multiplier: product-specific (IF=300, rb=10, au=1000, ...)
- Minimum trading unit: 1 contract
- Night session: 21:00-02:30 (varies by product, not enforced in bar-level sim)
"""
from __future__ import annotations
import re
import pandas as pd
from backtest.engines.futures_base import FuturesBaseEngine
# ── Contract multiplier lookup ──
_MULTIPLIER: dict[str, int] = {
# Stock index futures (CFFEX)
"IF": 300, "IC": 200, "IH": 300, "IM": 200,
# Treasury bond futures (CFFEX)
"T": 10000, "TF": 10000, "TS": 20000, "TL": 10000,
# Metals (SHFE)
"au": 1000, "ag": 15, "cu": 5, "al": 5, "zn": 5,
"pb": 5, "ni": 1, "sn": 1, "ss": 5,
# Ferrous (SHFE / DCE)
"rb": 10, "hc": 10, "i": 100, "j": 100, "jm": 60,
# Energy (SHFE / INE)
"sc": 1000, "fu": 10, "lu": 10, "bu": 10, "nr": 10,
# Agriculture (DCE)
"c": 10, "cs": 10, "m": 10, "y": 10, "a": 10,
"p": 10, "jd": 10, "lh": 16, "rr": 10, "pg": 20,
# Agriculture (ZCE)
"CF": 5, "SR": 10, "TA": 5, "MA": 10, "AP": 10,
"RM": 10, "OI": 10, "CJ": 5, "PK": 5, "CY": 5,
# Chemical (DCE / ZCE)
"pp": 5, "l": 5, "v": 5, "eg": 10, "eb": 5,
"PF": 5, "SA": 20, "FG": 20, "UR": 20,
# GFEX
"si": 5, "lc": 1,
}
# ── Margin rate (exchange minimum) ──
_MARGIN_RATE: dict[str, float] = {
# CFFEX stock index
"IF": 0.12, "IC": 0.12, "IH": 0.12, "IM": 0.12,
# CFFEX bonds
"T": 0.03, "TF": 0.02, "TS": 0.015, "TL": 0.035,
# SHFE metals
"au": 0.08, "ag": 0.09, "cu": 0.08, "al": 0.07,
"zn": 0.08, "pb": 0.08, "ni": 0.12, "sn": 0.10, "ss": 0.08,
# Ferrous
"rb": 0.10, "hc": 0.10, "i": 0.12, "j": 0.12, "jm": 0.12,
# Energy
"sc": 0.10, "fu": 0.10, "lu": 0.10, "bu": 0.10,
# Agriculture
"c": 0.07, "cs": 0.07, "m": 0.08, "y": 0.08, "a": 0.08,
"p": 0.08, "jd": 0.08, "lh": 0.12,
# Textiles / chemical
"CF": 0.07, "SR": 0.07, "TA": 0.07, "MA": 0.07,
"pp": 0.07, "l": 0.07, "v": 0.07, "eg": 0.08,
"SA": 0.08, "FG": 0.08, "UR": 0.08,
}
# ── Price limit (fraction, ± from settlement) ──
_PRICE_LIMIT: dict[str, float] = {
# CFFEX stock index ±10%
"IF": 0.10, "IC": 0.10, "IH": 0.10, "IM": 0.10,
# CFFEX bonds ±2% (simplified)
"T": 0.02, "TF": 0.012, "TS": 0.005, "TL": 0.035,
}
_DEFAULT_PRICE_LIMIT = 0.05 # most commodities ±4%~7%, use 5% as default
# ── Commission structure ──
# ("rate", pct) = per-notional | ("fixed", amount_per_lot) = per-contract
_COMMISSION: dict[str, tuple[str, float]] = {
# CFFEX stock index: ~0.0023% of notional
"IF": ("rate", 0.000023), "IC": ("rate", 0.000023),
"IH": ("rate", 0.000023), "IM": ("rate", 0.000023),
# CFFEX bonds
"T": ("fixed", 3.0), "TF": ("fixed", 3.0), "TS": ("fixed", 3.0),
# Metals
"au": ("fixed", 10.0), "ag": ("fixed", 3.0), "cu": ("fixed", 5.0),
"al": ("fixed", 3.0), "zn": ("fixed", 3.0), "ni": ("fixed", 3.0),
# Ferrous
"rb": ("rate", 0.0001), "hc": ("rate", 0.0001), "i": ("rate", 0.0001),
"j": ("rate", 0.0001), "jm": ("rate", 0.0001),
# Energy
"sc": ("fixed", 20.0), "fu": ("rate", 0.00005),
# Agriculture
"c": ("fixed", 1.2), "cs": ("fixed", 1.5), "m": ("fixed", 1.5),
"y": ("fixed", 2.5), "a": ("fixed", 2.0), "p": ("fixed", 2.5),
"jd": ("rate", 0.00015), "lh": ("rate", 0.0002),
# Textiles / chemical
"CF": ("fixed", 4.3), "SR": ("fixed", 3.0), "TA": ("fixed", 3.0),
"MA": ("fixed", 2.0), "pp": ("fixed", 1.0), "l": ("fixed", 1.0),
"v": ("fixed", 1.0), "SA": ("fixed", 3.5), "FG": ("fixed", 3.0),
}
_DEFAULT_COMMISSION: tuple[str, float] = ("fixed", 5.0)
def _extract_product(symbol: str) -> str:
"""Extract product code from futures symbol.
Examples:
'IF2406.CFFEX' -> 'IF'
'rb2410.SHFE' -> 'rb'
'au2412' -> 'au'
Args:
symbol: Futures symbol string.
Returns:
Product code (e.g. 'IF', 'rb', 'au').
"""
code = symbol.split(".")[0]
m = re.match(r"([A-Za-z]+)", code)
return m.group(1) if m else code
class ChinaFuturesEngine(FuturesBaseEngine):
"""China futures engine covering CFFEX / SHFE / DCE / ZCE / INE / GFEX.
Config keys:
- slippage: default 0.0005
- margin_rate_override: override margin rate for all products
- commission_override: override commission for all products
"""
def __init__(self, config: dict):
# Derive leverage from margin rate of first code, or use config override
margin_override = config.get("margin_rate_override")
if margin_override:
leverage = 1.0 / margin_override
else:
codes = config.get("codes", [])
if codes:
product = _extract_product(codes[0])
mr = _MARGIN_RATE.get(product, 0.10)
leverage = 1.0 / mr
else:
leverage = 10.0 # ~10% margin default
config = {**config, "leverage": leverage}
super().__init__(config)
self.slippage_rate: float = config.get("slippage", 0.0005)
self._commission_override = config.get("commission_override")
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""China futures: T+0, both directions, price-limit enforced.
Args:
symbol: Futures code.
direction: 1 (long), -1 (short), 0 (close).
bar: Current bar data.
Returns:
True if allowed.
"""
# T+0: no same-day sell restriction
# Both long and short allowed
# Price limit check
pct_chg = _calc_pct_change(bar)
if pct_chg is not None:
product = _extract_product(symbol)
limit = _PRICE_LIMIT.get(product, _DEFAULT_PRICE_LIMIT)
if direction == 1 and pct_chg >= limit - 0.001:
return False # limit-up: can't open long / can't buy
if direction == -1 and pct_chg <= -limit + 0.001:
return False # limit-down: can't open short
if direction == 0:
pos = self.positions.get(symbol)
if pos is not None:
# Can't close long at limit-down, can't close short at limit-up
if pos.direction == 1 and pct_chg <= -limit + 0.001:
return False
if pos.direction == -1 and pct_chg >= limit - 0.001:
return False
return True
def round_size(self, raw_size: float, price: float) -> float:
"""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.
``_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)
def calc_commission_for_symbol(
self, symbol: str, size: float, price: float, is_open: bool,
) -> float:
"""Symbol-aware commission calculation.
Args:
symbol: Futures code.
size: Number of contracts.
price: Execution price.
is_open: True for opening trade.
Returns:
Commission in RMB.
"""
product = _extract_product(symbol)
mode, value = _COMMISSION.get(product, _DEFAULT_COMMISSION)
cm = _MULTIPLIER.get(product, 10)
if mode == "rate":
return size * price * cm * value
return size * value
def apply_slippage(self, price: float, direction: int) -> float:
"""Futures slippage."""
return price * (1 + direction * self.slippage_rate)
def get_contract_multiplier(self, symbol: str) -> float:
"""Look up contract multiplier from product code."""
product = _extract_product(symbol)
return float(_MULTIPLIER.get(product, 10))
def get_margin_rate(self, symbol: str) -> float:
"""Look up exchange margin rate for a product.
Args:
symbol: Futures symbol.
Returns:
Margin rate (e.g. 0.10 for 10%).
"""
product = _extract_product(symbol)
return _MARGIN_RATE.get(product, 0.10)
# ── 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.
Priority: settle/pre_settle (futures native) > close/pre_close > pct_chg.
pct_chg from tushare is always in percentage points (0.5 = 0.5%).
"""
# Prefer settlement prices (unambiguous for futures)
settle = bar.get("settle")
pre_settle = bar.get("pre_settle")
if settle is not None and pre_settle is not None and pre_settle > 0:
return (float(settle) - float(pre_settle)) / float(pre_settle)
close = bar.get("close")
pre_close = bar.get("pre_close")
if close is not None and pre_close is not None and pre_close > 0:
return (float(close) - float(pre_close)) / float(pre_close)
# tushare pct_chg is in percentage points (e.g. 0.5 = 0.5%)
if "pct_chg" in bar.index:
val = bar["pct_chg"]
if pd.notna(val):
return float(val) / 100.0
return None
+184
View File
@@ -0,0 +1,184 @@
"""Composite cross-market backtest engine.
Manages a shared capital pool across multiple market engines.
Sub-engines are used as stateless "rule books" for market-specific
calculations (commission, slippage, lot rounding, etc.).
All state (capital, positions, trades) lives in CompositeEngine.
"""
from __future__ import annotations
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,
)
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}
engines: Dict[str, BaseEngine] = {}
for market in markets:
if market == "a_share":
from backtest.engines.china_a import ChinaAEngine
engines["a_share"] = ChinaAEngine(config)
elif market == "us_equity":
from backtest.engines.global_equity import GlobalEquityEngine
engines["us_equity"] = GlobalEquityEngine(config, market="us")
elif market == "hk_equity":
from backtest.engines.global_equity import GlobalEquityEngine
engines["hk_equity"] = GlobalEquityEngine(config, market="hk")
elif market == "crypto":
from backtest.engines.crypto import CryptoEngine
engines["crypto"] = CryptoEngine(config)
elif market == "forex":
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:
from backtest.engines.china_futures import ChinaFuturesEngine
engines["futures"] = ChinaFuturesEngine(config)
else:
from backtest.engines.global_futures import GlobalFuturesEngine
engines["futures"] = GlobalFuturesEngine(config)
return engines
class CompositeEngine(BaseEngine):
"""Cross-market engine with shared capital pool.
Sub-engines are stateless rule providers. All positions, capital,
and trades live here (inherited from BaseEngine).
Args:
config: Backtest configuration dict.
codes: List of instrument codes spanning multiple markets.
"""
def __init__(self, config: dict, codes: List[str]):
super().__init__(config)
# Build symbol -> market mapping
self._symbol_market: Dict[str, str] = {c: _detect_market(c) for c in codes}
# Build sub-engines (one per market type)
self._rule_engines = _build_rule_engines(config, codes)
# Crypto dedup state (owned by CompositeEngine, not sub-engine)
self._funding_applied: set = set()
self._funding_daily_done: set = set()
# Forex dedup state
self._last_swap_dates: dict = {}
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]
# ── Stateless method dispatch ──
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Market-rule check with T+1 interceptor for A-shares."""
market = self._symbol_market.get(symbol, "a_share")
# T+1: intercept here because sub-engine has no access to shared positions
if market == "a_share" and direction == 0:
pos = self.positions.get(symbol)
if pos is not None:
bar_date = None
if hasattr(bar, "name") and hasattr(bar.name, "date"):
bar_date = bar.name.date()
entry_date = (
pos.entry_time.date()
if hasattr(pos.entry_time, "date")
else None
)
if bar_date and entry_date and bar_date == entry_date:
return False
# Delegate remaining checks (price limits, short-sell block, etc.)
return self._rule_for(symbol).can_execute(symbol, direction, bar)
def round_size(self, raw_size: float, price: float) -> float:
"""Delegate to active symbol's sub-engine."""
return self._rule_for(self._active_symbol).round_size(raw_size, price)
def calc_commission(
self, size: float, price: float, direction: int, is_open: bool,
) -> float:
"""Delegate to active symbol's sub-engine."""
return self._rule_for(self._active_symbol).calc_commission(
size, price, direction, is_open,
)
def apply_slippage(self, price: float, direction: int) -> float:
"""Delegate to active symbol's sub-engine."""
sub = self._rule_for(self._active_symbol)
# ForexEngine needs _active_symbol set on the sub-engine
sub._active_symbol = self._active_symbol
return sub.apply_slippage(price, direction)
# ── PnL / margin dispatch (route by symbol, not _active_symbol) ──
def _calc_pnl(
self, symbol: str, direction: int, size: float,
entry_price: float, exit_price: float,
) -> float:
return self._rule_for(symbol)._calc_pnl(
symbol, direction, size, entry_price, exit_price,
)
def _calc_margin(
self, symbol: str, size: float, price: float, leverage: float,
) -> float:
return self._rule_for(symbol)._calc_margin(symbol, size, price, leverage)
def _calc_raw_size(
self, symbol: str, target_notional: float, price: float,
) -> float:
return self._rule_for(symbol)._calc_raw_size(symbol, target_notional, price)
# ── Stateful hooks (implemented directly, NO delegation) ──
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
"""Per-bar hooks dispatched by market type."""
market = self._symbol_market.get(symbol)
if market == "crypto":
crypto_sub = self._rule_engines["crypto"]
fee = calc_crypto_funding_fee(
symbol, bar, timestamp, self.positions,
crypto_sub.funding_rate,
self._funding_applied, self._funding_daily_done,
)
self.capital -= fee
if check_crypto_liquidation(symbol, bar, self.positions):
pos = self.positions.get(symbol)
if pos is not None:
mark_price = float(bar.get("close", pos.entry_price))
liq_price = crypto_sub.apply_slippage(mark_price, -pos.direction)
self._close_position(symbol, liq_price, timestamp, "liquidation")
elif market == "forex":
forex_sub = self._rule_engines["forex"]
if forex_sub.swap_enabled:
swap = calc_forex_swap(
symbol, timestamp, self.positions,
forex_sub.lot_size, self._last_swap_dates,
)
self.capital += swap
+77
View File
@@ -0,0 +1,77 @@
"""Crypto perpetual-contract backtest engine.
Market rules:
- 24/7 trading, no restrictions on direction
- Maker/Taker fee separation
- Funding fee settlement every 8 hours (00:00/08:00/16:00 UTC)
- Forced liquidation when maintenance margin ratio <= 100%
- Fractional position sizes allowed
"""
from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
from backtest.engines._market_hooks import (
calc_crypto_funding_fee,
check_crypto_liquidation,
)
class CryptoEngine(BaseEngine):
"""Crypto perpetual contract engine.
Config keys:
- leverage: default 1.0
- maker_rate: default 0.0002
- taker_rate: default 0.0005
- slippage: default 0.0005
- margin_mode: "isolated" (default) or "cross"
- funding_rate: fixed rate per settlement, default 0.0001
"""
def __init__(self, config: dict):
super().__init__(config)
self.maker_rate: float = config.get("maker_rate", 0.0002)
self.taker_rate: float = config.get("taker_rate", 0.0005)
self.slippage_rate: float = config.get("slippage", 0.0005)
self.funding_rate: float = config.get("funding_rate", 0.0001)
self._funding_applied: set = set() # (symbol, date, hour) — per-slot dedup
self._funding_daily_done: set = set() # (symbol, date) — daily fallback dedup
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Crypto: 24/7, long/short/close all allowed."""
return True
def round_size(self, raw_size: float, price: float) -> float:
"""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.
``_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
def apply_slippage(self, price: float, direction: int) -> float:
"""Slippage: unfavourable direction."""
return price * (1 + direction * self.slippage_rate)
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
"""Crypto per-bar hooks: funding fee + liquidation check."""
fee = calc_crypto_funding_fee(
symbol, bar, timestamp, self.positions,
self.funding_rate, self._funding_applied, self._funding_daily_done,
)
self.capital -= fee
if check_crypto_liquidation(symbol, bar, self.positions):
pos = self.positions.get(symbol)
if pos is not None:
mark_price = float(bar.get("close", pos.entry_price))
liq_price = self.apply_slippage(mark_price, -pos.direction)
self._close_position(symbol, liq_price, timestamp, "liquidation")
-461
View File
@@ -1,461 +0,0 @@
"""Portfolio-level vectorised backtest engine.
Supports arbitrary bar intervals (1m/5m/15m/30m/1H/4H/1D).
Signal lag: bar-T signal executes at bar T+1 (close-to-close return approx).
Position normalisation: sum(abs(weights)) <= 1.0, no leverage ever.
Commission: deducted as percentage of turnover.
Artifacts: equity.csv, metrics.csv, trades.csv, positions.csv, ohlcv_{code}.csv.
"""
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import numpy as np
import pandas as pd
# ─── Annualisation factor mapping (auto-inferred from source and interval) ───
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365}
_BARS_PER_DAY = {
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390},
"5m": {"tushare": 48, "okx": 288, "yfinance": 78},
"15m": {"tushare": 16, "okx": 96, "yfinance": 26},
"30m": {"tushare": 8, "okx": 48, "yfinance": 13},
"1H": {"tushare": 4, "okx": 24, "yfinance": 7},
"4H": {"tushare": 1, "okx": 6, "yfinance": 2},
"1D": {"tushare": 1, "okx": 1, "yfinance": 1},
}
def _calc_bars_per_year(interval: str = "1D", source: str = "tushare") -> int:
"""Calculate the number of bars per year for annualisation.
Args:
interval: Bar interval (e.g. 1m, 1H, 1D).
source: Data source (determines trading days and bars per day).
Returns:
Number of bars per year, used for annualised return and Sharpe ratio.
"""
trading_days = _TRADING_DAYS.get(source, 252)
bars_per_day = _BARS_PER_DAY.get(interval, {}).get(source, 1)
return trading_days * bars_per_day
def _align(
data_map: Dict[str, pd.DataFrame],
signal_map: Dict[str, pd.Series],
codes: List[str],
optimizer: Optional[Callable] = None,
) -> tuple:
"""Build aligned date index, close matrix, position matrix, and return matrix.
For multi-market mixes, takes the union of all trading dates and forward-fills gaps.
Applies signal shift(1), optional optimizer weighting, and normalisation (sum|w|<=1).
Args:
data_map: code -> OHLCV DataFrame.
signal_map: code -> signal Series.
codes: List of valid instrument codes.
optimizer: Optional weight optimisation function (ret_df, pos_df, date_idx) -> pos_df.
Returns:
(dates, close_df, positions_df, returns_df)
"""
all_dates = set()
for c in codes:
all_dates.update(data_map[c].index)
dates = pd.DatetimeIndex(sorted(all_dates))
close = pd.DataFrame(index=dates, columns=codes, dtype=float)
for c in codes:
close[c] = data_map[c]["close"].reindex(dates)
close = close.ffill().bfill()
pos = pd.DataFrame(0.0, index=dates, columns=codes)
for c in codes:
raw = signal_map[c].reindex(dates).fillna(0.0).clip(-1.0, 1.0)
pos[c] = raw.shift(1).fillna(0.0)
ret = close.pct_change().fillna(0.0)
# optimizer weighting (before normalisation)
if optimizer is not None:
pos = optimizer(ret, pos, dates)
scale = pos.abs().sum(axis=1).clip(lower=1.0)
pos = pos.div(scale, axis=0)
return dates, close, pos, ret
def _benchmark_equity(
ret: pd.DataFrame,
cash: float,
) -> tuple:
"""Calculate equal-weight buy-and-hold benchmark.
Args:
ret: Return matrix.
cash: Initial capital.
Returns:
(bench_ret Series, bench_equity Series)
"""
n_assets = ret.shape[1]
bench_ret = ret.mean(axis=1) if n_assets > 0 else pd.Series(0.0, index=ret.index)
bench_equity = cash * (1 + bench_ret).cumprod()
return bench_ret, bench_equity
def _portfolio_equity(
pos: pd.DataFrame,
ret: pd.DataFrame,
commission: float,
cash: float,
) -> tuple:
"""Portfolio returns -> equity curve -> drawdown.
Args:
pos: Position matrix.
ret: Return matrix.
commission: Commission rate.
cash: Initial capital.
Returns:
(port_ret Series, equity Series, drawdown Series)
"""
port_ret = (pos * ret).sum(axis=1)
turnover = pos.diff().abs().sum(axis=1).fillna(0.0)
port_ret = port_ret - turnover * commission
equity = cash * (1 + port_ret).cumprod()
peak = equity.cummax()
dd = (equity - peak) / peak.replace(0, 1)
return port_ret, equity, dd
def _log_trades(
pos: pd.DataFrame,
close: pd.DataFrame,
codes: List[str],
) -> List[Dict[str, Any]]:
"""Generate per-trade records from position diffs, including per-trade P&L.
Args:
pos: Position matrix.
close: Close price matrix.
codes: List of instrument codes.
Returns:
Trade list sorted by timestamp.
"""
diff = pos.diff()
diff.iloc[0] = pos.iloc[0]
# Track entry cost and entry date per instrument
cost: Dict[str, float] = {}
hold: Dict[str, float] = {}
entry_date: Dict[str, str] = {}
trades: List[Dict[str, Any]] = []
# Collect all trade events then sort by timestamp
raw_events = []
for c in codes:
delta = diff[c]
for ts in delta.index[delta.abs() > 1e-9]:
d = float(delta.loc[ts])
p = float(close.at[ts, c]) if pd.notna(close.at[ts, c]) else 0.0
raw_events.append((str(ts.date()) if hasattr(ts, "date") else str(ts), c, d, p))
raw_events.sort(key=lambda x: x[0])
for ts_str, c, d, p in raw_events:
side = "buy" if d > 0 else "sell"
qty = abs(d)
pnl = 0.0
holding_days = 0
return_pct = 0.0
prev = hold.get(c, 0.0)
if prev * d >= 0:
# Same-direction add: update average cost
old = cost.get(c, p)
total = abs(prev) + qty
cost[c] = (old * abs(prev) + p * qty) / total if total > 1e-9 else p
hold[c] = prev + d
if c not in entry_date:
entry_date[c] = ts_str
else:
# Opposite direction: realise P&L
close_qty = min(qty, abs(prev))
entry = cost.get(c, p)
pnl = (p - entry) * close_qty if prev > 0 else (entry - p) * close_qty
return_pct = (p / entry - 1) * 100 if entry > 1e-9 and prev > 0 else (entry / p - 1) * 100 if p > 1e-9 else 0.0
ed = entry_date.get(c, ts_str)
try:
holding_days = (pd.Timestamp(ts_str) - pd.Timestamp(ed)).days
except Exception:
holding_days = 0
remain = prev + d
if abs(remain) < 1e-9:
hold.pop(c, None)
cost.pop(c, None)
entry_date.pop(c, None)
else:
hold[c] = remain
if remain * prev <= 0:
cost[c] = p
entry_date[c] = ts_str
trades.append({
"timestamp": ts_str,
"code": c,
"side": side,
"price": round(p, 4),
"qty": round(qty, 6),
"reason": "signal",
"pnl": round(pnl, 4),
"holding_days": holding_days,
"return_pct": round(return_pct, 2),
})
return trades
def _win_rate_and_stats(trades: List[Dict[str, Any]]) -> Dict[str, float]:
"""Calculate win rate and P&L statistics from trade records.
Args:
trades: List of trade records.
Returns:
Dict containing win_rate, profit_loss_ratio, max_consecutive_loss, avg_holding_days.
"""
closed = [t for t in trades if t.get("pnl", 0) != 0]
if not closed:
return {"win_rate": 0.0, "profit_loss_ratio": 0.0, "max_consecutive_loss": 0, "avg_holding_days": 0.0}
wins = [t["pnl"] for t in closed if t["pnl"] > 0]
losses = [t["pnl"] for t in closed if t["pnl"] < 0]
win_rate = len(wins) / len(closed) if closed else 0.0
avg_win = np.mean(wins) if wins else 0.0
avg_loss = abs(np.mean(losses)) if losses else 1e-10
profit_loss_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
# Max consecutive losses
max_consec = 0
cur_consec = 0
for t in closed:
if t["pnl"] < 0:
cur_consec += 1
max_consec = max(max_consec, cur_consec)
else:
cur_consec = 0
# Average holding days
hold_days = [t["holding_days"] for t in closed if t["holding_days"] > 0]
avg_holding = np.mean(hold_days) if hold_days else 0.0
return {
"win_rate": win_rate,
"profit_loss_ratio": round(profit_loss_ratio, 4),
"max_consecutive_loss": max_consec,
"avg_holding_days": round(avg_holding, 1),
}
def _metrics(
port_ret: pd.Series,
equity: pd.Series,
dd: pd.Series,
cash: float,
trades: List[Dict[str, Any]],
bench_ret: Optional[pd.Series] = None,
bars_per_year: int = 252,
) -> Dict[str, Any]:
"""Calculate the full set of performance metrics.
Args:
port_ret: Portfolio bar return series.
equity: Equity curve.
dd: Drawdown series.
cash: Initial capital.
trades: Trade records.
bench_ret: Benchmark return series (optional).
bars_per_year: Bars per year (252 for daily, 8760 for 1H OKX, etc.).
Returns:
Metrics dictionary.
"""
n = len(port_ret)
if n == 0:
return dict(
final_value=cash, total_return=0, annual_return=0,
max_drawdown=0, sharpe=0, calmar=0, sortino=0,
win_rate=0, profit_loss_ratio=0, max_consecutive_loss=0,
avg_holding_days=0, trade_count=0,
benchmark_return=0, excess_return=0, information_ratio=0,
)
bpy = bars_per_year
total_ret = float(equity.iloc[-1] / 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))
# Calmar = annualised return / |max drawdown|
max_dd = float(dd.min())
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
# Sortino = mean(ret) / downside_std * sqrt(bars_per_year) (unchanged)
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))
# Trade statistics
trade_stats = _win_rate_and_stats(trades)
# Benchmark comparison
bench_return = 0.0
excess = 0.0
ir = 0.0
if bench_ret is not None and len(bench_ret) > 0:
bench_return = float((1 + bench_ret).prod() - 1)
excess = total_ret - bench_return
active_ret = port_ret - bench_ret
active_std = float(active_ret.std())
ir = float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
return {
"final_value": float(equity.iloc[-1]),
"total_return": total_ret,
"annual_return": ann_ret,
"max_drawdown": max_dd,
"sharpe": sharpe,
"calmar": round(calmar, 4),
"sortino": round(sortino, 4),
"win_rate": trade_stats["win_rate"],
"profit_loss_ratio": trade_stats["profit_loss_ratio"],
"max_consecutive_loss": trade_stats["max_consecutive_loss"],
"avg_holding_days": trade_stats["avg_holding_days"],
"trade_count": len(trades),
"benchmark_return": round(bench_return, 6),
"excess_return": round(excess, 6),
"information_ratio": round(ir, 4),
}
def run_backtest(
config: Dict[str, Any],
loader: Any,
engine: Any,
run_dir: Path,
bars_per_year: int = 252,
) -> Dict[str, Any]:
"""Main entry point: load data -> generate signals -> vectorised backtest -> write artifacts.
Args:
config: Backtest configuration (codes, start_date, end_date, initial_cash, commission,
extra_fields, optimizer, interval, source).
loader: DataLoader instance (must have a fetch method).
engine: SignalEngine instance (must have a generate method).
run_dir: Run directory path.
bars_per_year: Bars per year, computed by runner from interval+source.
Returns:
Metrics dictionary.
Raises:
SystemExit: When no data is fetched or no valid signals are generated.
"""
import json
import sys
codes = config.get("codes", [])
start_date = config.get("start_date", "")
end_date = config.get("end_date", "")
initial_cash = config.get("initial_cash", 1_000_000)
commission = config.get("commission", 0.001)
extra_fields = config.get("extra_fields") or None
interval = config.get("interval", "1D")
data_map = loader.fetch(codes, start_date, end_date, fields=extra_fields, interval=interval)
if not data_map:
print(json.dumps({"error": "No data fetched"}))
sys.exit(1)
signal_map = engine.generate(data_map)
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"}))
sys.exit(1)
# Load optimizer if configured
opt_fn = _load_optimizer(config)
dates, close, pos, ret = _align(data_map, signal_map, valid_codes, optimizer=opt_fn)
port_ret, equity, dd = _portfolio_equity(pos, ret, commission, initial_cash)
bench_ret, bench_equity = _benchmark_equity(ret, initial_cash)
trades = _log_trades(pos, close, valid_codes)
m = _metrics(port_ret, equity, dd, initial_cash, trades, bench_ret, bars_per_year)
out = run_dir / "artifacts"
out.mkdir(parents=True, exist_ok=True)
for code, df in data_map.items():
df.to_csv(out / f"ohlcv_{code}.csv")
eq = pd.DataFrame({
"ret": port_ret,
"equity": equity,
"drawdown": dd,
"benchmark_equity": bench_equity,
"active_ret": port_ret - bench_ret,
}, index=dates)
eq.index.name = "timestamp"
eq.to_csv(out / "equity.csv")
pos.index.name = "timestamp"
pos.to_csv(out / "positions.csv")
trade_cols = ["timestamp", "code", "side", "price", "qty", "reason", "pnl", "holding_days", "return_pct"]
pd.DataFrame(
trades or [],
columns=trade_cols,
).to_csv(out / "trades.csv", index=False)
pd.DataFrame([m]).to_csv(out / "metrics.csv", index=False)
print(json.dumps(m, indent=2))
return m
def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
"""Dynamically load an optimizer function from config.
Supports any module under backtest/optimizers/ that exposes an
`optimize(ret, pos, dates, **params)` function.
Args:
config: Backtest configuration.
Returns:
Optimizer callable, or None.
"""
import importlib
opt_name = config.get("optimizer")
if not opt_name:
return None
opt_params = config.get("optimizer_params") or {}
try:
mod = importlib.import_module(f"backtest.optimizers.{opt_name}")
return lambda ret, pos, dates: mod.optimize(ret, pos, dates, **opt_params)
except (ImportError, AttributeError) as e:
print(f"[WARN] Failed to load optimizer '{opt_name}': {e}, falling back to equal weight")
return None
+136
View File
@@ -0,0 +1,136 @@
"""Forex (FX spot / CFD) backtest engine.
Market rules:
- 24x5 (Mon Sydney open to Fri NYC close)
- Spread replaces explicit commission (bid-ask)
- Leverage: 50:1 to 500:1 (configurable)
- Standard lot = 100,000 units of base currency
- Swap (overnight rollover interest) at daily close
- No price limits, no restrictions on direction
- PnL in quote currency (converted via exit price for cross pairs)
"""
from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
# ``_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) ──
_SPREAD_PIPS: dict[str, float] = {
# Majors
"EUR/USD": 1.0, "GBP/USD": 1.2, "USD/JPY": 1.0, "USD/CHF": 1.3,
"AUD/USD": 1.2, "USD/CAD": 1.5, "NZD/USD": 1.5,
# Crosses
"EUR/GBP": 1.5, "EUR/JPY": 1.5, "GBP/JPY": 2.5, "EUR/CHF": 1.8,
"AUD/JPY": 2.0, "CHF/JPY": 2.5, "EUR/AUD": 2.0, "GBP/AUD": 3.0,
"EUR/CAD": 2.5, "GBP/CAD": 3.5, "AUD/CAD": 2.5, "NZD/JPY": 2.5,
# Exotics (wider spreads)
"USD/TRY": 15.0, "USD/ZAR": 10.0, "USD/MXN": 8.0,
"USD/SGD": 3.0, "USD/HKD": 3.0, "USD/CNH": 5.0,
}
_DEFAULT_SPREAD_PIPS = 2.0
# Standard lot size
STANDARD_LOT = 100_000
def _pip_value(symbol: str) -> float:
"""Size of 1 pip for the pair.
Args:
symbol: Forex pair (e.g. 'EUR/USD', 'USD/JPY').
Returns:
1 pip in price terms (0.0001 or 0.01 for JPY pairs).
"""
quote = symbol.split("/")[1] if "/" in symbol else symbol[3:6]
return 0.01 if quote.upper() == "JPY" else 0.0001
class ForexEngine(BaseEngine):
"""Forex engine for spot / CFD pairs.
Config keys:
- leverage: default 100.0 (100:1)
- spread_pips_override: override spread for all pairs
- lot_size: default 100000 (standard lot)
- swap_enabled: default True
- slippage_pips: additional slippage beyond spread, default 0.3
"""
def __init__(self, config: dict):
config = {**config, "leverage": config.get("leverage", 100.0)}
super().__init__(config)
self.spread_override = config.get("spread_pips_override")
self.lot_size: float = config.get("lot_size", STANDARD_LOT)
self.swap_enabled: bool = config.get("swap_enabled", True)
self.slippage_pips: float = config.get("slippage_pips", 0.3)
self._last_swap_dates: dict = {} # per-symbol swap tracking
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Forex: 24x5, no restrictions."""
return True
def round_size(self, raw_size: float, price: float) -> float:
"""Round to micro-lot granularity (0.01 lots = 1000 units).
Position size is in currency units (not lots) for PnL compatibility.
Round to nearest 1000 units (micro lot).
"""
return max(int(raw_size / 1000) * 1000, 0)
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
def apply_slippage(self, price: float, direction: int) -> float:
"""Apply half-spread + slippage using _active_symbol for correct pip/spread."""
return self.apply_slippage_for_symbol(self._active_symbol, price, direction)
def apply_slippage_for_symbol(self, symbol: str, price: float, direction: int) -> float:
"""Symbol-aware slippage with correct spread.
Args:
symbol: Forex pair.
price: Mid price.
direction: 1 (buy) or -1 (sell).
Returns:
Slipped price.
"""
pair = _normalize_symbol(symbol)
pip = _pip_value(pair)
if self.spread_override is not None:
spread_pips = self.spread_override
else:
spread_pips = _SPREAD_PIPS.get(pair, _DEFAULT_SPREAD_PIPS)
total_pips = (spread_pips / 2) + self.slippage_pips
return price + direction * total_pips * pip
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
"""Apply daily swap/rollover at end of trading day."""
if not self.swap_enabled:
return
swap = calc_forex_swap(
symbol, timestamp, self.positions,
self.lot_size, self._last_swap_dates,
)
self.capital += swap
def get_contract_multiplier(self, symbol: str) -> float:
"""Forex: multiplier is 1.0 (size is in currency units)."""
return 1.0
+56
View File
@@ -0,0 +1,56 @@
"""Base class for all futures engines.
Adds contract-multiplier awareness on top of BaseEngine.
Only futures engines inherit from this; stocks/crypto/forex use BaseEngine directly.
The multiplier affects:
- PnL: direction * size * multiplier * (exit - entry)
- Margin: size * price * multiplier / leverage
- Position sizing: target_notional / (price * multiplier)
"""
from __future__ import annotations
from abc import abstractmethod
from backtest.engines.base import BaseEngine
class FuturesBaseEngine(BaseEngine):
"""BaseEngine with contract-multiplier support.
Subclasses must implement ``get_contract_multiplier(symbol)``
in addition to the standard market-rule methods.
"""
@abstractmethod
def get_contract_multiplier(self, symbol: str) -> float:
"""Contract multiplier for the instrument.
Args:
symbol: Futures symbol (e.g. 'IF2406.CFFEX', 'ESZ4').
Returns:
Points-to-currency multiplier (e.g. IF=300, ES=50).
"""
# ── Override PnL / margin / sizing to include multiplier ──
def _calc_pnl(
self, symbol: str, direction: int, size: float,
entry_price: float, exit_price: float,
) -> float:
cm = self.get_contract_multiplier(symbol)
return direction * size * cm * (exit_price - entry_price)
def _calc_margin(
self, symbol: str, size: float, price: float, leverage: float,
) -> float:
cm = self.get_contract_multiplier(symbol)
return size * price * cm / leverage
def _calc_raw_size(
self, symbol: str, target_notional: float, price: float,
) -> float:
cm = self.get_contract_multiplier(symbol)
return target_notional / (price * cm)
+78
View File
@@ -0,0 +1,78 @@
"""Global equity (US / HK) backtest engine.
Market rules:
US:
- T+0, long/short allowed
- Zero commission (retail brokers)
- Fractional shares supported (round to 0.01)
- Low slippage (high liquidity)
HK:
- T+0, long/short allowed
- Stamp tax 0.1% bilateral + levies
- Lot-size rounding (simplified to 100 shares)
- Higher slippage than US
"""
from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
class GlobalEquityEngine(BaseEngine):
"""US / HK equity engine, selected by *market* parameter.
Config keys:
- slippage_us: default 0.0005
- slippage_hk: default 0.001
- hk_stamp_tax: default 0.001 (0.1% bilateral)
- hk_commission: default 0.00015 (万1.5)
- hk_levy: default 0.0000565 (SFC + FRC)
- hk_settlement: default 0.00002 (CCASS)
"""
def __init__(self, config: dict, market: str = "us"):
config = {**config, "leverage": config.get("leverage", 1.0)}
super().__init__(config)
self.market = market
# US defaults
self.slippage_us: float = config.get("slippage_us", 0.0005)
# HK defaults
self.slippage_hk: float = config.get("slippage_hk", 0.001)
self.hk_stamp_tax: float = config.get("hk_stamp_tax", 0.001)
self.hk_commission: float = config.get("hk_commission", 0.00015)
self.hk_levy: float = config.get("hk_levy", 0.0000565)
self.hk_settlement: float = config.get("hk_settlement", 0.00002)
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""US/HK: T+0, both directions allowed."""
return True
def round_size(self, raw_size: float, price: float) -> float:
"""US: fractional shares (0.01). HK: 100-share lots."""
if self.market == "hk":
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.
``_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
comm += notional * self.hk_stamp_tax # stamp tax bilateral
comm += notional * self.hk_levy # SFC + FRC levies
comm += notional * self.hk_settlement # CCASS settlement
return comm
# US: zero commission (SEC fee negligible)
return 0.0
def apply_slippage(self, price: float, direction: int) -> float:
"""US: low slippage. HK: moderate slippage."""
rate = self.slippage_hk if self.market == "hk" else self.slippage_us
return price * (1 + direction * rate)
+251
View File
@@ -0,0 +1,251 @@
"""Global futures backtest engine (CME / ICE / Eurex).
Market rules:
- Nearly 24x5 (CME Globex: Sun 17:00 - Fri 16:00 CT, daily pause 16:00-17:00)
- Margin: initial + maintenance (exchange-set per contract)
- Limit up/down: dynamic for equity index, fixed for commodities
- Contract multiplier: per-product (ES=$50/pt, CL=$1000/bbl, GC=$100/oz)
- Commission: per-contract ($1-3 per side typical)
- Minimum unit: 1 contract
- Roll/expiry: not modeled (assumes continuous front-month data)
"""
from __future__ import annotations
import re
import pandas as pd
from backtest.engines.futures_base import FuturesBaseEngine
# ── Contract multiplier (USD per point / per unit) ──
_MULTIPLIER: dict[str, float] = {
# Equity index (CME)
"ES": 50, "NQ": 20, "YM": 5, "RTY": 50,
# Micro equity index
"MES": 5, "MNQ": 2, "MYM": 0.5, "M2K": 5,
# Energy (NYMEX)
"CL": 1000, "NG": 10000, "RB": 42000, "HO": 42000,
# Metals (COMEX)
"GC": 100, "SI": 5000, "HG": 25000, "PL": 50, "PA": 100,
# Micro metals
"MGC": 10, "SIL": 1000,
# Grains (CBOT)
"ZC": 50, "ZS": 50, "ZW": 50, "ZM": 100, "ZL": 600,
# Bonds (CBOT)
"ZB": 1000, "ZN": 1000, "ZF": 1000, "ZT": 2000,
# Currencies (CME)
"6E": 125000, "6J": 12500000, "6B": 62500, "6A": 100000, "6C": 100000,
# Softs (ICE)
"KC": 37500, "SB": 112000, "CC": 10, "CT": 50000,
# Livestock (CME)
"LE": 400, "HE": 400, "GF": 500,
# Eurex
"FESX": 10, "FDAX": 25, "FGBL": 1000,
}
# ── 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
"ES": 12650, "NQ": 17600, "YM": 8800, "RTY": 6600,
"MES": 1265, "MNQ": 1760,
# Energy
"CL": 6270, "NG": 3300,
# Metals
"GC": 9950, "SI": 11000, "HG": 4400, "PL": 3300,
"MGC": 995,
# Grains
"ZC": 1650, "ZS": 2200, "ZW": 1925,
# Bonds
"ZB": 4400, "ZN": 2200, "ZF": 1375,
# Currencies
"6E": 2475, "6J": 3300, "6B": 2750,
}
# ── Price limit (fraction of prev settlement) ──
_PRICE_LIMIT: dict[str, float] = {
# Equity index: 7% (Level 1), simplified to single level
"ES": 0.07, "NQ": 0.07, "YM": 0.07, "RTY": 0.07,
"MES": 0.07, "MNQ": 0.07,
# Energy: varies, typically ~$10-15 for CL
# Not easily expressed as %, skip for most commodities
}
# ── Per-contract commission (USD, one side) ──
_COMMISSION_PER_CONTRACT: dict[str, float] = {
"ES": 2.25, "NQ": 2.25, "YM": 2.25, "RTY": 2.25,
"MES": 0.62, "MNQ": 0.62,
"CL": 2.25, "NG": 2.25,
"GC": 2.25, "SI": 2.25, "HG": 2.25,
"MGC": 0.62,
"ZC": 2.25, "ZS": 2.25, "ZW": 2.25,
"ZB": 1.52, "ZN": 1.52, "ZF": 1.02,
"6E": 2.25, "6J": 2.25, "6B": 2.25,
}
_DEFAULT_COMMISSION = 2.50
_MONTH_CODES = set("FGHJKMNQUVXZ")
def _extract_product(symbol: str) -> str:
"""Extract product code from futures symbol.
Handles CME conventions:
- Product + month-code + year: ESZ4, CLF25, GCM2025
- Product + YYMM: CL2412, NQ2503
- Product.exchange: ES.CME
- Bare product: ES
Args:
symbol: Futures symbol string.
Returns:
Product code (e.g. 'ES', 'CL', 'GC').
"""
code = symbol.split(".")[0].upper()
# Pattern 1: product + month-code + year (ESZ4, CLF25, GCM2025)
m = re.match(r"([A-Z]{2,4})([FGHJKMNQUVXZ])(\d{1,4})$", code)
if m:
return m.group(1)
# Pattern 2: product + YYMM (NQ2503, CL2412)
m = re.match(r"([A-Z]+)(\d{4})$", code)
if m:
return m.group(1)
# Pattern 3: bare product or fallback
m = re.match(r"([A-Z]+)", code)
return m.group(1) if m else code
class GlobalFuturesEngine(FuturesBaseEngine):
"""International futures engine (CME/CBOT/NYMEX/COMEX/ICE/Eurex).
Config keys:
- slippage: default 0.0003
- commission_per_contract: override, default varies by product
"""
def __init__(self, config: dict):
# Leverage: most futures have 5-15% margin → 7-20x leverage.
# Price is unknown at init, so use a reasonable fixed default.
# User can override via config["leverage"].
leverage = config.get("leverage", 10.0)
config = {**config, "leverage": leverage}
super().__init__(config)
self.slippage_rate: float = config.get("slippage", 0.0003)
self._comm_override = config.get("commission_per_contract")
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Global futures: T+0, both directions, limit checks for equity index.
Args:
symbol: Futures symbol.
direction: 1 (long), -1 (short), 0 (close).
bar: Current bar data.
Returns:
True if allowed.
"""
product = _extract_product(symbol)
limit = _PRICE_LIMIT.get(product)
if limit is None:
return True # no price limit for most commodities
pct_chg = _calc_pct_change(bar)
if pct_chg is not None:
if direction == 1 and pct_chg >= limit - 0.001:
return False # limit-up
if direction == -1 and pct_chg <= -limit + 0.001:
return False # limit-down
if direction == 0:
pos = self.positions.get(symbol)
if pos is not None:
if pos.direction == 1 and pct_chg <= -limit + 0.001:
return False
if pos.direction == -1 and pct_chg >= limit - 0.001:
return False
return True
def round_size(self, raw_size: float, price: float) -> float:
"""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).
``_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)
def calc_commission_for_symbol(
self, symbol: str, size: float, price: float, is_open: bool,
) -> float:
"""Symbol-aware commission.
Args:
symbol: Futures code.
size: Number of contracts.
price: Execution price (unused — fixed per-lot).
is_open: Opening or closing.
Returns:
Commission in USD.
"""
product = _extract_product(symbol)
rate = _COMMISSION_PER_CONTRACT.get(product, _DEFAULT_COMMISSION)
return size * rate
def apply_slippage(self, price: float, direction: int) -> float:
"""Slippage model for liquid global futures."""
return price * (1 + direction * self.slippage_rate)
def get_contract_multiplier(self, symbol: str) -> float:
"""Product-specific contract multiplier."""
product = _extract_product(symbol)
return float(_MULTIPLIER.get(product, 50))
# ── 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.
Priority: close/pre_close > settle/pre_settle > pct_chg.
Falls back to pct_chg only when price fields are absent.
"""
close = bar.get("close")
pre_close = bar.get("pre_close")
if close is not None and pre_close is not None and pre_close > 0:
return (float(close) - float(pre_close)) / float(pre_close)
settle = bar.get("settle")
pre_settle = bar.get("pre_settle")
if settle is not None and pre_settle is not None and pre_settle > 0:
return (float(settle) - float(pre_settle)) / float(pre_settle)
if "pct_chg" in bar.index:
val = bar["pct_chg"]
if pd.notna(val):
raw = float(val)
# Heuristic: values > 1 are likely percentage points
return raw / 100.0 if abs(raw) > 1.0 else raw
return None
+87 -8
View File
@@ -1,7 +1,14 @@
"""Options portfolio backtest engine.
"""Options portfolio backtest engine (v2).
European options backtest engine based on Black-Scholes model.
Synthesises theoretical option prices from underlying prices; supports multi-leg strategies.
Supports European and American options via Black-Scholes model with
IV smile approximation. Synthesises theoretical option prices from
underlying prices; supports multi-leg strategies.
v2 enhancements over v1:
- American option support (early exercise heuristic for calls on dividends,
always-exercise check for deep ITM puts)
- IV smile model: skew adjustment based on moneyness (log(K/S))
- Portfolio-level Greeks aggregation
Signal interface: OptionsSignalEngine.generate(data_map) returns a list of trade instructions.
Artifacts: equity.csv, metrics.csv, trades.csv, greeks.csv.
@@ -9,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
@@ -123,6 +129,32 @@ def historical_volatility(close: pd.Series, window: int = 30) -> pd.Series:
return hv.fillna(hv.dropna().iloc[0] if len(hv.dropna()) > 0 else 0.3)
# --- IV Smile model (v2) ---
def iv_smile_adjustment(S: float, K: float, base_iv: float,
skew: float = -0.15, curvature: float = 0.05) -> float:
"""Adjust IV for moneyness using a quadratic smile model.
IV(K) = base_iv + skew * log(K/S) + curvature * log(K/S)^2
Args:
S: Spot price.
K: Strike price.
base_iv: At-the-money implied volatility.
skew: Slope of the smile (negative = put skew). Default -0.15.
curvature: Curvature of the smile (always positive). Default 0.05.
Returns:
Adjusted implied volatility, floored at 0.01.
"""
if S <= 0 or K <= 0:
return max(base_iv, 0.01)
log_moneyness = np.log(K / S)
adj = base_iv + skew * log_moneyness + curvature * log_moneyness ** 2
return max(adj, 0.01)
# --- Option positions ---
@@ -227,8 +259,10 @@ 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)
iv_curvature = options_cfg.get("iv_curvature", 0.0) # v2: smile curvature
# Load underlying data
data_map = loader.fetch(codes, start_date, end_date)
@@ -282,7 +316,38 @@ def run_options_backtest(
spot_prices[code] = float(df.at[last, "close"])
ivs[code] = float(iv_map[code].at[last]) if last in iv_map[code].index else 0.3
# 2. Handle expiry
# 2a. American early exercise (v2): exercise if intrinsic > continuation
if exercise_style == "american":
for pos in list(positions):
if pos.is_expired(ts):
continue # handled below
spot = spot_prices.get(pos.underlying_code, 0.0)
iv_val_ex = ivs.get(pos.underlying_code, 0.3)
T_ex = pos.time_to_expiry(ts)
if T_ex <= 0:
continue
intrinsic = pos.intrinsic_value(spot)
continuation = bs_price(spot, pos.strike, T_ex, risk_free_rate, iv_val_ex, pos.option_type)
if intrinsic > 0 and intrinsic > continuation * 1.02:
# Early exercise is optimal
settlement = intrinsic * pos.qty * contract_multiplier
cash += settlement
pnl = (intrinsic - pos.entry_price) * pos.qty * contract_multiplier
trade_records.append({
"timestamp": date_str,
"code": pos.underlying_code,
"option_type": pos.option_type,
"strike": pos.strike,
"expiry": str(pos.expiry.date()),
"side": "early_exercise",
"price": round(intrinsic, 4),
"qty": pos.qty,
"pnl": round(pnl, 4),
"entry_date": pos.entry_date,
})
positions.remove(pos)
# 2b. Handle expiry
expired = [p for p in positions if p.is_expired(ts)]
for pos in expired:
spot = spot_prices.get(pos.underlying_code, 0.0)
@@ -327,8 +392,13 @@ def run_options_backtest(
expiry_ts = pd.Timestamp(expiry)
T = max((expiry_ts - ts).days / 365.0, 0.001)
# Black-Scholes price
opt_price = bs_price(spot, strike, T, risk_free_rate, iv_val, leg_type)
# Apply IV smile adjustment (v2) if configured
adj_iv = iv_val
if iv_skew != 0 or iv_curvature != 0:
adj_iv = iv_smile_adjustment(spot, strike, iv_val, iv_skew, iv_curvature)
# Black-Scholes price (with smile-adjusted IV if enabled)
opt_price = bs_price(spot, strike, T, risk_free_rate, adj_iv, leg_type)
if action == "open":
# Open: long pays premium, short receives premium
@@ -452,6 +522,15 @@ 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)
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",
)
print(json.dumps(metrics, indent=2))
return metrics
+274
View File
@@ -0,0 +1,274 @@
"""AKShare loader: free, no-auth data for A-shares, US, HK, futures, forex, macro.
AKShare (https://github.com/akfamily/akshare) is a completely free financial
data aggregator covering Chinese and global markets. No API token required.
"""
from __future__ import annotations
import logging
from typing import 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__)
_INTERVAL_MAP_DAILY = {
"1D": "daily",
"1W": "weekly",
"1M": "monthly",
}
def _is_a_share(code: str) -> bool:
return code.upper().endswith((".SZ", ".SH", ".BJ"))
def _is_hk(code: str) -> bool:
return code.upper().endswith(".HK")
def _is_us(code: str) -> bool:
return code.upper().endswith(".US")
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.
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")
try:
from akshare.forex.cons import symbol_market_map
except Exception:
return False
return upper in symbol_market_map
@register
class DataLoader:
"""AKShare universal OHLCV loader (free, no auth)."""
name = "akshare"
markets = {"a_share", "us_equity", "hk_equity", "futures", "fund", "macro", "forex"}
requires_auth = False
def is_available(self) -> bool:
"""Available if akshare is installed."""
try:
import akshare # 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 AKShare.
Args:
codes: Symbol list.
start_date: YYYY-MM-DD.
end_date: YYYY-MM-DD.
interval: Bar size (only 1D supported currently).
fields: Ignored.
Returns:
Mapping symbol -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
df = 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("akshare 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 a single symbol."""
import akshare as ak
# ETF check must precede A-share — 518880.SH ends with .SH but is an ETF.
if _is_etf_listed(code):
return self._fetch_etf(ak, code, start_date, end_date)
if _is_a_share(code):
return self._fetch_a_share(ak, code, start_date, end_date, interval)
if _is_us(code):
return self._fetch_us(ak, code, start_date, end_date)
if _is_hk(code):
return self._fetch_hk(ak, code, start_date, end_date)
if _is_forex(code):
return self._fetch_forex(ak, code, start_date, end_date)
# Default: try A-share
return self._fetch_a_share(ak, code, start_date, end_date, interval)
def _fetch_a_share(
self, ak, code: str, start_date: str, end_date: str, interval: str,
) -> Optional[pd.DataFrame]:
"""Fetch A-share via stock_zh_a_hist."""
symbol = code.split(".")[0]
period = _INTERVAL_MAP_DAILY.get(interval, "daily")
sd = start_date.replace("-", "")
ed = end_date.replace("-", "")
df = ak.stock_zh_a_hist(
symbol=symbol,
period=period,
start_date=sd,
end_date=ed,
adjust="qfq",
)
if df is None or df.empty:
return None
return self._normalize(df, date_col="日期")
def _fetch_us(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Fetch US stock via stock_us_hist."""
symbol = code.replace(".US", "")
# akshare uses the format like "105.AAPL" for NASDAQ
# Try common prefixes
for prefix in ["105.", "106.", ""]:
try:
df = ak.stock_us_hist(
symbol=f"{prefix}{symbol}",
period="daily",
start_date=start_date.replace("-", ""),
end_date=end_date.replace("-", ""),
adjust="qfq",
)
if df is not None and not df.empty:
return self._normalize(df, date_col="日期")
except Exception:
continue
return None
def _fetch_etf(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Fetch exchange-listed ETF / LOF via fund_etf_hist_sina.
Sina symbol format is ``sh518880`` / ``sz159915``. The endpoint returns
the full history; we filter to the requested window after fetching.
"""
digits, _, suffix = code.upper().partition(".")
symbol = f"{suffix.lower()}{digits}"
df = ak.fund_etf_hist_sina(symbol=symbol)
if df is None or df.empty:
return None
df = self._normalize(df, date_col="date")
# fund_etf_hist_sina returns full history — clip to window.
return df.loc[start_date:end_date]
def _fetch_forex(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Fetch forex pair via forex_hist_em.
Columns returned are 日期 / 代码 / 名称 / 今开 / 最新价 / 最高 / 最低 / 振幅
— 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")
df = ak.forex_hist_em(symbol=symbol)
if df is None or df.empty:
return None
df = df.rename(columns={
"日期": "trade_date",
"今开": "open",
"最新价": "close",
"最高": "high",
"最低": "low",
})
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date").sort_index()
df["volume"] = 0.0
for col in ("open", "high", "low", "close"):
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return df.loc[start_date:end_date]
def _fetch_hk(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Fetch HK stock via stock_hk_hist."""
symbol = code.replace(".HK", "").zfill(5)
df = ak.stock_hk_hist(
symbol=symbol,
period="daily",
start_date=start_date.replace("-", ""),
end_date=end_date.replace("-", ""),
adjust="qfq",
)
if df is None or df.empty:
return None
return self._normalize(df, date_col="日期")
@staticmethod
def _normalize(df: pd.DataFrame, date_col: str = "日期") -> pd.DataFrame:
"""Normalize AKShare DataFrame to standard OHLCV schema.
AKShare Chinese column names: 日期, 开盘, 最高, 最低, 收盘, 成交量
AKShare English column names: date, open, high, low, close, volume
"""
col_map_cn = {"开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "volume"}
col_map_en = {"date": "trade_date", "open": "open", "high": "high", "low": "low", "close": "close", "volume": "volume"}
if date_col in df.columns:
df = df.rename(columns={date_col: "trade_date"})
elif "date" in df.columns:
df = df.rename(columns={"date": "trade_date"})
# Try Chinese column names first, then English
if "开盘" in df.columns:
df = df.rename(columns=col_map_cn)
else:
df = df.rename(columns=col_map_en)
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date").sort_index()
for col in ["open", "high", "low", "close", "volume"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
ohlcv_cols = [c for c in ["open", "high", "low", "close", "volume"] if c in df.columns]
df = df[ohlcv_cols].dropna(subset=["open", "high", "low", "close"])
if "volume" not in df.columns:
df["volume"] = 0.0
return df
+152
View File
@@ -0,0 +1,152 @@
"""DataLoader Protocol, shared exceptions, and bounded-retry helpers.
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
import time
from typing import Callable, Protocol, TypeVar, runtime_checkable
import pandas as pd
class NoAvailableSourceError(Exception):
"""Raised when no data source is available for a given market."""
def validate_date_range(start_date: str, end_date: str) -> None:
"""Validate that start_date <= end_date.
Args:
start_date: Start date string (YYYY-MM-DD).
end_date: End date string (YYYY-MM-DD).
Raises:
ValueError: If dates are invalid or start > end.
"""
try:
start = pd.Timestamp(start_date)
end = pd.Timestamp(end_date)
except Exception as exc:
raise ValueError(f"Invalid date format: start={start_date!r}, end={end_date!r}") from exc
if start > end:
raise ValueError(f"start_date ({start_date}) > end_date ({end_date})")
# ---------------------------------------------------------------------------
# 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 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
@runtime_checkable
class DataLoaderProtocol(Protocol):
"""Interface that every data source loader must satisfy."""
name: str
markets: set[str]
requires_auth: bool
def is_available(self) -> bool:
"""Check whether this data source is usable (token present, network ok, etc.)."""
...
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 data.
Returns:
Mapping ``{symbol: DataFrame(trade_date, open, high, low, close, volume)}``.
"""
...
+184
View File
@@ -0,0 +1,184 @@
"""CCXT loader: unified crypto exchange data (100+ exchanges).
Uses the CCXT library to fetch OHLCV candles from any supported exchange.
Defaults to Binance; configurable via CCXT_EXCHANGE env var.
No API key required for public market data.
"""
from __future__ import annotations
import logging
import os
import time
from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import (
check_budget,
retry_with_budget,
validate_date_range,
)
from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
_INTERVAL_MAP = {
"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m",
"1H": "1h", "4H": "4h", "1D": "1d",
}
# 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 = int(os.getenv("CCXT_TIMEOUT_MS", "15000"))
_CCXT_FETCH_BUDGET_S = float(os.getenv("CCXT_FETCH_BUDGET_S", "60"))
def _first_proxy_env(*names: str) -> str:
for name in names:
value = os.getenv(name, "").strip()
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
@register
class DataLoader:
"""CCXT-backed crypto OHLCV loader (100+ exchanges)."""
name = "ccxt"
markets = {"crypto"}
requires_auth = False
def is_available(self) -> bool:
"""Available if ccxt is installed."""
try:
import ccxt # noqa: F401
return True
except ImportError:
return False
def __init__(self) -> None:
pass
def _get_exchange(self):
"""Create exchange instance."""
import ccxt
exchange_id = os.getenv("CCXT_EXCHANGE", "binance").lower()
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
config = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS}
proxies = _ccxt_proxy_config()
if proxies:
config["proxies"] = proxies
return exchange_cls(config)
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 crypto OHLCV via CCXT.
Args:
codes: Symbols like ``["BTC-USDT", "ETH-USDT"]``.
start_date: Start date (YYYY-MM-DD).
end_date: End date (YYYY-MM-DD).
interval: Bar size.
fields: Ignored.
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)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
ccxt_symbol = code.replace("-", "/").upper()
df = self._fetch_one(exchange, ccxt_symbol, timeframe, since_ms, end_ms)
if df is not None and not df.empty:
result[code] = df
except Exception as exc:
logger.warning("CCXT failed for %s: %s", code, exc)
return result
@staticmethod
def _fetch_one(
exchange, symbol: str, timeframe: str, since_ms: int, end_ms: int,
) -> 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}"
for _ in range(200):
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.
ohlcv = retry_with_budget(
lambda: exchange.fetch_ohlcv(symbol, timeframe, since=cursor, limit=limit),
transient=ccxt.NetworkError,
deadline=deadline,
label=label,
)
if not ohlcv:
break
all_rows.extend(ohlcv)
last_ts = ohlcv[-1][0]
if last_ts >= end_ms or len(ohlcv) < limit:
break
cursor = last_ts + 1
if not all_rows:
return None
df = pd.DataFrame(all_rows, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["trade_date"] = pd.to_datetime(df["timestamp"], unit="ms")
df = df.set_index("trade_date").sort_index()
start_dt = pd.Timestamp(since_ms, unit="ms")
end_dt = pd.Timestamp(end_ms, unit="ms")
df = df[(df.index >= start_dt) & (df.index < end_dt)]
for col in ["open", "high", "low", "close", "volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
return df if not df.empty else None
+163
View File
@@ -0,0 +1,163 @@
"""Futu OpenAPI-backed loader for HK and China A-share OHLCV data."""
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.registry import register
logger = logging.getLogger(__name__)
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
_INTERVAL_MAP: dict[str, str] = {
"1D": "K_DAY",
"1H": "K_60M",
"4H": "K_240M",
"1W": "K_WEEK",
"1M": "K_MON",
}
def _to_futu_symbol(code: str) -> str:
"""Convert project symbol to Futu OpenAPI format.
Examples:
700.HK -> HK.00700
5.HK -> HK.00005
000001.SZ -> SZ.000001
600519.SH -> SH.600519
"""
upper = code.strip().upper()
if upper.endswith(".HK"):
return f"HK.{upper[:-3].zfill(5)}"
if upper.endswith(".SZ"):
return f"SZ.{upper[:-3].zfill(6)}"
if upper.endswith(".SH"):
return f"SH.{upper[:-3].zfill(6)}"
return upper
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.
"""
from futu import KLType # noqa: PLC0415
return getattr(KLType, _INTERVAL_MAP.get(interval.strip(), "K_DAY"))
def _normalize_frame(df: pd.DataFrame) -> pd.DataFrame:
"""Normalise a Futu kline DataFrame to the standard OHLCV schema.
Args:
df: Raw DataFrame returned by ``request_history_kline()``.
Returns:
DataFrame with columns [open, high, low, close, volume] indexed
by ``trade_date`` (timezone-naive DatetimeIndex), sorted ascending.
"""
if df.empty:
return pd.DataFrame(columns=_OHLCV_COLUMNS)
result = df.copy()
result.index = pd.to_datetime(result["time_key"])
result.index.name = "trade_date"
result = result[_OHLCV_COLUMNS].copy()
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()
@register
class FutuLoader:
"""Fetch HK and China A-share bars from Futu OpenAPI.
Requires FutuOpenD running locally (https://www.futunn.com/download/openAPI)
and the environment variables ``FUTU_HOST`` / ``FUTU_PORT`` to be set.
"""
name = "futu"
markets = {"hk_equity", "a_share"}
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"))
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 False
try:
import futu # noqa: PLC0415
ctx = futu.OpenQuoteContext(host=self._host, port=self._port)
ctx.close()
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 Futu OpenAPI.
Args:
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``.
fields: Ignored; included for interface compatibility.
Returns:
Mapping of input symbol to normalised OHLCV dataframe.
Raises:
NoAvailableSourceError: If FutuOpenD connection fails.
"""
del fields
if not codes:
return {}
validate_date_range(start_date, end_date)
try:
import futu # noqa: PLC0415
ktype = _to_futu_ktype(interval)
ctx = futu.OpenQuoteContext(host=self._host, port=self._port)
except Exception as exc:
raise NoAvailableSourceError(
f"Cannot connect to FutuOpenD at {self._host}:{self._port}: {exc}"
) from exc
results: Dict[str, pd.DataFrame] = {}
try:
for code in codes:
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)
continue
results[code] = _normalize_frame(data)
finally:
ctx.close()
return results
+243
View File
@@ -0,0 +1,243 @@
"""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 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 = 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:
logger.warning(
"mootdx: %s %s pagination hit cap (%d pages) without reaching %s",
symbol, freq, _MAX_PAGES, 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
+44 -2
View File
@@ -5,18 +5,42 @@ Supports 1m/5m/15m/30m/1H/4H/1D.
Up to 300 bars per request; paginates with ``after`` for longer history.
"""
import os
import time
from typing import Dict, List, Optional
import pandas as pd
import requests
from backtest.loaders.base import (
check_budget,
retry_with_budget,
validate_date_range,
)
from backtest.loaders.registry import register
BASE_URL = "https://www.okx.com/api/v5"
_MAX_PER_PAGE = 300
# P12-b parity: OKX already sets a per-request timeout but had no retry
# budget, so a transient blip dropped the whole symbol and a slow tier
# could stall ~max_pages*timeout. Bound it like the ccxt loader; retry
# scheduling is delegated to :mod:`backtest.loaders.base`.
_OKX_TIMEOUT = int(os.getenv("OKX_TIMEOUT_S", "15"))
_OKX_FETCH_BUDGET_S = float(os.getenv("OKX_FETCH_BUDGET_S", "60"))
@register
class DataLoader:
"""OKX crypto OHLCV loader."""
name = "okx"
markets = {"crypto"}
requires_auth = False
def is_available(self) -> bool:
"""Always available (public API, no auth)."""
return True
def __init__(self) -> None:
"""No credentials required for public candles."""
pass
@@ -41,6 +65,8 @@ class DataLoader:
Returns:
Mapping symbol -> DataFrame.
"""
validate_date_range(start_date, end_date)
if fields:
print(f"[WARN] OKX ignores extra fields: {fields}")
@@ -84,16 +110,32 @@ class DataLoader:
"""
all_rows: list = []
after = str(end_ts)
deadline = time.monotonic() + _OKX_FETCH_BUDGET_S
label = f"OKX fetch for {inst_id}"
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()
def _do_request() -> dict:
resp = requests.get(
f"{BASE_URL}/market/candles",
params=params,
timeout=_OKX_TIMEOUT,
)
return resp.json()
data = retry_with_budget(
_do_request,
transient=requests.RequestException,
deadline=deadline,
label=label,
)
if data.get("code") != "0" or not data.get("data"):
break
+159
View File
@@ -0,0 +1,159 @@
"""Loader registry with market-level fallback chains.
Loaders self-register via the ``@register`` decorator when their module is
first imported. The ``_ensure_registered()`` helper lazily imports every
known loader module so that callers of ``resolve_loader`` /
``get_loader_cls_with_fallback`` never see an empty registry — regardless
of import order.
"""
from __future__ import annotations
import logging
from typing import Any, Type
from backtest.loaders.base import NoAvailableSourceError
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Global registry: source_name -> loader class
# ---------------------------------------------------------------------------
LOADER_REGISTRY: dict[str, Type[Any]] = {}
_registered = False
def register(cls: Type[Any]) -> Type[Any]:
"""Class decorator: register a loader into the global registry.
The class must have a ``name`` class attribute.
"""
LOADER_REGISTRY[cls.name] = cls
return cls
def _ensure_registered() -> None:
"""Import every known loader module so ``@register`` decorators fire.
Safe to call multiple times — only runs the imports once.
Loaders whose dependencies are missing (e.g. ``akshare`` not installed)
are silently skipped.
"""
global _registered
if _registered:
return
_registered = True
_loader_modules = [
"backtest.loaders.tushare",
"backtest.loaders.okx",
"backtest.loaders.yfinance_loader",
"backtest.loaders.akshare_loader",
"backtest.loaders.mootdx_loader",
"backtest.loaders.ccxt_loader",
"backtest.loaders.futu",
]
import importlib
for mod in _loader_modules:
try:
importlib.import_module(mod)
except Exception:
pass
# ---------------------------------------------------------------------------
# Fallback chains: market_type -> ordered list of source names
# ---------------------------------------------------------------------------
FALLBACK_CHAINS: dict[str, list[str]] = {
"a_share": ["tushare", "mootdx", "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"],
}
def resolve_loader(market: str) -> Any:
"""Return the first *available* loader instance for *market*.
Walks the fallback chain and returns the first loader whose
``is_available()`` returns ``True``.
Args:
market: Market type key (e.g. ``"a_share"``, ``"crypto"``).
Returns:
A loader instance.
Raises:
NoAvailableSourceError: If every candidate is unavailable.
"""
_ensure_registered()
chain = FALLBACK_CHAINS.get(market, [])
tried: list[str] = []
for name in chain:
if name not in LOADER_REGISTRY:
continue
tried.append(name)
# Issue #50 — some loaders (e.g. Tushare) call into the SDK during
# __init__ and raise on missing credentials. Treat that the same as
# is_available()=False so the fallback chain keeps walking.
try:
loader = LOADER_REGISTRY[name]()
except Exception as exc:
logger.debug("loader %s failed to construct: %s", name, exc)
continue
if loader.is_available():
return loader
raise NoAvailableSourceError(
f"No available data source for market '{market}'. "
f"Tried: {tried or chain}. Check network and API token config."
)
def get_loader_cls_with_fallback(source: str) -> Type[Any]:
"""Return a loader *class* for *source*, falling back if unavailable.
Args:
source: Requested data source name.
Returns:
A DataLoader class (not instance).
Raises:
NoAvailableSourceError: If the source and all fallbacks are unavailable.
"""
_ensure_registered()
if source not in LOADER_REGISTRY:
raise NoAvailableSourceError(f"Unknown data source: {source}")
loader_cls = LOADER_REGISTRY[source]
try:
instance = loader_cls()
except Exception as exc:
logger.debug("loader %s failed to construct: %s", source, exc)
instance = None
if instance is not None and instance.is_available():
return loader_cls
# Source unavailable — try same-market fallback
for market in loader_cls.markets:
try:
fallback = resolve_loader(market)
logger.warning(
"%s is unavailable, falling back to %s for market %s",
source, fallback.name, market,
)
return type(fallback)
except NoAvailableSourceError:
continue
raise NoAvailableSourceError(
f"Data source '{source}' is unavailable and no fallback found."
)
+17
View File
@@ -9,10 +9,25 @@ from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
@register
class DataLoader:
"""Tushare-backed OHLCV loader."""
name = "tushare"
markets = {"a_share", "futures", "fund"}
requires_auth = True
def is_available(self) -> bool:
"""Available when TUSHARE_TOKEN is set."""
return os.getenv("TUSHARE_TOKEN", "").strip() not in TUSHARE_TOKEN_PLACEHOLDERS
def __init__(self) -> None:
"""Initialize Tushare pro API."""
import tushare as ts
@@ -40,6 +55,8 @@ class DataLoader:
Returns:
Mapping code -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
if interval != "1D":
return self._fetch_minutes(codes, start_date, end_date, interval)
@@ -0,0 +1,286 @@
"""Tushare fundamental data provider with point-in-time safeguards."""
from __future__ import annotations
import os
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
token = os.getenv("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
+13
View File
@@ -8,6 +8,9 @@ from typing import Dict, List, Optional, Union
import pandas as pd
import yfinance as yf
from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register
_OHLCV_COLUMNS = ["open", "high", "low", "close", "volume"]
_COLUMN_RENAMES = {
"Open": "open",
@@ -186,9 +189,18 @@ def _normalize_frame(frame: pd.DataFrame, requested_interval: str) -> pd.DataFra
return normalized
@register
class DataLoader:
"""Fetch HK/US equity bars from Yahoo Finance via yfinance."""
name = "yfinance"
markets = {"us_equity", "hk_equity"}
requires_auth = False
def is_available(self) -> bool:
"""Always available (free public data, no auth)."""
return True
def __init__(self) -> None:
"""Initialize the loader.
@@ -219,6 +231,7 @@ class DataLoader:
del fields
if not codes:
return {}
validate_date_range(start_date, end_date)
requested_interval = str(interval or "1D").strip()
yf_interval = _to_yfinance_interval(requested_interval)
+241
View File
@@ -0,0 +1,241 @@
"""Shared backtest metrics, extracted from daily_portfolio.py for reuse.
Provides annualisation helpers, trade statistics, and full metric calculation.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import numpy as np
import pandas as pd
from backtest.models import TradeRecord
# ─── Annualisation factor mapping ───
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365}
_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},
}
def calc_bars_per_year(interval: str = "1D", source: str = "tushare") -> int:
"""Number of bars per year for annualisation.
Args:
interval: Bar size (1m / 5m / 15m / 30m / 1H / 4H / 1D).
source: Data source (tushare / yfinance / okx).
Returns:
Bars per year.
"""
trading_days = _TRADING_DAYS.get(source, 252)
bars_per_day = _BARS_PER_DAY.get(interval, {}).get(source, 1)
return trading_days * bars_per_day
def win_rate_and_stats(trades: List[TradeRecord]) -> Dict[str, float]:
"""Win rate and P&L statistics from completed trades.
Args:
trades: Completed round-trip trades.
Returns:
Dict with win_rate, profit_loss_ratio, max_consecutive_loss,
avg_holding_bars, profit_factor.
"""
if not trades:
return {
"win_rate": 0.0,
"profit_loss_ratio": 0.0,
"max_consecutive_loss": 0,
"avg_holding_bars": 0.0,
"profit_factor": 0.0,
}
wins = [t.pnl for t in trades if t.pnl > 0]
losses = [t.pnl for t in trades if t.pnl < 0]
win_rate = len(wins) / len(trades)
avg_win = float(np.mean(wins)) if wins else 0.0
avg_loss = abs(float(np.mean(losses))) if losses else 1e-10
profit_loss_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
gross_profit = sum(wins) if wins else 0.0
gross_loss = abs(sum(losses)) if losses else 1e-10
profit_factor = gross_profit / gross_loss if gross_loss > 1e-10 else 0.0
max_consec = 0
cur_consec = 0
for t in trades:
if t.pnl < 0:
cur_consec += 1
max_consec = max(max_consec, cur_consec)
else:
cur_consec = 0
hold_bars = [t.holding_bars for t in trades if t.holding_bars > 0]
avg_holding = float(np.mean(hold_bars)) if hold_bars else 0.0
return {
"win_rate": win_rate,
"profit_loss_ratio": round(profit_loss_ratio, 4),
"max_consecutive_loss": max_consec,
"avg_holding_bars": round(avg_holding, 1),
"profit_factor": round(profit_factor, 4),
}
def by_symbol_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]:
"""Per-symbol trade statistics.
Args:
trades: Completed round-trip trades.
Returns:
{symbol: {count, win_rate, total_pnl, avg_pnl}}.
"""
groups: Dict[str, list] = {}
for t in trades:
groups.setdefault(t.symbol, []).append(t)
result = {}
for sym, sym_trades in groups.items():
pnls = [t.pnl for t in sym_trades]
wins = [p for p in pnls if p > 0]
result[sym] = {
"count": len(sym_trades),
"win_rate": round(len(wins) / len(sym_trades), 4) if sym_trades else 0.0,
"total_pnl": round(sum(pnls), 2),
"avg_pnl": round(float(np.mean(pnls)), 2) if pnls else 0.0,
}
return result
def by_exit_reason_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]:
"""Per-exit-reason trade statistics.
Args:
trades: Completed round-trip trades.
Returns:
{reason: {count, total_pnl}}.
"""
groups: Dict[str, list] = {}
for t in trades:
groups.setdefault(t.exit_reason, []).append(t)
result = {}
for reason, reason_trades in groups.items():
pnls = [t.pnl for t in reason_trades]
result[reason] = {
"count": len(reason_trades),
"total_pnl": round(sum(pnls), 2),
}
return result
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,
) -> Dict[str, Any]:
"""Full set of performance metrics.
Args:
equity_curve: Equity time series (index=timestamp, values=equity).
trades: Completed round-trip trades.
initial_cash: Starting capital.
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).
Returns:
Metrics dictionary (compatible with daily_portfolio format).
"""
if len(equity_curve) == 0:
return _empty_metrics(initial_cash)
n = len(equity_curve)
# Calendar-day annualization for cross-market (bars_per_year=None)
if bars_per_year is None:
first, last = equity_curve.index[0], equity_curve.index[-1]
calendar_days = (last - first).days
years = calendar_days / 365.25 if calendar_days > 0 else 1.0
bpy = int(n / years) if years > 0 else 252
else:
bpy = bars_per_year
port_ret = equity_curve.pct_change().fillna(0.0)
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))
# Drawdown
peak = equity_curve.cummax()
dd = (equity_curve - 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
# 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))
trade_stats = win_rate_and_stats(trades)
# Benchmark comparison
bench_return = 0.0
excess = 0.0
ir = 0.0
if bench_ret is not None and len(bench_ret) > 0:
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))
return {
"final_value": float(equity_curve.iloc[-1]),
"total_return": total_ret,
"annual_return": ann_ret,
"max_drawdown": max_dd,
"sharpe": sharpe,
"calmar": round(calmar, 4),
"sortino": round(sortino, 4),
"win_rate": trade_stats["win_rate"],
"profit_loss_ratio": trade_stats["profit_loss_ratio"],
"profit_factor": trade_stats["profit_factor"],
"max_consecutive_loss": trade_stats["max_consecutive_loss"],
"avg_holding_days": trade_stats["avg_holding_bars"],
"trade_count": len(trades),
"benchmark_return": round(bench_return, 6),
"excess_return": round(excess, 6),
"information_ratio": round(ir, 4),
}
def _empty_metrics(initial_cash: float) -> Dict[str, Any]:
"""Return zero-valued metrics when no data is available."""
return {
"final_value": initial_cash,
"total_return": 0, "annual_return": 0, "max_drawdown": 0,
"sharpe": 0, "calmar": 0, "sortino": 0,
"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,
}
+89
View File
@@ -0,0 +1,89 @@
"""Shared data models for backtest engines.
Immutable dataclasses for positions, trades, and equity snapshots.
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
@dataclass(frozen=True)
class Position:
"""An open position in a single instrument.
Args:
symbol: Instrument identifier.
direction: 1 for long, -1 for short.
entry_price: Execution price at entry.
entry_time: Timestamp when position was opened.
size: Number of shares / coins.
leverage: Effective leverage (1 for spot/stocks).
entry_bar_idx: Index in the dates array at entry (for holding_bars).
entry_commission: Commission paid at entry.
"""
symbol: str
direction: int
entry_price: float
entry_time: pd.Timestamp
size: float
leverage: float = 1.0
entry_bar_idx: int = 0
entry_commission: float = 0.0
@dataclass(frozen=True)
class TradeRecord:
"""A completed round-trip trade.
Args:
symbol: Instrument identifier.
direction: 1 for long, -1 for short.
entry_price: Entry execution price.
exit_price: Exit execution price.
entry_time: Entry timestamp.
exit_time: Exit timestamp.
size: Number of shares / coins traded.
leverage: Effective leverage.
pnl: Realised profit/loss in cash terms.
pnl_pct: Realised P&L as percentage of margin.
exit_reason: Why closed (signal / liquidation / end_of_backtest).
holding_bars: Number of bars held.
commission: Total commission (entry + exit).
"""
symbol: str
direction: int
entry_price: float
exit_price: float
entry_time: pd.Timestamp
exit_time: pd.Timestamp
size: float
leverage: float
pnl: float
pnl_pct: float
exit_reason: str
holding_bars: int
commission: float
@dataclass(frozen=True)
class EquitySnapshot:
"""Portfolio state at a single point in time.
Args:
timestamp: Bar timestamp.
capital: Free cash.
unrealized: Total unrealised P&L across all positions.
equity: capital + margin_in_use + unrealized.
positions: Number of open positions.
"""
timestamp: pd.Timestamp
capital: float
unrealized: float
equity: float
positions: int
+200
View File
@@ -0,0 +1,200 @@
"""Trust Layer run card generator for backtest runs."""
from __future__ import annotations
import hashlib
import json
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,
) -> 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.
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),
}
if "validation" in metrics:
card["validation"] = metrics["validation"]
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) + "\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 _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 _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.")
return "\n".join(lines) + "\n"
+457 -61
View File
@@ -7,12 +7,123 @@ Supports ``engine`` for backtest engine (daily/options, default daily).
Usage: ``python -m backtest.runner <run_dir>``
"""
import ast
import importlib.util
import inspect
import json
import re
import logging
import sys
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from backtest.loaders.registry import (
FALLBACK_CHAINS,
LOADER_REGISTRY,
get_loader_cls_with_fallback,
resolve_loader,
)
from backtest.loaders.base import NoAvailableSourceError
# Symbol classification lives in ``_market_hooks`` so runner.py and
# composite.py share a single source of truth (audit-2026-05-18 B1+C1+C2).
# ``_detect_market`` is also re-exported here for back-compat with
# ``agent/src/swarm/grounding.py`` and existing tests that import it
# from ``backtest.runner``.
from backtest.engines._market_hooks import ( # noqa: F401 (re-exported)
_detect_market,
_detect_submarket,
_is_china_futures,
)
logger = logging.getLogger(__name__)
_VALID_INTERVALS = {"1m", "5m", "15m", "30m", "1H", "4H", "1D"}
_VALID_ENGINES = {"daily", "options"}
_VALID_SOURCES = {"tushare", "okx", "yfinance", "akshare", "ccxt", "auto"}
class BacktestConfigSchema(BaseModel):
"""Validates backtest config.json before execution."""
model_config = ConfigDict(extra="allow")
codes: List[str]
start_date: str
end_date: str
source: str = "tushare"
interval: str = "1D"
engine: str = "daily"
fundamental_fields: Optional[Dict[str, List[str]]] = None
@field_validator("codes")
@classmethod
def codes_not_empty(cls, v: List[str]) -> List[str]:
if not v:
raise ValueError("codes must be a non-empty list")
if any(not c.strip() for c in v):
raise ValueError("codes must not contain empty strings")
return v
@field_validator("start_date", "end_date")
@classmethod
def valid_date(cls, v: str) -> str:
try:
pd.Timestamp(v)
except Exception:
raise ValueError(f"invalid date format: {v!r} (expected YYYY-MM-DD)")
return v
@field_validator("interval")
@classmethod
def valid_interval(cls, v: str) -> str:
if v not in _VALID_INTERVALS:
raise ValueError(f"unsupported interval {v!r}, must be one of {_VALID_INTERVALS}")
return v
@field_validator("engine")
@classmethod
def valid_engine(cls, v: str) -> str:
if v not in _VALID_ENGINES:
raise ValueError(f"unsupported engine {v!r}, must be one of {_VALID_ENGINES}")
return v
@field_validator("source")
@classmethod
def valid_source(cls, v: str) -> str:
if v not in _VALID_SOURCES:
raise ValueError(f"unsupported source {v!r}, must be one of {_VALID_SOURCES}")
return v
@field_validator("fundamental_fields")
@classmethod
def valid_fundamental_fields(
cls,
v: Optional[Dict[str, List[str]]],
) -> Optional[Dict[str, List[str]]]:
if v is None:
return v
for table, fields in v.items():
if not table.strip():
raise ValueError("fundamental_fields table names must be non-empty strings")
if any(not field.strip() for field in fields):
raise ValueError("fundamental_fields field names must be non-empty strings")
return v
@model_validator(mode="after")
def start_before_end(self) -> "BacktestConfigSchema":
if pd.Timestamp(self.start_date) > pd.Timestamp(self.end_date):
raise ValueError(
f"start_date ({self.start_date}) must be <= end_date ({self.end_date})"
)
return self
def _load_module_from_file(file_path: Path, module_name: str):
@@ -25,6 +136,7 @@ def _load_module_from_file(file_path: Path, module_name: str):
Returns:
Loaded module object.
"""
_validate_signal_engine_source(file_path)
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
@@ -32,33 +144,185 @@ def _load_module_from_file(file_path: Path, module_name: str):
return module
# --- Market detection ---
def _is_literal_node(node: ast.AST) -> bool:
"""Return whether an AST node is made only from literal values."""
if isinstance(node, ast.Constant):
return True
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
return all(_is_literal_node(item) for item in node.elts)
if isinstance(node, ast.Dict):
return all(
(key is None or _is_literal_node(key)) and _is_literal_node(value)
for key, value in zip(node.keys, node.values)
)
return False
_PATTERNS = [
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "tushare"),
(re.compile(r"^[A-Z]+\.US$", re.I), "yfinance"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "yfinance"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "okx"),
]
def _is_safe_constant_assignment(node: ast.AST) -> bool:
"""Return whether a top-level assignment is literal-only."""
if isinstance(node, ast.Assign):
return _is_literal_node(node.value)
if isinstance(node, ast.AnnAssign):
return node.value is None or _is_literal_node(node.value)
return False
def _is_safe_reference(node: ast.AST | None) -> bool:
"""Return whether an annotation/base expression cannot call code."""
if node is None:
return True
if isinstance(node, (ast.Name, ast.Attribute, ast.Constant)):
return True
if isinstance(node, ast.Subscript):
return _is_safe_reference(node.value) and _is_safe_reference(node.slice)
if isinstance(node, ast.Tuple):
return all(_is_safe_reference(item) for item in node.elts)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return _is_safe_reference(node.left) and _is_safe_reference(node.right)
return False
def _validate_function_def(node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
"""Reject import-time execution in function definitions."""
if node.decorator_list:
raise ValueError(f"Decorators are not allowed on function {node.name!r}")
for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]:
if not _is_literal_node(default):
raise ValueError(f"Non-literal default is not allowed on function {node.name!r}")
annotations = [node.returns]
annotations.extend(arg.annotation for arg in node.args.posonlyargs)
annotations.extend(arg.annotation for arg in node.args.args)
annotations.extend(arg.annotation for arg in node.args.kwonlyargs)
annotations.append(node.args.vararg.annotation if node.args.vararg else None)
annotations.append(node.args.kwarg.annotation if node.args.kwarg else None)
for annotation in annotations:
if not _is_safe_reference(annotation):
raise ValueError(f"Unsafe annotation is not allowed on function {node.name!r}")
def _validate_class_body(node: ast.ClassDef) -> None:
"""Reject import-time execution inside class bodies."""
if node.decorator_list:
raise ValueError(f"Decorators are not allowed on class {node.name!r}")
for base in node.bases:
if not _is_safe_reference(base):
raise ValueError(f"Unsafe base class is not allowed on class {node.name!r}")
if node.keywords:
raise ValueError(f"Class keywords are not allowed on class {node.name!r}")
for child in node.body:
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
continue
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
_validate_function_def(child)
continue
if _is_safe_constant_assignment(child):
continue
if isinstance(child, ast.Pass):
continue
raise ValueError(
f"Executable class-level statement {type(child).__name__} is not allowed"
)
def _validate_signal_engine_source(file_path: Path) -> None:
"""Reject import-time executable statements before loading signal_engine.py."""
try:
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
except SyntaxError as exc:
raise ValueError(f"Invalid signal_engine.py syntax: {exc}") from exc
for node in tree.body:
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
continue
if isinstance(node, ast.ImportFrom) and node.module == "signal_engine":
raise ValueError(
"Circular import: 'from signal_engine import ...' imports the file from itself. "
"Remove this import — SignalEngine is defined in this same file."
)
if isinstance(node, (ast.Import, ast.ImportFrom)):
continue
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
_validate_function_def(node)
continue
if isinstance(node, ast.ClassDef):
_validate_class_body(node)
continue
if _is_safe_constant_assignment(node):
continue
raise ValueError(
f"Executable top-level statement {type(node).__name__} is not allowed"
)
def _validate_signal_engine_class(engine_cls) -> None:
"""Pre-flight check: SignalEngine can be instantiated with no args and has generate()."""
sig = inspect.signature(engine_cls.__init__)
required = [
p.name for p in sig.parameters.values()
if p.name != "self" and p.default is inspect.Parameter.empty
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
]
if required:
raise ValueError(
f"SignalEngine.__init__() has required arguments {required}. "
"All parameters must have default values so the runner can call SignalEngine()."
)
if not callable(getattr(engine_cls, "generate", None)):
raise ValueError(
"SignalEngine must have a callable 'generate' method. "
"Expected: def generate(self, data_map: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]"
)
# --- Market detection ---
# ``_MARKET_PATTERNS``, ``_detect_market``, ``_is_china_futures``,
# ``_detect_submarket`` are imported from ``_market_hooks`` above and
# re-exported here for back-compat (swarm/grounding.py, tests).
# Back-compat: market type -> legacy source name (for engine selection & metrics)
_MARKET_TO_SOURCE = {
"a_share": "tushare",
"us_equity": "yfinance",
"hk_equity": "yfinance",
"crypto": "okx",
"futures": "tushare",
"fund": "tushare",
"macro": "akshare",
"forex": "akshare",
}
def _detect_source(code: str) -> str:
"""Infer data source from symbol format.
"""Infer legacy source name from symbol (back-compat for metrics/engine).
Args:
code: Ticker / symbol string.
Returns:
Source name (tushare/okx/yfinance); unknown defaults to ``tushare``.
Source name (tushare/okx/yfinance/akshare).
"""
for pattern, source in _PATTERNS:
if pattern.match(code):
return source
return "tushare"
market = _detect_market(code)
return _MARKET_TO_SOURCE.get(market, "tushare")
def _group_codes_by_market(codes: List[str]) -> Dict[str, List[str]]:
"""Group symbols by detected market type.
Args:
codes: List of symbol strings.
Returns:
Mapping market_type -> list of codes.
"""
groups: Dict[str, List[str]] = {}
for code in codes:
market = _detect_market(code)
groups.setdefault(market, []).append(code)
return groups
def _group_codes_by_source(codes: List[str]) -> Dict[str, List[str]]:
"""Group symbols by inferred source.
"""Group symbols by inferred source (back-compat).
Args:
codes: List of symbol strings.
@@ -74,23 +338,21 @@ def _group_codes_by_source(codes: List[str]) -> Dict[str, List[str]]:
def _get_loader(source: str):
"""Return the DataLoader class for a source name.
"""Return a DataLoader class for a source name, with fallback.
Args:
source: Source name.
source: Source name (tushare/okx/yfinance/akshare/ccxt).
Returns:
DataLoader class.
"""
if source == "okx":
from backtest.loaders.okx import DataLoader
elif source == "yfinance":
from backtest.loaders.yfinance_loader import DataLoader
elif source == "tushare":
from backtest.loaders.tushare import DataLoader
else:
from backtest.loaders.tushare import DataLoader
return DataLoader
try:
return get_loader_cls_with_fallback(source)
except NoAvailableSourceError:
# Ultimate fallback for unknown sources
if "tushare" in LOADER_REGISTRY:
return LOADER_REGISTRY["tushare"]
raise
def _normalize_codes(codes: List[str], source: str) -> List[str]:
@@ -103,7 +365,7 @@ def _normalize_codes(codes: List[str], source: str) -> List[str]:
Returns:
Normalized codes.
"""
if source == "okx":
if source in ("okx", "ccxt"):
return [c.replace("/", "-").upper() for c in codes]
return codes
@@ -117,13 +379,40 @@ def main(run_dir: Path) -> None:
Args:
run_dir: Run directory containing ``config.json`` and ``code/signal_engine.py``.
The path is validated against the allowed run roots
(``VIBE_TRADING_ALLOWED_RUN_ROOTS`` plus the defaults) before any
file is read so an arbitrary filesystem location cannot be used
to source ``code/signal_engine.py``.
"""
# Guard the CLI entry point with the same root whitelist the MCP
# ``backtest`` tool already uses (src/tools/backtest_tool.py:23). Without
# this, ``python -m backtest.runner /tmp/attacker_path`` would happily
# import ``signal_engine.py`` from anywhere on disk; the AST scrubber
# below blocks executable top-level statements but a method body still
# runs on instantiation. See ``safe_run_dir`` for the policy.
from src.tools.path_utils import safe_run_dir
try:
run_dir = safe_run_dir(str(run_dir))
except ValueError as exc:
print(json.dumps({"error": str(exc)}))
sys.exit(1)
config_path = run_dir / "config.json"
if not config_path.exists():
print(json.dumps({"error": "config.json not found"}))
sys.exit(1)
config = json.loads(config_path.read_text(encoding="utf-8"))
raw_config = json.loads(config_path.read_text(encoding="utf-8"))
# Validate config schema
try:
BacktestConfigSchema(**raw_config)
except Exception as exc:
errors = str(exc)
print(json.dumps({"error": f"Invalid config: {errors}"}))
sys.exit(1)
config = raw_config
source = config.get("source", "tushare")
codes = config.get("codes", [])
@@ -133,12 +422,26 @@ def main(run_dir: Path) -> None:
print(json.dumps({"error": "code/signal_engine.py not found"}))
sys.exit(1)
signal_module = _load_module_from_file(signal_path, "signal_engine")
try:
signal_module = _load_module_from_file(signal_path, "signal_engine")
except ValueError as exc:
# Source-level AST validation (circular self-import, unsafe imports,
# decorators, top-level statements) raises ValueError. Surface it as a
# clean JSON envelope instead of a raw traceback so the agent gets an
# actionable message.
print(json.dumps({"error": f"SignalEngine source error: {exc}"}))
sys.exit(1)
engine_cls = getattr(signal_module, "SignalEngine", None)
if engine_cls is None:
print(json.dumps({"error": "SignalEngine class not found in signal_engine.py"}))
sys.exit(1)
try:
_validate_signal_engine_class(engine_cls)
except ValueError as exc:
print(json.dumps({"error": f"SignalEngine interface error: {exc}"}))
sys.exit(1)
# Data: auto split vs single loader
interval = config.get("interval", "1D")
@@ -156,44 +459,115 @@ def main(run_dir: Path) -> None:
fields=config.get("extra_fields") or None,
interval=interval,
)
# Runtime fallback: try next sources in chain when primary returns empty
if not data_map and codes:
market = _detect_market(codes[0])
for fb_name in FALLBACK_CHAINS.get(market, []):
if fb_name == source or fb_name not in LOADER_REGISTRY:
continue
fb_loader = LOADER_REGISTRY[fb_name]()
if not fb_loader.is_available():
continue
fb_codes = _normalize_codes(codes, fb_name)
data_map = fb_loader.fetch(
fb_codes, config.get("start_date", ""),
config.get("end_date", ""), interval=interval,
)
if data_map:
logger.info("Runtime fallback: %s -> %s", source, fb_name)
source = fb_name
loader = fb_loader
break
if not data_map:
print(json.dumps({"error": "No data fetched"}))
sys.exit(1)
if source == "auto":
config["_run_card_effective_sources"] = sorted(_group_codes_by_source(codes))
else:
config["_run_card_effective_sources"] = [source]
# Engine
engine_type = config.get("engine", "daily")
run_backtest_fn = _get_engine(engine_type)
engine = engine_cls()
signal_engine = engine_cls()
# Annualization bars
effective_source = _detect_primary_source(codes, source)
from backtest.engines.daily_portfolio import _calc_bars_per_year
bars_per_year = _calc_bars_per_year(interval, effective_source)
from backtest.metrics import calc_bars_per_year
# Cross-market: use calendar-day annualization (bars_per_year=None)
market_types = {_detect_market(c) for c in codes}
if len(market_types) > 1:
bars_per_year = None
else:
bars_per_year = calc_bars_per_year(interval, effective_source)
# Auto mode: wrap preloaded data in a dummy loader
if source == "auto":
loader = _AutoLoader(data_map)
run_backtest_fn(config, loader, engine, run_dir, bars_per_year=bars_per_year)
else:
run_backtest_fn(config, loader, engine, run_dir, bars_per_year=bars_per_year)
def _get_engine(engine_type: str):
"""Resolve ``run_backtest`` / ``run_options_backtest`` by engine name.
Args:
engine_type: ``daily`` or ``options``.
Returns:
Callable backtest entry.
"""
if engine_type == "options":
from backtest.engines.options_portfolio import run_options_backtest
return run_options_backtest
run_options_backtest(config, loader, signal_engine, run_dir, bars_per_year=bars_per_year)
else:
from backtest.engines.daily_portfolio import run_backtest
return run_backtest
market_engine = _create_market_engine(effective_source, config, codes)
market_engine.run_backtest(config, loader, signal_engine, run_dir, bars_per_year=bars_per_year)
def _create_market_engine(source: str, config: dict, codes: List[str]):
"""Create the appropriate market engine based on data source and market type.
Routing priority:
1. Detect market type from symbol patterns (futures, forex, etc.)
2. Fall back to source-based routing (okx->crypto, tushare->china_a, etc.)
Args:
source: Data source (okx/ccxt/tushare/akshare/yfinance).
config: Backtest configuration.
codes: Instrument codes.
Returns:
BaseEngine subclass instance.
"""
# Detect dominant market type from codes
markets = {_detect_market(c) for c in codes} if codes else set()
# Cross-market -> CompositeEngine
if len(markets) > 1:
from backtest.engines.composite import CompositeEngine
return CompositeEngine(config, codes)
# Futures routing (Wave 2)
if "futures" in markets:
# Distinguish China vs global futures by exchange suffix
if any(_is_china_futures(c) for c in codes):
from backtest.engines.china_futures import ChinaFuturesEngine
return ChinaFuturesEngine(config)
from backtest.engines.global_futures import GlobalFuturesEngine
return GlobalFuturesEngine(config)
# Forex routing (Wave 2)
if "forex" in markets:
from backtest.engines.forex import ForexEngine
return ForexEngine(config)
# Original routing (Wave 1)
if source in ("okx", "ccxt"):
from backtest.engines.crypto import CryptoEngine
return CryptoEngine(config)
elif source in ("tushare", "akshare"):
if markets & {"us_equity", "hk_equity"}:
from backtest.engines.global_equity import GlobalEquityEngine
market = _detect_submarket(codes)
return GlobalEquityEngine(config, market=market)
from backtest.engines.china_a import ChinaAEngine
return ChinaAEngine(config)
elif source == "yfinance":
from backtest.engines.global_equity import GlobalEquityEngine
market = _detect_submarket(codes)
return GlobalEquityEngine(config, market=market)
else:
from backtest.engines.crypto import CryptoEngine
return CryptoEngine(config)
def _detect_primary_source(codes: List[str], source: str) -> str:
@@ -216,7 +590,7 @@ def _detect_primary_source(codes: List[str], source: str) -> str:
def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
"""Auto mode: fetch per source and merge into one data map.
"""Auto mode: route each market group through fallback chain.
Args:
codes: All symbols.
@@ -226,18 +600,40 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
Returns:
Merged ``code -> DataFrame`` map.
"""
groups = _group_codes_by_source(codes)
market_groups = _group_codes_by_market(codes)
merged = {}
start_date = config.get("start_date", "")
end_date = config.get("end_date", "")
for src, src_codes in groups.items():
src_codes = _normalize_codes(src_codes, src)
LoaderCls = _get_loader(src)
loader = LoaderCls()
# Only tushare supports extra_fields
fields = config.get("extra_fields") if src == "tushare" else None
result = loader.fetch(src_codes, start_date, end_date, fields=fields, interval=interval)
for market, market_codes in market_groups.items():
try:
loader = resolve_loader(market)
except NoAvailableSourceError as exc:
# Fallback: try legacy source mapping
legacy_src = _MARKET_TO_SOURCE.get(market, "tushare")
logger.warning("Fallback chain failed for %s: %s — trying %s", market, exc, legacy_src)
LoaderCls = _get_loader(legacy_src)
loader = LoaderCls()
src_name = getattr(loader, "name", "unknown")
normalized_codes = _normalize_codes(market_codes, src_name)
fields = config.get("extra_fields") if src_name == "tushare" else None
result = loader.fetch(normalized_codes, start_date, end_date, fields=fields, interval=interval)
# Runtime fallback: try remaining sources when primary returns empty
if not result:
for fb_name in FALLBACK_CHAINS.get(market, []):
if fb_name == src_name or fb_name not in LOADER_REGISTRY:
continue
fb_loader = LOADER_REGISTRY[fb_name]()
if not fb_loader.is_available():
continue
fb_codes = _normalize_codes(market_codes, fb_name)
result = fb_loader.fetch(fb_codes, start_date, end_date, interval=interval)
if result:
logger.info("Runtime fallback: %s -> %s for %s", src_name, fb_name, market)
break
merged.update(result)
return merged
@@ -256,6 +652,6 @@ class _AutoLoader:
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: python -m backtest.runner <run_dir>")
print("Usage: python -m backtest.runner <run_dir>")
sys.exit(1)
main(Path(sys.argv[1]))
+399
View File
@@ -0,0 +1,399 @@
"""Statistical validation for backtest results.
Three independent tools:
- Monte Carlo permutation test: is the strategy significantly better than random?
- 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\"]
is present, or invoked directly on backtest outputs.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import numpy as np
import pandas as pd
from backtest.models import TradeRecord
# ─── Monte Carlo Permutation Test ───
def monte_carlo_test(
trades: List[TradeRecord],
initial_capital: float,
n_simulations: int = 1000,
seed: int = 42,
) -> Dict[str, Any]:
"""Shuffle trade PnL order to test path significance.
Null hypothesis: the observed Sharpe / max-drawdown is no better than
a random ordering of the same trades.
Args:
trades: Completed round-trip trades from backtest.
initial_capital: Starting capital.
n_simulations: Number of random permutations.
seed: Random seed for reproducibility.
Returns:
Dict with actual_sharpe, p_value_sharpe, actual_max_dd,
p_value_max_dd, simulated_sharpes (percentiles).
"""
if len(trades) < 3:
return {"error": "need at least 3 trades", "p_value_sharpe": 1.0}
pnls = np.array([t.pnl for t in trades])
actual = _path_metrics(pnls, initial_capital)
rng = np.random.default_rng(seed)
sharpe_count = 0
dd_count = 0
sim_sharpes = []
for _ in range(n_simulations):
shuffled = rng.permutation(pnls)
sim = _path_metrics(shuffled, initial_capital)
sim_sharpes.append(sim["sharpe"])
if sim["sharpe"] >= actual["sharpe"]:
sharpe_count += 1
if sim["max_dd"] >= actual["max_dd"]: # less negative = "better"
dd_count += 1
sim_arr = np.array(sim_sharpes)
return {
"actual_sharpe": round(actual["sharpe"], 4),
"actual_max_dd": round(actual["max_dd"], 4),
"p_value_sharpe": round(sharpe_count / n_simulations, 4),
"p_value_max_dd": round(dd_count / n_simulations, 4),
"simulated_sharpe_mean": round(float(sim_arr.mean()), 4),
"simulated_sharpe_std": round(float(sim_arr.std()), 4),
"simulated_sharpe_p5": round(float(np.percentile(sim_arr, 5)), 4),
"simulated_sharpe_p95": round(float(np.percentile(sim_arr, 95)), 4),
"n_simulations": n_simulations,
"n_trades": len(trades),
}
def _path_metrics(pnls: np.ndarray, initial_capital: float) -> Dict[str, float]:
"""Compute Sharpe and max drawdown from a PnL sequence."""
equity = initial_capital + np.cumsum(pnls)
returns = np.diff(equity) / equity[:-1] if len(equity) > 1 else np.array([0.0])
std = returns.std()
sharpe = float(returns.mean() / (std + 1e-10) * np.sqrt(252))
peak = np.maximum.accumulate(equity)
dd = (equity - peak) / np.where(peak > 0, peak, 1.0)
max_dd = float(dd.min())
return {"sharpe": sharpe, "max_dd": max_dd}
# ─── Bootstrap Sharpe CI ───
def bootstrap_sharpe_ci(
equity_curve: pd.Series,
n_bootstrap: int = 1000,
confidence: float = 0.95,
bars_per_year: int = 252,
seed: int = 42,
) -> Dict[str, Any]:
"""Resample daily returns to estimate Sharpe confidence interval.
Args:
equity_curve: Equity time series.
n_bootstrap: Number of bootstrap samples.
confidence: Confidence level (e.g. 0.95 for 95% CI).
bars_per_year: Annualisation factor.
seed: Random seed.
Returns:
Dict with observed_sharpe, ci_lower, ci_upper, median_sharpe,
prob_positive (fraction of samples with Sharpe > 0).
"""
returns = equity_curve.pct_change().dropna().values
if len(returns) < 5:
return {"error": "need at least 5 return observations"}
observed = _sharpe(returns, bars_per_year)
rng = np.random.default_rng(seed)
boot_sharpes = []
for _ in range(n_bootstrap):
sample = rng.choice(returns, size=len(returns), replace=True)
boot_sharpes.append(_sharpe(sample, bars_per_year))
arr = np.array(boot_sharpes)
alpha = (1 - confidence) / 2
lower = float(np.percentile(arr, alpha * 100))
upper = float(np.percentile(arr, (1 - alpha) * 100))
prob_pos = float(np.mean(arr > 0))
return {
"observed_sharpe": round(observed, 4),
"ci_lower": round(lower, 4),
"ci_upper": round(upper, 4),
"median_sharpe": round(float(np.median(arr)), 4),
"prob_positive": round(prob_pos, 4),
"confidence": confidence,
"n_bootstrap": n_bootstrap,
}
def _sharpe(returns: np.ndarray, bars_per_year: int = 252) -> float:
std = returns.std()
return float(returns.mean() / (std + 1e-10) * np.sqrt(bars_per_year))
# ─── Walk-Forward Analysis ───
def walk_forward_analysis(
equity_curve: pd.Series,
trades: List[TradeRecord],
n_windows: int = 5,
bars_per_year: int = 252,
) -> Dict[str, Any]:
"""Split backtest into sequential windows, check consistency.
Each window is evaluated independently (returns normalised to window start).
Args:
equity_curve: Equity time series.
trades: Completed trades.
n_windows: Number of non-overlapping windows.
bars_per_year: Annualisation factor.
Returns:
Dict with per_window stats, consistency metrics.
"""
if len(equity_curve) < n_windows * 2:
return {"error": f"need at least {n_windows * 2} bars for {n_windows} windows"}
indices = equity_curve.index
window_size = len(indices) // n_windows
windows = []
for i in range(n_windows):
start_idx = i * window_size
end_idx = (i + 1) * window_size if i < n_windows - 1 else len(indices)
win_eq = equity_curve.iloc[start_idx:end_idx]
win_start = indices[start_idx]
win_end = indices[end_idx - 1]
# Per-window trades
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
win_returns = win_eq.pct_change().dropna().values
sharpe = _sharpe(win_returns, bars_per_year) if len(win_returns) > 1 else 0.0
peak = win_eq.cummax()
dd = (win_eq - peak) / peak.replace(0, 1)
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
)
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]
sharpes_list = [w["sharpe"] for w in windows]
profitable_windows = sum(1 for r in returns_list if r > 0)
return {
"n_windows": n_windows,
"windows": windows,
"profitable_windows": profitable_windows,
"consistency_rate": round(profitable_windows / n_windows, 4),
"return_mean": round(float(np.mean(returns_list)), 6),
"return_std": round(float(np.std(returns_list)), 6),
"sharpe_mean": round(float(np.mean(sharpes_list)), 4),
"sharpe_std": round(float(np.std(sharpes_list)), 4),
}
# ─── Runner integration ───
def run_validation(
config: Dict[str, Any],
equity_curve: pd.Series,
trades: List[TradeRecord],
initial_capital: float,
bars_per_year: int = 252,
) -> Dict[str, Any]:
"""Run configured validation checks.
Reads from config["validation"]:
- monte_carlo: {n_simulations, seed}
- bootstrap: {n_bootstrap, confidence, seed}
- walk_forward: {n_windows}
Args:
config: Backtest config (must contain "validation" key).
equity_curve: Equity time series.
trades: Completed trades.
initial_capital: Starting capital.
bars_per_year: Annualisation factor.
Returns:
Dict keyed by validation type with results.
"""
v_cfg = config.get("validation", {})
results: Dict[str, Any] = {}
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,
n_simulations=mc_cfg.get("n_simulations", 1000),
seed=mc_cfg.get("seed", 42),
)
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,
n_bootstrap=bs_cfg.get("n_bootstrap", 1000),
confidence=bs_cfg.get("confidence", 0.95),
seed=bs_cfg.get("seed", 42),
)
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,
n_windows=wf_cfg.get("n_windows", 5),
bars_per_year=bars_per_year,
)
return results
# ─── Standalone CLI ───
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"]
def _load_trades(run_dir: Path) -> List[TradeRecord]:
"""Load trades from artifacts/trades.csv and convert to TradeRecord list."""
path = run_dir / "artifacts" / "trades.csv"
df = pd.read_csv(path)
if df.empty:
return []
# trades.csv has entry+exit row pairs; extract exit rows (they have pnl != 0)
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,
))
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 main(run_dir: Path) -> Dict[str, Any]:
"""Run all three validations on existing backtest artifacts.
Reads equity.csv, trades.csv, and config.json from run_dir.
Args:
run_dir: Directory with artifacts/ subdirectory.
Returns:
Validation results dict.
"""
import json
# Load config for initial_cash
config_path = run_dir / "config.json"
if config_path.exists():
config = json.loads(config_path.read_text(encoding="utf-8"))
else:
config = {}
initial_capital = config.get("initial_cash", 1_000_000)
equity = _load_equity(run_dir)
trades = _load_trades(run_dir)
results = {
"monte_carlo": monte_carlo_test(trades, initial_capital),
"bootstrap": bootstrap_sharpe_ci(equity),
"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")
print(json.dumps(results, indent=2))
return results
if __name__ == "__main__":
import sys
main(_parse_run_dir(sys.argv))
-1521
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()
+4614
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
"""Single source of truth for the CLI version string.
Reads ``vibe-trading-ai``'s installed package metadata when available
(``pip install -e .`` is enough). Falls back to the static constant
below for un-installed checkouts (e.g. running straight from a clone
with ``PYTHONPATH=agent``).
Keep the fallback in sync with ``pyproject.toml`` ``[project] version``.
"""
from __future__ import annotations
from typing import Final
try:
from importlib.metadata import PackageNotFoundError, version as _pkg_version
try:
__version__: Final[str] = _pkg_version("vibe-trading-ai")
except PackageNotFoundError:
__version__ = "0.1.8"
except ImportError: # pragma: no cover — importlib.metadata is stdlib on 3.8+
__version__ = "0.1.8"
__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",
]
+254
View File
@@ -0,0 +1,254 @@
"""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
import os
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
provider = os.getenv("LANGCHAIN_PROVIDER", "(not set)")
model = os.getenv("LANGCHAIN_MODEL_NAME", "(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",
]
+400
View File
@@ -0,0 +1,400 @@
"""``/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)
env_session_id = os.getenv("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 nanobot called out in their 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
nanobot's 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",
]
+5
View File
@@ -0,0 +1,5 @@
"""Compatibility wrapper for the interactive startup banner."""
from cli.ui.banner import print_banner
__all__ = ["print_banner"]
+1309
View File
File diff suppressed because it is too large. Load diff
+499
View File
@@ -0,0 +1,499 @@
"""First-launch onboarding wizard.
Triggered automatically when ``~/.vibe-trading/.env`` does not exist. Five
back-steppable steps (provider → model → key → timeout → optional Tushare),
matching §3.2 of the 2026-05-19 UI/UX design proposal.
Each step persists immediately to ``~/.vibe-trading/.env.partial`` and the
file is atomically renamed to ``.env`` only on completion, so a crash mid-
wizard never leaves a corrupt ``.env`` behind. API key entry is masked.
Prefers ``questionary`` when available; otherwise drives prompt_toolkit
directly so Esc / Left-arrow can be bound to a back-step sentinel.
"""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Final, Sequence
from rich.console import Console
from rich.text import Text
from cli.theme import Theme, get_console
# Sentinels for back-navigation / cancel returned by selectors.
BACK = object()
CANCEL = object()
# ---------------------------------------------------------------------------
# Provider catalogue — mirrors _PROVIDER_CHOICES in cli/_legacy.py
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Provider:
"""One selectable LLM provider option shown in step 1."""
key: str
label: str
description: str
default_model: str
key_env: str | None
base_env: str
base_url: str
key_prefix: str | None
suggested_models: tuple[str, ...]
PROVIDERS: Final[tuple[Provider, ...]] = (
Provider("openrouter", "OpenRouter", "recommended — 200+ models, one key",
"deepseek/deepseek-v4-pro",
"OPENROUTER_API_KEY", "OPENROUTER_BASE_URL",
"https://openrouter.ai/api/v1", "sk-or-",
("deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
"openai/gpt-5.5-pro", "google/gemini-3.5-flash")),
Provider("openai", "OpenAI", "GPT-5.5 direct",
"gpt-5.5-instant", "OPENAI_API_KEY", "OPENAI_BASE_URL",
"https://api.openai.com/v1", "sk-",
("gpt-5.5-instant", "gpt-5.5-pro", "gpt-5.5")),
Provider("deepseek", "DeepSeek",
"cheapest tier — good for batch backtest research",
"deepseek-v4-pro", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL",
"https://api.deepseek.com/v1", "sk-",
("deepseek-v4-pro", "deepseek-v4-flash")),
Provider("ollama", "Ollama", "local, free, no API key",
"qwen2.5:32b", None, "OLLAMA_BASE_URL",
"http://localhost:11434", None,
("qwen2.5:32b", "llama3.3:70b", "deepseek-r1:14b")),
)
TIMEOUT_CHOICES: Final[tuple[tuple[str, str], ...]] = (
("2400", "2400s (4 minutes — research mode, recommended)"),
("600", "600s (10 minutes — large backtests / swarm runs)"),
("120", "120s (2 minutes — quick lookup mode)"),
)
# ---------------------------------------------------------------------------
# Filesystem helpers (atomic partial save → final rename)
# ---------------------------------------------------------------------------
def _env_dir() -> Path: return Path.home() / ".vibe-trading"
def _env_path() -> Path: return _env_dir() / ".env"
def _partial_path() -> Path: return _env_dir() / ".env.partial"
def _render_env(values: dict[str, str]) -> str:
"""Render values as a stable ``.env`` body (KEY=value lines)."""
return "\n".join(f"{k}={v}" for k, v in values.items() if v) + "\n"
def _save_partial(values: dict[str, str]) -> None:
"""Best-effort write to ``.env.partial`` (crash-resilience nicety)."""
try:
_env_dir().mkdir(parents=True, exist_ok=True)
_partial_path().write_text(_render_env(values), encoding="utf-8")
try:
_partial_path().chmod(0o600)
except OSError:
pass
except OSError:
pass
def _finalize(values: dict[str, str]) -> Path:
"""Atomically write ``.env``. Returns the final path."""
_env_dir().mkdir(parents=True, exist_ok=True)
content = _render_env(values)
fd, tmp_name = tempfile.mkstemp(prefix=".env.", dir=str(_env_dir()))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
tmp_path = Path(tmp_name)
try:
tmp_path.chmod(0o600)
except OSError:
pass
tmp_path.replace(_env_path())
finally:
try:
Path(tmp_name).unlink()
except (FileNotFoundError, OSError):
pass
try:
_partial_path().unlink()
except (FileNotFoundError, OSError):
pass
return _env_path()
# ---------------------------------------------------------------------------
# Selector with back-step (custom prompt_toolkit Application)
# ---------------------------------------------------------------------------
def _select_with_back(prompt: str, choices: Sequence[tuple[str, str]], *,
default_index: int = 0,
console: Console | None = None) -> str | object:
"""Vertically-scrollable selector. Returns chosen value, BACK, or CANCEL.
Keybindings: ↑/↓ navigate, Enter confirm, Esc/← back, Ctrl+C cancel.
Falls back to a numeric stdin prompt if prompt_toolkit is unavailable.
"""
cons = console or get_console()
cons.print()
cons.print(Text(f"? {prompt}", style=Theme.label))
try:
from prompt_toolkit import Application
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.styles import Style as PTStyle
except ImportError:
return _select_numeric(choices, default_index, cons)
state = {"index": max(0, min(default_index, len(choices) - 1)), "result": None}
def _format() -> FormattedText:
out: list[tuple[str, str]] = []
for i, (_, label) in enumerate(choices):
if i == state["index"]:
out.append(("class:cursor", " > "))
out.append(("class:selected", f"{label}\n"))
else:
out.append(("", " "))
out.append(("class:option", f"{label}\n"))
out.append(("class:hint",
"\n ↑/↓ navigate · Enter select · Esc/← back · Ctrl+C cancel"))
return FormattedText(out)
kb = KeyBindings()
@kb.add("up")
@kb.add("c-p")
def _(event): # type: ignore[no-redef]
state["index"] = (state["index"] - 1) % len(choices); event.app.invalidate()
@kb.add("down")
@kb.add("c-n")
def _(event): # type: ignore[no-redef]
state["index"] = (state["index"] + 1) % len(choices); event.app.invalidate()
@kb.add("enter")
def _(event): # type: ignore[no-redef]
state["result"] = choices[state["index"]][0]; event.app.exit()
@kb.add("escape", eager=True)
@kb.add("left")
def _(event): # type: ignore[no-redef]
state["result"] = BACK; event.app.exit()
@kb.add("c-c")
@kb.add("c-d")
def _(event): # type: ignore[no-redef]
state["result"] = CANCEL; event.app.exit()
style = PTStyle.from_dict({
"cursor": f"{Theme.brand_hex} bold",
"selected": f"{Theme.brand_hex} bold",
"option": "",
"hint": "#808080",
})
layout = Layout(HSplit([Window(FormattedTextControl(_format), wrap_lines=False)]))
app: Application = Application(layout=layout, key_bindings=kb, style=style, full_screen=False)
try:
app.run()
except (EOFError, KeyboardInterrupt):
return CANCEL
return state["result"] if state["result"] is not None else CANCEL
def _select_numeric(choices: Sequence[tuple[str, str]], default_index: int,
console: Console) -> str | object:
"""Stdin-only fallback selector."""
for i, (_, label) in enumerate(choices, start=1):
marker = ">" if (i - 1) == default_index else " "
console.print(f" {marker} [{i}] {label}", style=Theme.muted)
console.print(Text(" (type number, b=back, q=cancel)", style=Theme.muted))
try:
raw = input("> ").strip().lower()
except (EOFError, KeyboardInterrupt):
return CANCEL
if raw in {"b", "back"}: return BACK
if raw in {"q", "quit", "cancel"}: return CANCEL
if not raw: return choices[default_index][0]
try:
idx = int(raw)
if 1 <= idx <= len(choices):
return choices[idx - 1][0]
except ValueError:
pass
console.print(Text(" invalid selection, try again", style=Theme.danger))
return _select_numeric(choices, default_index, console)
# ---------------------------------------------------------------------------
# Masked / plain prompts
# ---------------------------------------------------------------------------
def _prompt_secret(prompt: str, *, console: Console) -> str | object:
"""Read a masked secret. Returns string, BACK, or CANCEL."""
console.print()
console.print(Text(f"? {prompt}", style=Theme.label))
console.print(Text(
" (input hidden · Enter to submit · Esc to go back · Ctrl+C to cancel)",
style=Theme.muted,
))
try:
from prompt_toolkit import prompt as pt_prompt
from prompt_toolkit.key_binding import KeyBindings
kb = KeyBindings()
sentinel: dict[str, object] = {"action": None}
@kb.add("escape", eager=True)
def _(event): # type: ignore[no-redef]
sentinel["action"] = BACK; event.app.exit(result="")
try:
value = pt_prompt("> ", is_password=True, key_bindings=kb)
except (EOFError, KeyboardInterrupt):
return CANCEL
if sentinel["action"] is BACK:
return BACK
return value.strip()
except ImportError:
import getpass
try:
return getpass.getpass("> ").strip()
except (EOFError, KeyboardInterrupt):
return CANCEL
def _prompt_text(prompt: str, *, default: str = "",
console: Console) -> str | object:
"""Read a plain string. Returns string, BACK, or CANCEL."""
console.print()
console.print(Text(f"? {prompt}", style=Theme.label))
if default:
console.print(Text(f" (Enter for default: {default} · Esc to go back)",
style=Theme.muted))
else:
console.print(Text(" (Enter to skip · Esc to go back)", style=Theme.muted))
try:
from prompt_toolkit import prompt as pt_prompt
from prompt_toolkit.key_binding import KeyBindings
kb = KeyBindings()
sentinel: dict[str, object] = {"action": None}
@kb.add("escape", eager=True)
def _(event): # type: ignore[no-redef]
sentinel["action"] = BACK; event.app.exit(result="")
try:
value = pt_prompt("> ", key_bindings=kb)
except (EOFError, KeyboardInterrupt):
return CANCEL
if sentinel["action"] is BACK:
return BACK
v = value.strip()
return v if v else default
except ImportError:
try:
raw = input("> ").strip()
return raw if raw else default
except (EOFError, KeyboardInterrupt):
return CANCEL
# ---------------------------------------------------------------------------
# Wizard
# ---------------------------------------------------------------------------
def _validate_key(provider: Provider, key: str) -> str | None:
"""Return error message or None if key looks plausible."""
if not key:
return "API key cannot be empty."
if provider.key_prefix and not key.startswith(provider.key_prefix):
return f"Expected key to start with '{provider.key_prefix}'."
if len(key) < 12:
return "That key looks too short."
return None
def _intro_header(console: Console) -> None:
console.print()
console.print(Text(" Vibe-Trading setup", style=Theme.primary))
console.print(Text(
" We didn't find a config at ~/.vibe-trading/.env.\n"
" Let's set up in under a minute.",
style=Theme.muted,
))
def run_onboarding(*, console: Console | None = None) -> Path | None:
"""Run the 5-step wizard. Returns the written ``.env`` path or None.
Returns ``None`` when the user cancels (Ctrl+C / Esc at step 1) so the
caller can exit cleanly without writing a partial config.
"""
cons = console or get_console()
_intro_header(cons)
values: dict[str, str] = {"LANGCHAIN_TEMPERATURE": "0.0", "MAX_RETRIES": "2"}
state: dict[str, object] = {"provider": None, "model": None, "key": None}
def step_provider() -> object:
choices = [(p.key, f"{p.label:<14} {p.description}") for p in PROVIDERS]
result = _select_with_back("Pick a model provider", choices,
default_index=0, console=cons)
if result is BACK or result is CANCEL:
return result
provider = next(p for p in PROVIDERS if p.key == result)
state["provider"] = provider
values["LANGCHAIN_PROVIDER"] = provider.key
values[provider.base_env] = provider.base_url
_save_partial(values)
return "ok"
def step_model() -> object:
provider: Provider = state["provider"] # type: ignore[assignment]
choices: list[tuple[str, str]] = [
(m, f"{m}{' (default)' if m == provider.default_model else ''}")
for m in provider.suggested_models
]
choices.append(("__custom__", "other (type custom model id)"))
default_idx = next(
(i for i, (v, _) in enumerate(choices) if v == provider.default_model),
0,
)
choice = _select_with_back("Pick a model", choices,
default_index=default_idx, console=cons)
if choice is BACK or choice is CANCEL:
return choice
if choice == "__custom__":
custom = _prompt_text("Type the model id",
default=provider.default_model, console=cons)
if custom is BACK or custom is CANCEL:
return custom
model = str(custom) or provider.default_model
else:
model = str(choice)
state["model"] = model
values["LANGCHAIN_MODEL_NAME"] = model
_save_partial(values)
return "ok"
def step_key() -> object:
provider: Provider = state["provider"] # type: ignore[assignment]
if provider.key_env is None:
cons.print()
cons.print(Text(" Ollama runs locally — no API key needed.",
style=Theme.success))
return "ok"
while True:
key = _prompt_secret(
f"Paste your {provider.label} API key "
"(saved to ~/.vibe-trading/.env, never logged)",
console=cons,
)
if key is BACK or key is CANCEL:
return key
err = _validate_key(provider, str(key))
if err is None:
state["key"] = key
values[provider.key_env] = str(key)
_save_partial(values)
return "ok"
cons.print(Text(f" {err} Try again, or press Esc to go back.",
style=Theme.danger))
def step_timeout() -> object:
choice = _select_with_back("Default request timeout",
list(TIMEOUT_CHOICES),
default_index=0, console=cons)
if choice is BACK or choice is CANCEL:
return choice
values["TIMEOUT_SECONDS"] = str(choice)
_save_partial(values)
return "ok"
def step_tushare() -> object:
choices = [
("__skip__", "No, skip (most users)"),
("__paste__", "Yes — paste my Tushare token"),
]
decision = _select_with_back(
"Enable Tushare for China A-share data? (optional)",
choices, default_index=0, console=cons,
)
if decision is BACK or decision is CANCEL:
return decision
if decision == "__paste__":
token = _prompt_secret("Tushare token", console=cons)
if token is BACK or token is CANCEL:
return token
if str(token).strip():
values["TUSHARE_TOKEN"] = str(token).strip()
_save_partial(values)
return "ok"
steps: list[Callable[[], object]] = [
step_provider, step_model, step_key, step_timeout, step_tushare,
]
i = 0
while i < len(steps):
result = steps[i]()
if result is CANCEL:
cons.print()
cons.print(Text(" Setup cancelled. No config written.",
style=Theme.warning))
return None
if result is BACK:
if i == 0:
cons.print()
cons.print(Text(" Setup cancelled. No config written.",
style=Theme.warning))
return None
i -= 1
continue
i += 1
final_path = _finalize(values)
cons.print()
cons.print(Text(f" ✓ Wrote {final_path}", style=Theme.success))
cons.print()
tour = _select_with_back(
"Want a quick tour? (or jump straight in)",
[("__skip__", "Skip — drop me in chat"),
("__tour__", "Show me a 30-second sample run")],
default_index=0, console=cons,
)
if tour == "__tour__":
cons.print()
cons.print(Text(
" Tip: try `analyze AAPL last 30 days` as your first prompt.\n"
" Type /help any time to see all commands.",
style=Theme.muted,
))
return final_path
__all__ = ["run_onboarding", "PROVIDERS", "Provider", "BACK", "CANCEL"]
+345
View File
@@ -0,0 +1,345 @@
"""Streaming renderer + thinking spinner for the interactive CLI.
Single-agent mode (the only mode demoed in Parcel α): a short-lived
``Rich.Live`` with ``transient=True`` drives the spinner while the model
thinks; tool events and the final answer are emitted as *static* prints so
they persist on screen after the live area is erased. This avoids the
"ghost re-paint" bug that plagued Vibe-Trading's earlier Live-based
dashboards (nanobot lesson, design proposal §3.5).
Swarm mode is a stub: callers should fall through to the legacy Rich Live
dashboard in ``cli/_legacy.py``. The full multi-agent grid lands in Parcel β.
Tool events use the dexter-style ``⏺ Tool Name (args) duration · summary``
format. Tool names are auto-Title-Cased with a small acronym whitelist.
"""
from __future__ import annotations
import re
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Iterator, Optional
from rich.console import Console
from rich.live import Live
from rich.text import Text
from cli.theme import Theme, get_console
from cli.utils.format import format_duration
from cli.utils.thinking_verbs import pick_thinking_verb
_PREFIX_RE = re.compile(r"^(get|run|do|fetch|load|build|compute|calc(?:ulate)?)_")
# Canonical acronyms that should render UPPERCASE rather than Title-Case.
# Whitelist (not "len <= 3" heuristic) so common words like "web" stay "Web".
_ACRONYMS: frozenset[str] = frozenset({
"api", "url", "csv", "json", "yaml", "sql", "tsv", "pdf",
"ai", "ml", "dcf", "fcf", "roe", "roi", "pe", "pb", "iv",
"btc", "eth", "usd", "cny", "id",
})
def beautify_tool_name(raw: str) -> str:
"""``get_market_data`` → ``Market Data``. Acronyms in whitelist UPPER."""
if not raw:
return raw
parts = _PREFIX_RE.sub("", raw).split("_")
out: list[str] = []
for part in parts:
if not part:
continue
out.append(part.upper() if part.lower() in _ACRONYMS else part.capitalize())
return " ".join(out) if out else raw
def summarize_args(args: dict | str | None, *, max_len: int = 60) -> str:
"""Compact single-line preview. Prefers query/symbol/url; truncates long."""
if not args:
return ""
if isinstance(args, str):
return _truncate(args, max_len)
if not isinstance(args, dict):
return _truncate(str(args), max_len)
for priority_key in ("query", "prompt", "url", "symbol", "ticker", "code"):
if priority_key in args and args[priority_key]:
return f'"{_truncate(str(args[priority_key]), max_len - 2)}"'
pieces: list[str] = []
used = 0
for k, v in args.items():
token = f"{k}={_truncate(str(v), 20)}"
if used + len(token) + 2 > max_len:
pieces.append("…"); break
pieces.append(token); used += len(token) + 2
return ", ".join(pieces)
def _truncate(value: str, max_len: int) -> str:
if len(value) <= max_len:
return value
if max_len <= 1:
return "…"
return value[: max_len - 1] + "…"
# ---------------------------------------------------------------------------
# ThinkingSpinner
# ---------------------------------------------------------------------------
@dataclass
class _SpinnerState:
verb: str
started_at: float
paused: bool = False
stopped: bool = False
extra: str = ""
class ThinkingSpinner:
"""Transient spinner that can be paused/resumed safely.
``pause()`` is a context manager: stops the underlying ``Live`` (clearing
its line), yields so the caller can print a static line, then restarts.
This is nanobot's pattern and avoids ANSI escape interleaving.
"""
def __init__(self, console: Console | None = None) -> None:
self._console = console or get_console()
self._state = _SpinnerState(
verb=pick_thinking_verb(), started_at=time.monotonic()
)
self._live: Optional[Live] = None
self._lock = threading.Lock()
self._tick_thread: Optional[threading.Thread] = None
def start(self, verb: str | None = None) -> None:
"""Begin rendering. ``verb`` rerolls per turn if not supplied."""
with self._lock:
if self._live is not None:
return
self._state.verb = verb or pick_thinking_verb()
self._state.started_at = time.monotonic()
self._state.paused = False
self._state.stopped = False
self._live = Live(self._render(), console=self._console,
refresh_per_second=10, transient=True)
self._live.start(refresh=False)
self._tick_thread = threading.Thread(target=self._tick, daemon=True)
self._tick_thread.start()
def stop(self) -> None:
"""Stop and erase the spinner line."""
with self._lock:
self._state.stopped = True
if self._live is not None:
try:
self._live.stop()
except Exception: # noqa: BLE001 — Rich shutdown can race
pass
self._live = None
def set_extra(self, extra: str) -> None:
"""Update the right-hand suffix (e.g. token / cost preview)."""
self._state.extra = extra
@contextmanager
def pause(self) -> Iterator[None]:
"""Suspend the spinner so the caller can safely ``console.print``."""
was_running = False
with self._lock:
if self._live is not None and not self._state.stopped:
was_running = True
self._state.paused = True
try:
self._live.stop()
except Exception: # noqa: BLE001
pass
self._live = None
try:
yield
finally:
if was_running and not self._state.stopped:
with self._lock:
self._state.paused = False
self._live = Live(self._render(), console=self._console,
refresh_per_second=10, transient=True)
self._live.start(refresh=False)
def _render(self) -> Text:
elapsed_ms = int((time.monotonic() - self._state.started_at) * 1000)
text = Text()
text.append(" ")
text.append("●", style=Theme.warning) # pulse anchor (no emoji)
text.append(" ")
text.append(self._state.verb, style=Theme.primary_dim)
text.append(" ")
text.append(format_duration(elapsed_ms), style=Theme.muted)
if self._state.extra:
text.append(" · ", style=Theme.muted)
text.append(self._state.extra, style=Theme.muted)
return text
def _tick(self) -> None:
"""Refresh duration label every 100 ms."""
while not self._state.stopped:
time.sleep(0.1)
with self._lock:
if self._live is not None and not self._state.paused:
try:
self._live.update(self._render(), refresh=True)
except Exception: # noqa: BLE001
pass
# ---------------------------------------------------------------------------
# StreamRenderer
# ---------------------------------------------------------------------------
@dataclass
class ToolCall:
"""A single tool invocation observed during a turn."""
name: str
args: dict | str | None
started_at: float = field(default_factory=time.monotonic)
finished_at: Optional[float] = None
summary: str = ""
@property
def duration_ms(self) -> int | None:
if self.finished_at is None:
return None
return int((self.finished_at - self.started_at) * 1000)
class StreamRenderer:
"""Drives the streaming display for a single agent turn.
Mode ``"single"`` is the focus of Parcel α. Mode ``"swarm"`` is a stub
— callers should detect it and route to ``_legacy`` swarm rendering.
"""
def __init__(self, *, mode: str = "single",
console: Console | None = None) -> None:
if mode not in {"single", "swarm"}:
raise ValueError(f"unknown StreamRenderer mode: {mode!r}")
self._mode = mode
self._console = console or get_console()
self._spinner: Optional[ThinkingSpinner] = None
self._active_calls: dict[str, ToolCall] = {}
@property
def mode(self) -> str:
return self._mode
@contextmanager
def turn(self, *, verb: str | None = None) -> Iterator["StreamRenderer"]:
"""Context manager wrapping one agent turn. Manages spinner lifecycle."""
if self._mode == "swarm":
yield self
return
self._spinner = ThinkingSpinner(self._console)
self._spinner.start(verb=verb)
try:
yield self
finally:
if self._spinner is not None:
self._spinner.stop()
self._spinner = None
self._active_calls.clear()
def on_tool_start(self, name: str, args: dict | str | None) -> None:
"""Record a tool call start. Line emitted only when call finishes."""
self._active_calls[name] = ToolCall(name=name, args=args)
def on_tool_end(self, name: str, *, summary: str = "") -> None:
"""Mark a tool call finished and emit its static line."""
call = self._active_calls.pop(name, None)
if call is None:
call = ToolCall(name=name, args=None,
finished_at=time.monotonic(), summary=summary)
else:
call.finished_at = time.monotonic()
call.summary = summary
self._print_tool_line(call)
def _print_tool_line(self, call: ToolCall) -> None:
line = Text()
line.append("●", style=Theme.success)
line.append(" ")
line.append(beautify_tool_name(call.name), style=Theme.label)
args_preview = summarize_args(
call.args if isinstance(call.args, (dict, str)) else None
)
if args_preview:
line.append(" ")
line.append(f"({args_preview})", style=Theme.muted)
# Right-pad so the duration column aligns at column 60.
pad_to = 60
current_len = len(line.plain)
line.append(" " * max(2, pad_to - current_len))
if call.duration_ms is not None:
line.append(format_duration(call.duration_ms), style=Theme.muted)
if call.summary:
line.append(" · ", style=Theme.muted)
line.append(call.summary, style=Theme.muted)
self._emit_static(line)
def _emit_static(self, renderable) -> None: # type: ignore[no-untyped-def]
"""Print a static line, pausing the spinner if running."""
if self._spinner is not None:
with self._spinner.pause():
self._console.print(renderable)
else:
self._console.print(renderable)
def print_answer(self, body: str) -> None:
"""Print the assistant's final answer, prefixed with the brand marker."""
if self._spinner is not None:
self._spinner.stop()
self._spinner = None
lines = body.splitlines() or [""]
head = Text()
head.append("● ", style=Theme.primary)
head.append(lines[0])
self._console.print(head)
for line in lines[1:]:
self._console.print(Text(" " + line))
self._console.print()
def print_footer(self, *, run_id: str, tool_count: int,
duration_ms: int | float | None,
token_count: int | None, cost: float | None) -> None:
"""``/show <id> · N tool calls · 4.1s · 1.2k tokens · $0.003``."""
from cli.utils.format import abbreviate_num, format_tokens
line = Text(" ")
line.append(f"/show {run_id}", style=Theme.info)
line.append(" · ", style=Theme.muted)
line.append(
f"{tool_count} tool call{'s' if tool_count != 1 else ''}",
style=Theme.muted,
)
line.append(" · ", style=Theme.muted)
line.append(format_duration(duration_ms), style=Theme.muted)
if token_count is not None:
line.append(" · ", style=Theme.muted)
line.append(format_tokens(token_count), style=Theme.muted)
if cost is not None:
line.append(" · ", style=Theme.muted)
line.append(abbreviate_num(cost, currency="$"), style=Theme.muted)
self._console.print(line)
self._console.print()
__all__ = [
"StreamRenderer",
"ThinkingSpinner",
"ToolCall",
"beautify_tool_name",
"summarize_args",
]
+239
View File
@@ -0,0 +1,239 @@
"""Centralised Rich Style table for the Vibe-Trading CLI.
All visible colour decisions for the CLI live here so the look stays consistent
with the web app (see ``frontend/src/index.css`` design tokens). The brand
accent is the orange wordmark colour selected in the 2026-05-19 UI/UX design
proposal (§1.3):
* light terminals: ``#d97706`` (WCAG-AA white-on-orange contrast 4.8:1)
* dark terminals: ``#fa9842`` (matches web ``--primary`` in dark mode)
Semantic styles follow the design proposal's "Orange discipline rule" — only
the brand wordmark, the primary CTA, and the agent identity should ever use
``Theme.primary``. Tool status, success, danger and warning use their own
semantic tokens so the orange stays meaningful.
The module exposes a single shared :class:`rich.console.Console` instance via
:func:`get_console` so every component (banner, onboarding wizard, stream
renderer, status bar) renders to the same TTY. ``NO_COLOR`` is honoured by
forcing Rich into no-color mode at construction time.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from typing import Final
from rich.console import Console
from rich.style import Style
# ---------------------------------------------------------------------------
# Brand palette (single source of truth — mirrored in frontend/src/index.css)
# ---------------------------------------------------------------------------
BRAND_ORANGE_LIGHT: Final[str] = "#d97706"
"""Wordmark / primary CTA colour on light terminals (web ``--primary`` light)."""
BRAND_ORANGE_DARK: Final[str] = "#fa9842"
"""Wordmark / primary CTA colour on dark terminals (web ``--primary`` dark)."""
def _is_dark_terminal(console: Console) -> bool:
"""Heuristically decide whether the terminal is rendering on a dark theme.
Rich does not expose a foolproof "dark mode?" probe, so we use a best-effort
chain:
1. Explicit override via ``VIBE_TRADING_THEME`` env var (``dark``/``light``).
2. ``COLORFGBG`` (set by xterm/rxvt/Konsole): the part after ``;`` is the
background colour index. ``0`` (black) → dark, anything else → light.
3. macOS Terminal.app sets ``TERM_PROGRAM=Apple_Terminal`` without a colour
hint — assume dark (it is the macOS Big Sur+ default).
4. Fallback: dark (matches the modern default across iTerm, Alacritty,
Windows Terminal, VS Code integrated terminal).
Args:
console: Rich console (used to short-circuit when colour is disabled).
Returns:
``True`` if dark mode should be assumed.
"""
override = os.environ.get("VIBE_TRADING_THEME", "").strip().lower()
if override in {"dark", "light"}:
return override == "dark"
if console.color_system is None:
# No colour at all — pick dark so dim styles read correctly when forced
# to ANSI later, but in practice nothing will be coloured.
return True
colorfgbg = os.environ.get("COLORFGBG", "")
if ";" in colorfgbg:
bg = colorfgbg.split(";")[-1].strip()
if bg.isdigit():
return int(bg) in {0, 1, 2, 3, 4, 5, 6, 7, 8} # low ANSI ⇒ dark
if os.environ.get("TERM_PROGRAM", "").lower() == "apple_terminal":
return True
return True
@dataclass(frozen=True)
class _ThemeStyles:
"""Immutable bundle of Rich :class:`Style` instances used CLI-wide."""
primary: Style
"""Brand orange. Reserved for wordmark, primary CTA, agent identity."""
primary_dim: Style
"""Slightly muted brand orange for hover/inactive variants."""
success: Style
"""Tool completed, backtest passed, env saved (green)."""
danger: Style
"""Failures, validation errors, destructive prompts (red)."""
warning: Style
"""Tool running, partial state, non-blocking caution (amber)."""
info: Style
"""Hints, secondary headings, neutral attention (cyan)."""
muted: Style
"""Captions, dim metadata, separators (dim grey)."""
bold: Style
"""Section headers, table column titles."""
label: Style
"""Inline labels (e.g. ``Model:``) — slightly bolder than ``muted``."""
accent_bg: Style
"""Reverse-video accent — used for the slash-typeahead highlight."""
_NO_COLOR: Final[bool] = "NO_COLOR" in os.environ
def _build_styles(dark: bool, no_color: bool) -> _ThemeStyles:
"""Construct the style bundle for the current terminal mode."""
if no_color:
plain = Style()
return _ThemeStyles(
primary=Style(bold=True),
primary_dim=Style(),
success=Style(),
danger=Style(bold=True),
warning=Style(),
info=Style(),
muted=Style(dim=True),
bold=Style(bold=True),
label=Style(bold=True),
accent_bg=Style(reverse=True),
)
brand = BRAND_ORANGE_DARK if dark else BRAND_ORANGE_LIGHT
brand_dim = "#a35a04" if not dark else "#c87a2f"
return _ThemeStyles(
primary=Style(color=brand, bold=True),
primary_dim=Style(color=brand_dim),
success=Style(color="#16a34a", bold=True),
danger=Style(color="#dc2626", bold=True),
warning=Style(color="#d97706"), # amber for in-flight tools
info=Style(color="#0891b2"),
muted=Style(color="#737373", dim=True) if not dark else Style(color="#9ca3af", dim=True),
bold=Style(bold=True),
label=Style(color="#525252", bold=True) if not dark else Style(color="#d4d4d8", bold=True),
accent_bg=Style(color=brand, reverse=True, bold=True),
)
# ---------------------------------------------------------------------------
# Singleton console + style accessors
# ---------------------------------------------------------------------------
def _make_console() -> Console:
"""Create the shared Rich console.
``force_terminal`` is *not* set: Rich's own ``isatty`` detection is
correct and forcing it leaks ANSI escapes into ``docker exec -i`` /
piped output (nanobot lesson, issue #3265).
``no_color`` honours ``NO_COLOR`` (https://no-color.org).
"""
return Console(
no_color=_NO_COLOR,
soft_wrap=False,
highlight=False,
emoji=False, # project rule: no emoji anywhere
markup=True,
stderr=False,
legacy_windows=False if sys.platform == "win32" else None,
)
_console: Console = _make_console()
_dark: bool = _is_dark_terminal(_console)
_styles: _ThemeStyles = _build_styles(_dark, _NO_COLOR or _console.color_system is None)
def get_console() -> Console:
"""Return the shared console instance.
Returns:
The single :class:`Console` everyone in ``agent/cli`` writes to.
"""
return _console
def is_dark() -> bool:
"""Return ``True`` if dark-mode styles are active."""
return _dark
class Theme:
"""Namespace of Rich :class:`Style` instances used across the CLI.
Use as attribute access (``Theme.primary``) so that swapping the
underlying palette only requires editing this module. Each attribute is a
:class:`rich.style.Style` and can be passed to ``console.print`` directly
or composed via ``Text("…", style=Theme.primary)``.
Example:
>>> from cli.theme import Theme, get_console
>>> get_console().print("Vibe-Trading", style=Theme.primary)
"""
primary: Final[Style] = _styles.primary
primary_dim: Final[Style] = _styles.primary_dim
success: Final[Style] = _styles.success
danger: Final[Style] = _styles.danger
warning: Final[Style] = _styles.warning
info: Final[Style] = _styles.info
muted: Final[Style] = _styles.muted
bold: Final[Style] = _styles.bold
label: Final[Style] = _styles.label
accent_bg: Final[Style] = _styles.accent_bg
# Convenience aliases used by intro / stream
brand_hex: Final[str] = BRAND_ORANGE_DARK if _dark else BRAND_ORANGE_LIGHT
__all__ = [
"Theme",
"get_console",
"is_dark",
"BRAND_ORANGE_LIGHT",
"BRAND_ORANGE_DARK",
]
+14
View File
@@ -0,0 +1,14 @@
"""Visual building blocks for the interactive CLI."""
from cli.ui.banner import print_banner
from cli.ui.rail import RailRunDashboard
from cli.ui.transcript import render_answer, render_elapsed_status, render_prompt_footer, render_recap
__all__ = [
"RailRunDashboard",
"print_banner",
"render_answer",
"render_elapsed_status",
"render_prompt_footer",
"render_recap",
]
+81
View File
@@ -0,0 +1,81 @@
"""Large startup banner for the interactive CLI."""
from __future__ import annotations
from typing import Final
from rich.console import Console
from rich.text import Text
from cli._version import __version__ as _VERSION
from cli.theme import Theme
_LOGO: Final[tuple[str, ...]] = (
r"__ ___ _ _______ _ _ ",
r"\ \ / (_) | |__ __| | (_) ",
r" \ \ / / _| |__ ___ | |_ __ __ _ __| |_ _ __ __ _ ",
r" \ \/ / | | '_ \ / _ \ | | '__/ _` |/ _` | | '_ \ / _` |",
r" \ / | | |_) | __/ | | | | (_| | (_| | | | | | (_| |",
r" \/ |_|_.__/ \___| |_|_| \__,_|\__,_|_|_| |_|\__, |",
r" __/ |",
r" |___/ ",
)
_GRADIENT_START: Final[tuple[int, int, int]] = (0x25, 0x8B, 0xFF)
_GRADIENT_END: Final[tuple[int, int, int]] = (0xA5, 0xCF, 0xFF)
def _lerp(start: int, end: int, ratio: float) -> int:
return round(start + (end - start) * ratio)
def _gradient_style(index: int, total: int) -> str:
ratio = 0.0 if total <= 1 else index / (total - 1)
red = _lerp(_GRADIENT_START[0], _GRADIENT_END[0], ratio)
green = _lerp(_GRADIENT_START[1], _GRADIENT_END[1], ratio)
blue = _lerp(_GRADIENT_START[2], _GRADIENT_END[2], ratio)
return f"bold #{red:02x}{green:02x}{blue:02x}"
def _gradient_line(line: str) -> Text:
text = Text()
total = max(1, len(line.rstrip()))
for idx, char in enumerate(line):
text.append(char, style=_gradient_style(idx, total) if char != " " else None)
return text
def _center_pad(console: Console, width: int) -> str:
columns = console.size.width
if columns >= width + 4:
return " " * max(0, (columns - width) // 2)
return " "
def print_banner(
console: Console,
*,
model: str,
skills: int,
tools: int,
sessions: int,
version: str = _VERSION,
mode: str = "cli",
**_: object,
) -> None:
"""Render the cold-start banner once before the prompt appears."""
del skills, tools, sessions
width = max(len(line.rstrip()) for line in _LOGO)
pad = _center_pad(console, width)
console.print()
for line in _LOGO:
rendered = Text(pad)
rendered.append(_gradient_line(line.rstrip()))
console.print(rendered)
meta = Text(pad)
meta.append(f"vibe-trading v{version} · {mode} · {model}", style=Theme.muted)
console.print(meta)
console.print()
+599
View File
@@ -0,0 +1,599 @@
"""Codex-style rail renderer for agent activity."""
from __future__ import annotations
import json
import re
import shutil
import textwrap
import threading
import time
from dataclasses import dataclass, field
from pathlib import PureWindowsPath
from typing import Any, Literal
from urllib.parse import urlparse
from rich.console import Group
from rich.text import Text
from cli.theme import Theme
from cli.utils.thinking_verbs import pick_thinking_verb
StepStatus = Literal["active", "done", "error", "warning"]
_MARKER = "#8a8f98"
_MARKER_ACTIVE = "#d1d5db"
_ACTION = "bold white"
_CMD = "bold #3b82f6"
_ARG = "#7dd3fc"
_PATH = "#f0abfc"
_DETAIL = "#6b7280"
_DONE = "bold #22c55e"
_ERROR = "bold #ef4444"
_WARNING = "bold #f59e0b"
_PLAIN_TAG = re.compile(
r"\[/?(?:#[0-9a-fA-F]{3,6}|[a-zA-Z][a-zA-Z0-9_ -]*)(?:\s+[^\]]+)?\]"
)
_PATH_TOKEN = re.compile(
r'("[^"]*[\\/][^"]*"|[A-Za-z]:[\\/][^\s,)]+|(?:\.{1,2}[\\/])?[^\s"]*[\\/][^\s"]+)'
)
@dataclass
class RailStep:
title: str
tool: str = ""
args: Any = None
status: StepStatus = "active"
lines: list[str] = field(default_factory=list)
started_at: float = field(default_factory=time.monotonic)
duration_s: float = 0.0
def _strip_markup(value: Any) -> str:
return _PLAIN_TAG.sub("", str(value or "")).replace("\r\n", "\n").strip()
def _collapse_ws(value: Any) -> str:
return re.sub(r"\s+", " ", _strip_markup(value)).strip()
def _coerce_int(value: Any) -> int:
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _format_tool_name(name: str) -> str:
cleaned = str(name or "tool").strip().replace("-", " ").replace("_", " ")
if cleaned.lower().startswith("get "):
cleaned = cleaned[4:]
return " ".join(part[:1].upper() + part[1:] for part in cleaned.split()) or "Tool"
def _tool_title(tool: str, args: Any) -> str:
lower = str(tool or "tool").lower()
if lower in {"bash", "background_run", "shell"}:
return "shell command"
if lower == "load_skill":
return "skill"
if lower in {"read_file", "file_read"}:
return "file"
if lower in {"write_file", "edit_file"}:
return "file"
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
return "url"
if "search" in lower:
return "web"
if lower in {"get_market_data", "market_data", "stock_price", "get_stock_price"}:
return "market data"
if lower in {"answer", "final_answer"}:
return "answer"
return _format_tool_name(tool)
def _arg_value(args: Any, *keys: str) -> str:
if not isinstance(args, dict):
return ""
for key in keys:
value = args.get(key)
if value:
return _collapse_ws(value)
return ""
def _initial_detail(tool: str, args: Any) -> str | None:
if not isinstance(args, dict):
return None
lower = str(tool or "").lower()
if lower in {"bash", "background_run", "shell"}:
return None
if lower in {"read_file", "file_read", "write_file", "edit_file"}:
return _arg_value(args, "path", "file_path") or None
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
url = _arg_value(args, "url")
return f"fetching {_short_url(url)}" if url else None
if "search" in lower:
query = _arg_value(args, "query", "q")
return query or None
symbol = _arg_value(args, "symbol", "ticker", "code", "asset")
return symbol or None
def _result_summary(tool: str, status: str, preview: Any) -> list[str]:
text = _strip_markup(preview)
lower = str(tool or "").lower()
if lower == "load_skill":
match = re.search(r"<skill\s+name=[\"']?([^\"'>\s]+)", text)
if match:
return [f"loaded {match.group(1)}"]
payload: Any = None
compact = _collapse_ws(text)
source = text.strip()
if source.startswith("{") or source.startswith("["):
try:
payload = json.loads(source)
except json.JSONDecodeError:
try:
payload = json.loads(compact)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
if status != "ok":
detail = payload.get("error") or payload.get("stderr") or payload.get("message") or compact
return _compact_lines(f"Error: {_collapse_ws(detail)}", max_lines=3)
if lower in {"bash", "background_run", "shell"}:
stdout = payload.get("stdout")
stderr = payload.get("stderr")
if stdout:
return _compact_lines(str(stdout), max_lines=5)
if stderr:
return _compact_lines(str(stderr), max_lines=4)
return ["completed"]
if lower in {"read_file", "file_read"}:
content = str(payload.get("content") or "")
return [f"read {len(content):,} chars"] if content else ["read file"]
if lower == "load_skill":
content = str(payload.get("content") or "")
match = re.search(r"<skill\s+name=[\"']([^\"']+)[\"']", content)
return [f"loaded {match.group(1)}"] if match else ["loaded skill"]
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
title = payload.get("title")
if title:
return [_clip(_collapse_ws(title), 96)]
content = payload.get("content") or payload.get("markdown") or payload.get("text")
if content:
return [f"read {len(str(content)):,} chars"]
results = payload.get("results")
if isinstance(results, list):
return [f"Did 1 search · {len(results)} results"]
for key in ("summary", "message", "stdout", "content"):
if payload.get(key):
return _compact_lines(str(payload[key]), max_lines=4)
if not compact:
return []
if status != "ok":
return _compact_lines(f"Error: {compact}", max_lines=3)
return _compact_lines(text, max_lines=4)
def _compact_lines(value: str, *, max_lines: int) -> list[str]:
raw_lines = [line.rstrip() for line in _strip_markup(value).splitlines() if line.strip()]
if not raw_lines:
return []
clipped = [_clip_preserve(line, 112) for line in raw_lines]
if len(clipped) <= max_lines:
return clipped
head_count = max(1, max_lines // 2)
tail_count = max(1, max_lines - head_count - 1)
hidden = len(clipped) - head_count - tail_count
return [
*clipped[:head_count],
f"... +{hidden} lines (ctrl + t to view transcript)",
*clipped[-tail_count:],
]
def _short_url(url: str, limit: int = 64) -> str:
if not url:
return ""
try:
parsed = urlparse(url)
except ValueError:
return _clip(url, limit)
if not parsed.netloc:
return _clip(url, limit)
display = parsed.netloc + parsed.path
return _clip(display.rstrip("/") or parsed.netloc, limit)
def _clip(text: str, limit: int) -> str:
text = _collapse_ws(text)
if len(text) <= limit:
return text
return text[: max(1, limit - 1)].rstrip() + "…"
def _clip_preserve(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: max(1, limit - 1)].rstrip() + "…"
def _wrap(text: str, width: int) -> list[str]:
if width <= 8:
return [_clip_preserve(text, max(1, width))]
return textwrap.wrap(
text,
width=width,
break_long_words=False,
break_on_hyphens=False,
replace_whitespace=False,
drop_whitespace=False,
) or [""]
def _command_from_args(args: Any) -> str:
return _arg_value(args, "command", "cmd")
def _split_command(command: str) -> tuple[str, str]:
command = _collapse_ws(command)
if not command:
return "", ""
if command.startswith('"'):
end = command.find('"', 1)
if end > 0:
return _display_executable(command[1:end]), command[end + 1 :].strip()
first, _, rest = command.partition(" ")
return _display_executable(first.strip('"')), rest.strip()
def _display_executable(executable: str) -> str:
if not executable:
return ""
cleaned = executable.strip('"')
if "\\" in cleaned or "/" in cleaned:
cleaned = PureWindowsPath(cleaned).name
if cleaned.lower().endswith(".exe"):
cleaned = cleaned[:-4]
return cleaned or executable
def _append_arg_text(line: Text, args: str, *, limit: int) -> None:
args = _clip(args, limit)
if not args:
return
pos = 0
for match in _PATH_TOKEN.finditer(args):
if match.start() > pos:
line.append(args[pos : match.start()], style=_ARG)
line.append(match.group(0), style=_PATH)
pos = match.end()
if pos < len(args):
line.append(args[pos:], style=_ARG)
class RailRunDashboard:
"""Compatibility dashboard that renders agent events like Codex transcripts."""
def __init__(self, prompt: str, max_iter: int) -> None:
self.prompt = prompt
self.max_iter = max_iter
self.start_time = time.monotonic()
self.iterations = 0
self.live: Any = None
self.steps: list[RailStep] = []
self.latest_text = ""
self.thinking_active = True
self.completion_summary: str | None = None
self.status: str = "running"
self.thinking_verb = pick_thinking_verb()
self.input_tokens = 0
self.output_tokens = 0
self._last_progress_render = 0.0
self._ticker_stop = threading.Event()
self._ticker = threading.Thread(
target=self._tick,
daemon=True,
name="vibe-rail-ticker",
)
self._ticker.start()
def _tick(self) -> None:
while not self._ticker_stop.wait(1.0):
if self.status == "running" and self.live is not None:
self.refresh()
def close(self) -> None:
self._ticker_stop.set()
def refresh(self) -> None:
if self.live is not None:
self.live.update(self.render())
def handle_event(self, event_type: str, data: dict[str, Any]) -> None:
if event_type == "text_delta":
delta = _collapse_ws(data.get("delta"))
if delta:
self.latest_text = (self.latest_text + delta).strip()[-500:]
self._append_to_active(delta, throttle=True)
self.refresh()
return
if event_type == "thinking_done":
self.thinking_active = False
self.refresh()
return
if event_type == "llm_usage":
input_tokens = _coerce_int(data.get("input_tokens"))
output_tokens = _coerce_int(data.get("output_tokens"))
total_tokens = _coerce_int(data.get("total_tokens"))
if input_tokens or output_tokens:
self.input_tokens += input_tokens
self.output_tokens += output_tokens
else:
self.output_tokens += total_tokens
self.refresh()
return
if event_type == "tool_call":
self.thinking_active = False
tool = str(data.get("tool") or "tool")
args = data.get("arguments") or {}
self.iterations += 1
step = RailStep(title=_tool_title(tool, args), tool=tool, args=args)
detail = _initial_detail(tool, args)
if detail:
step.lines.append(detail)
self.steps.append(step)
self.steps = self.steps[-10:]
self.refresh()
return
if event_type == "tool_progress":
tool = str(data.get("tool") or "")
step = self._active_step(tool)
pieces: list[str] = []
stage = _collapse_ws(data.get("stage"))
current = data.get("current")
total = data.get("total")
message = _collapse_ws(data.get("message"))
if stage:
pieces.append(stage)
if isinstance(current, int) and isinstance(total, int) and total > 0:
pieces.append(f"{current}/{total}")
if message:
pieces.append(message)
if pieces:
step.lines.append(" · ".join(pieces))
step.lines = step.lines[-6:]
now = time.monotonic()
if now - self._last_progress_render >= 0.2:
self._last_progress_render = now
self.refresh()
return
if event_type == "tool_heartbeat":
tool = str(data.get("tool") or "")
self._active_step(tool)
self.refresh()
return
if event_type == "tool_result":
tool = str(data.get("tool") or "")
status = str(data.get("status") or "ok")
step = self._active_step(tool)
step.status = "done" if status == "ok" else "error"
step.duration_s = float(data.get("elapsed_ms") or 0.0) / 1000
for line in _result_summary(tool, status, data.get("preview")):
if not step.lines or step.lines[-1] != line:
if step.lines and step.lines[-1].startswith("still running…"):
step.lines[-1] = line
else:
step.lines.append(line)
step.lines = step.lines[-7:]
self.refresh()
return
if event_type == "compact":
tokens = data.get("tokens_before", "?")
self.steps.append(
RailStep(
title="context",
tool="compact",
status="warning",
lines=[f"{tokens} tokens summarized"],
duration_s=0.0,
)
)
self.steps = self.steps[-10:]
self.refresh()
def finish(self, result: dict[str, Any], elapsed: float | None = None) -> None:
self.status = str(result.get("status") or "done")
for step in self.steps:
if step.status == "active":
step.status = "done"
if not step.duration_s and elapsed is not None:
step.duration_s = max(0.0, elapsed)
content = _collapse_ws(result.get("content"))
reason = _collapse_ws(result.get("reason"))
if content:
self.completion_summary = "Done."
elif reason:
self.completion_summary = f"{self.status.title()}. {reason}"
else:
self.completion_summary = f"{self.status.title()}."
self.thinking_active = False
self.refresh()
self.close()
def render(self) -> Group:
width = max(44, shutil.get_terminal_size((88, 24)).columns)
detail_width = max(18, width - 7)
rows: list[Text] = []
for step in self.steps:
rows.append(self._step_line(step, width=max(18, width - 3)))
for line in self._render_lines(step):
rows.extend(self._detail_lines(line, width=detail_width, active=step.status == "active"))
if self.completion_summary is None:
rows.append(self._activity_line())
if self.completion_summary is not None:
if rows:
rows.append(Text(""))
rows.append(self._finish_line(self.completion_summary))
return Group(*rows)
def _render_lines(self, step: RailStep) -> list[str]:
lines = list(step.lines)
return lines[-7:]
def _active_step(self, tool: str) -> RailStep:
raw_tool = str(tool or "")
for step in reversed(self.steps):
if step.status == "active" and (not raw_tool or step.tool == raw_tool):
return step
step = RailStep(title=_tool_title(raw_tool or "tool", None), tool=raw_tool)
self.steps.append(step)
self.steps = self.steps[-10:]
return step
def _append_to_active(self, text: str, *, throttle: bool) -> None:
if not text:
return
now = time.monotonic()
if throttle and now - self._last_progress_render < 0.2:
return
self._last_progress_render = now
if self.steps and self.steps[-1].status == "active":
self.steps[-1].lines.append(_clip(text, 112))
self.steps[-1].lines = self.steps[-1].lines[-6:]
def _activity_line(self) -> Text:
elapsed = time.monotonic() - self.start_time
line = Text()
line.append("·", style=_MARKER_ACTIVE)
line.append(" ")
line.append(self.thinking_verb, style=Theme.muted)
line.append(f" ({elapsed:.0f}s)", style=Theme.muted)
return line
def _step_line(self, step: RailStep, *, width: int) -> Text:
marker_style = _MARKER_ACTIVE if step.status == "active" else _MARKER
if step.status == "error":
marker_style = _ERROR
elif step.status == "warning":
marker_style = _WARNING
line = Text()
line.append("•", style=marker_style)
line.append(" ")
lower = step.tool.lower()
if lower in {"bash", "background_run", "shell"}:
verb = "Running" if step.status == "active" else "Ran"
command = _command_from_args(step.args)
exe, rest = _split_command(command)
duration = self._duration_label(step)
budget = max(12, width - len(verb) - len(exe) - len(duration) - 7)
line.append(verb, style=_ACTION)
if exe:
line.append(" ")
line.append(exe, style=_CMD)
if rest:
line.append(" ")
_append_arg_text(line, rest, limit=budget)
elif not exe:
line.append(" shell command", style=_ACTION)
self._append_duration(line, duration)
return line
action = self._action_label(step)
duration = self._duration_label(step)
line.append(action, style=_ACTION)
detail = self._title_detail(step)
if detail:
line.append(" ")
if _looks_like_path(detail):
line.append(_clip(detail, max(12, width - len(action) - len(duration) - 6)), style=_PATH)
else:
line.append(_clip(detail, max(12, width - len(action) - len(duration) - 6)), style=_ARG)
self._append_duration(line, duration)
return line
def _duration_label(self, step: RailStep) -> str:
return ""
def _append_duration(self, line: Text, duration: str) -> None:
if duration:
line.append(" ")
line.append(duration, style=Theme.muted)
def _action_label(self, step: RailStep) -> str:
lower = step.tool.lower()
active = step.status == "active"
if lower == "load_skill":
return "Loading skill…" if active else "Loaded skill"
if lower in {"read_file", "file_read"}:
return "Reading file…" if active else "Read file"
if lower in {"write_file", "edit_file"}:
return "Writing file…" if active else "Wrote file"
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
return "Fetching…" if active else "Fetched"
if "search" in lower:
return "Searching web…" if active else "Searched web"
if lower in {"get_market_data", "market_data", "stock_price", "get_stock_price"}:
return "Reading market data…" if active else "Read market data"
if lower == "compact":
return "Compacted context"
return step.title
def _title_detail(self, step: RailStep) -> str:
args = step.args
lower = step.tool.lower()
if lower == "load_skill":
return _arg_value(args, "name", "skill")
if lower in {"read_file", "file_read", "write_file", "edit_file"}:
return _arg_value(args, "path", "file_path")
if lower in {"read_url", "web_fetch", "fetch", "read_url_tool"}:
return _short_url(_arg_value(args, "url"))
if "search" in lower:
query = _arg_value(args, "query", "q")
return f'"{query}"' if query else ""
return _arg_value(args, "symbol", "ticker", "code", "asset")
def _detail_lines(self, body: str, *, width: int, active: bool) -> list[Text]:
style = _DETAIL
parts = _wrap(_strip_markup(body), width)
rows: list[Text] = []
for index, part in enumerate(parts):
line = Text(" ")
if index == 0:
line.append("└", style=style)
line.append(" ")
else:
line.append(" ")
line.append(part, style=style)
rows.append(line)
return rows
def _finish_line(self, summary: str) -> Text:
line = Text()
line.append("•", style=_DONE if self.status in {"success", "ok", "done"} else _WARNING)
line.append(" ")
line.append(summary, style="bold")
return line
def _looks_like_path(value: str) -> bool:
return bool(_PATH_TOKEN.search(value))
+175
View File
@@ -0,0 +1,175 @@
"""Transcript helpers for the interactive CLI."""
from __future__ import annotations
import re
import shutil
from typing import Any
from rich import box
from rich.console import Group
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from cli.theme import Theme
_PIPE_SEPARATOR = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
# Standalone markdown horizontal rule (---, ***, ___). Stripped from
# assistant answers so they don't render as full-width terminal lines.
_HR_LINE = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")
_PROMPT_BORDER = "#4b5563"
_MUTED = "#6b7280"
def render_prompt_footer(width: int | None = None) -> Text:
"""Bottom border printed after prompt_toolkit accepts the user input."""
cols = width or shutil.get_terminal_size((88, 24)).columns
return Text("─" * max(10, cols), style=_PROMPT_BORDER)
def render_recap(history: list[dict[str, str]]) -> Text | None:
"""Return a deterministic dim recap of the previous turn."""
user = ""
assistant = ""
for message in reversed(history):
role = message.get("role")
content = (message.get("content") or "").strip()
if not content:
continue
if role == "assistant" and not assistant:
assistant = content
elif role == "user" and not user:
user = content
if user and assistant:
break
if not user and not assistant:
return None
parts = []
if user:
parts.append(f"Last request: {_sentence(user, 92)}")
if assistant:
parts.append(f"Result: {_sentence(assistant, 128)}")
line = Text("※ recap: ", style=Theme.muted)
line.append("; ".join(parts), style=Theme.muted)
return line
def render_elapsed_status(elapsed: float) -> Text:
"""Render the post-run timing line."""
line = Text("✻ ", style=_MUTED)
line.append("Analyzed", style=Theme.muted)
line.append(f" for {_format_duration(elapsed)}", style=Theme.muted)
return line
def render_answer(content: str) -> Group:
"""Render assistant Markdown, upgrading pipe tables to Rich tables."""
blocks: list[Any] = []
pending: list[str] = []
def flush_pending() -> None:
if pending:
blocks.append(Markdown("\n".join(pending).strip()))
pending.clear()
lines = content.splitlines()
index = 0
while index < len(lines):
if _is_table_start(lines, index):
flush_pending()
table_lines = [lines[index], lines[index + 1]]
index += 2
while index < len(lines) and _is_pipe_row(lines[index]):
table_lines.append(lines[index])
index += 1
table = _parse_table(table_lines)
if table is not None:
blocks.append(table)
else:
pending.extend(table_lines)
continue
if _HR_LINE.match(lines[index]):
# Drop standalone horizontal rules — Rich renders them as a
# full-width terminal line that looks like a separator border.
index += 1
continue
pending.append(lines[index])
index += 1
flush_pending()
return Group(*blocks) if blocks else Group(Text(content))
def _is_table_start(lines: list[str], index: int) -> bool:
return (
index + 1 < len(lines)
and _is_pipe_row(lines[index])
and bool(_PIPE_SEPARATOR.match(lines[index + 1]))
)
def _is_pipe_row(line: str) -> bool:
stripped = line.strip()
return stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2
def _parse_table(lines: list[str]) -> Table | None:
if len(lines) < 2:
return None
headers = _split_row(lines[0])
rows = [_split_row(line) for line in lines[2:] if _is_pipe_row(line)]
if not headers or not rows:
return None
table = Table(
box=box.SQUARE,
show_header=True,
show_lines=True,
header_style="bold white",
border_style="dim",
padding=(0, 1),
)
for header in headers:
table.add_column(_clean_inline_markdown(header), overflow="fold")
for row in rows:
cells = list(row[: len(headers)])
while len(cells) < len(headers):
cells.append("")
table.add_row(*[_clean_inline_markdown(cell) for cell in cells])
return table
def _split_row(line: str) -> list[str]:
stripped = line.strip().strip("|")
return [cell.strip() for cell in stripped.split("|")]
def _clean_inline_markdown(text: str) -> str:
cleaned = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text)
cleaned = re.sub(r"(\*|_)(.*?)\1", r"\2", cleaned)
cleaned = re.sub(r"`([^`]+)`", r"\1", cleaned)
return cleaned.strip()
def _sentence(text: str, limit: int) -> str:
collapsed = re.sub(r"\s+", " ", _clean_inline_markdown(text)).strip()
if len(collapsed) <= limit:
return collapsed
return collapsed[: max(1, limit - 1)].rstrip() + "…"
def _format_duration(seconds: float) -> str:
total = max(0, int(round(seconds)))
if total < 60:
return f"{total}s"
minutes, secs = divmod(total, 60)
if minutes < 60:
return f"{minutes}m {secs:02d}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h {minutes:02d}m {secs:02d}s"
+16
View File
@@ -0,0 +1,16 @@
"""Small, pure helpers shared across CLI components.
Everything here is stateless and side-effect free — safe to import from any
module in ``agent/cli``. Larger or stateful helpers belong in their own module
(see ``cli/stream.py`` for the streaming renderer).
"""
from cli.utils.format import abbreviate_num, format_duration, format_tokens
from cli.utils.thinking_verbs import pick_thinking_verb
__all__ = [
"abbreviate_num",
"format_duration",
"format_tokens",
"pick_thinking_verb",
]
+157
View File
@@ -0,0 +1,157 @@
"""Pure formatting helpers for the CLI surface.
These mirror the formatting conventions used in the web app (see
``frontend/src/lib/format.ts``) so a duration printed in the CLI reads the
same as one printed in the chat bubble:
* ``format_duration(ms_or_s)`` → ``"230ms" | "1.4s" | "4m 12s"``
* ``format_tokens(n)`` → ``"1.2k tokens" | "452 tokens"``
* ``abbreviate_num(n)`` → ``"$0.003" | "12.4M" | "452"``
All functions are pure and tolerate ``None`` / negative inputs so callers can
hand them raw counter values without pre-validation.
"""
from __future__ import annotations
from typing import Union
Number = Union[int, float]
# ---------------------------------------------------------------------------
# Duration
# ---------------------------------------------------------------------------
def format_duration(value: Number | None, *, unit: str = "ms") -> str:
"""Render a duration as a short, human-readable string.
Args:
value: Numeric duration. Treated as milliseconds by default — this
matches the rest of the codebase (tool callbacks, ``elapsed_ms``
in ``cli/_legacy.py``, the spinner clock). Pass ``unit="s"`` if
you already have seconds.
unit: Either ``"ms"`` (default) or ``"s"``.
Returns:
Compact label, e.g. ``"230ms"``, ``"1.4s"``, ``"2m 05s"``. ``None``
and negative values render as ``"—"``.
"""
if value is None:
return "—"
try:
v = float(value)
except (TypeError, ValueError):
return "—"
if v < 0:
return "—"
if unit == "s":
seconds = v
elif unit == "ms":
seconds = v / 1000.0
else:
raise ValueError(f"unit must be 'ms' or 's', got {unit!r}")
if seconds < 1.0:
return f"{int(round(seconds * 1000))}ms"
if seconds < 60:
# 1.4s, 12.0s — keep one decimal to communicate sub-second jitter
return f"{seconds:.1f}s"
minutes, rem = divmod(int(round(seconds)), 60)
if minutes < 60:
return f"{minutes}m {rem:02d}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h {minutes:02d}m"
# ---------------------------------------------------------------------------
# Tokens
# ---------------------------------------------------------------------------
def format_tokens(count: Number | None) -> str:
"""Render a token count with a thousands-suffix.
Args:
count: Integer-ish token count. ``None`` / negatives → ``"0 tokens"``.
Returns:
E.g. ``"452 tokens"``, ``"1.2k tokens"``, ``"3.4M tokens"``.
"""
if count is None:
return "0 tokens"
try:
n = int(count)
except (TypeError, ValueError):
return "0 tokens"
if n <= 0:
return "0 tokens"
if n < 1_000:
return f"{n} tokens"
if n < 1_000_000:
return f"{n / 1_000:.1f}k tokens"
if n < 1_000_000_000:
return f"{n / 1_000_000:.1f}M tokens"
return f"{n / 1_000_000_000:.1f}B tokens"
# ---------------------------------------------------------------------------
# Generic abbreviation (dollars / counts / sizes)
# ---------------------------------------------------------------------------
def abbreviate_num(value: Number | None, *, currency: str | None = None) -> str:
"""Abbreviate a number with magnitude suffix.
Use this for status-bar numbers where horizontal space is scarce. Currency
amounts under ``$1`` keep 3-decimal precision so per-call cost reads
sensibly (e.g. ``$0.003``).
Args:
value: Raw number to abbreviate. ``None`` → ``"—"``.
currency: Optional currency symbol prefix (e.g. ``"$"``). When set,
small fractional values keep 3 decimals.
Returns:
Abbreviated string. Examples::
abbreviate_num(452) → "452"
abbreviate_num(12_400) → "12.4k"
abbreviate_num(3_200_000) → "3.2M"
abbreviate_num(0.003, currency="$") → "$0.003"
abbreviate_num(1.42, currency="$") → "$1.42"
"""
if value is None:
return "—"
try:
n = float(value)
except (TypeError, ValueError):
return "—"
prefix = currency or ""
sign = "-" if n < 0 else ""
n_abs = abs(n)
if currency is not None and n_abs < 1.0:
# 3 decimals communicates per-token / per-call cost precision
return f"{sign}{prefix}{n_abs:.3f}"
if n_abs < 1_000:
if currency is not None or not float(n_abs).is_integer():
return f"{sign}{prefix}{n_abs:.2f}".rstrip("0").rstrip(".")
return f"{sign}{prefix}{int(n_abs)}"
for unit, divisor in (("k", 1_000), ("M", 1_000_000), ("B", 1_000_000_000), ("T", 1_000_000_000_000)):
if n_abs < divisor * 1_000:
return f"{sign}{prefix}{n_abs / divisor:.1f}{unit}"
return f"{sign}{prefix}{n_abs:.1e}"
__all__ = ["format_duration", "format_tokens", "abbreviate_num"]
+41
View File
@@ -0,0 +1,41 @@
"""Random "thinking" verb for the streaming spinner.
The status bar shows a verb while the agent is generating to communicate that
work is happening even before the first token arrives. Borrowed from the
dexter CLI: the verb is rerolled per agent turn so the user perceives variety
across runs rather than a single hard-coded label.
"""
from __future__ import annotations
import random
from typing import Final, Tuple
THINKING_VERBS: Final[Tuple[str, ...]] = (
"Pondering",
"Analyzing",
"Reasoning",
"Investigating",
"Synthesizing",
"Cross-checking",
)
def pick_thinking_verb(*, seed: int | None = None) -> str:
"""Pick a random verb suffixed with an ellipsis.
Args:
seed: Optional deterministic seed (used by tests). When ``None`` the
module-level :func:`random.choice` is used.
Returns:
``"Pondering…"``, ``"Analyzing…"``, etc.
"""
if seed is not None:
rng = random.Random(seed)
return f"{rng.choice(THINKING_VERBS)}…"
return f"{random.choice(THINKING_VERBS)}…"
__all__ = ["pick_thinking_verb", "THINKING_VERBS"]
+1020 -89
View File
File diff suppressed because it is too large. Load diff
+25 -6
View File
@@ -1,12 +1,14 @@
# Core LLM & Orchestration
rich>=13.0.0
pyyaml>=6.0.0
langchain>=0.1.0
langchain-core>=0.1.0
langchain-openai>=0.0.5
langgraph>=0.2.50,<0.3
langgraph-checkpoint>=2.0.0
langchain>=1.0.0,<2
langchain-core>=1.0.0,<2
langchain-openai>=1.0.0,<2
langgraph>=1.0.10,<1.1
langgraph-checkpoint>=2.1.0,<5
python-dotenv>=1.0.0
httpx>=0.28.0
oauth-cli-kit>=0.1.3
# Data & Computation
pandas>=2.0.0
@@ -14,6 +16,13 @@ numpy>=1.24.0
scipy>=1.10.0
duckdb>=1.2.0
# Document Readers (doc_reader_tool)
openpyxl>=3.1.0
python-docx>=1.1.0
python-pptx>=0.6.23
pypdfium2>=4.0.0
Pillow>=10.0.0
# ML Strategy
scikit-learn>=1.3.0
joblib>=1.3.0
@@ -26,13 +35,23 @@ pyharmonics>=1.5.0
tushare>=1.2.89
requests>=2.31.0
yfinance>=0.2.30
akshare>=1.12.0
ccxt>=4.0.0
# REST API & SSE
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
pydantic>=2.0.0
python-multipart>=0.0.6
python-multipart>=0.0.18
sse-starlette>=1.6.0
# MCP Server
fastmcp>=2.0.0
# Web Search
ddgs>=6.0.0
# Shadow Account HTML/PDF Report
jinja2>=3.1.0
matplotlib>=3.7.0
weasyprint>=60.0
+250
View File
@@ -0,0 +1,250 @@
"""W4.a blog patcher: fill TBD placeholders in alpha-191-in-2026.html.
Reads ~/.vibe-trading/reports/bench_summary.json (produced by w4a_run_benches.py)
and patches the HTML in place.
Substitutions performed:
- num-alive / num-reversed / num-dead spans in the two stat blocks
- Theme survival table rows (count + survival rate per theme)
- Top 5 alphas (id + formula + IC mean)
- 3 famously-dead alphas (replace invented IDs with worst-IC from gtja191)
- Section-level "bench failed" callouts where the corresponding bench errored
The patcher never fabricates a number: if the gtja191 bench is missing, every
TBD that depends on it becomes a "bench failed" note instead. The script is
idempotent — re-running with the same summary produces the same HTML.
"""
from __future__ import annotations
import html
import json
import re
import sys
from pathlib import Path
from typing import Any
REPO = Path(__file__).resolve().parents[2]
HTML_PATH = REPO / "wiki" / "research-lab" / "posts" / "alpha-191-in-2026.html"
SUMMARY_PATH = Path.home() / ".vibe-trading" / "reports" / "bench_summary.json"
GTJA_KEY = "gtja191_csi300"
# Themes shown in the table — these are the human labels in the HTML, mapped
# to the canonical theme tags in registry meta. "Volume-price interaction"
# requires *both* volume and reversal-or-momentum, so it gets a custom probe.
THEME_LABEL_TO_TAGS: dict[str, list[str]] = {
"Volume-price interaction": ["volume"],
"Short-horizon volatility": ["volatility"],
"Reversal": ["reversal"],
"Momentum": ["momentum"],
"Turnover / liquidity": ["liquidity"],
"Microstructure / range": ["microstructure"],
}
def _load_summary() -> dict[str, Any]:
if not SUMMARY_PATH.is_file():
raise SystemExit(f"summary not found: {SUMMARY_PATH}")
return json.loads(SUMMARY_PATH.read_text(encoding="utf-8"))
def _gtja_entry(summary: dict[str, Any]) -> dict[str, Any] | None:
entry = summary.get("zoos", {}).get(GTJA_KEY)
if not entry or entry.get("status") != "ok":
return None
return entry
def _patch_stats(text: str, entry: dict[str, Any]) -> str:
for span_class, key in (
("num-alive", "alive"),
("num-reversed", "reversed"),
("num-dead", "dead"),
):
value = entry.get(key)
if value is None:
continue
pattern = re.compile(
rf'(<span class="{span_class}">)\[TBD\](</span>)'
)
text = pattern.sub(rf"\g<1>{int(value)}\g<2>", text)
return text
def _theme_survival_rows(entry: dict[str, Any]) -> str:
"""Build the inner <tr> rows for the theme-survival table."""
by_theme = entry.get("by_theme", {})
rows: list[str] = []
definitions = {
"Volume-price interaction": "Correlation / covariance of volume with close, high-low range",
"Short-horizon volatility": "Rolling std / range over 5-20 day windows",
"Reversal": "Negative-sign return signals over 1-5 day horizons",
"Momentum": "Positive-sign return signals over 10-60 day horizons",
"Turnover / liquidity": "Volume ratios, turnover-rate transforms",
"Microstructure / range": "Open-close-high-low decompositions, intraday range proxies",
}
for label, tags in THEME_LABEL_TO_TAGS.items():
# Aggregate across all listed tags (most themes are single-tag).
alive = dead = reversed_ = count = 0
for tag in tags:
bucket = by_theme.get(tag)
if bucket is None:
continue
alive += bucket.get("alive", 0)
dead += bucket.get("dead", 0)
reversed_ += bucket.get("reversed", 0)
count += bucket.get("count", 0)
if count == 0:
count_cell = "n/a"
rate_cell = "n/a"
else:
count_cell = str(count)
rate_cell = f"{alive / count * 100:.0f}% ({alive}/{count})"
rows.append(
" <tr>\n"
f" <td>{html.escape(label)}</td>\n"
f" <td>{html.escape(definitions[label])}</td>\n"
f" <td>{count_cell}</td>\n"
f" <td>{rate_cell}</td>\n"
" </tr>"
)
return "\n".join(rows)
def _patch_theme_table(text: str, entry: dict[str, Any]) -> str:
new_rows = _theme_survival_rows(entry)
pattern = re.compile(
r'(<tbody>)(.*?)(</tbody>)',
re.DOTALL,
)
# The HTML has exactly one <tbody> inside the theme-table; surgical replace.
def _swap(match: re.Match[str]) -> str:
body = match.group(2)
if "Volume-price interaction" not in body:
return match.group(0) # not the theme table
return f"{match.group(1)}\n{new_rows}\n {match.group(3)}"
return pattern.sub(_swap, text, count=1)
def _formula_to_html(formula: str) -> str:
return html.escape(formula)
def _build_top5_cards(entry: dict[str, Any]) -> str:
rows = entry.get("top5_by_ir", [])[:5]
cards: list[str] = []
for row in rows:
aid = row["id"]
formula = _formula_to_html(row.get("formula_latex", ""))
ic = row.get("ic_mean", 0.0)
ir = row.get("ir", 0.0)
cards.append(
" <article class=\"alpha-card\">\n"
f" <span class=\"alpha-id\">{html.escape(aid)}</span>\n"
f" <pre class=\"formula-block\"><code>{formula}</code></pre>\n"
" <p class=\"alpha-note\">"
f"Mean IC = {ic:.4f}, IR = {ir:.4f} over the CSI 300 / 2018&ndash;2025 window. "
"Formula reproduced verbatim from the registry "
f"(<code>__alpha_meta__[\"formula_latex\"]</code> of <code>{html.escape(aid)}</code>)."
"</p>\n"
" </article>"
)
return "\n".join(cards)
def _build_dead_cards(entry: dict[str, Any]) -> str:
rows = entry.get("dead_examples", [])[:3]
cards: list[str] = []
for row in rows:
aid = row["id"]
formula = _formula_to_html(row.get("formula_latex", ""))
ic = row.get("ic_mean", 0.0)
cat = row.get("category", "dead")
ir = row.get("ir", 0.0)
cards.append(
" <article class=\"alpha-card\">\n"
f" <span class=\"alpha-id\">{html.escape(aid)} ({html.escape(cat)})</span>\n"
f" <pre class=\"formula-block\"><code>{formula}</code></pre>\n"
" <p class=\"alpha-note\">"
f"Mean IC = {ic:.4f}, IR = {ir:.4f}. Worst-performing slice of the gtja191 "
"zoo on CSI 300 / 2018&ndash;2025 by raw IC."
"</p>\n"
" </article>"
)
return "\n".join(cards)
def _patch_top5(text: str, entry: dict[str, Any]) -> str:
cards = _build_top5_cards(entry)
pattern = re.compile(
r'(<h3>Top 5 surviving alphas</h3>.*?</p>\s*)' # heading + lead paragraph
r'(?:<article class="alpha-card">.*?</article>\s*){5}',
re.DOTALL,
)
return pattern.sub(rf"\g<1>{cards}\n\n ", text, count=1)
def _patch_dead(text: str, entry: dict[str, Any]) -> str:
cards = _build_dead_cards(entry)
pattern = re.compile(
r'(<h3>Three famously dead alphas</h3>.*?</p>\s*)'
r'(?:<article class="alpha-card">.*?</article>\s*){3}',
re.DOTALL,
)
return pattern.sub(rf"\g<1>{cards}\n\n ", text, count=1)
def _patch_bench_failed_note(text: str) -> str:
"""Replace the W4-roadmap placeholder callout with a real timestamp note."""
return text.replace(
"Numbers will be filled in once the full bench finishes (Vibe-Trading\n roadmap W4.a). "
"This post is published as a methodology preview &mdash; the structure, definitions\n and caveats are final.",
"Numbers below are the live W4.a bench output on the bundled gtja191 zoo "
"(CSI 300, 2018&ndash;2025) &mdash; reproducible via the CLI snippet at the end of the post.",
)
def main() -> int:
summary = _load_summary()
entry = _gtja_entry(summary)
text = HTML_PATH.read_text(encoding="utf-8")
if entry is None:
# Surface the failure once at the top; leave all TBDs intact so the
# reader sees we have not invented numbers.
zoo_blob = summary.get("zoos", {}).get(GTJA_KEY, {})
reason = zoo_blob.get("error", "bench did not run")
note = (
'<p class="bench-failed"><strong>Note:</strong> the gtja191 / CSI 300 bench '
f"failed during W4.a (reason: {html.escape(str(reason))}). All TBD numbers below "
"remain to be filled in 0.1.9 once the bench is re-run.</p>"
)
text = text.replace(
'roadmap W4.a). This post',
f'roadmap W4.a). {note} This post'
)
HTML_PATH.write_text(text, encoding="utf-8")
print(f"bench failed; left TBDs intact and added note. tbd count = "
f"{text.count('[TBD]')}", file=sys.stderr)
return 0
text = _patch_stats(text, entry)
text = _patch_theme_table(text, entry)
text = _patch_top5(text, entry)
text = _patch_dead(text, entry)
text = _patch_bench_failed_note(text)
# Mean IC ~ [TBD] placeholders inside the per-card paragraphs are no longer
# needed (we rewrote the cards with real numbers). Sanity-check sweep.
HTML_PATH.write_text(text, encoding="utf-8")
remaining = text.count("[TBD]")
print(f"patched {HTML_PATH}; remaining [TBD] = {remaining}")
return 0
if __name__ == "__main__":
sys.exit(main())
+170
View File
@@ -0,0 +1,170 @@
"""W4.a bench driver: run 4 zoo×universe combinations and emit summary JSON.
Usage:
cd agent && python scripts/w4a_run_benches.py
Output:
~/.vibe-trading/reports/alpha_bench_*.html — per-run HTML reports
~/.vibe-trading/reports/bench_<zoo>_<uni>.json — per-run raw results
~/.vibe-trading/reports/bench_summary.json — aggregate summary
Safe to re-run; each bench result is cached by ``_load_universe_panel`` so the
expensive Tushare/yfinance fetches only happen once per (universe, period).
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
# Repo-relative imports
HERE = Path(__file__).resolve().parent
AGENT_DIR = HERE.parent
sys.path.insert(0, str(AGENT_DIR))
load_dotenv(AGENT_DIR / ".env")
# Configure logging early so universe loaders log their progress.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s | %(message)s",
)
logger = logging.getLogger("w4a")
from src.factors.bench_runner import run_bench # noqa: E402
from src.tools.alpha_bench_tool import run_alpha_bench # noqa: E402
REPORTS_DIR = Path.home() / ".vibe-trading" / "reports"
BENCHES = [
{"key": "gtja191_csi300", "zoo": "gtja191", "universe": "csi300", "period": "2018-2025"},
{"key": "alpha101_sp500", "zoo": "alpha101", "universe": "sp500", "period": "2020-2025"},
{"key": "qlib158_csi300", "zoo": "qlib158", "universe": "csi300", "period": "2020-2025"},
{"key": "alpha101_btc", "zoo": "alpha101", "universe": "btc-usdt", "period": "2022-2025"},
]
def _run_one(bench: dict) -> dict:
"""Run a single bench end-to-end. Returns a summary entry.
Delegates the math to ``src.factors.bench_runner.run_bench``; this wrapper
just adds the HTML report (via ``run_alpha_bench``) and persists the raw
per-alpha rows for downstream tooling, both of which the CLI driver wants
but the API route does not.
"""
key = bench["key"]
zoo = bench["zoo"]
universe = bench["universe"]
period = bench["period"]
logger.info("=== bench %s: zoo=%s universe=%s period=%s ===", key, zoo, universe, period)
start = time.monotonic()
entry: dict = {
"key": key,
"zoo": zoo,
"universe": universe,
"period": period,
"status": "pending",
}
# 1. HTML report (also exercises the universe loader so the panel is cached
# on disk for the run_bench call below).
try:
envelope = run_alpha_bench(
zoo=zoo, universe=universe, period=period, top=20
)
except Exception as exc: # noqa: BLE001 — never let one bench abort the suite
logger.exception("bench %s crashed (run_alpha_bench)", key)
entry["status"] = "error"
entry["error"] = f"unexpected: {exc!s}"
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
if envelope.get("status") != "ok":
entry["status"] = "error"
entry["error"] = envelope.get("error", "unknown")
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
# 2. Full bench (per-alpha rows + categorisation).
try:
result = run_bench(zoo=zoo, universe=universe, period=period, top=20)
except Exception as exc: # noqa: BLE001
logger.exception("bench %s crashed (run_bench)", key)
entry["status"] = "error"
entry["error"] = f"unexpected: {exc!s}"
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
if result.get("status") != "ok":
entry["status"] = "error"
entry["error"] = result.get("error", "unknown")
entry["wall_seconds"] = round(time.monotonic() - start, 2)
return entry
rows = result.pop("rows", [])
skipped = result.pop("skipped", [])
entry.update(result)
entry["report_path"] = envelope.get("report_path")
entry["wall_seconds"] = round(time.monotonic() - start, 2)
# Write per-bench raw rows for downstream tooling.
raw_path = REPORTS_DIR / f"bench_{key}.json"
raw_path.write_text(
json.dumps(
{"meta": {k: entry[k] for k in ("key", "zoo", "universe", "period", "status")},
"rows": rows, "skipped": skipped},
indent=2,
default=str,
),
encoding="utf-8",
)
logger.info(
"bench %s done: tested=%d skipped=%d alive=%d reversed=%d dead=%d (%.1fs)",
key, len(rows), len(skipped),
entry["alive"], entry["reversed"], entry["dead"],
entry["wall_seconds"],
)
return entry
def main() -> int:
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
summary: dict = {
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"zoos": {},
}
tushare_ok = bool(os.getenv("TUSHARE_TOKEN", "").strip())
if not tushare_ok:
logger.warning("TUSHARE_TOKEN unset; csi300 benches will be skipped")
for bench in BENCHES:
if bench["universe"] == "csi300" and not tushare_ok:
summary["zoos"][bench["key"]] = {
**{k: bench[k] for k in ("zoo", "universe", "period")},
"status": "skipped",
"error": "TUSHARE_TOKEN not set",
}
continue
entry = _run_one(bench)
summary["zoos"][bench["key"]] = entry
out = REPORTS_DIR / "bench_summary.json"
out.write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
logger.info("wrote summary -> %s", out)
return 0
if __name__ == "__main__":
sys.exit(main())
+95 -12
View File
@@ -3,14 +3,21 @@
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from src.agent.memory import WorkspaceMemory
from src.agent.skills import SkillsLoader
from src.agent.tools import ToolRegistry
_SYSTEM_PROMPT = """You are a finance research agent with 64 specialist skills, 19 tools, and 29 multi-agent swarm teams.
You handle backtesting, factor analysis, options pricing, risk audits, research reports, document/web reading, and team-based workflows.
if TYPE_CHECKING:
from src.memory.persistent import PersistentMemory
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """You are a finance research agent with {skill_count} specialist skills, {tool_count} tools, 7 data sources (with auto-fallback), and 29 multi-agent swarm teams.
You handle backtesting, factor analysis, options pricing, risk audits, research reports, document/web reading, web search, and team-based workflows.
## Tools
@@ -35,10 +42,9 @@ Decide which workflow to use based on the request:
4. Syntax check → `backtest(run_dir=...)` → `read_file("artifacts/metrics.csv")`
5. Do NOT write run_backtest.py. The engine is built-in.
**Swarm team** — user wants multi-agent analysis, risk audit, committee review, or any task with "team/committee/panel/desk":
**Swarm team** — ONLY when the user explicitly requests team/committee/swarm analysis:
- Call `run_swarm(prompt="<user's full request>")` — it auto-selects the right preset.
- Examples: risk audit, investment committee, equity research, technical panel, cross-market allocation.
- Do NOT attempt multi-dimensional analysis solo. Use swarm.
- Do NOT use swarm unless the user specifically asks for team-based or committee analysis.
**Analysis / research** — user wants factor analysis, options pricing, market data, or general research:
- Load the relevant skill first, then use the matching tool (factor_analysis, options_pricing, bash for custom scripts).
@@ -46,13 +52,42 @@ Decide which workflow to use based on the request:
**Document / web** — user provides a PDF or URL:
- `read_document(path=...)` for PDFs, `read_url(url=...)` for web pages.
**Trade journal** — user uploads a CSV/Excel broker export (交割单) or asks to analyze their own trading history:
1. `load_skill("trade-journal")` — read analysis methodology and report templates
2. `analyze_trade_journal(file_path=..., analysis_type="full")` — parse + profile + behavior diagnostics
3. Present results as the markdown report in the skill. Offer follow-ups: time-slice, symbol deep-dive, market split.
4. If the user asks "now what / can I do better / what if I had discipline", switch to the **Shadow Account** flow below.
**Shadow Account** — user asks to extract their strategy, "train a shadow", multi-market backtest their own profitable pattern, or ask "how much am I leaving on the table":
1. **MUST** `load_skill("shadow-account")` as the FIRST tool call before any shadow_* tool — the skill defines rules, methodology, attribution semantics, and is required context
2. Confirm the journal has been parsed (same session or known `journal_path`). If not, run `analyze_trade_journal` first.
3. `extract_shadow_strategy(journal_path=...)` → show rules, ask user to confirm they look like their own behavior
4. `run_shadow_backtest(shadow_id=..., journal_path=...)` → multi-market metrics + delta attribution
5. `render_shadow_report(shadow_id=...)` → share html/pdf path, lead with the Section 5 "you vs shadow" delta
6. Optional: `scan_shadow_signals(shadow_id=...)` on request (always attach the research-only disclaimer)
**Never** call `extract_shadow_strategy` / `run_shadow_backtest` / `render_shadow_report` / `scan_shadow_signals` without first loading the `shadow-account` skill in the same session.
## Guidelines
- Load the relevant skill BEFORE starting any task. Skills contain the exact API contracts and examples.
- Ask the user if critical info is missing (assets, dates, strategy type). Never guess.
- Output results as markdown tables. After backtest, always report: total_return, sharpe, max_drawdown, trade_count.
- Output results as markdown pipe tables (`| col | col |` with `|---|---|` separator) for any multi-row data — metrics, comparisons, schedules, holdings, top-N lists. Renderers upgrade these to native tables. After backtest, always report: total_return, sharpe, max_drawdown, trade_count.
- Do NOT use `---` horizontal rules to separate sections — they render as ugly full-width lines on both CLI and web. Use `##` / `###` markdown headings instead.
- All file paths are relative to run_dir (auto-injected).
- Respond in the same language the user used.
- You have persistent cross-session memory (`remember` tool). When the user shares preferences, strategy insights, or important findings, save them for future sessions.
- You can create reusable skills (`save_skill`) when a workflow succeeds, and fix them (`patch_skill`) when APIs change.
{memory_section}
## Current Date & Time
Today is {current_datetime}.
"""
_MEMORY_SECTION = """
## Persistent Memory (cross-session)
{snapshot}
"""
@@ -66,22 +101,26 @@ class ContextBuilder:
"""
def __init__(self, registry: ToolRegistry, memory: WorkspaceMemory,
skills_loader: Optional[SkillsLoader] = None) -> None:
skills_loader: Optional[SkillsLoader] = None,
persistent_memory: Optional[PersistentMemory] = None) -> None:
"""Initialize ContextBuilder.
Args:
registry: Tool registry.
memory: Workspace memory.
skills_loader: Skills loader (auto-created if not provided).
persistent_memory: PersistentMemory instance for cross-session recall.
"""
self.registry = registry
self.memory = memory
self.skills_loader = skills_loader or SkillsLoader()
self._persistent_memory = persistent_memory
def build_system_prompt(self, user_message: str = "") -> str:
"""Build system prompt.
Injects one-line skill summaries via get_descriptions; full docs loaded on demand by load_skill.
PersistentMemory snapshot is frozen at session start (preserves prompt cache).
Args:
user_message: User message (kept for API compatibility).
@@ -89,15 +128,32 @@ class ContextBuilder:
Returns:
System prompt text.
"""
now = datetime.now()
# Build memory section only if there are saved memories
memory_section = ""
if self._persistent_memory and self._persistent_memory.snapshot:
memory_section = _MEMORY_SECTION.format(
snapshot=self._persistent_memory.snapshot,
)
return _SYSTEM_PROMPT.format(
tool_count=len(self.registry._tools),
skill_count=len(self.skills_loader.skills),
tool_descriptions=self._format_tool_descriptions(),
skill_descriptions=self.skills_loader.get_descriptions(),
memory_summary=self.memory.to_summary(),
memory_section=memory_section,
current_datetime=now.strftime("%A, %B %d, %Y %H:%M (local)"),
)
def build_messages(self, user_message: str, history: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
"""Build full message list.
Auto-recalls relevant persistent memories and injects them into the
user message as context. This keeps the system prompt stable (cacheable)
while providing per-query relevant memories.
Args:
user_message: User message.
history: Prior conversation messages.
@@ -110,7 +166,23 @@ class ContextBuilder:
]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_message})
# Auto-recall: inject relevant memories into user message
enriched = user_message
if self._persistent_memory:
try:
recalls = self._persistent_memory.find_relevant(user_message, max_results=3)
if recalls:
lines = [f"- **{r.title}** ({r.memory_type}): {r.body[:500]}" for r in recalls]
recall_block = "\n".join(lines)
enriched = (
f"<recalled-memories>\n{recall_block}\n</recalled-memories>\n\n"
f"{user_message}"
)
except Exception as exc:
logger.debug("Auto-recall failed: %s", exc)
messages.append({"role": "user", "content": enriched})
return messages
def _format_tool_descriptions(self) -> str:
@@ -138,17 +210,25 @@ class ContextBuilder:
}
@staticmethod
def format_assistant_tool_calls(tool_calls: list, content: Optional[str] = None) -> Dict[str, Any]:
def format_assistant_tool_calls(
tool_calls: list,
content: Optional[str] = None,
reasoning_content: Optional[str] = None,
) -> Dict[str, Any]:
"""Format an assistant tool_calls message, preserving thinking text.
Args:
tool_calls: List of tool call objects.
content: Model reasoning/thinking text.
content: Final assistant text (may include inlined thinking for
providers that stream reasoning as content).
reasoning_content: Provider-specific reasoning field (Kimi K2.5,
DeepSeek reasoner, Qwen thinking). Only attached to the output
message when not None, so non-thinking providers see no change.
Returns:
OpenAI-format assistant message.
"""
return {
message = {
"role": "assistant",
"content": content,
"tool_calls": [
@@ -163,3 +243,6 @@ class ContextBuilder:
for tc in tool_calls
],
}
if reasoning_content is not None:
message["reasoning_content"] = reasoning_content
return message
+40
View File
@@ -0,0 +1,40 @@
"""Shared YAML-like frontmatter parser for skills and memory files."""
from __future__ import annotations
import re
from typing import Any, Dict
def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
"""Parse YAML-like frontmatter and body from a markdown file.
Supports string, list (``[a, b]``), and boolean values.
Args:
text: Markdown text with optional ``---`` delimited frontmatter.
Returns:
Tuple of (metadata dict, body text).
"""
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
if not match:
return {}, text.strip()
meta: Dict[str, Any] = {}
for line in match.group(1).strip().split("\n"):
line = line.strip()
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if value.startswith("[") and value.endswith("]"):
items = [item.strip().strip("'\"") for item in value[1:-1].split(",")]
meta[key] = [i for i in items if i]
elif value.lower() in ("true", "false"):
meta[key] = value.lower() == "true"
else:
meta[key] = value
return meta, match.group(2).strip()
+737 -92
View File
@@ -1,31 +1,57 @@
"""AgentLoop: ReAct core loop.
Three-layer context management:
Layer 1 (microcompact) — silently prunes old tool results each iteration, keeping the most recent 3
Layer 2 (auto_compact) — LLM summarises and compresses when token count exceeds threshold, saves transcript
Layer 3 (compact tool) — model explicitly calls the compact tool to trigger compression
Five-layer context management:
Layer 1 (microcompact) — silently prunes old tool results each iteration
Layer 2 (context_collapse) — folds long text blocks without LLM call (zero cost)
Layer 3 (auto_compact) — LLM structured summary with token-budget tail protection
Layer 4 (compact tool) — model explicitly calls the compact tool to trigger L3
Layer 5 (iterative update) — Nth compression updates previous summary instead of starting fresh
Tool execution:
- Read/write batching: consecutive readonly tools run in parallel via threads
"""
from __future__ import annotations
import concurrent.futures
import json
import logging
import os
import time as _time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from src.agent.context import ContextBuilder
from src.agent.memory import WorkspaceMemory
from src.agent.progress import HeartbeatTimer, ProgressEvent, _set_emitter
from src.agent.tools import ToolRegistry
from src.agent.trace import TraceWriter
from src.core.state import RunStateStore
from src.goal.context import (
format_goal_continuation_prompt,
get_current_goal_context,
goal_needs_continuation,
goal_progress_tuple,
)
from src.providers.chat import ChatLLM
from src.tools.background_tools import get_background_manager
RUNS_DIR = Path(__file__).resolve().parents[2] / "runs"
TOKEN_THRESHOLD = 50_000
TOKEN_THRESHOLD = int(os.getenv("TOKEN_THRESHOLD", "40000"))
KEEP_RECENT = 3
TOOL_RESULT_LIMIT = 10_000
HEARTBEAT_INTERVAL_S = float(os.getenv("VT_HEARTBEAT_INTERVAL_S", "3.0"))
GOAL_MAX_CONTINUATIONS = int(os.getenv("VIBE_TRADING_GOAL_MAX_CONTINUATIONS", "3"))
# Layer 2: Context collapse thresholds
COLLAPSE_THRESHOLD = int(TOKEN_THRESHOLD * 0.7)
COLLAPSE_PRESERVE_RECENT = 6
COLLAPSE_TEXT_MIN = 2400
COLLAPSE_HEAD = 900
COLLAPSE_TAIL = 500
# Layer 3: Token-budget tail protection
TAIL_TOKEN_BUDGET = 20_000
logger = logging.getLogger(__name__)
@@ -57,9 +83,192 @@ def _microcompact(messages: list) -> None:
msg["content"] = "[cleared]"
def _context_collapse(messages: list) -> None:
"""Layer 2: fold long text blocks in older messages without LLM call.
Preserves head + tail of large text, collapses the middle.
Zero API cost — pure string operation.
Args:
messages: Message list (mutated in place).
"""
if len(messages) <= COLLAPSE_PRESERVE_RECENT + 1:
return
for msg in messages[1:-COLLAPSE_PRESERVE_RECENT]:
content = msg.get("content")
if not isinstance(content, str) or len(content) <= COLLAPSE_TEXT_MIN:
continue
if content == "[cleared]":
continue
head = content[:COLLAPSE_HEAD]
tail = content[-COLLAPSE_TAIL:]
trimmed = len(content) - COLLAPSE_HEAD - COLLAPSE_TAIL
msg["content"] = f"{head}\n\n...[{trimmed} chars collapsed]...\n\n{tail}"
def _fix_tool_pairs(messages: list) -> None:
"""Repair orphaned tool_call / tool_result pairs after compression.
Two fixes:
1. Remove tool results whose matching tool_call was compressed away.
2. Insert stub results for tool_calls whose results were compressed away.
Args:
messages: Message list (mutated in place).
"""
# Collect all tool_call IDs from assistant messages
call_ids: set[str] = set()
for msg in messages:
if msg.get("role") == "assistant":
for tc in msg.get("tool_calls", []):
tc_id = tc.get("id", "")
if tc_id:
call_ids.add(tc_id)
# Remove orphaned tool results
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") == "tool" and msg.get("tool_call_id") not in call_ids:
messages.pop(i)
else:
i += 1
# Collect existing result IDs
result_ids: set[str] = set()
for msg in messages:
if msg.get("role") == "tool":
tcid = msg.get("tool_call_id", "")
if tcid:
result_ids.add(tcid)
# Insert stub results for orphaned tool_calls
inserts: list[tuple[int, dict]] = []
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls", []):
tc_id = tc.get("id", "")
if tc_id and tc_id not in result_ids:
stub = {
"role": "tool",
"tool_call_id": tc_id,
"name": tc.get("function", {}).get("name", "unknown"),
"content": "[Result from earlier context — see summary above]",
}
inserts.append((idx + 1, stub))
result_ids.add(tc_id)
for pos, stub in reversed(inserts):
messages.insert(pos, stub)
# -- Structured summary templates ------------------------------------------
_STRUCTURED_SUMMARY_PROMPT = """\
Summarize this conversation for handoff to a fresh context window.
This summary is the ONLY context available — omitted information is lost.
Use EXACTLY this structure:
## Goal
What the user is trying to accomplish.
## Constraints & Preferences
User-stated requirements: risk tolerance, strategy parameters, asset preferences.
## Progress
### Done
- Completed steps with key results and specific numbers.
### In Progress
- Current work when compression triggered.
## Key Decisions
Choices made and rationale.
## Resolved Questions
Questions already answered — do NOT re-answer these.
## Pending User Asks
Unfinished requests still needing action.
## Relevant Files
File paths, run_dir, signal engines, artifact locations.
## Remaining Work
What still needs to be done (background reference, NOT active instructions).
## Critical Context
Specific numbers, parameters, error messages, configuration values.
## Tools & Patterns
Which tools worked, what failed, effective approaches.
IMPORTANT: This is a handoff — background reference, NOT active instructions.
Preserve ALL specific numbers, file paths, and parameter values.
{focus_section}
Conversation to summarize:
"""
_FOCUS_SECTION = """
FOCUS TOPIC: {topic}
Allocate 60-70% of the summary budget to content related to this topic.
Aggressively compress unrelated content to make room.
"""
_ITERATIVE_UPDATE_PROMPT = """\
Update the existing summary with new conversation turns.
PREVIOUS SUMMARY:
{previous_summary}
NEW TURNS TO INCORPORATE:
{new_turns}
Rules:
- PRESERVE all existing information from the previous summary.
- ADD new progress, decisions, and findings.
- Move "In Progress" items to "Done" when completed.
- Move answered questions to "Resolved Questions".
- Keep the same section structure.
- Do NOT drop any critical context from the previous summary.
{focus_section}"""
def _is_tool_success(result: str) -> bool:
"""Return True if the tool result does not contain an error."""
return '"error"' not in result[:200]
"""Return True if the tool result does not look like an error response."""
try:
data = json.loads(result)
if isinstance(data, dict) and data.get("status") == "error":
return False
except (json.JSONDecodeError, TypeError):
pass
return True
def _normalize_tool_run_dir(args: dict[str, Any], memory_run_dir: str | None) -> dict[str, Any]:
"""Normalize ``run_dir`` in tool args to an absolute path when possible.
If the model supplies a relative ``run_dir`` (for example ``"."`` or
``"risk_parity_run"``), resolve it against the active run directory.
"""
normalized = dict(args)
if not memory_run_dir:
return normalized
if "run_dir" not in normalized:
normalized["run_dir"] = memory_run_dir
return normalized
run_dir_value = str(normalized["run_dir"]).strip()
if not run_dir_value:
normalized["run_dir"] = memory_run_dir
return normalized
candidate = Path(run_dir_value)
if not candidate.is_absolute():
normalized["run_dir"] = str((Path(memory_run_dir) / candidate).resolve())
return normalized
class AgentLoop:
@@ -79,6 +288,7 @@ class AgentLoop:
memory: Optional[WorkspaceMemory] = None,
event_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
max_iterations: int = 50,
persistent_memory: Optional[Any] = None,
) -> None:
"""Initialize AgentLoop.
@@ -88,6 +298,7 @@ class AgentLoop:
memory: Workspace memory (created fresh if not provided).
event_callback: Event callback (event_type, data).
max_iterations: Maximum number of loop iterations.
persistent_memory: PersistentMemory for cross-session recall.
"""
self.registry = registry
self.llm = llm
@@ -96,6 +307,8 @@ class AgentLoop:
self.max_iterations = max_iterations
self._called_ok: set[str] = set()
self._cancelled: bool = False
self._previous_summary: str = ""
self._persistent_memory = persistent_memory
def cancel(self) -> None:
"""Cancel the current loop.
@@ -115,6 +328,11 @@ class AgentLoop:
Returns:
Execution result dict.
"""
# Reset per-run state (safe for reuse across multiple run() calls)
self._cancelled = False
self._called_ok = set()
self._previous_summary = ""
state_store = RunStateStore()
RUNS_DIR.mkdir(parents=True, exist_ok=True)
@@ -126,8 +344,18 @@ class AgentLoop:
state_store.save_request(run_dir, user_message, {"session_id": session_id})
context = ContextBuilder(self.registry, self.memory)
messages = context.build_messages(user_message, history)
context = ContextBuilder(self.registry, self.memory,
persistent_memory=self._persistent_memory)
goal_context, active_goal_id = get_current_goal_context(session_id) if session_id else ("", None)
llm_user_message = user_message
if goal_context:
llm_user_message = (
f"{goal_context}\n\n"
f"<user-message>\n{user_message}\n</user-message>"
)
goal_store = None
goal_turn_accounted = False
messages = context.build_messages(llm_user_message, history)
react_trace: List[Dict[str, Any]] = []
trace = TraceWriter(run_dir)
@@ -135,6 +363,9 @@ class AgentLoop:
iteration = 0
final_content = ""
goal_continuations = 0
goal_last_progress: tuple[int, int] | None = None
wrap_up_at = max(1, int(self.max_iterations * 0.8))
try:
while iteration < self.max_iterations:
@@ -156,14 +387,36 @@ class AgentLoop:
# Layer 1: microcompact (every iteration)
_microcompact(messages)
# Layer 2: auto_compact (token threshold exceeded)
# Layer 2: context collapse (fold long text, zero API cost)
tokens = estimate_tokens(messages)
if tokens > COLLAPSE_THRESHOLD:
_context_collapse(messages)
tokens = estimate_tokens(messages)
# Layer 3: auto_compact (token threshold exceeded)
if tokens > TOKEN_THRESHOLD:
logger.info(f"Auto compact triggered: {tokens} tokens > {TOKEN_THRESHOLD}")
self._auto_compact(messages, run_dir, trace)
logger.info(f"ReAct iteration {iteration}/{self.max_iterations}")
# Inject wrap-up nudge when approaching iteration limit.
# Skip on the first iteration (tiny budgets) and on the last
# iteration (the forced text-only path already guarantees an
# answer there) so the nudge never displaces the active-goal
# context as the most recent user message.
if iteration == wrap_up_at and 1 < iteration < self.max_iterations:
remaining = self.max_iterations - iteration
messages.append({
"role": "user",
"content": (
f"[SYSTEM] You have {remaining} iterations remaining out of "
f"{self.max_iterations}. Please wrap up your work. "
"Stop calling tools and provide your final answer as plain text. "
"If you have partial results, summarize what you have so far."
),
})
# Streaming output + collect thinking text
thinking_chunks: List[str] = []
@@ -171,11 +424,53 @@ class AgentLoop:
thinking_chunks.append(delta)
self._emit("text_delta", {"delta": delta, "iter": iteration})
# On last iteration, drop tool definitions to force text output
is_last_iteration = (iteration == self.max_iterations)
tool_defs = None if is_last_iteration else self.registry.get_definitions()
if is_last_iteration:
trace.write({"type": "forced_text_only", "iter": iteration})
response = self.llm.stream_chat(
messages,
tools=self.registry.get_definitions(),
tools=tool_defs,
on_text_chunk=_on_text_chunk,
)
usage = getattr(response, "usage_metadata", None) or {}
if usage:
self._emit(
"llm_usage",
{
"input_tokens": int(usage.get("input_tokens") or 0),
"output_tokens": int(usage.get("output_tokens") or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
"iter": iteration,
},
)
if active_goal_id and session_id:
token_delta = int(usage.get("total_tokens") or 0) if usage else 0
turn_delta = 0 if goal_turn_accounted else 1
if token_delta or turn_delta:
try:
if goal_store is None:
from src.goal import GoalStore
goal_store = GoalStore()
goal_store.account_usage(
session_id=session_id,
goal_id=active_goal_id,
expected_goal_id=active_goal_id,
token_delta=token_delta,
turn_delta=turn_delta,
)
goal_turn_accounted = True
snapshot = goal_store.get_goal_snapshot(active_goal_id)
if snapshot is not None:
self._emit(
"goal.updated",
{"goal": snapshot["goal"], "snapshot": snapshot},
)
except Exception as exc: # noqa: BLE001
logger.debug("Goal usage accounting skipped: %s", exc)
thinking_text = "".join(thinking_chunks)
if thinking_text:
@@ -184,62 +479,88 @@ class AgentLoop:
if not response.has_tool_calls:
final_content = response.content or ""
should_continue_goal = False
continuation_snapshot = None
if active_goal_id and session_id and GOAL_MAX_CONTINUATIONS > 0:
try:
if goal_store is None:
from src.goal import GoalStore
goal_store = GoalStore()
continuation_snapshot = goal_store.get_goal_snapshot(active_goal_id)
should_continue_goal = bool(
continuation_snapshot
and goal_needs_continuation(continuation_snapshot)
)
except Exception as exc: # noqa: BLE001
logger.debug("Goal continuation check skipped: %s", exc)
if should_continue_goal and continuation_snapshot is not None:
current_progress = goal_progress_tuple(continuation_snapshot)
no_new_progress = (
goal_last_progress is not None
and current_progress <= goal_last_progress
)
if goal_continuations >= GOAL_MAX_CONTINUATIONS or (
no_new_progress and goal_continuations > 0
):
trace.write(
{
"type": "goal_continuation_suppressed",
"iter": iteration,
"goal_id": active_goal_id,
"progress": current_progress,
"continuations": goal_continuations,
}
)
else:
trace.write(
{
"type": "goal_intermediate_answer",
"iter": iteration,
"goal_id": active_goal_id,
"content": final_content[:2000],
"progress": current_progress,
}
)
react_trace.append(
{"type": "goal_intermediate_answer", "content": final_content[:500]}
)
messages.append({"role": "assistant", "content": final_content})
messages.append(
{
"role": "user",
"content": format_goal_continuation_prompt(
continuation_snapshot,
previous_answer=final_content,
),
}
)
goal_last_progress = current_progress
goal_continuations += 1
continue
trace.write({"type": "answer", "iter": iteration, "content": final_content[:2000]})
react_trace.append({"type": "answer", "content": final_content[:500]})
break
messages.append(context.format_assistant_tool_calls(response.tool_calls, content=response.content))
messages.append(
context.format_assistant_tool_calls(
response.tool_calls,
content=response.content,
reasoning_content=response.reasoning_content or thinking_text or None,
)
)
compact_requested = False
for tc in response.tool_calls:
tool_def = self.registry.get(tc.name)
# Layer 3: compact tool — mark then defer execution
if tc.name == "compact":
compact_requested = True
messages.append(context.format_tool_result(tc.id, "compact", '{"status":"ok","message":"Compressing..."}'))
trace.write({"type": "compact_requested", "iter": iteration})
continue
is_repeatable = tool_def.repeatable if tool_def else False
if tc.name in self._called_ok and not is_repeatable:
logger.warning(f"Blocked duplicate call: {tc.name} (already succeeded)")
skip_msg = json.dumps({"skipped": True, "reason": f"{tc.name} already completed successfully. Use the previous result."})
messages.append(context.format_tool_result(tc.id, tc.name, skip_msg))
trace.write({"type": "tool_skipped", "iter": iteration, "tool": tc.name})
react_trace.append({"type": "tool_skipped", "tool": tc.name})
continue
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in tc.arguments.items()}, "iter": iteration})
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in tc.arguments.items()}})
logger.info(f"Tool call: {tc.name}({list(tc.arguments.keys())})")
if "run_dir" not in tc.arguments and self.memory.run_dir:
tc.arguments["run_dir"] = self.memory.run_dir
t0 = _time.perf_counter()
result = self.registry.execute(tc.name, tc.arguments)
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
self._update_memory(tc.name, result, state_store, run_dir)
if _is_tool_success(result):
self._called_ok.add(tc.name)
status = "ok" if _is_tool_success(result) else "error"
truncated = result[:TOOL_RESULT_LIMIT]
messages.append(context.format_tool_result(tc.id, tc.name, truncated))
trace.write({"type": "tool_result", "iter": iteration, "tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
react_trace.append({"type": "tool_call", "tool": tc.name, "result_preview": result[:200]})
self._emit("tool_result", {"tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
# Execute tools with read/write batching
compact_requested, focus_topic = self._process_tool_calls(
response.tool_calls, context, messages, trace, react_trace, iteration,
)
# Layer 3: compress after all tools have executed
if compact_requested:
logger.info("Manual compact triggered by model")
self._auto_compact(messages, run_dir, trace)
self._auto_compact(messages, run_dir, trace, focus_topic=focus_topic)
except Exception as exc:
logger.exception(f"AgentLoop error: {exc}")
@@ -255,69 +576,399 @@ class AgentLoop:
"react_trace": react_trace,
}
# Determine final status
# Determine final status. The reason is also propagated into the
# returned dict so SessionService can surface a meaningful UI
# message instead of "Execution failed: unknown" (issue #114).
final_reason: str | None = None
if self._cancelled:
state_store.mark_failure(run_dir, "cancelled by user")
final_reason = "cancelled by user"
state_store.mark_failure(run_dir, final_reason)
final_status = "cancelled"
elif (run_dir / "artifacts" / "metrics.csv").exists() or final_content:
state_store.mark_success(run_dir)
final_status = "success"
else:
state_store.mark_failure(run_dir, "pipeline did not complete")
final_reason = (
f"reached max iterations ({self.max_iterations}) without final answer"
)
state_store.mark_failure(run_dir, final_reason)
final_status = "failed"
trace.write({"type": "end", "status": final_status, "iterations": iteration})
end_event: dict[str, Any] = {
"type": "end",
"status": final_status,
"iterations": iteration,
}
if final_reason is not None:
end_event["reason"] = final_reason
trace.write(end_event)
trace.close()
return {
result: dict[str, Any] = {
"status": final_status,
"run_dir": str(run_dir),
"run_id": run_dir.name,
"content": final_content,
"react_trace": react_trace,
"iterations": iteration,
"max_iterations": self.max_iterations,
}
if final_reason is not None:
result["reason"] = final_reason
return result
def _auto_compact(self, messages: list, run_dir: Path, trace: TraceWriter) -> None:
"""Layer 2/3: LLM summarise-and-compress; saves transcript before compressing.
# -- Tool execution with read/write batching --------------------------------
def _process_tool_calls(
self,
tool_calls: list,
context: ContextBuilder,
messages: list,
trace: TraceWriter,
react_trace: list,
iteration: int,
) -> tuple[bool, str]:
"""Pre-process tool calls: handle compact, filter duplicates, batch execute.
Args:
tool_calls: Raw tool calls from LLM response.
context: ContextBuilder for formatting messages.
messages: Conversation messages (appended in place).
trace: TraceWriter.
react_trace: React trace list.
iteration: Current iteration number.
Returns:
Tuple of (compact_requested, focus_topic).
"""
compact_requested = False
focus_topic = ""
to_execute = []
for tc in tool_calls:
# Layer 4: compact tool — mark then defer execution
if tc.name == "compact":
compact_requested = True
focus_topic = tc.arguments.get("focus_topic", "")
messages.append(context.format_tool_result(tc.id, "compact", '{"status":"ok","message":"Compressing..."}'))
trace.write({"type": "compact_requested", "iter": iteration})
continue
tool_def = self.registry.get(tc.name)
is_repeatable = tool_def.repeatable if tool_def else False
if tc.name in self._called_ok and not is_repeatable:
logger.warning(f"Blocked duplicate call: {tc.name} (already succeeded)")
skip_msg = json.dumps({"skipped": True, "reason": f"{tc.name} already completed successfully. Use the previous result."})
messages.append(context.format_tool_result(tc.id, tc.name, skip_msg))
trace.write({"type": "tool_skipped", "iter": iteration, "tool": tc.name})
react_trace.append({"type": "tool_skipped", "tool": tc.name})
continue
to_execute.append(tc)
if not to_execute:
return compact_requested, focus_topic
# Batch execute: consecutive readonly → parallel, write → serial
if len(to_execute) == 1:
self._execute_single(to_execute[0], context, messages, trace, react_trace, iteration)
else:
self._batch_execute(to_execute, context, messages, trace, react_trace, iteration)
return compact_requested, focus_topic
def _batch_execute(
self,
tool_calls: list,
context: ContextBuilder,
messages: list,
trace: TraceWriter,
react_trace: list,
iteration: int,
) -> None:
"""Execute tools with read/write batching.
Consecutive readonly tools run in parallel via ThreadPoolExecutor.
Write tools run serially between readonly batches.
Args:
tool_calls: Tool calls to execute.
context: ContextBuilder.
messages: Conversation messages.
trace: TraceWriter.
react_trace: React trace list.
iteration: Current iteration.
"""
# Split into batches: consecutive readonly → parallel, write → serial
batches: list[tuple[str, list]] = []
current_ro: list = []
for tc in tool_calls:
tool_def = self.registry.get(tc.name)
if tool_def and tool_def.is_readonly:
current_ro.append(tc)
else:
if current_ro:
batches.append(("parallel", current_ro))
current_ro = []
batches.append(("serial", [tc]))
if current_ro:
batches.append(("parallel", current_ro))
for mode, batch in batches:
if mode == "parallel" and len(batch) > 1:
self._execute_parallel(batch, context, messages, trace, react_trace, iteration)
else:
for tc in batch:
self._execute_single(tc, context, messages, trace, react_trace, iteration)
def _execute_parallel(
self,
tool_calls: list,
context: ContextBuilder,
messages: list,
trace: TraceWriter,
react_trace: list,
iteration: int,
) -> None:
"""Execute readonly tools in parallel using threads.
Args:
tool_calls: Readonly tool calls to execute in parallel.
context: ContextBuilder.
messages: Conversation messages.
trace: TraceWriter.
react_trace: React trace list.
iteration: Current iteration.
"""
# Prepare args + emit events
runnable: list[tuple] = []
for tc in tool_calls:
args = _normalize_tool_run_dir(tc.arguments, self.memory.run_dir)
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in args.items()}, "iter": iteration})
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in args.items()}})
runnable.append((tc, args))
# Execute in parallel — each worker gets its own heartbeat + progress emitter.
def _run(tc_args: tuple) -> tuple:
tc, args = tc_args
result, elapsed_ms = self._invoke_tool(tc.name, args)
return tc, result, elapsed_ms
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(runnable), 8)) as pool:
futures = [pool.submit(_run, item) for item in runnable]
results = []
for i, f in enumerate(futures):
try:
results.append(f.result())
except Exception as exc:
tc = runnable[i][0]
results.append((tc, json.dumps({"status": "error", "error": str(exc)}), 0))
# Process results in order
for tc, result, elapsed_ms in results:
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
def _execute_single(
self,
tc: Any,
context: ContextBuilder,
messages: list,
trace: TraceWriter,
react_trace: list,
iteration: int,
) -> None:
"""Execute a single tool call.
Args:
tc: Tool call object.
context: ContextBuilder.
messages: Conversation messages.
trace: TraceWriter.
react_trace: React trace list.
iteration: Current iteration.
"""
args = _normalize_tool_run_dir(tc.arguments, self.memory.run_dir)
self._emit("tool_call", {"tool": tc.name, "arguments": {k: str(v)[:200] for k, v in args.items()}, "iter": iteration})
trace.write({"type": "tool_call", "iter": iteration, "tool": tc.name, "args": {k: str(v)[:200] for k, v in args.items()}})
logger.info(f"Tool call: {tc.name}({list(args.keys())})")
result, elapsed_ms = self._invoke_tool(tc.name, args)
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
def _invoke_tool(self, tool_name: str, args: Dict[str, Any]) -> tuple[str, int]:
"""Execute a tool with heartbeat + structured progress emission.
Installs a thread-local progress emitter so the tool may call
``emit_progress()`` without taking a callback parameter, and runs a
background heartbeat timer that ticks every ``HEARTBEAT_INTERVAL_S``
seconds. Both event streams are forwarded through ``self._emit`` and
therefore land in the same SSE bus and CLI dashboard as normal
tool events.
Args:
tool_name: Tool name to execute.
args: Tool arguments dict.
Returns:
Tuple of (result_str, elapsed_ms).
"""
def _on_progress(event: ProgressEvent) -> None:
payload = event.to_dict()
payload["tool"] = tool_name
self._emit("tool_progress", payload)
def _on_heartbeat(payload: Dict[str, Any]) -> None:
self._emit("tool_heartbeat", payload)
_set_emitter(_on_progress)
t0 = _time.perf_counter()
try:
with HeartbeatTimer(
tool_name=tool_name,
interval=HEARTBEAT_INTERVAL_S,
emit=_on_heartbeat,
):
result = self.registry.execute(tool_name, args)
finally:
_set_emitter(None)
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
return result, elapsed_ms
def _finalize_tool_result(
self,
tc: Any,
result: str,
elapsed_ms: int,
context: ContextBuilder,
messages: list,
trace: TraceWriter,
react_trace: list,
iteration: int,
) -> None:
"""Record a tool result: update memory, append message, write trace, emit event.
Args:
tc: Tool call object.
result: Raw tool result string.
elapsed_ms: Execution time in milliseconds.
context: ContextBuilder.
messages: Conversation messages.
trace: TraceWriter.
react_trace: React trace list.
iteration: Current iteration.
"""
self._update_memory(tc.name)
success = _is_tool_success(result)
if success:
self._called_ok.add(tc.name)
status = "ok" if success else "error"
truncated = result[:TOOL_RESULT_LIMIT]
messages.append(context.format_tool_result(tc.id, tc.name, truncated))
trace.write({"type": "tool_result", "iter": iteration, "tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
react_trace.append({"type": "tool_call", "tool": tc.name, "result_preview": result[:200]})
self._emit("tool_result", {"tool": tc.name, "status": status, "elapsed_ms": elapsed_ms, "preview": result[:200]})
# -- Context compression ---------------------------------------------------
def _auto_compact(self, messages: list, run_dir: Path, trace: TraceWriter,
focus_topic: str = "") -> None:
"""Layer 3/4/5: structured LLM summary with token-budget tail protection.
Upgrades over the original:
- Token-budget tail: keeps ~20K tokens of recent messages (not a fixed count).
- Structured summary template: preserves goal, progress, decisions, files, etc.
- Iterative update: Nth compression updates previous summary, zero info decay.
- Tool pair fix: repairs orphaned tool_call/tool_result after compression.
- Focus-topic: optionally prioritize specific topic in summary.
Args:
messages: Message list (replaced in place).
run_dir: Run directory.
trace: TraceWriter.
focus_topic: Optional topic to prioritize in the summary.
"""
# Save full transcript (timestamped, one file per compression)
# Save full transcript before compressing
transcript_path = run_dir / f"transcript_{int(_time.time())}.jsonl"
with open(transcript_path, "w", encoding="utf-8") as f:
for msg in messages:
f.write(json.dumps(msg, default=str, ensure_ascii=False) + "\n")
# LLM summary (no tools, plain text)
conv_text = json.dumps(messages[1:], default=str, ensure_ascii=False)[:80000]
summary_resp = self.llm.chat([
{"role": "user", "content": (
"Summarize this conversation for continuity. Include: "
"1) What was accomplished, 2) Current state, 3) Key decisions made. "
"Be concise but preserve critical details.\n\n" + conv_text
)},
])
system_msg = messages[0]
body = messages[1:]
# Token-budget tail: walk backward to find how many recent messages to preserve
accumulated = 0
cut_idx = len(body)
for i in range(len(body) - 1, -1, -1):
content = body[i].get("content", "")
msg_tokens = (len(str(content)) // 4) + 10
if accumulated + msg_tokens > TAIL_TOKEN_BUDGET:
cut_idx = i + 1
break
accumulated += msg_tokens
cut_idx = i
# Don't split in the middle of a tool_call/tool_result pair
while 0 < cut_idx < len(body) and body[cut_idx].get("role") == "tool":
cut_idx += 1
head = body[:cut_idx]
tail = body[cut_idx:]
if not head:
# All body fits in tail budget — force a split to avoid infinite loop
if len(body) > 2:
cut_idx = max(1, len(body) // 2)
head = body[:cut_idx]
tail = body[cut_idx:]
else:
logger.warning("Auto compact: nothing to compress (body too small)")
return
# Build focus section
focus_section = _FOCUS_SECTION.format(topic=focus_topic) if focus_topic else ""
# Build summary prompt (structured template or iterative update)
conv_text = json.dumps(head, default=str, ensure_ascii=False)[:80000]
if self._previous_summary:
prompt = _ITERATIVE_UPDATE_PROMPT.format(
previous_summary=self._previous_summary,
new_turns=conv_text,
focus_section=focus_section,
)
else:
prompt = _STRUCTURED_SUMMARY_PROMPT.format(focus_section=focus_section) + conv_text
summary_resp = self.llm.chat([{"role": "user", "content": prompt}])
summary = summary_resp.content or ""
self._previous_summary = summary
tokens_before = estimate_tokens(messages)
trace.write({"type": "compact", "tokens_before": tokens_before, "summary": summary[:500]})
trace.write({"type": "compact", "tokens_before": tokens_before, "summary": summary[:500],
"focus_topic": focus_topic or "(none)"})
self._emit("compact", {"tokens_before": tokens_before, "summary": summary[:200]})
# Replace: keep system prompt + inject compressed summary + restore identity
system_msg = messages[0]
# Reconstruct: system + summary + acknowledge + preserved tail
state_summary = self.memory.to_summary()
compressed = f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"
compressed = f"[Conversation compressed — handoff summary. Transcript: {transcript_path}]\n\n{summary}"
if state_summary and state_summary != "(empty state)":
compressed += f"\n\nCurrent agent state:\n{state_summary}"
messages.clear()
messages.extend([
system_msg,
{"role": "user", "content": compressed},
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
])
messages.append(system_msg)
messages.append({"role": "user", "content": compressed})
messages.append({"role": "assistant", "content": "Understood. Continuing from the summary."})
messages.extend(tail)
# Fix orphaned tool pairs in the reconstructed message list
_fix_tool_pairs(messages)
def _emit(self, event_type: str, data: Dict[str, Any]) -> None:
"""Fire an event via the callback."""
@@ -327,12 +978,6 @@ class AgentLoop:
except Exception:
pass
def _update_memory(self, tool_name: str, result: str, state_store: RunStateStore, run_dir: Path) -> None:
"""Update memory and persist tool result."""
def _update_memory(self, tool_name: str) -> None:
"""Update workspace memory counters after tool execution."""
self.memory.increment(tool_name)
try:
data = json.loads(result)
if isinstance(data, dict):
state_store.persist_tool_result(tool_name, data, run_dir)
except (json.JSONDecodeError, TypeError):
pass
+13 -86
View File
@@ -1,64 +1,32 @@
"""Workspace memory: shared state across tool calls.
"""Workspace memory: shared state across tool calls within a single run.
WorkspaceMemory passes intermediate results between tools during an agent run.
Generic KV store + counters; no tool-specific fields.
Lightweight runtime state — survives within one AgentLoop.run() invocation only.
Cross-session persistence is handled by src.memory.persistent.PersistentMemory.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from typing import Dict, Optional
@dataclass
class WorkspaceMemory:
"""Shared workspace state between tools.
"""Shared workspace state between tools during a single agent run.
Attributes:
run_dir: Current run directory.
store: Generic result store (tool memory_key -> JSON string).
run_dir: Current run directory path.
counters: Tool invocation counters.
extra: Generic KV store for derived values passed between tools.
"""
run_dir: Optional[str] = None
store: Dict[str, str] = field(default_factory=dict)
counters: Dict[str, int] = field(default_factory=dict)
extra: Dict[str, Any] = field(default_factory=dict)
def reset(self) -> None:
"""Reset all state."""
self.run_dir = None
self.store.clear()
self.counters.clear()
self.extra.clear()
def set_result(self, key: str, value: str) -> None:
"""Store a tool result.
Args:
key: Memory key (e.g. "plan_output").
value: JSON string.
"""
self.store[key] = value
def get_result(self, key: str, default: Optional[str] = None) -> Optional[str]:
"""Retrieve a tool result.
Args:
key: Memory key.
default: Default value.
Returns:
JSON string or default.
"""
return self.store.get(key, default)
def increment(self, key: str) -> int:
"""Increment a counter and return the new value.
Args:
key: Counter key.
key: Counter key (typically tool name).
Returns:
Updated counter value.
@@ -66,60 +34,19 @@ class WorkspaceMemory:
self.counters[key] = self.counters.get(key, 0) + 1
return self.counters[key]
def set_extra(self, key: str, value: Any) -> None:
"""Set an extra KV entry.
Args:
key: Key name.
value: Value.
"""
self.extra[key] = value
def get_extra(self, key: str, default: Any = None) -> Any:
"""Get an extra KV entry.
Args:
key: Key name.
default: Default value.
Returns:
Stored value or default.
"""
return self.extra.get(key, default)
def set(self, key: str, value: Any) -> None:
"""Set a KV entry (alias for set_extra).
Args:
key: Key name.
value: Value.
"""
self.extra[key] = value
def get(self, key: str, default: Any = None) -> Any:
"""Get a KV entry (alias for get_extra).
Args:
key: Key name.
default: Default value.
Returns:
Stored value or default.
"""
return self.extra.get(key, default)
def to_summary(self) -> str:
"""Generate a state summary for the LLM indicating completed steps.
"""Generate a state summary for the LLM.
Includes run_dir and tool counters.
This summary survives context compression and helps the LLM
remember what it was working on.
Returns:
State summary text.
"""
lines = []
lines: list[str] = []
if self.run_dir:
lines.append(f"- run_dir: {self.run_dir}")
if self.store:
completed = list(self.store.keys())
lines.append(f"- completed: {', '.join(completed)}")
if self.counters:
counter_parts = [f"{k}={v}" for k, v in self.counters.items()]
lines.append(f"- counters: {', '.join(counter_parts)}")
+184
View File
@@ -0,0 +1,184 @@
"""Progress emission channel for long-running tools.
Two mechanisms:
* Tool-level heartbeat: a background timer fires keepalive events every N
seconds while a tool runs, so the UI never looks frozen. Driven by the
agent loop, opaque to the tool.
* Structured progress: tools can opt in via ``emit_progress()`` to publish
quantified state (stage, current/total, message). Routed back to the
agent loop's event channel via a thread-local emitter set before the
tool executes.
Thread model: tools run synchronously inside ``ToolRegistry.execute`` from
worker threads (read batches) or the main loop thread (writes). A
``threading.local`` slot holds the per-thread emitter so structured
progress flows back to the correct AgentLoop instance even when multiple
read-only tools run in parallel.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProgressEvent:
"""Structured progress event emitted by a tool mid-execution.
Attributes:
tool: Tool name (filled by the loop, tools don't supply this).
stage: Short stage label, e.g. ``loading_data`` or ``simulating``.
current: Current unit count (e.g. page 23 of 100). Optional.
total: Total unit count. Optional.
message: Free-form human-readable detail.
elapsed_s: Seconds since the tool started.
ts: Wall-clock timestamp.
"""
tool: str = ""
stage: str = ""
current: Optional[int] = None
total: Optional[int] = None
message: str = ""
elapsed_s: float = 0.0
ts: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
"""Return a JSON-serializable dict for SSE payloads."""
return {
"tool": self.tool,
"stage": self.stage,
"current": self.current,
"total": self.total,
"message": self.message,
"elapsed_s": round(self.elapsed_s, 2),
"ts": self.ts,
}
# Thread-local emitter slot. The agent loop sets ``_local.emit`` to a callable
# before invoking the tool and clears it after, so tools running on the same
# thread can publish structured progress without a circular import.
_local = threading.local()
def _set_emitter(emit: Optional[Callable[[ProgressEvent], None]]) -> None:
"""Install the active progress emitter for the current thread.
Args:
emit: Callable that consumes a ``ProgressEvent``. Pass ``None`` to clear.
"""
if emit is None:
if hasattr(_local, "emit"):
del _local.emit
return
_local.emit = emit
def _get_emitter() -> Optional[Callable[[ProgressEvent], None]]:
"""Return the active emitter on the current thread, if any."""
return getattr(_local, "emit", None)
def emit_progress(
stage: str = "",
*,
current: Optional[int] = None,
total: Optional[int] = None,
message: str = "",
) -> None:
"""Publish a structured progress event from a tool.
Silently no-ops when called outside an active tool context (e.g. during
unit tests that invoke tools directly). Never raises.
Args:
stage: Short stage label.
current: Current unit count.
total: Total unit count.
message: Free-form human-readable detail.
"""
emit = _get_emitter()
if emit is None:
return
try:
event = ProgressEvent(
stage=stage,
current=current,
total=total,
message=message,
)
emit(event)
except Exception:
# Progress emission must never break a tool.
pass
class HeartbeatTimer:
"""Background thread that emits keepalive ticks while a tool runs.
Use as a context manager around a single tool invocation:
with HeartbeatTimer(tool_name="run_backtest", interval=3.0, emit=fn):
result = registry.execute(...)
The timer wakes every ``interval`` seconds and calls ``emit`` with a
dict containing ``tool`` and ``elapsed_s``. Stops cleanly on context
exit; ``join`` is bounded so a hung emitter can't deadlock the loop.
"""
def __init__(
self,
tool_name: str,
interval: float,
emit: Callable[[Dict[str, Any]], None],
) -> None:
"""Initialize the heartbeat timer (not started until ``__enter__``).
Args:
tool_name: Tool name surfaced in each tick payload.
interval: Seconds between ticks. Values <0.5 are clamped.
emit: Callback invoked with each tick payload.
"""
self._tool_name = tool_name
requested_interval = float(interval)
self._interval = max(0.5, requested_interval)
if requested_interval < 0.5:
logger.warning(
"HeartbeatTimer interval %s clamped to 0.5s", interval
)
self._emit = emit
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._t0 = 0.0
def __enter__(self) -> "HeartbeatTimer":
self._t0 = time.perf_counter()
self._thread = threading.Thread(
target=self._run,
name=f"heartbeat-{self._tool_name}",
daemon=True,
)
self._thread.start()
return self
def __exit__(self, exc_type, exc, tb) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=1.0)
def _run(self) -> None:
"""Tick loop: wait + emit until the stop event fires."""
while not self._stop_event.wait(self._interval):
elapsed = time.perf_counter() - self._t0
try:
self._emit({"tool": self._tool_name, "elapsed_s": round(elapsed, 2)})
except Exception:
# A failing callback must not crash the heartbeat thread.
pass
+59 -47
View File
@@ -7,7 +7,6 @@ Uses progressive disclosure:
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -20,6 +19,7 @@ class Skill:
Attributes:
name: Skill name.
description: Skill description.
category: Skill category for grouped display.
body: SKILL.md body text.
dir_path: Skill directory path (used for on-demand loading of supporting files).
metadata: Parsed frontmatter metadata.
@@ -27,6 +27,7 @@ class Skill:
name: str
description: str = ""
category: str = "other"
body: str = ""
dir_path: Optional[Path] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@@ -51,36 +52,7 @@ class Skill:
return None
def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter and body.
Args:
text: Markdown text.
Returns:
Tuple of (metadata dict, body text).
"""
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
if not match:
return {}, text.strip()
meta: Dict[str, Any] = {}
for line in match.group(1).strip().split("\n"):
line = line.strip()
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if value.startswith("[") and value.endswith("]"):
items = [item.strip().strip("'\"") for item in value[1:-1].split(",")]
meta[key] = [i for i in items if i]
elif value.lower() in ("true", "false"):
meta[key] = value.lower() == "true"
else:
meta[key] = value
return meta, match.group(2).strip()
from src.agent.frontmatter import parse_frontmatter as _parse_frontmatter # shared util
def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
@@ -108,55 +80,87 @@ def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
return Skill(
name=name,
description=meta.get("description", ""),
category=meta.get("category", "other"),
body=body,
dir_path=dir_path,
metadata=meta,
)
USER_SKILLS_DIR = Path.home() / ".vibe-trading" / "skills" / "user"
class SkillsLoader:
"""Load skills from the skills/ directory.
"""Load skills from bundled skills/ directory and user skills directory.
Attributes:
skills: Loaded skill list.
skills: Loaded skill list (bundled + user-created).
"""
def __init__(self, skills_dir: Optional[Path] = None) -> None:
def __init__(self, skills_dir: Optional[Path] = None,
user_skills_dir: Optional[Path] = None) -> None:
"""Initialize SkillsLoader.
Args:
skills_dir: Skills directory path; defaults to agent/skills/.
skills_dir: Bundled skills directory path; defaults to agent/skills/.
user_skills_dir: User-created skills directory; defaults to ~/.vibe-trading/skills/user/.
"""
self.skills_dir = skills_dir or Path(__file__).resolve().parents[1] / "skills"
self._user_skills_dir = user_skills_dir or USER_SKILLS_DIR
self.skills: List[Skill] = []
self._load()
def _load(self) -> None:
"""Load all skill subdirectories from the skills directory."""
if not self.skills_dir.exists():
return
for path in sorted(self.skills_dir.iterdir()):
if path.is_dir() and (path / "SKILL.md").exists():
skill = _load_skill_dir(path)
if skill:
self.skills.append(skill)
"""Load all skill subdirectories from user and bundled directories.
User skills are loaded first so they override bundled skills with the same name
(e.g. after patch_skill copies and modifies a bundled skill).
"""
seen_names: set[str] = set()
for directory in (self._user_skills_dir, self.skills_dir):
if not directory or not directory.exists():
continue
for path in sorted(directory.iterdir()):
if path.is_dir() and (path / "SKILL.md").exists():
skill = _load_skill_dir(path)
if skill and skill.name not in seen_names:
self.skills.append(skill)
seen_names.add(skill.name)
# Display order for categories (unlisted categories appear at the end).
_CATEGORY_ORDER = [
"data-source", "strategy", "analysis", "asset-class",
"crypto", "flow", "tool", "other",
]
def get_descriptions(self) -> str:
"""Return a one-line summary of every skill for the system prompt (low token cost).
"""Return skills grouped by category for the system prompt.
Returns:
One line per skill with name + description.
Grouped skill list with category headers.
"""
if not self.skills:
return "(no skills)"
lines = []
groups: Dict[str, List[Skill]] = {}
for skill in self.skills:
lines.append(f" - {skill.name}: {skill.description}")
groups.setdefault(skill.category, []).append(skill)
ordered_cats = [c for c in self._CATEGORY_ORDER if c in groups]
ordered_cats += [c for c in sorted(groups) if c not in ordered_cats]
lines: List[str] = []
for cat in ordered_cats:
lines.append(f"\n### {cat}")
for skill in groups[cat]:
lines.append(f" - {skill.name}: {skill.description}")
return "\n".join(lines)
def get_content(self, name: str) -> str:
"""Return the full documentation for a skill (used by the load_skill tool).
Falls back to disk lookup for user skills created mid-session.
Args:
name: Skill name.
@@ -166,5 +170,13 @@ class SkillsLoader:
for skill in self.skills:
if skill.name == name:
return f'<skill name="{name}">\n{skill.body}\n</skill>'
# Fallback: check user skills directory on disk (mid-session created skills)
if self._user_skills_dir:
skill = _load_skill_dir(self._user_skills_dir / name)
if skill:
self.skills.append(skill)
return f'<skill name="{name}">\n{skill.body}\n</skill>'
available = ", ".join(s.name for s in self.skills)
return f"Error: Unknown skill '{name}'. Available: {available}"
+15 -2
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
import json
import traceback
import logging
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
from typing import Any, Dict, List, Optional
@@ -22,6 +24,16 @@ class BaseTool(ABC):
description: str = ""
parameters: Dict[str, Any] = {}
repeatable: bool = False
is_readonly: bool = True
@classmethod
def check_available(cls) -> bool:
"""Check if this tool's dependencies are met.
Override in subclasses to check for API keys, packages, etc.
Tools that return False are excluded from the registry.
"""
return True
@abstractmethod
def execute(self, **kwargs: Any) -> str:
@@ -65,9 +77,10 @@ class ToolRegistry:
try:
return tool.execute(**params)
except Exception as exc:
logger.exception("Tool %s failed", name)
return json.dumps({
"status": "error", "tool": name,
"error": str(exc), "traceback": traceback.format_exc()[-500:],
"error": str(exc),
}, ensure_ascii=False)
@property
+1 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
class TraceWriter:
+1
View File
@@ -0,0 +1 @@
"""HTTP route packages mounted onto the main FastAPI app in ``api_server.py``."""
Loaded 100 of 1042 files, more files were not shown because too many files have changed in this diff. Show more