56 Commits
Author SHA1 Message Date
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
153 changed files with 14151 additions and 1520 deletions

No files matched your search

+1 -1
View File
@@ -67,7 +67,7 @@ body:
attributes:
label: Version
description: "Run `pip show vibe-trading-ai` to check"
placeholder: "e.g., 0.1.3"
placeholder: "e.g., 0.1.5"
- type: input
id: environment
+17 -1
View File
@@ -1,4 +1,4 @@
.venv/
.venv/
agent/.venv/
__pycache__/
.pytest_cache/
@@ -16,6 +16,7 @@ agent/sessions/
agent/runs/
agent/.ui_runtime/
agent/.tasks/
agent/uploads/
agent/tests/*_result.json
.ruff_cache/
frontend/dist/
@@ -40,3 +41,18 @@ 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
# 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
+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.
+3 -3
View File
@@ -42,10 +42,10 @@ cd frontend && npm install && npm run dev
| Directory | What lives here | Open to contribute? |
|-----------|----------------|:-------------------:|
| `agent/src/skills/` | 68 finance skill definitions (SKILL.md) | Yes |
| `agent/src/skills/` | 69 finance skill definitions (SKILL.md) | Yes |
| `agent/src/tools/` | 21 agent tools | Yes |
| `agent/backtest/` | Backtest engines, loaders, optimizers | Yes |
| `agent/config/swarm/` | 29 swarm preset YAMLs | Yes |
| `agent/src/swarm/presets/` | 29 swarm preset YAMLs | Yes |
| `frontend/` | React 19 + Vite web UI | Yes |
| `agent/src/agent/` | ReAct agent core (loop, context, skills) | Ask first |
| `agent/src/session/` | Session management | Ask first |
@@ -112,7 +112,7 @@ Each skill is a single `SKILL.md` file in `agent/src/skills/<category>/<skill-na
### Easy: New Swarm Preset
A YAML file in `agent/config/swarm/` defining agents, roles, and DAG workflow. See existing presets.
A YAML file in `agent/src/swarm/presets/` defining agents, roles, and DAG workflow. See existing presets.
### Medium: New Data Source Loader
+26
View File
@@ -0,0 +1,26 @@
include LICENSE
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
# 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
+176 -64
View File
@@ -1,5 +1,5 @@
<p align="center">
<b>English</b> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a>
<b>English</b> | <a href="README_zh.md">中文</a> | <a href="README_ja.md">日本語</a> | <a href="README_ko.md">한국어</a> | <a href="README_ar.md">العربية</a>
</p>
<p align="center">
@@ -19,10 +19,10 @@
<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>
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
<img src="https://img.shields.io/badge/Skills-72-orange" alt="Skills">
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
<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>
@@ -51,10 +51,29 @@
## 📰 News
- **2026-04-10** 📦 **v0.1.4**: Fix Docker build ([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), add `web_search` MCP tool (17 total), `akshare`/`ccxt` in deps & MCP. 11 LLM providers (DeepSeek, Groq, Gemini, Ollama, etc.), all tuning params via `.env`. Hardened `ml-strategy` skill. Published to PyPI and ClawHub.
- **2026-04-09** 📊 **Backtest Wave 2 — multi-asset engines**: added ChinaFutures (CFFEX/SHFE/DCE/ZCE, 50+ contracts), GlobalFutures (CME/ICE/Eurex, 30+ contracts), Forex (24 pairs, spread + swap), Options v2 (American exercise, IV smile). Statistical validation: Monte Carlo permutation test, Bootstrap Sharpe CI, Walk-Forward analysis.
- **2026-04-08** 🔧 **Multi-market backtest** with per-market rules; **Pine Script v6 export** for TradingView. **Data source expansion**: 5 sources with auto-fallback, `web_search` tool, skill categorization (7 categories).
- **2026-04-01** 🚀 **v0.1.0** — Initial release: ReAct agent, 64 skills, 29 swarm presets, cross-market backtest, CLI + Web UI + MCP server.
- **2026-04-28** 🚀 **v0.1.6 released** (`pip install -U vibe-trading-ai`): Fixes `vibe-trading --swarm-presets` returning empty after `pip install` / `uv tool install` ([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)) — preset YAMLs now bundled inside the `src.swarm` package and pinned by a 6-test regression suite. Plus AKShare loader correctly routes ETFs (`510300.SH`) and forex (`USDCNH`) to the right endpoints with hardened registry fallback. Rolls up everything since v0.1.5: benchmark comparison panel, `/upload` streaming + size limits, Futu loader (HK + A-share), vnpy export skill, security hardening, frontend lazy loading (688KB → 262KB).
- **2026-04-27** 📊 **Benchmark panel + upload safety**: Backtest output now ships a benchmark comparison panel (ticker / benchmark return / excess return / information ratio) with yfinance-backed resolution for SPY, CSI 300, etc. ([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). Plus `/upload` streams the request body in 1 MB chunks and aborts past `MAX_UPLOAD_SIZE`, bounding memory under oversized/malformed clients ([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)) — pinned by a 4-case regression suite.
- **2026-04-22** 🛡️ **Hardening + new integrations**: Path containment enforced in `safe_path` + journal/shadow tool sandbox, `MANIFEST.in` ships `.env.example` / tests / Docker files in sdist, route-level lazy loading shrinks frontend initial bundle 688KB → 262KB. Plus Futu data loader for HK & A-share equities ([#47](https://github.com/HKUDS/Vibe-Trading/pull/47)) and vnpy CtaTemplate export skill ([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)).
<details>
<summary>Earlier news</summary>
- **2026-04-21** 🛡️ **Workspace + docs**: Relative `run_dir` normalized to 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` preserved across all `ChatOpenAI` paths — Kimi / DeepSeek / Qwen thinking work 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**: Published to PyPI & ClawHub. `python-multipart` CVE floor bump, 5 new MCP tools wired (`analyze_trade_journal` + 4 shadow-account tools), `pattern_recognition` → `pattern` registry fix, Docker dep parity, SKILL manifest synced (22 MCP tools / 71 skills).
- **2026-04-18** 👥 **Shadow Account**: Extract your strategy rules from a broker journal → backtest the shadow across markets → 8-section HTML/PDF report showing exactly how much you leave on the table (rule violations, early exits, missed signals, counterfactual trades). 4 new tools, 1 skill, 32 tools total. Trade Journal + Shadow Account samples now live in the web UI welcome screen.
- **2026-04-17** 📊 **Trade Journal Analyzer + Universal File Reader**: Upload broker exports (同花顺/东财/富途/generic CSV) → auto trading profile (holding days, win rate, PnL ratio, drawdown) + 4 bias diagnostics (disposition effect, overtrading, chasing momentum, anchoring). `read_document` now dispatches PDF, Word, Excel, PowerPoint, images (OCR), and 40+ text formats behind one unified call.
- **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**: Fixed backtest tool `Connection closed` error on stdio transport ([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
- **2026-04-13** 🌐 **Cross-Market Composite Backtest**: New `CompositeEngine` backtests mixed-market portfolios (e.g. A-shares + crypto) with shared capital pool and per-market rules. Also fixed swarm template variable fallback and frontend timeout.
- **2026-04-12** 🌍 **Multi-Platform Export**: `/pine` exports strategies to TradingView (Pine Script v6), TDX (通达信/同花顺/东方财富), and MetaTrader 5 (MQL5) in one command.
- **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. Published to PyPI and 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>
---
@@ -63,12 +82,12 @@
Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural language requests into executable trading strategies, research insights, and portfolio analysis across global markets.
### Key Capabilities:
• **Strategy Generation** — Automatically writes trading code from your ideas<br>
• **Smart Data Access** — 5 data sources with automatic fallback; zero-config for all markets<br>
• **Performance Testing** — Tests your strategies against historical market data<br>
• **TradingView Export** — One-click convert strategies to Pine Script v6 for TradingView<br>
• **Expert Teams** — Deploys specialized AI agents for complex research tasks<br>
• **Live Updates** — Watch the entire analysis process in real-time
• **Natural Language → Strategy** — Describe an idea; the agent writes, tests, and exports trading code<br>
• **5 Data Sources, Zero Config** — A-shares, HK/US, crypto, futures & forex with automatic fallback<br>
• **29 Expert Teams** — Pre-built multi-agent swarm workflows for investment, trading & risk<br>
• **Cross-Session Memory** — Remembers preferences and insights; creates & evolves reusable skills<br>
• **7 Backtest Engines** — Cross-market composite testing with statistical validation & 4 optimizers<br>
• **Multi-Platform Export** — One-click to TradingView, TDX (通达信/同花顺), and MetaTrader 5
---
@@ -79,12 +98,12 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
<td align="center" width="25%" valign="top">
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
<h3>🔍 DeepResearch for Trading</h3>
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<img src="https://img.shields.io/badge/72_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• Multi-domain analysis coverage across markets<br>
• Auto strategy and signal generation<br>
• Macro economic research and insights<br>
• Natural-language task routing via chat
• 72 specialist skills with persistent cross-session memory<br>
• Self-evolving: agent creates & refines workflows from experience<br>
• 5-layer context compression — no info lost in long sessions<br>
• Natural-language task routing across all finance domains
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,8 +113,8 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
<div align="left">
• 29 out-of-the-box trading team presets<br>
• DAG-based multi-agent orchestration<br>
• Real-time decision streaming dashboard<br>
• Custom team building through YAML
• Real-time streaming dashboard with live agent status<br>
• FTS5 session search across all past conversations
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -104,7 +123,7 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<div align="left">
• A-shares, HK/US equities, crypto, futures & forex<br>
• 7 market engines: A-share, US/HK equity, crypto, China futures, global futures, forex<br>
• 7 market engines + composite cross-market engine with shared capital pool<br>
• Statistical validation: Monte Carlo, Bootstrap CI, Walk-Forward<br>
• 15+ performance metrics & 4 optimizers
</div>
@@ -123,16 +142,16 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
</tr>
</table>
## 68 Skills across 7 Categories
## 72 Skills across 7 Categories
- 📊 68 specialized finance skills organized into 7 categories
- 📊 72 specialized finance skills organized into 7 categories
- 🌐 Complete coverage from traditional markets to crypto & DeFi
- 🔬 Comprehensive capabilities spanning data sourcing to quantitative research
| Category | Skills | Examples |
|----------|--------|----------|
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-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` |
@@ -223,7 +242,7 @@ vibe-trading-mcp # start MCP server (stdio)
- **Python 3.11+** for Path B
- **Docker** for Path A
> **Supported LLM providers:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Ollama (local). See `.env.example` for config.
> **Supported LLM providers:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama (local). See `.env.example` for config.
> **Tip:** All markets work without any API keys thanks to automatic fallback. yfinance (HK/US), OKX (crypto), and AKShare (A-shares, US, HK, futures, forex) are all free. Tushare token is optional — AKShare covers A-shares as a free fallback.
@@ -308,6 +327,18 @@ Copy `agent/.env.example` to `agent/.env` and uncomment the provider block you w
**Free data (no key needed):** A-shares via AKShare, HK/US equities via yfinance, crypto via OKX, 100+ crypto exchanges via CCXT. The system automatically selects the best available source for each market.
### 🎯 Recommended Models
Vibe-Trading is a tool-heavy agent — skills, backtests, memory, and swarms all flow through tool calls. Model choice directly decides whether the agent *uses* its tools or fabricates answers from training data.
| Tier | Examples | When to use |
|------|----------|-------------|
| **Best** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.4`, `google/gemini-3.1-pro-preview` | Complex swarms (3+ agents), long research sessions, paper-grade analysis |
| **Sweet spot** (default) | `deepseek/deepseek-v3.2`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.5`, `qwen/qwen3-max-thinking` | Daily driver — reliable tool-calling at ~1/10 the cost |
| **Avoid for agent use** | `*-nano`, `*-flash-lite`, `*-coder-next`, small / distilled variants | Tool-calling is unreliable — the agent will appear to "answer from memory" instead of loading skills or running backtests |
The default `agent/.env.example` ships with `deepseek/deepseek-v3.2` — the cheapest option in the sweet-spot tier.
---
## 🖥 CLI Reference
@@ -324,7 +355,7 @@ vibe-trading serve # API server
| Command | Description |
|---------|-------------|
| `/help` | Show all commands |
| `/skills` | List all 68 finance skills |
| `/skills` | List all 72 finance skills |
| `/swarm` | List 29 swarm team presets |
| `/swarm run <preset> [vars_json]` | Run a swarm team with live streaming |
| `/swarm list` | Swarm run history |
@@ -333,7 +364,7 @@ vibe-trading serve # API server
| `/list` | Recent runs |
| `/show <run_id>` | Run details + metrics |
| `/code <run_id>` | Generated strategy code |
| `/pine <run_id>` | Pine Script for TradingView |
| `/pine <run_id>` | Export indicators (TradingView + TDX + MT5) |
| `/trace <run_id>` | Full execution replay |
| `/continue <run_id> <prompt>` | Continue a run with new instructions |
| `/sessions` | List chat sessions |
@@ -361,7 +392,7 @@ 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> # Pine Script for TradingView
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
@@ -371,6 +402,76 @@ vibe-trading --upload report.pdf
---
## 💡 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>
```
### 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
@@ -381,7 +482,7 @@ vibe-trading serve --port 8899
|--------|----------|-------------|
| `GET` | `/runs` | List runs |
| `GET` | `/runs/{run_id}` | Run details |
| `GET` | `/runs/{run_id}/pine` | Pine Script export |
| `GET` | `/runs/{run_id}/pine` | Multi-platform indicator export |
| `POST` | `/sessions` | Create session |
| `POST` | `/sessions/{id}/messages` | Send message |
| `GET` | `/sessions/{id}/events` | SSE event stream |
@@ -396,7 +497,7 @@ Interactive docs: `http://localhost:8899/docs`
## 🔌 MCP Plugin
Vibe-Trading exposes 17 MCP tools for any MCP-compatible client. Runs as a stdio subprocess — no server setup needed. **16 of 17 tools work with zero API keys** (HK/US/crypto). Only `run_swarm` needs an LLM key.
Vibe-Trading exposes 22 MCP tools for any MCP-compatible client. Runs as a stdio subprocess — no server setup needed. **21 of 22 tools work with zero API keys** (HK/US/crypto). Only `run_swarm` needs an LLM key.
<details>
<summary><b>Claude Desktop</b></summary>
@@ -438,7 +539,7 @@ vibe-trading-mcp --transport sse # SSE for web clients
</details>
**MCP tools exposed (17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
**MCP tools exposed (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>Install from ClawHub (one command)</b></summary>
@@ -458,7 +559,7 @@ Browse on ClawHub: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vi
<details>
<summary><b>OpenSpace — self-evolving skills</b></summary>
All 68 finance skills are published on [open-space.cloud](https://open-space.cloud) and evolve autonomously through OpenSpace's self-evolution engine.
All 72 finance skills are published on [open-space.cloud](https://open-space.cloud) and evolve autonomously through OpenSpace's self-evolution engine.
To use with OpenSpace, add both MCP servers to your agent config:
@@ -480,7 +581,7 @@ To use with OpenSpace, add both MCP servers to your agent config:
}
```
OpenSpace will auto-discover all 68 skills, enabling auto-fix, auto-improve, and community sharing. Search for Vibe-Trading skills via `search_skills("finance backtest")` in any OpenSpace-connected agent.
OpenSpace will auto-discover all 72 skills, enabling auto-fix, auto-improve, and community sharing. Search for Vibe-Trading skills via `search_skills("finance backtest")` in any OpenSpace-connected agent.
</details>
@@ -496,41 +597,42 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli.py # CLI entrypoint — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 17 tools for OpenClaw / Claude Desktop
│ ├── mcp_server.py # MCP server — 22 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
│ │ │ ├── loop.py # main reasoning loop
│ │ │ ├── skills.py # skill loader (68 SKILL.md files, 7 categories)
│ │ │ ├── tools.py # tool orchestration
│ │ │ ├── context.py # system prompt builder
│ │ │ ├── memory.py # run memory / artifact store
│ │ │ ├── loop.py # 5-layer compression + read/write tool batching
│ │ │ ├── context.py # system prompt + auto-recall from persistent memory
│ │ │ ├── skills.py # skill loader (72 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
│ │ │
│ │ ├── tools/ # 21 agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── factor_analysis_tool.py
│ │ │ ├── options_pricing_tool.py
│ │ │ ├── pattern_tool.py # chart pattern detection
│ │ │ ├── doc_reader_tool.py # PDF reader (OCR fallback)
│ │ │ ├── web_reader_tool.py # web page reader (Jina)
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
│ │ │ ├── swarm_tool.py # launch swarm teams
│ │ │ └── ... # file I/O, bash, tasks, etc.
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── skills/ # 68 finance skills in 7 categories (SKILL.md each)
│ │ ├── tools/ # 27 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, etc.
│ │ │
│ │ ├── skills/ # 72 finance skills in 7 categories (SKILL.md each)
│ │ ├── swarm/ # Swarm DAG execution engine
│ │ ├── session/ # Multi-turn chat session management
│ │ │ └── presets/ # 29 swarm preset YAML definitions
│ │ ├── session/ # Multi-turn chat + FTS5 session search
│ │ └── providers/ # LLM provider abstraction
│ │
│ ├── backtest/ # Backtest engines
│ │ ├── engines/ # 7 engines: china_a, global_equity, crypto, china_futures, global_futures, forex + options_portfolio
│ │ ├── loaders/ # 5 sources: tushare, okx, yfinance, akshare, ccxt
│ │ │ ├── base.py # DataLoader Protocol
│ │ │ └── registry.py # Registry + auto-fallback chains
│ │ └── optimizers/ # MVO, equal vol, max div, risk parity
│ │
│ └── config/swarm/ # 29 swarm preset YAML definitions
│ └── backtest/ # Backtest engines
│ ├── engines/ # 7 engines + composite cross-market engine + options_portfolio
│ ├── loaders/ # 6 sources: tushare, okx, yfinance, akshare, 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/
@@ -581,17 +683,21 @@ Vibe-Trading is part of the **[HKUDS](https://github.com/HKUDS)** agent ecosyste
| Phase | Feature | Status |
|-------|---------|--------|
| **Agent Harness** | Persistent cross-session memory (remember / recall / forget) | **Done** |
| | Self-evolving skills — agent creates, patches, and deletes its own workflows | **Done** |
| | FTS5 cross-session search across all past conversations | **Done** |
| | 5-layer context compression (micro → collapse → auto → manual → iterative) | **Done** |
| | Read/write tool batching — parallel execution for readonly tools | **Done** |
| **Next Up** | Autonomous research loop — agent iterates hypotheses overnight | In Progress |
| | IM integration (Slack / Telegram / WeChat) | Planned |
| **Analysis & Viz** | Options volatility surface & Greeks 3D visualization | Planned |
| | Cross-asset correlation heatmap with rolling window & clustering | Planned |
| | Benchmark comparison in CLI backtest output | Planned |
| | Calmar Ratio & Omega Ratio in backtest metrics | Planned |
| **Skills & Presets** | Dividend Analysis skill | Planned |
| | ESG / Sustainable Investing swarm preset | Planned |
| | Emerging Markets Research Desk swarm preset | Planned |
| **Portfolio & Optimization** | Advanced portfolio optimizer: leverage, sector caps, turnover constraints | Planned |
| **Future** | Beginner tutorial: "5-minute natural language backtest" | Planned |
| **Future** | Strategy marketplace (share & discover) | Exploring |
| | Live data streaming via WebSocket | Exploring |
| | Strategy marketplace (share & discover) | Exploring |
---
@@ -625,6 +731,12 @@ 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">
Thanks for visiting <b>Vibe-Trading</b> ✨
</p>
+675
View File
@@ -0,0 +1,675 @@
<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="الرخصة"></a>
<br>
<img src="https://img.shields.io/badge/Skills-72-orange" alt="المهارات">
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="السرب">
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="الأدوات">
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="مصادر البيانات">
<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="#-أحدث-الأخبار">الأخبار</a> &nbsp;&middot;&nbsp;
<a href="#-ما-هو-vibe-trading">ما هو</a> &nbsp;&middot;&nbsp;
<a href="#-الميزات-الرئيسية">الميزات</a> &nbsp;&middot;&nbsp;
<a href="#-البدء-السريع">البدء</a> &nbsp;&middot;&nbsp;
<a href="#-مرجع-سطر-الأوامر">CLI</a> &nbsp;&middot;&nbsp;
<a href="#-خادم-api">API</a> &nbsp;&middot;&nbsp;
<a href="#-إضافة-mcp">MCP</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>
</p>
<p align="center">
<a href="#-البدء-السريع"><img src="assets/pip-install.svg" height="45" alt="pip install vibe-trading-ai"></a>
</p>
---
## 📰 أحدث الأخبار
- **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)) — ملفات YAML للإعدادات المسبقة الآن مضمّنة داخل حزمة `src.swarm` ومثبّتة بـ 6 اختبارات انحدار. إضافة إلى ذلك، محمّل AKShare يوجّه الآن صناديق ETF (`510300.SH`) والعملات الأجنبية (`USDCNH`) إلى نقاط النهاية الصحيحة مع تعزيز سلسلة البديل. تجميع كل التحديثات منذ v0.1.5: لوحة مقارنة المرجع، تدفق `/upload` + حدود الحجم، محمّل Futu (HK + أسهم A)، مهارة تصدير vnpy، تصليب أمني، التحميل الكسول للواجهة (688KB → 262KB).
- **2026-04-27** 📊 **لوحة مقارنة المرجع + أمان الرفع**: مخرجات الاختبار الخلفي تشمل الآن لوحة مقارنة مرجعية (الرمز / عائد المرجع / العائد الفائض / نسبة المعلومات) مع الحلّ عبر yfinance لـ SPY و CSI 300 وغيرها ([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). إضافة إلى ذلك، نقطة `/upload` تتدفق جسم الطلب في أجزاء 1 ميغابايت وتتوقف فور تجاوز `MAX_UPLOAD_SIZE` مع تنظيف الملف الجزئي، بما يجعل حد 50 ميغابايت فعالاً تحت الطلبات الخبيثة/الضخمة ([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)) — مثبّت بـ 4 اختبارات انحدار.
- **2026-04-22** 🛡️ **تصليب + تكاملات جديدة**: فرض احتواء المسار في `safe_path` + عزل أدوات سجل التداول/حساب الظل، إضافة `MANIFEST.in` لتضمين `.env.example` / الاختبارات / ملفات Docker في sdist، التحميل الكسول على مستوى المسار يقلّص حزمة الواجهة الأولية من 688KB إلى 262KB. إضافة محمّل بيانات Futu لأسهم هونغ كونغ وA ([#47](https://github.com/HKUDS/Vibe-Trading/pull/47)) ومهارة تصدير vnpy CtaTemplate ([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)).
<details>
<summary>أخبار سابقة</summary>
- **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** 🔌 **نماذج التفكير + إصلاحات Swarm**: الحفاظ على `reasoning_content` عبر جميع مسارات تسلسل `ChatOpenAI` — يعمل Kimi / DeepSeek / Qwen thinking من البداية إلى النهاية ([#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 أدوات حساب الظل)، إصلاح عدم تطابق اسم السجل `pattern_recognition` → `pattern`، مزامنة تبعيات Docker، مزامنة بيان SKILL (22 أداة MCP / 71 مهارة).
- **2026-04-18** 👥 **حساب الظل Shadow Account**: استخرج قواعد إستراتيجيتك من سجل التداول الخاص بك → اختبر الظل عبر الأسواق → تقرير HTML/PDF من 8 أقسام يوضح بدقة أين فقدت المال (خرق القواعد، الخروج المبكر، الإشارات المفقودة، الصفقات العكسية). 4 أدوات جديدة، مهارة واحدة جديدة، إجمالي 32 أداة. أمثلة Trade Journal / Shadow Account متاحة الآن في شاشة الترحيب على واجهة الويب.
- **2026-04-17** 📊 **محلل سجل التداول + قارئ ملفات عالمي**: حمّل سجلات التداول من الوسطاء (同花顺/东财/富途/CSV عام) → ملف تعريف تداول تلقائي (أيام الاحتفاظ، معدل الفوز، نسبة الربح/الخسارة، أقصى تراجع) + تشخيص 4 تحيزات سلوكية (تأثير التصرف، الإفراط في التداول، مطاردة الزخم، التثبيت السعري). `read_document` يوزّع الآن PDF وWord وExcel وPowerPoint والصور (OCR) و40+ صيغة نصية خلف استدعاء موحد.
- **2026-04-16** 🧠 **هيكل الوكيل**: ذاكرة دائمة عبر الجلسات، بحث جلسات FTS5، مهارات ذاتية التطور (CRUD كامل)، ضغط سياق 5 طبقات، معالجة أدوات القراءة/الكتابة دفعة واحدة. 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 + العملات المشفرة) بمجمع رأسمال مشترك. إصلاح متغيرات قوالب Swarm ومهلة الواجهة الأمامية.
- **2026-04-12** 🌍 **تصدير متعدد المنصات**: أمر `/pine` يصدّر إلى TradingView (Pine Script v6) وTDX (通达信/同花顺/东方财富) وMetaTrader 5 (MQL5) دفعة واحدة.
- **2026-04-11** 🛡️ **الموثوقية وDX**: إعداد `.env` عبر `vibe-trading init` ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))، فحوصات مسبقة، بديل تلقائي لمصادر البيانات، تعزيز محرك الاختبار الرجعي. 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. تحقق مونت كارلو وBootstrap CI وWalk-Forward.
- **2026-04-08** 🔧 **اختبار رجعي متعدد الأسواق**: قواعد خاصة بكل سوق، تصدير Pine Script v6، 5 مصادر بيانات مع بديل تلقائي.
</details>
---
## 💡 ما هو Vibe-Trading؟
Vibe-Trading هو مساحة عمل مالية متعددة الوكلاء مدعومة بالذكاء الاصطناعي تحول الطلبات بلغة طبيعية إلى استراتيجيات تداول قابلة للتنفيذ ورؤى بحثية وتحليل محافظ عبر الأسواق العالمية.
### القدرات الرئيسية:
• **لغة طبيعية → استراتيجية** — صِف فكرتك؛ الوكيل يكتب الكود ويختبره ويصدّره<br>
• **6 مصادر بيانات، بدون إعداد** — أسهم A، HK/US، العملات المشفرة، العقود الآجلة، الفوركس مع بديل تلقائي<br>
• **29 فريق خبراء** — سير عمل سرب متعدد الوكلاء للاستثمار والتداول وإدارة المخاطر<br>
• **ذاكرة عبر الجلسات** — يتذكر التفضيلات والرؤى؛ ينشئ ويطور المهارات القابلة لإعادة الاستخدام<br>
• **7 محركات اختبار رجعي** — اختبار مركب عبر الأسواق + تحقق إحصائي + 4 محسّنات<br>
• **تصدير متعدد المنصات** — نقرة واحدة إلى TradingView وTDX وMetaTrader 5
---
## ✨ الميزات الرئيسية
<table width="100%">
<tr>
<td align="center" width="25%" valign="top">
<img src="assets/scene-research.png" height="150" alt="البحث"/><br>
<h3>🔍 بحث عميق للتداول</h3>
<img src="https://img.shields.io/badge/72_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="المهارات" /><br><br>
<div align="left" style="font-size: 4px;">
• 72 مهارة متخصصة مع ذاكرة دائمة عبر الجلسات<br>
• تطور ذاتي: الوكيل ينشئ ويحسّن سير العمل من التجربة<br>
• ضغط سياق 5 طبقات — بلا فقدان معلومات في المحادثات الطويلة<br>
• توجيه المهام بلغة طبيعية عبر جميع المجالات المالية
</div>
</td>
<td align="center" width="25%" valign="top">
<img src="assets/scene-swarm.png" height="150" alt="السرب"/><br>
<h3>🐝 ذكاء السرب</h3>
<img src="https://img.shields.io/badge/29_Trading_Teams-4ECDC4?style=for-the-badge&logo=hive&logoColor=white" alt="السرب" /><br><br>
<div align="left">
• 29 إعداد مسبق لفرق التداول الجاهزة<br>
• تنسيق متعدد الوكلاء قائم على DAG<br>
• لوحة معلومات بث مباشر مع حالة الوكلاء الحية<br>
• بحث FTS5 عبر الجلسات في جميع المحادثات السابقة
</div>
</td>
<td align="center" width="25%" valign="top">
<img src="assets/scene-backtest.png" height="150" alt="الاختبار الرجعي"/><br>
<h3>📊 اختبار رجعي عبر الأسواق</h3>
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="الاختبار الرجعي" /><br><br>
<div align="left">
• أسهم A، أسهم HK/US، العملات المشفرة، العقود الآجلة والفوركس<br>
• 7 محركات سوق: أسهم A، أسهم US/HK، العملات المشفرة، العقود الآجلة الصينية، العقود الآجلة العالمية، الفوركس<br>
• التحقق الإحصائي: مونت كارلو، Bootstrap CI، المشي للأمام<br>
• 15+ مقياس أداء و4 محسّنات
</div>
</td>
<td align="center" width="25%" valign="top">
<img src="assets/scene-quant.png" height="150" alt="الكمي"/><br>
<h3>🧮 أدوات التحليل الكمي</h3>
<img src="https://img.shields.io/badge/Quant_Tools-C77DFF?style=for-the-badge&logo=wolfram&logoColor=white" alt="الكمي" /><br><br>
<div align="left">
• تحليل العامل IC/IR والاختبار الرجعي للشرائح<br>
• تسعير Black-Scholes وحساب كامل للمتغيرات اليونانية<br>
• التعرف على الأنماط الفنية واكتشافها<br>
• تحسين المحافظ عبر MVO/Risk Parity/BL
</div>
</td>
</tr>
</table>
## 72 مهارة عبر 7 فئات
- 📊 72 مهارة مالية متخصصة منظمة في 7 فئات
- 🌐 تغطية شاملة من الأسواق التقليدية إلى العملات المشفرة وDeFi
- 🔬 قدرات شاملة تمتد من مصادر البيانات إلى البحث الكمي
| الفئة | المهارات | أمثلة |
|----------|--------|----------|
| مصدر البيانات | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
| الاستراتيجية | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| التحليل | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
| فئة الأصول | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| العملات المشفرة | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| التدفقات | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| الأدوات | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
## 29 إعداد مسبق لفرق وكلاء السرب
- 🏢 29 فرق وكلاء جاهزة للاستخدام
- ⚡ سير عمل مالية مُعدة مسبقاً
- 🎯 إعدادات مسبقة للاستثمار والتداول وإدارة المخاطر
| الإعداد المسبق | سير العمل |
|--------|----------|
| `investment_committee` | مناظرة صعود/هبوط ← مراجعة مخاطر ← قرار مدير المحفظة النهائي |
| `global_equities_desk` | باحث أسهم A + HK/US + العملات المشفرة ← استراتيجي عالمي |
| `crypto_trading_desk` | التمويل/الأساس + التصفية + التدفق ← مدير مخاطر |
| `earnings_research_desk` | أساسي + مراجعة + خيارات ← استراتيجي الأرباح |
| `macro_rates_fx_desk` | أسعار الفائدة + الفوركس + السلع ← مدير محفظة كلية |
| `quant_strategy_desk` | فرز + بحث العوامل ← اختبار رجعي ← تدقيق مخاطر |
| `technical_analysis_panel` | TA كلاسيكي + إيشيموكو + هارمونيك + إليوت + SMC ← إجماع |
| `risk_committee` | السحب + مخاطر الذيل + مراجعة النظام ← موافقة |
| `global_allocation_committee` | أسهم A + عملات مشفرة + HK/US ← تخصيص عبر الأسواق |
<sub>بالإضافة إلى 20+ إعداد مسبق متخصص إضافي — شغّل vibe-trading --swarm-presets لاستكشافها جميعاً.
</sub>
### 🎬 عرض توضيحي
<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>☝️ اختبار رجعي بلغة طبيعية ومناظرة سرب متعدد الوكلاء — واجهة ويب + CLI</sub></td>
</tr>
</table>
</div>
---
## 🚀 البدء السريع
### تثبيت بسطر واحد (PyPI)
```bash
pip install vibe-trading-ai
```
> **اسم الحزمة مقابل الأوامر:** حزمة PyPI هي `vibe-trading-ai`. بعد التثبيت، ستحصل على ثلاثة أوامر:
>
> | الأمر | الغرض |
> |---------|---------|
> | `vibe-trading` | CLI تفاعلي / TUI |
> | `vibe-trading serve` | تشغيل خادم ويب FastAPI |
> | `vibe-trading-mcp` | بدء خادم MCP (لـ Claude Desktop, OpenClaw, Cursor, إلخ) |
```bash
vibe-trading init # إعداد تفاعلي لملف .env
vibe-trading # تشغيل CLI
vibe-trading serve --port 8899 # تشغيل واجهة الويب
vibe-trading-mcp # بدء خادم MCP (stdio)
```
### أو اختر مساراً
| المسار | الأنسب لـ | الوقت |
|------|----------|------|
| **A. Docker** | تجربته الآن، بدون إعداد محلي | دقيقتان |
| **B. تثبيت محلي** | التطوير، وصول كامل لـ CLI | 5 دقائق |
| **C. إضافة MCP** | ربطه بوكيلك الحالي | 3 دقائق |
| **D. ClawHub** | أمر واحد، بدون استنساخ | دقيقة واحدة |
### المتطلبات المسبقة
- **مفتاح API لنموذج لغة** من أي مزود مدعوم — أو التشغيل محلياً مع **Ollama** (بدون مفتاح)
- **Python 3.11+** للمسار B
- **Docker** للمسار A
> **مزودو نماذج اللغة المدعومون:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama (محلي). راجع `.env.example` للإعدادات.
> **نصيحة:** جميع الأسواق تعمل بدون أي مفاتيح API بفضل البديل التلقائي. yfinance (HK/US) و OKX (العملات المشفرة) و AKShare (أسهم A، US، HK، العقود الآجلة، الفوركس) جميعها مجانية. رمز Tushare اختياري — AKShare يغطي أسهم A كبديل مجاني.
### المسار A: Docker (بدون إعداد)
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
cp agent/.env.example agent/.env
# عدّل agent/.env — أزل التعليق عن مزود نموذج اللغة وحدد مفتاح API
docker compose up --build
```
افتح `http://localhost:8899`. الخلفية + الواجهة الأمامية في حاوية واحدة.
### المسار B: التثبيت المحلي
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
# التفعيل
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -e .
cp agent/.env.example agent/.env # عدّل — حدد مفتاح API لمزود نموذج اللغة
vibe-trading # تشغيل TUI التفاعلي
```
<details>
<summary><b>تشغيل واجهة الويب (اختياري)</b></summary>
```bash
# الطرفية 1: خادم API
vibe-trading serve --port 8899
# الطرفية 2: خادم تطوير الواجهة الأمامية
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 مجلد dist/ كملفات ثابتة
```
</details>
### المسار C: إضافة MCP
راجع قسم [إضافة MCP](#-إضافة-mcp) أدناه.
### المسار D: ClawHub (أمر واحد)
```bash
npx clawhub@latest install vibe-trading --force
```
يتم تنزيل المهارة + إعدادات MCP إلى مجلد مهارات وكيلك. راجع [تثبيت ClawHub](#-إضافة-mcp) للتفاصيل.
---
## 🧠 متغيرات البيئة
انسخ `agent/.env.example` إلى `agent/.env` وأزل التعليق عن كتلة المزود التي تريدها. كل مزود يحتاج إلى 3-4 متغيرات:
| المتغير | مطلوب | الوصف |
|----------|:--------:|-------------|
| `LANGCHAIN_PROVIDER` | نعم | اسم المزود (`openrouter`, `deepseek`, `groq`, `z.ai`, `ollama`, إلخ) |
| `<PROVIDER>_API_KEY` | نعم* | مفتاح API (`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, إلخ) |
| `<PROVIDER>_BASE_URL` | نعم | رابط نقطة نهاية API |
| `LANGCHAIN_MODEL_NAME` | نعم | اسم النموذج (مثلاً `deepseek/deepseek-v3.2`) |
| `TUSHARE_TOKEN` | لا | رمز Tushare Pro لبيانات أسهم A (بديل AKShare) |
| `TIMEOUT_SECONDS` | لا | مهلة استدعاء نموذج اللغة، الافتراضي 120 ثانية |
<sub>* Ollama لا يتطلب مفتاح API.</sub>
**بيانات مجانية (بدون مفتاح):** أسهم A عبر AKShare، أسهم HK/US عبر yfinance، العملات المشفرة عبر OKX، 100+ بورصة عملات مشفرة عبر CCXT. يختار النظام تلقائياً أفضل مصدر متاح لكل سوق.
### 🎯 النماذج الموصى بها
Vibe-Trading وكيل يعتمد بكثافة على استدعاءات الأدوات — المهارات والاختبار الخلفي والذاكرة و swarm كلها تعمل عبر tool calls. اختيار النموذج يحدد مباشرة ما إذا كان الوكيل **يستخدم أدواته فعلاً** أو يلفّق الإجابات من بيانات التدريب.
| المستوى | أمثلة | متى يُستخدم |
|---------|-------|-------------|
| **الأفضل** | `anthropic/claude-opus-4.7`، `anthropic/claude-sonnet-4.6`، `openai/gpt-5.4`، `google/gemini-3.1-pro-preview` | swarm معقد (3+ وكلاء)، جلسات بحث طويلة، تحليل بمستوى ورقة علمية |
| **نقطة مثلى** (افتراضي) | `deepseek/deepseek-v3.2`، `x-ai/grok-4.20`، `z-ai/glm-5.1`، `moonshotai/kimi-k2.5`، `qwen/qwen3-max-thinking` | الاستخدام اليومي — tool-calling موثوق بحوالي 1/10 من التكلفة |
| **تجنّب كوكيل** | `*-nano`، `*-flash-lite`، `*-coder-next`، إصدارات صغيرة / مُقطّرة | tool-calling غير موثوق — سيبدو الوكيل وكأنه "يجيب من الذاكرة" بدلاً من تحميل المهارات أو تشغيل الاختبار الخلفي |
يأتي `agent/.env.example` الافتراضي مع `deepseek/deepseek-v3.2` — الخيار الأرخص في مستوى النقطة المثلى.
---
## 🖥 مرجع سطر الأوامر
```bash
vibe-trading # TUI تفاعلي
vibe-trading run -p "..." # تشغيل واحد
vibe-trading serve # خادم API
```
<details>
<summary><b>أوامر الشرطة المائلة داخل TUI</b></summary>
| الأمر | الوصف |
|---------|-------------|
| `/help` | عرض جميع الأوامر |
| `/skills` | عرض جميع مهارات التداول الـ 69 |
| `/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>` | Pine Script لـ TradingView |
| `/trace <run_id>` | إعادة تشغيل التنفيذ الكاملة |
| `/continue <run_id> <prompt>` | متابعة تشغيل بتعليمات جديدة |
| `/sessions` | عرض جلسات الدردشة |
| `/settings` | عرض إعدادات التشغيل |
| `/clear` | مسح الشاشة |
| `/quit` | الخروج |
</details>
<details>
<summary><b>التشغيل الفردي والعلامات</b></summary>
```bash
vibe-trading run -p "اختبر استراتيجية BTC-USDT MACD رجعياً، آخر 30 يوماً"
vibe-trading run -p "حلل زخم AAPL" --json
vibe-trading run -f strategy.txt
echo "اختبر 000001.SZ RSI رجعياً" | vibe-trading run
```
```bash
vibe-trading -p "طلبك"
vibe-trading --skills
vibe-trading --swarm-presets
vibe-trading --swarm-run investment_committee '{"topic":"توقعات BTC"}'
vibe-trading --list
vibe-trading --show <run_id>
vibe-trading --code <run_id>
vibe-trading --pine <run_id> # Pine Script لـ TradingView
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "حسّن الاستراتيجية"
vibe-trading --upload report.pdf
```
</details>
---
## 🌐 خادم API
```bash
vibe-trading serve --port 8899
```
| الطريقة | نقطة النهاية | الوصف |
|--------|----------|-------------|
| `GET` | `/runs` | عرض التشغيلات |
| `GET` | `/runs/{run_id}` | تفاصيل التشغيل |
| `GET` | `/runs/{run_id}/pine` | تصدير Pine Script |
| `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 للسرب |
توثيق تفاعلي: `http://localhost:8899/docs`
---
## 🔌 إضافة MCP
يقدم Vibe-Trading 22 أداة MCP لأي عميل متوافق مع MCP. يعمل كعملية فرعية stdio — بدون إعداد خادم. **21 من 22 أداة تعمل بدون مفاتيح API** (HK/US/العملات المشفرة). فقط `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 / عملاء MCP الآخرين</b></summary>
```bash
vibe-trading-mcp # stdio (الافتراضي)
vibe-trading-mcp --transport sse # SSE لعملاء الويب
```
</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` مطلوب لأن المهارة تشير إلى واجهات برمجية خارجية، مما يؤدي إلى فحص تلقائي من VirusTotal. الكود مفتوح المصدر بالكامل وآمن للفحص.
هذا ينزّل المهارة + إعدادات MCP إلى مجلد مهارات وكيلك. بدون الحاجة للاستنساخ.
تصفح على ClawHub: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vibe-trading)
</details>
<details>
<summary><b>OpenSpace — مهارات ذاتية التطور</b></summary>
جميع مهارات التداول الـ 69 منشورة على [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 تلقائياً جميع المهارات الـ 69، مما يتيح الإصلاح التلقائي والتحسين التلقائي والمشاركة المجتمعية. ابحث عن مهارات Vibe-Trading عبر `search_skills("finance backtest")` في أي وكيل متصل بـ OpenSpace.
</details>
---
## 📁 هيكل المشروع
<details>
<summary><b>انقر للتوسيع</b></summary>
```
Vibe-Trading/
├── agent/ # الخلفية (Python)
│ ├── cli.py # نقطة دخول CLI — TUI تفاعلي + أوامر فرعية
│ ├── api_server.py # خادم FastAPI — تشغيلات، جلسات، رفع، سرب، SSE
│ ├── mcp_server.py # خادم MCP — 22 أداة لـ OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # نواة وكيل ReAct
│ │ │ ├── loop.py # ضغط 5 طبقات + معالجة أدوات القراءة/الكتابة دفعة واحدة
│ │ │ ├── context.py # موجه النظام + استرجاع تلقائي من الذاكرة الدائمة
│ │ │ ├── skills.py # محمل المهارات (69 مدمجة + إنشاء CRUD من المستخدم)
│ │ │ ├── tools.py # فئة الأدوات الأساسية + السجل
│ │ │ ├── memory.py # حالة مساحة عمل خفيفة لكل تشغيل
│ │ │ ├── frontmatter.py # محلل YAML frontmatter مشترك
│ │ │ └── trace.py # كاتب أثر التنفيذ
│ │ │
│ │ ├── memory/ # ذاكرة دائمة عبر الجلسات
│ │ │ └── persistent.py # ذاكرة قائمة على الملفات (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 27 أداة وكيل مكتشفة تلقائياً
│ │ │ ├── backtest_tool.py # تشغيل الاختبارات الرجعية
│ │ │ ├── remember_tool.py # ذاكرة عبر الجلسات (حفظ/استرجاع/نسيان)
│ │ │ ├── skill_writer_tool.py # CRUD للمهارات (حفظ/تصحيح/حذف/ملف)
│ │ │ ├── session_search_tool.py # بحث FTS5 عبر الجلسات
│ │ │ ├── swarm_tool.py # إطلاق فرق السرب
│ │ │ ├── web_search_tool.py # بحث ويب DuckDuckGo
│ │ │ └── ... # bash، إدخال/إخراج ملف، تحليل العوامل، الخيارات، إلخ
│ │ │
│ │ ├── skills/ # 72 مهارة مالية في 7 فئات (SKILL.md لكل منها)
│ │ ├── swarm/ # محرك تنفيذ DAG للسرب
│ │ │ └── presets/ # 29 تعريف YAML للإعدادات المسبقة للسرب
│ │ ├── session/ # دردشة متعددة الأدوار + بحث FTS5 عبر الجلسات
│ │ └── providers/ # تجريد مزود نموذج اللغة
│ │
│ └── backtest/ # محركات الاختبار الرجعي
│ ├── engines/ # 7 محركات + محرك مركب عبر الأسواق + options_portfolio
│ ├── loaders/ # 6 مصادر: tushare, okx, yfinance, akshare, ccxt, futu
│ │ ├── base.py # بروتوكول DataLoader
│ │ └── registry.py # السجل + سلاسل البديل التلقائي
│ └── optimizers/ # MVO، تساوي التقلب، أقصى تنويع، تكافؤ المخاطر
│
├── frontend/ # واجهة الويب (React 19 + Vite + TypeScript)
│ └── src/
│ ├── pages/ # الرئيسية، الوكيل، تفاصيل التشغيل، المقارنة
│ ├── components/ # دردشة، رسوم بيانية، تخطيط
│ └── stores/ # إدارة حالة Zustand
│
├── Dockerfile # بناء متعدد المراحل
├── docker-compose.yml # نشر بأمر واحد
├── pyproject.toml # إعدادات الحزمة + نقطة دخول CLI
└── LICENSE # MIT
```
</details>
---
## 🏛 النظام البيئي
Vibe-Trading هو جزء من النظام البيئي للوكلاء **[HKUDS](https://github.com/HKUDS)**:
<table>
<tr>
<td align="center" width="25%">
<a href="https://github.com/HKUDS/ClawTeam"><b>ClawTeam</b></a><br>
<sub>ذكاء سرب الوكلاء</sub>
</td>
<td align="center" width="25%">
<a href="https://github.com/HKUDS/nanobot"><b>NanoBot</b></a><br>
<sub>مساعد ذكاء اصطناعي شخصي فائق الخفة</sub>
</td>
<td align="center" width="25%">
<a href="https://github.com/HKUDS/CLI-Anything"><b>CLI-Anything</b></a><br>
<sub>جعل جميع البرامج أصلية للوكلاء</sub>
</td>
<td align="center" width="25%">
<a href="https://github.com/HKUDS/OpenSpace"><b>OpenSpace</b></a><br>
<sub>مهارات وكلاء ذكاء اصطناعي ذاتية التطور</sub>
</td>
</tr>
</table>
---
## 🗺 خارطة الطريق
> نشحن على مراحل. تنتقل العناصر إلى [المشكلات](https://github.com/HKUDS/Vibe-Trading/issues) عند بدء العمل.
| المرحلة | الميزة | الحالة |
|-------|---------|--------|
| **هيكل الوكيل** | ذاكرة دائمة عبر الجلسات (حفظ / استرجاع / نسيان) | **تم** |
| | مهارات ذاتية التطور — الوكيل ينشئ ويعدل ويحذف سير العمل بنفسه | **تم** |
| | بحث FTS5 عبر الجلسات في جميع المحادثات السابقة | **تم** |
| | ضغط سياق 5 طبقات (micro → collapse → auto → manual → iterative) | **تم** |
| | معالجة أدوات القراءة/الكتابة دفعة واحدة — تنفيذ موازٍ للأدوات للقراءة فقط | **تم** |
| **التالي** | حلقة بحث مستقلة — الوكيل يكرر الفرضيات طوال الليل | قيد التنفيذ |
| | تكامل المراسلة (Slack / Telegram / WeChat) | مخطط |
| **التحليل والتصور** | سطح التقلب للخيارات والمتغيرات اليونانية مع تصور ثلاثي الأبعاد | مخطط |
| | خريطة حرارة الارتباط عبر الأصول مع نافذة متدحرجة وتجميع | مخطط |
| | مقارنة المعيار في مخرجات الاختبار الرجعي لـ CLI | مخطط |
| **المهارات والإعدادات المسبقة** | مهارة تحليل الأرباح | مخطط |
| | إعداد سرب مسبق للاستثمار المستدام / ESG | مخطط |
| **المحافظ والتحسين** | محسّن محافظ متقدم: الرافعة المالية، حدود القطاعات، قيود التداول | مخطط |
| **المستقبل** | سوق الاستراتيجيات (مشاركة واكتشاف) | قيد الاستكشاف |
| | بث بيانات مباشر عبر WebSocket | قيد الاستكشاف |
---
## المساهمة
نرحب بالمساهمات! راجع [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) — اختر واحدة وابدأ.
ترغب في المساهمة بشيء أكبر؟ راجع [خارطة الطريق](#-خارطة-الطريق) أعلاه وافتح مشكلة للمناقشة قبل البدء.
---
## المساهمون
شكراً لكل من ساهم في Vibe-Trading!
<a href="https://github.com/HKUDS/Vibe-Trading/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/Vibe-Trading" />
</a>
---
## إخلاء المسؤولية
Vibe-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">
شكراً لزيارتك <b>Vibe-Trading</b> ✨
</p>
<p align="center">
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="الزوار"/>
</p>
+106 -64
View File
@@ -1,5 +1,5 @@
<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.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">
@@ -19,10 +19,10 @@
<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>
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
<img src="https://img.shields.io/badge/Skills-72-orange" alt="Skills">
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
<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>
@@ -51,10 +51,29 @@
## 📰 ニュース
- **2026-04-10** 📦 **v0.1.4**: Dockerビルドを修正([#8](https://github.com/HKUDS/Vibe-Trading/issues/8))、`web_search` MCPツールを追加(合計17)、依存関係とMCPに`akshare`/`ccxt`を追加。11のLLMプロバイダー(DeepSeek, Groq, Gemini, Ollama など)、すべての調整パラメータを`.env`で設定可能。`ml-strategy`スキルを強化。PyPIとClawHubに公開。
- **2026-04-09** 📊 **Backtest Wave 2 — マルチアセットエンジン**: ChinaFutures(CFFEX/SHFE/DCE/ZCE、50+銘柄)、GlobalFutures(CME/ICE/Eurex、30+銘柄)、Forex(24通貨ペア、スプレッド+スワップ)、Options v2(アメリカン行使、IVスマイル)を追加。統計的検証: モンテカルロ置換検定、ブートストラップSharpe信頼区間、ウォークフォワード分析。
- **2026-04-08** 🔧 **マルチマーケットバックテスト**(市場別ルール対応)と**TradingView向けPine Script v6エクスポート**。**データソース拡張**: 自動フォールバック付き5ソース、`web_search`ツール、スキル分類(7カテゴリ)。
- **2026-04-01** 🚀 **v0.1.0** — 初期リリース: ReActエージェント、64スキル、29スウォームプリセット、クロスマーケットバックテスト、CLI + Web UI + MCPサーバー。
- **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 ローダーが ETF(`510300.SH`)と外国為替(`USDCNH`)を正しいエンドポイントにルーティング、レジストリフォールバックも強化。v0.1.5 以降の更新を集約: ベンチマーク比較パネル、`/upload` ストリーミング + サイズ制限、Futu ローダー(HK + A 株)、vnpy エクスポートスキル、セキュリティ強化、フロントエンド遅延ロード(688KB → 262KB)。
- **2026-04-27** 📊 **ベンチマーク比較パネル + アップロード安全性**: バックテスト出力にベンチマーク比較パネル(銘柄 / ベンチマークリターン / 超過リターン / 情報比率)を追加、yfinance 経由で SPY・CSI 300 などを解決([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。加えて `/upload` を 1MB チャンクのストリーミングに変更し、`MAX_UPLOAD_SIZE` を超えた時点で中断 + 部分ファイルをクリーンアップ。50MB 上限が悪意/巨大リクエスト下でも実効化([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))—— 4 件の回帰テストでピン留め。
- **2026-04-22** 🛡️ **ハードニング + 新規連携**: `safe_path` でパス封じ込めを強制 + 取引明細/シャドウアカウント系ツールをサンドボックス化、`MANIFEST.in` を追加して sdist に `.env.example` / テスト / Docker ファイルを同梱、フロントエンドのルート単位遅延ロードで初期バンドル 688KB → 262KB。加えて富途(Futu)の香港株/A 株データローダー([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))と vnpy CtaTemplate エクスポートスキル([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))を追加。
<details>
<summary>過去のニュース</summary>
- **2026-04-21** 🛡️ **ワークスペース + ドキュメント**: 相対 `run_dir` をアクティブな run ディレクトリに正規化([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README に使用例を追加([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
- **2026-04-20** 🔌 **推論モデル + Swarm 修正**: `reasoning_content` を `ChatOpenAI` の全シリアライズパスで保持 — Kimi / DeepSeek / Qwen thinking がエンドツーエンドで動作([#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 下限バンプ、新規 MCP ツール5つ接続(`analyze_trade_journal` + シャドウアカウント系4つ)、`pattern_recognition` → `pattern` レジストリ名の不一致を修正、Docker 依存を本体に合わせる、SKILL マニフェスト同期(22 MCP ツール / 71 スキル)。
- **2026-04-18** 👥 **シャドウアカウント Shadow Account**: ブローカーの取引明細から自分の戦略ルールを抽出 → マーケット横断でシャドウをバックテスト → 8セクションのHTML/PDFレポートが、どこでいくら取りこぼしたか(ルール違反・早すぎる利確・見逃したシグナル・逆張り)を正確に可視化。新規ツール4つ、新スキル1つ、合計32ツール。Trade Journal / Shadow Accountのサンプル例文がWeb UIウェルカム画面に追加。
- **2026-04-17** 📊 **取引明細アナライザー + ユニバーサルファイルリーダー**: ブローカーの取引明細(同花順/東方財富/富途/汎用CSV)をアップロード → 取引プロフィール(保有日数、勝率、損益比、最大ドローダウン)+ 4つの行動バイアス診断(処分効果、過剰取引、追随買い、アンカリング)を自動生成。`read_document`はPDF、Word、Excel、PowerPoint、画像(OCR)、40+テキスト形式を1回の呼び出しで統一処理。
- **2026-04-16** 🧠 **エージェントハーネス**: クロスセッション永続メモリ、FTS5セッション検索、自己進化スキル(完全CRUD)、5層コンテキスト圧縮、読み書きツールバッチ処理。27ツール、107新テスト。
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.aiプロバイダー追加([#35](https://github.com/HKUDS/Vibe-Trading/pull/35))、MiniMax temperature修正+モデル更新([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。13プロバイダー対応。
- **2026-04-14** 🔧 **MCP安定性**: バックテストツールのstdioトランスポートにおける`Connection closed`エラーを修正([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
- **2026-04-13** 🌐 **クロスマーケット複合バックテスト**: 新`CompositeEngine`で異なる市場の銘柄(例:A株+暗号資産)を共有資金プールで同時バックテスト、市場ルールは銘柄ごとに適用。Swarmテンプレート変数フォールバックとフロントエンドタイムアウトも修正。
- **2026-04-12** 🌍 **マルチプラットフォーム出力**: `/pine`でTradingView (Pine Script v6)、TDX(通達信/同花順/東方財富)、MetaTrader 5 (MQL5) に一括エクスポート。
- **2026-04-11** 🛡️ **信頼性とDX**: `vibe-trading init` .envブートストラップ([#19](https://github.com/HKUDS/Vibe-Trading/pull/19))、プリフライトチェック、データソースフォールバック、バックテストエンジン強化。多言語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ツール、12 LLMプロバイダー、`akshare`/`ccxt`依存追加。PyPIとClawHubに公開。
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures、GlobalFutures、Forex、Options v2エンジン追加。モンテカルロ、Bootstrap CI、ウォークフォワード検証。
- **2026-04-08** 🔧 **マルチマーケットバックテスト**: 市場別ルール、Pine Script v6エクスポート、自動フォールバック付き5データソース。
</details>
---
@@ -63,12 +82,12 @@
Vibe-Tradingは、自然言語リクエストをグローバル市場向けの実行可能なトレーディング戦略、リサーチ洞察、ポートフォリオ分析へと変換する、AI駆動のマルチエージェント金融ワークスペースです。
### 主な能力:
• **Strategy Generation** — アイデアから自動でトレーディングコードを生成<br>
• **Smart Data Access** — 自動フォールバック付き5つのデータソース、全市場ゼロ設定<br>
• **Performance Testing** — 歴史的市場データで戦略を検証<br>
• **TradingView Export** — ワンクリックでTradingView用Pine Script v6に変換<br>
• **Expert Teams** — 複雑なリサーチタスクに特化したAIエージェントチームを展開<br>
• **Live Updates** — 分析プロセス全体をリアルタイムで観察
• **自然言語 → 戦略** — アイデアを記述するだけ、エージェントがコードの作成・テスト・エクスポートを実行<br>
• **5データソース、ゼロ設定** — A株、HK/US、暗号、先物、FX対応、自動フォールバック<br>
• **29の専門チーム** — 投資・トレーディング・リスク向けマルチエージェントスウォームワークフロー<br>
• **クロスセッションメモリ** — ユーザーの好みやインサイトを記憶し、再利用可能なスキルを自動生成・進化<br>
• **7つのバックテストエンジン** — クロスマーケット複合テスト + 統計検証 + 4種オプティマイザー<br>
• **マルチプラットフォームエクスポート** — ワンクリックでTradingView、TDX、MetaTrader 5へ
---
@@ -79,12 +98,12 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
<td align="center" width="25%" valign="top">
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
<h3>🔍 トレーディング向けDeepResearch</h3>
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<img src="https://img.shields.io/badge/72_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• 市場横断のマルチドメイン分析<br>
• 自動ストラテジー/シグナル生成<br>
• マクロ経済リサーチとインサイト<br>
• チャットによる自然言語タスクルーティング
• 72の専門スキル + クロスセッション永続メモリ<br>
• 自己進化: エージェントが経験からワークフローを構築・改善<br>
• 5層コンテキスト圧縮 — 長い会話でも情報損失なし<br>
• 全金融ドメインにわたる自然言語タスクルーティング
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,8 +113,8 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
<div align="left">
• 即戦力の29種トレーディングチームプリセット<br>
• DAGベースのマルチエージェントオーケストレーション<br>
• リアルタイム意思決定ストリーミングダッシュボード<br>
• YAMLによるカスタムチーム構築
• リアルタイムストリーミングダッシュボード(エージェントステータス表示)<br>
• FTS5クロスセッション検索で過去の全会話を横断検索
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -104,7 +123,7 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<div align="left">
• A株、HK/US株式、暗号資産、先物、FXに対応<br>
• 7つの市場エンジン: A株、US/HK株、暗号資産、中国先物、グローバル先物、FX<br>
• 7つの市場エンジン + クロスマーケット複合エンジン(共有資金プール)<br>
• 統計的検証: モンテカルロ、ブートストラップCI、ウォークフォワード<br>
• 15以上のパフォーマンス指標と4種類のオプティマイザー
</div>
@@ -123,16 +142,16 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
</tr>
</table>
## 7カテゴリにわたる68スキル
## 7カテゴリにわたる72スキル
- 📊 68の金融特化スキルを7カテゴリに整理
- 📊 72の金融特化スキルを7カテゴリに整理
- 🌐 伝統的市場から暗号・DeFiまで完全カバー
- 🔬 データ取得からクオンツリサーチまでの包括的能力
| Category | Skills | Examples |
|----------|--------|----------|
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-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` |
@@ -221,7 +240,7 @@ vibe-trading-mcp # MCPサーバー(stdio)起動
- Path Bでは**Python 3.11+**
- Path Aでは**Docker**
> **サポートされるLLMプロバイダー:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Ollama(ローカル)。設定は`.env.example`を参照。
> **サポートされるLLMプロバイダー:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama(ローカル)。設定は`.env.example`を参照。
> **Tip:** すべての市場でAPIキーなしでも動作(自動フォールバック)。yfinance(HK/US)、OKX(暗号)、AKShare(A株・US・HK・先物・FX)は無料。Tushareトークンは任意 — A株はAKShareで無料フォールバック。
@@ -306,6 +325,18 @@ npx clawhub@latest install vibe-trading --force
**無料データ(キー不要):** AKShare経由のA株、yfinance経由のHK/US株式、OKX経由の暗号、CCXT経由の100+暗号取引所。市場ごとに最適なソースを自動選択。
### 🎯 推奨モデル
Vibe-Tradingはツール呼び出しに大きく依存するエージェントです — skill・バックテスト・メモリ・swarmはすべてtool callで動作します。モデル選択が、エージェントが**実際にツールを使う**か、訓練データから答えを捏造するかを直接決定します。
| 層 | 例 | 用途 |
|----|-----|------|
| **ベスト** | `anthropic/claude-opus-4.7`、`anthropic/claude-sonnet-4.6`、`openai/gpt-5.4`、`google/gemini-3.1-pro-preview` | 複雑なswarm(3+エージェント)、長い研究セッション、論文レベル分析 |
| **コスパ**(デフォルト) | `deepseek/deepseek-v3.2`、`x-ai/grok-4.20`、`z-ai/glm-5.1`、`moonshotai/kimi-k2.5`、`qwen/qwen3-max-thinking` | 日常利用 — tool-callingが安定、コスト約1/10 |
| **エージェント用途では非推奨** | `*-nano`、`*-flash-lite`、`*-coder-next`、小型 / 蒸留版 | tool-callingが不安定 — skillのロードやbacktest実行をせず「記憶で回答」してしまう |
デフォルトの `agent/.env.example` は `deepseek/deepseek-v3.2` — コスパ層の最安オプション。
---
## 🖥 CLI リファレンス
@@ -322,7 +353,7 @@ vibe-trading serve # APIサーバー
| Command | Description |
|---------|-------------|
| `/help` | すべてのコマンドを表示 |
| `/skills` | 68の金融スキルを一覧表示 |
| `/skills` | 72の金融スキルを一覧表示 |
| `/swarm` | 29のスウォームチームプリセットを一覧表示 |
| `/swarm run <preset> [vars_json]` | スウォームチームをライブストリーミングで実行 |
| `/swarm list` | スウォーム実行履歴 |
@@ -331,7 +362,7 @@ vibe-trading serve # APIサーバー
| `/list` | 直近の実行 |
| `/show <run_id>` | 実行詳細と指標 |
| `/code <run_id>` | 生成された戦略コード |
| `/pine <run_id>` | TradingView用Pine Script |
| `/pine <run_id>` | インジケーターエクスポート(TradingView + TDX + MT5)|
| `/trace <run_id>` | 実行リプレイ |
| `/continue <run_id> <prompt>` | 実行を新指示で継続 |
| `/sessions` | チャットセッション一覧 |
@@ -358,7 +389,7 @@ 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> # TradingView用Pine Script
vibe-trading --pine <run_id> # インジケーターエクスポート(TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
@@ -378,7 +409,7 @@ vibe-trading serve --port 8899
|--------|----------|-------------|
| `GET` | `/runs` | 実行一覧 |
| `GET` | `/runs/{run_id}` | 実行詳細 |
| `GET` | `/runs/{run_id}/pine` | Pine Scriptエクスポート |
| `GET` | `/runs/{run_id}/pine` | マルチプラットフォームインジケーターエクスポート |
| `POST` | `/sessions` | セッション作成 |
| `POST` | `/sessions/{id}/messages` | メッセージ送信 |
| `GET` | `/sessions/{id}/events` | SSEイベントストリーム |
@@ -393,7 +424,7 @@ vibe-trading serve --port 8899
## 🔌 MCP プラグイン
Vibe-Tradingは、あらゆるMCP互換クライアント向けに17のMCPツールを提供します。stdioサブプロセスとして実行 — サーバーセットアップ不要。**17中16ツールはAPIキー不要**(HK/US/暗号)。`run_swarm`のみLLMキーが必要。
Vibe-Tradingは、あらゆるMCP互換クライアント向けに22のMCPツールを提供します。stdioサブプロセスとして実行 — サーバーセットアップ不要。**22中21ツールはAPIキー不要**(HK/US/暗号)。`run_swarm`のみLLMキーが必要。
<details>
<summary><b>Claude Desktop</b></summary>
@@ -435,7 +466,7 @@ vibe-trading-mcp --transport sse # Webクライアント向けSSE
</details>
**公開MCPツール(17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`。
**公開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>
@@ -455,7 +486,7 @@ ClawHubで閲覧: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills/vib
<details>
<summary><b>OpenSpace — 自己進化スキル</b></summary>
全68金融スキルは[open-space.cloud](https://open-space.cloud)に公開され、OpenSpaceの自己進化エンジンで自律的に進化します。
全72金融スキルは[open-space.cloud](https://open-space.cloud)に公開され、OpenSpaceの自己進化エンジンで自律的に進化します。
OpenSpaceで使うには、両方のMCPサーバーをエージェント設定に追加:
@@ -477,7 +508,7 @@ OpenSpaceで使うには、両方のMCPサーバーをエージェント設定
}
```
OpenSpaceが全68スキルを自動検出し、auto-fix/auto-improve/コミュニティ共有を有効化。`search_skills("finance backtest")`でVibe-Tradingスキルを検索可能。
OpenSpaceが全72スキルを自動検出し、auto-fix/auto-improve/コミュニティ共有を有効化。`search_skills("finance backtest")`でVibe-Tradingスキルを検索可能。
</details>
@@ -493,41 +524,42 @@ Vibe-Trading/
├── agent/ # バックエンド (Python)
│ ├── cli.py # CLIエントリーポイント — 対話型TUI + サブコマンド
│ ├── api_server.py # FastAPIサーバー — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCPサーバー — OpenClaw / Claude Desktop向け17ツール
│ ├── mcp_server.py # MCPサーバー — OpenClaw / Claude Desktop向け22ツール
│ │
│ ├── src/
│ │ ├── agent/ # ReActエージェントコア
│ │ │ ├── loop.py # メイン推論ループ
│ │ │ ├── skills.py # スキルローダー(68 SKILL.md, 7カテゴリ)
│ │ │ ├── tools.py # ツールオーケストレーション
│ │ │ ├── context.py # システムプロンプトビルダー
│ │ │ ├── memory.py # 実行メモリ / アーティファクトストア
│ │ │ ├── loop.py # 5層圧縮 + 読み書きツールバッチ処理
│ │ │ ├── context.py # システムプロンプト + 永続メモリからの自動リコール
│ │ │ ├── skills.py # スキルローダー(72バンドル + ユーザーCRUD作成)
│ │ │ ├── tools.py # ツール基底クラス + レジストリ
│ │ │ ├── memory.py # 実行ごとの軽量ワークスペースステート
│ │ │ ├── frontmatter.py # 共有YAMLフロントマターパーサー
│ │ │ └── trace.py # 実行トレースライター
│ │ │
│ │ ├── tools/ # 21のエージェントツール
│ │ │ ├── backtest_tool.py # バックテスト実行
│ │ │ ├── factor_analysis_tool.py
│ │ │ ├── options_pricing_tool.py
│ │ │ ├── pattern_tool.py # チャートパターン検出
│ │ │ ├── doc_reader_tool.py # PDFリーダー(OCRフォールバック)
│ │ │ ├── web_reader_tool.py # webページリーダー(Jina)
│ │ │ ├── web_search_tool.py # DuckDuckGoウェブ検索
│ │ │ ├── swarm_tool.py # スウォームチーム起動
│ │ │ └── ... # ファイルI/O, bash, tasks など
│ │ ├── memory/ # クロスセッション永続メモリ
│ │ │ └── persistent.py # ファイルベースメモリ (~/.vibe-trading/memory/)
│ │ │
│ │ ├── skills/ # 7カテゴリに渡る68金融スキル (各SKILL.md)
│ │ ├── tools/ # 27の自動検出エージェントツール
│ │ │ ├── backtest_tool.py # バックテスト実行
│ │ │ ├── remember_tool.py # クロスセッションメモリ (保存/リコール/削除)
│ │ │ ├── skill_writer_tool.py # スキルCRUD (保存/パッチ/削除/ファイル)
│ │ │ ├── session_search_tool.py # FTS5クロスセッション検索
│ │ │ ├── swarm_tool.py # スウォームチーム起動
│ │ │ ├── web_search_tool.py # DuckDuckGoウェブ検索
│ │ │ └── ... # bash, ファイルI/O, ファクター分析, オプション等
│ │ │
│ │ ├── skills/ # 7カテゴリに渡る72金融スキル (各SKILL.md)
│ │ ├── swarm/ # スウォームDAG実行エンジン
│ │ ├── session/ # マルチターンチャットセッション管理
│ │ │ └── presets/ # 29のスウォームプリセットYAML定義
│ │ ├── session/ # マルチターンチャット + FTS5セッション検索
│ │ └── providers/ # LLMプロバイダー抽象化
│ │
│ ├── backtest/ # バックテストエンジン
│ │ ├── engines/ # 7エンジン: china_a, global_equity, crypto, china_futures, global_futures, forex + options_portfolio
│ │ ├── loaders/ # 5ソース: tushare, okx, yfinance, akshare, ccxt
│ │ │ ├── base.py # DataLoader Protocol
│ │ │ └── registry.py # レジストリ + 自動フォールバックチェーン
│ │ └── optimizers/ # MVO, equal vol, max div, risk parity
│ │
│ └── config/swarm/ # 29のスウォームプリセットYAML定義
│ └── backtest/ # バックテストエンジン
│ ├── engines/ # 7エンジン + クロスマーケット複合エンジン + options_portfolio
│ ├── loaders/ # 6ソース: tushare, okx, yfinance, akshare, ccxt, futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # レジストリ + 自動フォールバックチェーン
│ └── optimizers/ # MVO, equal vol, max div, risk parity
│
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
│ └── src/
@@ -578,17 +610,21 @@ Vibe-Tradingは**[HKUDS](https://github.com/HKUDS)**エージェントエコシ
| Phase | Feature | Status |
|-------|---------|--------|
| **Agent Harness** | クロスセッション永続メモリ(保存/リコール/削除) | **Done** |
| | 自己進化スキル — エージェントが自らワークフローを作成・修正・削除 | **Done** |
| | FTS5クロスセッション検索(過去の全会話を横断) | **Done** |
| | 5層コンテキスト圧縮(micro → collapse → auto → manual → iterative) | **Done** |
| | 読み書きツールバッチ処理 — 読み取り専用ツールの並列実行 | **Done** |
| **Next Up** | 自律リサーチループ — エージェントが仮説を一晩中反復 | In Progress |
| | IM統合(Slack / Telegram / WeChat) | Planned |
| **Analysis & Viz** | オプションボラティリティサーフェスとギリシャ値3D可視化 | Planned |
| | ローリングウィンドウ+クラスタリングによるクロスアセット相関ヒートマップ | Planned |
| | CLIバックテスト出力でのベンチマーク比較 | Planned |
| | バックテスト指標へのCalmar Ratio & Omega Ratio追加 | Planned |
| **Skills & Presets** | 配当分析スキル | Planned |
| | ESG/サステナブル投資スウォームプリセット | Planned |
| | 新興市場リサーチデスクスウォームプリセット | Planned |
| **Portfolio & Optimization** | レバレッジ・セクター上限・回転率制約付き高度ポートフォリオオプティマイザー | Planned |
| **Future** | 初心者チュートリアル: 「5分で自然言語バックテスト」 | Planned |
| **Future** | ストラテジーマーケットプレイス(共有・発見) | Exploring |
| | WebSocket経由のライブデータストリーミング | Exploring |
| | ストラテジーマーケットプレイス(共有・発見) | Exploring |
---
@@ -622,6 +658,12 @@ MIT License — [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">
<b>Vibe-Trading</b>への訪問に感謝 ✨
</p>
+107 -65
View File
@@ -1,5 +1,5 @@
<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.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">
@@ -19,10 +19,10 @@
<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>
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
<img src="https://img.shields.io/badge/Skills-72-orange" alt="Skills">
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
<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>
@@ -51,10 +51,29 @@
## 📰 뉴스
- **2026-04-10** 📦 **v0.1.4**: Docker 빌드 수정([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)), `web_search` MCP 도구 추가(총 17개), 의존성 및 MCP에 `akshare`/`ccxt` 추가. 11개 LLM 제공자(DeepSeek, Groq, Gemini, Ollama 등), 모든 튜닝 파라미터를 `.env`로 설정. `ml-strategy` 스킬 하드닝. PyPI와 ClawHub에 게시.
- **2026-04-09** 📊 **Backtest Wave 2 — 멀티자산 엔진**: ChinaFutures(CFFEX/SHFE/DCE/ZCE, 50+ 종목), GlobalFutures(CME/ICE/Eurex, 30+ 종목), Forex(24 페어, 스프레드 + 스왑), Options v2(미국형 행사, IV 스마일) 추가. 통계 검증: 몬테카를로 순열 테스트, Bootstrap 샤프 CI, 워크포워드 분석.
- **2026-04-08** 🔧 **다중 시장 백테스트**: 시장별 규칙, TradingView용 **Pine Script v6 내보내기**. **데이터 소스 확장**: 자동 폴백 포함 5개 소스, `web_search` 도구, 스킬 7개 카테고리화.
- **2026-04-01** 🚀 **v0.1.0** — 초기 릴리스: ReAct 에이전트, 64 스킬, 29 스웜 프리셋, 크로스마켓 백테스트, CLI + Web UI + MCP 서버.
- **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 로더가 ETF(`510300.SH`)와 외환(`USDCNH`)을 올바른 엔드포인트로 라우팅하고 레지스트리 폴백 강화. v0.1.5 이후 업데이트 종합: 벤치마크 비교 패널, `/upload` 스트리밍 + 크기 제한, Futu 로더(HK + A주), vnpy 내보내기 스킬, 보안 강화, 프론트엔드 지연 로딩(688KB → 262KB).
- **2026-04-27** 📊 **벤치마크 비교 패널 + 업로드 안전성**: 백테스트 출력에 벤치마크 비교 패널(티커 / 벤치마크 수익률 / 초과 수익률 / 정보 비율) 추가, yfinance로 SPY · CSI 300 등 자동 해석([#48](https://github.com/HKUDS/Vibe-Trading/issues/48)). 또한 `/upload` 엔드포인트를 1MB 청크 스트리밍으로 전환, `MAX_UPLOAD_SIZE` 초과 시 즉시 중단 + 부분 파일 정리. 50MB 상한이 악성/초대형 요청에도 실효화([#53](https://github.com/HKUDS/Vibe-Trading/pull/53)) — 4개 회귀 테스트로 고정.
- **2026-04-22** 🛡️ **하드닝 + 신규 연동**: `safe_path`에 경로 컨테인먼트 강제 + 거래 명세서/섀도우 계정 도구 샌드박스화, `MANIFEST.in` 추가로 sdist에 `.env.example` / 테스트 / Docker 파일 포함, 프론트엔드 라우트 단위 지연 로딩으로 초기 번들 688KB → 262KB. 또한 Futu 홍콩/A주 데이터 로더([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))와 vnpy CtaTemplate 내보내기 스킬([#46](https://github.com/HKUDS/Vibe-Trading/pull/46)) 추가.
<details>
<summary>이전 뉴스</summary>
- **2026-04-21** 🛡️ **워크스페이스 + 문서**: 상대 `run_dir`을 활성 run 디렉토리로 정규화([#43](https://github.com/HKUDS/Vibe-Trading/pull/43)). README 사용 예제 추가([#45](https://github.com/HKUDS/Vibe-Trading/pull/45)).
- **2026-04-20** 🔌 **추론 모델 + Swarm 수정**: `reasoning_content`을 모든 `ChatOpenAI` 직렬화 경로에서 보존 — Kimi / DeepSeek / Qwen thinking 엔드투엔드 작동([#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개 섀도우 계정 도구), `pattern_recognition` → `pattern` 레지스트리 이름 불일치 수정, Docker 의존성 동기화, SKILL 매니페스트 동기화(22개 MCP 도구 / 71개 스킬).
- **2026-04-18** 👥 **섀도우 계정 Shadow Account**: 증권사 거래 명세서에서 자신의 전략 규칙을 추출 → 여러 시장에서 섀도우 백테스트 실행 → 8개 섹션 HTML/PDF 리포트가 어디에서 얼마를 놓쳤는지(규칙 위반, 조기 익절, 놓친 시그널, 역방향 거래) 정확히 보여줌. 신규 도구 4개, 신규 스킬 1개, 총 32개 도구. Trade Journal / Shadow Account 샘플 프롬프트가 Web UI 웰컴 화면에 추가.
- **2026-04-17** 📊 **거래 명세서 분석기 + 유니버설 파일 리더**: 증권사 거래 명세서(同花顺/东财/富途/일반 CSV) 업로드 → 거래 프로필(보유 일수, 승률, 손익비, 최대 드로다운) + 4가지 행동 편향 진단(처분 효과, 과잉 거래, 추격 매수, 앵커링) 자동 생성. `read_document`는 이제 PDF, Word, Excel, PowerPoint, 이미지(OCR), 40+ 텍스트 형식을 하나의 호출로 통합 처리.
- **2026-04-16** 🧠 **에이전트 하네스**: 크로스세션 영구 메모리, FTS5 세션 검색, 자가 진화 스킬(전체 CRUD), 5계층 컨텍스트 압축, 읽기/쓰기 도구 배치 처리. 27개 도구, 107개 신규 테스트.
- **2026-04-15** 🤖 **Z.ai + MiniMax**: Z.ai 제공자 추가([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)), MiniMax temperature 수정 + 모델 업데이트([#33](https://github.com/HKUDS/Vibe-Trading/pull/33)). 13개 제공자.
- **2026-04-14** 🔧 **MCP 안정성**: 백테스트 도구의 stdio 전송에서 `Connection closed` 오류 수정([#32](https://github.com/HKUDS/Vibe-Trading/pull/32)).
- **2026-04-13** 🌐 **크로스마켓 복합 백테스트**: 새 `CompositeEngine`으로 서로 다른 시장 종목(예: A주 + 암호화폐)을 공유 자금 풀로 동시 백테스트, 시장 규칙은 종목별 적용. Swarm 템플릿 변수 폴백 및 프론트엔드 타임아웃도 수정.
- **2026-04-12** 🌍 **멀티 플랫폼 내보내기**: `/pine`으로 TradingView (Pine Script v6), TDX (통달신/동화순/동방재부), MetaTrader 5 (MQL5) 한 번에 내보내기.
- **2026-04-11** 🛡️ **안정성 및 DX**: `vibe-trading init` .env 부트스트랩([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)), 프리플라이트 체크, 데이터소스 폴백, 백테스트 엔진 강화. 다국어 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 도구, 12개 LLM 제공자, `akshare`/`ccxt` 의존성. PyPI와 ClawHub에 게시.
- **2026-04-09** 📊 **Backtest Wave 2**: ChinaFutures, GlobalFutures, Forex, Options v2 엔진. 몬테카를로, Bootstrap CI, 워크포워드 검증.
- **2026-04-08** 🔧 **다중 시장 백테스트**: 시장별 규칙, Pine Script v6 내보내기, 자동 폴백 5개 데이터 소스.
</details>
---
@@ -63,12 +82,12 @@
Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자연어 요청을 전 세계 시장의 실행 가능한 트레이딩 전략, 리서치 인사이트, 포트폴리오 분석으로 전환합니다.
### 핵심 역량:
• **전략 생성** — 아이디어를 자동으로 트레이딩 코드로 작성<br>
• **스마트 데이터 접근** — 자동 폴백이 있는 5개 데이터 소스; 모든 시장 무설정<br>
• **성능 테스트** — 전략을 과거 시장 데이터로 검증<br>
• **TradingView 내보내기** — 클릭 한 번으로 전략을 Pine Script v6로 변환<br>
• **전문 팀** — 복잡한 리서치 작업에 특화 에이전트 팀 배치<br>
• **실시간 업데이트** — 전체 분석 과정을 실시간 스트리밍
• **자연어 → 전략** — 아이디어를 설명하면 에이전트가 코드 작성, 테스트, 내보내기까지 실행<br>
• **5개 데이터 소스, 무설정** — A주, HK/US, 크립토, 선물, FX 자동 폴백<br>
• **29개 전문 팀** — 투자, 트레이딩, 리스크를 위한 멀티 에이전트 스웜 워크플로우<br>
• **크로스세션 메모리** — 선호도와 인사이트를 기억하고 재사용 가능한 스킬을 자동 생성·진화<br>
• **7개 백테스트 엔진** — 크로스마켓 복합 테스트 + 통계 검증 + 4개 옵티마이저<br>
• **멀티 플랫폼 내보내기** — 클릭 한 번으로 TradingView, TDX(통달신/동화순), MetaTrader 5
---
@@ -79,12 +98,12 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
<td align="center" width="25%" valign="top">
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
<h3>🔍 트레이딩용 DeepResearch</h3>
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<img src="https://img.shields.io/badge/72_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• 시장 전반을 아우르는 다중 도메인 분석<br>
• 자동 전략 및 시그널 생성<br>
• 거시경제 리서치와 인사이트<br>
• 대화형 자연어 태스크 라우팅
• 72개 전문 스킬 + 크로스세션 영구 메모리<br>
• 자가 진화: 에이전트가 경험으로부터 워크플로우를 생성·개선<br>
• 5계층 컨텍스트 압축 — 긴 대화에서도 정보 손실 없음<br>
• 전 금융 도메인에 걸친 자연어 태스크 라우팅
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,8 +113,8 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
<div align="left">
• 29개 즉시 사용 가능한 트레이딩 팀 프리셋<br>
• DAG 기반 멀티 에이전트 오케스트레이션<br>
• 실시간 의사결정 스트리밍 대시보드<br>
• YAML을 통한 커스텀 팀 구성
• 실시간 스트리밍 대시보드(에이전트 상태 표시)<br>
• FTS5 크로스세션 검색으로 모든 과거 대화 검색
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -104,7 +123,7 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<div align="left">
• A주, 홍콩/미국 주식, 크립토, 선물 및 FX<br>
• 7개 시장 엔진: A주, 미/홍콩 주식, 크립토, 중국 선물, 글로벌 선물, FX<br>
• 7개 시장 엔진 + 크로스마켓 복합 엔진(공유 자금 풀)<br>
• 통계 검증: 몬테카를로, Bootstrap CI, 워크포워드<br>
• 15+ 성과 지표 및 4개 옵티마이저
</div>
@@ -123,16 +142,16 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
</tr>
</table>
## 7개 카테고리에 걸친 68개 스킬
## 7개 카테고리에 걸친 72개 스킬
- 📊 7개 카테고리에 조직된 68개 금융 스킬
- 📊 7개 카테고리에 조직된 72개 금융 스킬
- 🌐 전통 시장부터 크립토·DeFi까지 완전 커버리지
- 🔬 데이터 소싱부터 정량 리서치까지 포괄적 기능
| 카테고리 | 스킬 | 예시 |
|----------|------|------|
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-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` |
@@ -221,7 +240,7 @@ vibe-trading-mcp # MCP 서버 시작(stdio)
- 경로 B용 **Python 3.11+**
- 경로 A용 **Docker**
> **지원 LLM 제공자:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Ollama(로컬). 설정은 `.env.example` 참고.
> **지원 LLM 제공자:** OpenRouter, OpenAI, DeepSeek, Gemini, Groq, DashScope/Qwen, Zhipu, Moonshot/Kimi, MiniMax, Xiaomi MIMO, Z.ai, Ollama(로컬). 설정은 `.env.example` 참고.
> **팁:** 모든 시장은 자동 폴백 덕분에 API 키 없이도 작동합니다. yfinance(HK/US), OKX(크립토), AKShare(A주, 미국, HK, 선물, FX)는 모두 무료입니다. Tushare 토큰은 선택 사항 — AKShare가 A주 무료 폴백을 제공합니다.
@@ -295,7 +314,7 @@ npx clawhub@latest install vibe-trading --force
| Variable | Required | Description |
|----------|:--------:|-------------|
| `LANGCHAIN_PROVIDER` | Yes | 제공자 이름(`openrouter`, `deepseek`, `groq`, `ollama` 등) |
| `LANGCHAIN_PROVIDER` | Yes | 제공자 이름(`openrouter`, `deepseek`, `groq`, `z.ai`, `ollama` 등) |
| `<PROVIDER>_API_KEY` | Yes* | API 키(`OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` 등) |
| `<PROVIDER>_BASE_URL` | Yes | API 엔드포인트 URL |
| `LANGCHAIN_MODEL_NAME` | Yes | 모델 이름(예: `deepseek/deepseek-v3.2`) |
@@ -306,6 +325,18 @@ npx clawhub@latest install vibe-trading --force
**무료 데이터(키 불필요):** AKShare의 A주, yfinance의 HK/US 주식, OKX의 크립토, CCXT의 100+ 크립토 거래소. 시스템이 시장별로 최적 소스를 자동 선택합니다.
### 🎯 권장 모델
Vibe-Trading은 툴 호출에 크게 의존하는 에이전트입니다 — skill, 백테스트, 메모리, swarm이 모두 tool call을 통해 실행됩니다. 모델 선택이 에이전트가 **실제로 툴을 사용하는지**, 아니면 학습 데이터에서 답을 꾸며내는지를 결정합니다.
| 등급 | 예시 | 용도 |
|------|------|------|
| **최상** | `anthropic/claude-opus-4.7`, `anthropic/claude-sonnet-4.6`, `openai/gpt-5.4`, `google/gemini-3.1-pro-preview` | 복잡한 swarm(3+ 에이전트), 긴 연구 세션, 논문급 분석 |
| **가성비**(기본값) | `deepseek/deepseek-v3.2`, `x-ai/grok-4.20`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.5`, `qwen/qwen3-max-thinking` | 일상 사용 — 안정적인 tool-calling, 비용 약 1/10 |
| **에이전트용으로 피할 것** | `*-nano`, `*-flash-lite`, `*-coder-next`, 소형 / 증류 버전 | tool-calling 불안정 — skill 로드나 backtest 실행 대신 "기억으로 답변" |
기본 `agent/.env.example`은 `deepseek/deepseek-v3.2` 사용 — 가성비 등급에서 가장 저렴한 옵션.
---
## 🖥 CLI 참조
@@ -322,7 +353,7 @@ vibe-trading serve # API 서버
| Command | Description |
|---------|-------------|
| `/help` | 모든 명령 표시 |
| `/skills` | 68개 금융 스킬 목록 |
| `/skills` | 72개 금융 스킬 목록 |
| `/swarm` | 29개 스웜 팀 프리셋 목록 |
| `/swarm run <preset> [vars_json]` | 라이브 스트리밍으로 스웜 팀 실행 |
| `/swarm list` | 스웜 실행 이력 |
@@ -331,7 +362,7 @@ vibe-trading serve # API 서버
| `/list` | 최근 실행 |
| `/show <run_id>` | 실행 상세 + 지표 |
| `/code <run_id>` | 생성된 전략 코드 |
| `/pine <run_id>` | TradingView용 Pine Script |
| `/pine <run_id>` | 인디케이터 내보내기 (TradingView + TDX + MT5) |
| `/trace <run_id>` | 전체 실행 리플레이 |
| `/continue <run_id> <prompt>` | 새 지시로 실행 계속 |
| `/sessions` | 채팅 세션 목록 |
@@ -359,7 +390,7 @@ 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> # Pine Script for TradingView
vibe-trading --pine <run_id> # 인디케이터 내보내기 (TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
@@ -379,7 +410,7 @@ vibe-trading serve --port 8899
|--------|----------|-------------|
| `GET` | `/runs` | 실행 목록 |
| `GET` | `/runs/{run_id}` | 실행 상세 |
| `GET` | `/runs/{run_id}/pine` | Pine Script 내보내기 |
| `GET` | `/runs/{run_id}/pine` | 멀티 플랫폼 인디케이터 내보내기 |
| `POST` | `/sessions` | 세션 생성 |
| `POST` | `/sessions/{id}/messages` | 메시지 전송 |
| `GET` | `/sessions/{id}/events` | SSE 이벤트 스트림 |
@@ -394,7 +425,7 @@ vibe-trading serve --port 8899
## 🔌 MCP 플러그인
Vibe-Trading은 MCP 호환 클라이언트용 17개 MCP 도구를 제공합니다. stdio 서브프로세스로 실행 — 서버 설정 불필요. **17개 중 16개 도구는 API 키 없이 작동**(HK/US/크립토). `run_swarm`만 LLM 키가 필요합니다.
Vibe-Trading은 MCP 호환 클라이언트용 22개 MCP 도구를 제공합니다. stdio 서브프로세스로 실행 — 서버 설정 불필요. **22개 중 21개 도구는 API 키 없이 작동**(HK/US/크립토). `run_swarm`만 LLM 키가 필요합니다.
<details>
<summary><b>Claude Desktop</b></summary>
@@ -436,7 +467,7 @@ vibe-trading-mcp --transport sse # 웹 클라이언트용 SSE
</details>
**제공 MCP 도구(17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`.
**제공 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>
@@ -456,7 +487,7 @@ ClawHub에서 보기: [clawhub.ai/skills/vibe-trading](https://clawhub.ai/skills
<details>
<summary><b>OpenSpace — 자가 진화 스킬</b></summary>
모든 68개 금융 스킬은 [open-space.cloud](https://open-space.cloud)에 게시되어 OpenSpace의 자가 진화 엔진을 통해 스스로 발전합니다.
모든 72개 금융 스킬은 [open-space.cloud](https://open-space.cloud)에 게시되어 OpenSpace의 자가 진화 엔진을 통해 스스로 발전합니다.
OpenSpace와 함께 사용하려면 두 MCP 서버를 에이전트 설정에 추가하세요:
@@ -478,7 +509,7 @@ OpenSpace와 함께 사용하려면 두 MCP 서버를 에이전트 설정에 추
}
```
OpenSpace는 모든 68개 스킬을 자동으로 탐지하여 자동 수정, 자동 개선, 커뮤니티 공유를 활성화합니다. OpenSpace 연결 에이전트에서 `search_skills("finance backtest")`로 Vibe-Trading 스킬을 검색하세요.
OpenSpace는 모든 72개 스킬을 자동으로 탐지하여 자동 수정, 자동 개선, 커뮤니티 공유를 활성화합니다. OpenSpace 연결 에이전트에서 `search_skills("finance backtest")`로 Vibe-Trading 스킬을 검색하세요.
</details>
@@ -494,41 +525,42 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli.py # CLI 엔트리포인트 — 인터랙티브 TUI + 서브커맨드
│ ├── api_server.py # FastAPI 서버 — 실행, 세션, 업로드, 스웜, SSE
│ ├── mcp_server.py # MCP 서버 — OpenClaw / Claude Desktop용 17개 도구
│ ├── mcp_server.py # MCP 서버 — OpenClaw / Claude Desktop용 22개 도구
│ │
│ ├── src/
│ │ ├── agent/ # ReAct 에이전트 코어
│ │ │ ├── loop.py # 메인 추론 루프
│ │ │ ├── skills.py # 스킬 로더(68 SKILL.md, 7 카테고리)
│ │ │ ├── tools.py # 도구 오케스트레이션
│ │ │ ├── context.py # 시스템 프롬프트 빌더
│ │ │ ├── memory.py # 실행 메모리 / 아티팩트 저장소
│ │ │ ├── loop.py # 5계층 압축 + 읽기/쓰기 도구 배치 처리
│ │ │ ├── context.py # 시스템 프롬프트 + 영구 메모리 자동 리콜
│ │ │ ├── skills.py # 스킬 로더(69 번들 + 사용자 CRUD 생성)
│ │ │ ├── tools.py # 도구 기본 클래스 + 레지스트리
│ │ │ ├── memory.py # 실행별 경량 워크스페이스 상태
│ │ │ ├── frontmatter.py # 공유 YAML frontmatter 파서
│ │ │ └── trace.py # 실행 트레이스 기록기
│ │ │
│ │ ├── tools/ # 21개 에이전트 도구
│ │ │ ├── backtest_tool.py # 백테스트 실행
│ │ │ ├── factor_analysis_tool.py
│ │ │ ├── options_pricing_tool.py
│ │ │ ├── pattern_tool.py # 차트 패턴 감지
│ │ │ ├── doc_reader_tool.py # PDF 리더(OCR 폴백)
│ │ │ ├── web_reader_tool.py # 웹 페이지 리더(Jina)
│ │ │ ├── web_search_tool.py # DuckDuckGo 웹 검색
│ │ │ ├── swarm_tool.py # 스웜 팀 실행
│ │ │ └── ... # 파일 I/O, bash, 태스크 등
│ │ ├── memory/ # 크로스세션 영구 메모리
│ │ │ └── persistent.py # 파일 기반 메모리 (~/.vibe-trading/memory/)
│ │ │
│ │ ├── skills/ # 7개 카테고리의 68개 금융 스킬(SKILL.md 각각)
│ │ ├── tools/ # 27개 자동 탐지 에이전트 도구
│ │ │ ├── backtest_tool.py # 백테스트 실행
│ │ │ ├── remember_tool.py # 크로스세션 메모리 (저장/리콜/삭제)
│ │ │ ├── skill_writer_tool.py # 스킬 CRUD (저장/패치/삭제/파일)
│ │ │ ├── session_search_tool.py # FTS5 크로스세션 검색
│ │ │ ├── swarm_tool.py # 스웜 팀 실행
│ │ │ ├── web_search_tool.py # DuckDuckGo 웹 검색
│ │ │ └── ... # bash, 파일 I/O, 팩터 분석, 옵션 등
│ │ │
│ │ ├── skills/ # 7개 카테고리의 72개 금융 스킬(SKILL.md 각각)
│ │ ├── swarm/ # 스웜 DAG 실행 엔진
│ │ ├── session/ # 멀티턴 채팅 세션 관리
│ │ │ └── presets/ # 29개 스웜 프리셋 YAML 정의
│ │ ├── session/ # 멀티턴 채팅 + FTS5 세션 검색
│ │ └── providers/ # LLM 제공자 추상화
│ │
│ ├── backtest/ # 백테스트 엔진
│ │ ├── engines/ # 7개 엔진: china_a, global_equity, crypto, china_futures, global_futures, forex + options_portfolio
│ │ ├── loaders/ # 5개 소스: tushare, okx, yfinance, akshare, ccxt
│ │ │ ├── base.py # DataLoader Protocol
│ │ │ └── registry.py # 레지스트리 + 자동 폴백 체인
│ │ └── optimizers/ # MVO, equal vol, max div, risk parity
│ │
│ └── config/swarm/ # 29개 스웜 프리셋 YAML 정의
│ └── backtest/ # 백테스트 엔진
│ ├── engines/ # 7개 엔진 + 크로스마켓 복합 엔진 + options_portfolio
│ ├── loaders/ # 6개 소스: tushare, okx, yfinance, akshare, ccxt, futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # 레지스트리 + 자동 폴백 체인
│ └── optimizers/ # MVO, equal vol, max div, risk parity
│
├── frontend/ # Web UI (React 19 + Vite + TypeScript)
│ └── src/
@@ -579,17 +611,21 @@ Vibe-Trading은 **[HKUDS](https://github.com/HKUDS)** 에이전트 생태계의
| Phase | Feature | Status |
|-------|---------|--------|
| **Agent Harness** | 크로스세션 영구 메모리 (저장 / 리콜 / 삭제) | **Done** |
| | 자가 진화 스킬 — 에이전트가 스스로 워크플로우를 생성·수정·삭제 | **Done** |
| | FTS5 크로스세션 검색 (모든 과거 대화 횡단) | **Done** |
| | 5계층 컨텍스트 압축 (micro → collapse → auto → manual → iterative) | **Done** |
| | 읽기/쓰기 도구 배치 처리 — 읽기 전용 도구 병렬 실행 | **Done** |
| **Next Up** | 자율 리서치 루프 — 에이전트가 밤새 가설을 반복 | In Progress |
| | IM 통합 (Slack / Telegram / WeChat) | Planned |
| **Analysis & Viz** | 옵션 변동성 서피스 및 그릭스 3D 시각화 | Planned |
| | 롤링 윈도우 + 클러스터링 기반 크로스자산 상관 히트맵 | Planned |
| | CLI 백테스트 출력에 벤치마크 비교 | Planned |
| | 백테스트 지표에 Calmar Ratio & Omega Ratio | Planned |
| **Skills & Presets** | 배당 분석 스킬 | Planned |
| | ESG / 지속가능 투자 스웜 프리셋 | Planned |
| | 이머징 마켓 리서치 데스크 스웜 프리셋 | Planned |
| **Portfolio & Optimization** | 레버리지, 섹터 캡, 턴오버 제약을 포함한 고급 포트폴리오 옵티마이저 | Planned |
| **Future** | 초보자 튜토리얼: "5분 자연어 백테스트" | Planned |
| **Future** | 전략 마켓플레이스 (공유 & 발견) | Exploring |
| | WebSocket 기반 실시간 데이터 스트리밍 | Exploring |
| | 전략 마켓플레이스(공유 & 발견) | Exploring |
---
@@ -623,6 +659,12 @@ MIT License — [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">
방문해 주셔서 감사합니다 <b>Vibe-Trading</b> ✨
</p>
+107 -65
View File
@@ -1,5 +1,5 @@
<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.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">
@@ -19,10 +19,10 @@
<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>
<img src="https://img.shields.io/badge/Skills-68-orange" alt="Skills">
<img src="https://img.shields.io/badge/Skills-72-orange" alt="Skills">
<img src="https://img.shields.io/badge/Swarm_Presets-29-7C3AED" alt="Swarm">
<img src="https://img.shields.io/badge/Tools-21-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-5-2563EB" alt="Data Sources">
<img src="https://img.shields.io/badge/Tools-27-0F766E" alt="Tools">
<img src="https://img.shields.io/badge/Data_Sources-6-2563EB" alt="Data Sources">
<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>
@@ -51,10 +51,29 @@
## 📰 新闻
- **2026-04-10** 📦 **v0.1.4**:修复 Docker 构建([#8](https://github.com/HKUDS/Vibe-Trading/issues/8)),新增 `web_search` MCP 工具(共 17 个),在依赖与 MCP 中加入 `akshare`/`ccxt`。支持 11 家 LLM 提供商(DeepSeek、Groq、Gemini、Ollama 等),全部调优参数可通过 `.env` 配置。加固 `ml-strategy` 技能。已发布至 PyPI 和 ClawHub。
- **2026-04-09** 📊 **回测 Wave 2 —— 多资产引擎**:新增 ChinaFutures(CFFEX/SHFE/DCE/ZCE,50+ 合约)、GlobalFutures(CME/ICE/Eurex,30+ 合约)、Forex(24 货币对,点差 + 掉期)、Options v2(美式行权、IV 微笑)。统计验证:蒙特卡洛置换检验、Bootstrap 夏普区间、Walk-Forward 分析。
- **2026-04-08** 🔧 **多市场回测** 支持分市场规则;**TradingView Pine Script v6 导出**。**数据源扩展**:5 源自动回退,`web_search` 工具,技能分 7 类。
- **2026-04-01** 🚀 **v0.1.0** —— 初始发布:ReAct 代理,64 技能,29 个 swarm 预设,跨市场回测,CLI + Web UI + MCP 服务器。
- **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 加载器正确路由 ETF(`510300.SH`)和外汇(`USDCNH`)到对应端点,并加固注册表回退链。汇总自 v0.1.5 以来全部更新:基准对比面板、`/upload` 流式上传 + 大小限制、富途数据源(港股/A 股)、vnpy 导出技能、安全加固、前端懒加载(688KB → 262KB)。
- **2026-04-27** 📊 **基准对比面板 + 上传安全**:回测输出新增基准对比面板(标的 / 基准收益 / 超额收益 / 信息比率),通过 yfinance 解析 SPY、沪深 300 等基准([#48](https://github.com/HKUDS/Vibe-Trading/issues/48))。同时 `/upload` 端点改为 1MB 分块流式落盘,超过 `MAX_UPLOAD_SIZE` 立即中断并清理半截文件,让 50MB 上限在恶意/超大请求下真正生效([#53](https://github.com/HKUDS/Vibe-Trading/pull/53))—— 配套 4 个回归测试。
- **2026-04-22** 🛡️ **加固 + 新接入**:`safe_path` 强制路径包含校验 + 交割单/影子账户工具沙箱化,新增 `MANIFEST.in` 让 sdist 打包 `.env.example` / 测试 / Docker 文件,前端按路由懒加载把首屏包从 688KB 压到 262KB。同时新增富途港股/A 股数据加载器([#47](https://github.com/HKUDS/Vibe-Trading/pull/47))和 vnpy CtaTemplate 导出技能([#46](https://github.com/HKUDS/Vibe-Trading/pull/46))。
<details>
<summary>更早的新闻</summary>
- **2026-04-21** 🛡️ **工作区与文档**:相对路径 `run_dir` 归一化到当前 run 目录([#43](https://github.com/HKUDS/Vibe-Trading/pull/43))。README 加入使用示例([#45](https://github.com/HKUDS/Vibe-Trading/pull/45))。
- **2026-04-20** 🔌 **推理模型与 Swarm 修复**:`reasoning_content` 在所有 `ChatOpenAI` 序列化路径上保留 —— Kimi / DeepSeek / Qwen thinking 端到端可用([#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 个影子账户工具),修复 `pattern_recognition` → `pattern` 工具注册名不一致,Docker 依赖对齐,SKILL 清单同步(22 MCP 工具 / 71 技能)。
- **2026-04-18** 👥 **影子账户 Shadow Account**:从券商交割单提取你自己的策略规则 → 跨市场回测这个"影子" → 8 节 HTML/PDF 报告精确告诉你每一块钱是怎么错过的(规则违反、过早止盈、漏掉信号、反向操作)。4 个新工具、1 个新技能、共 32 工具。Trade Journal / Shadow Account 例句已进 Web UI 欢迎屏。
- **2026-04-17** 📊 **交割单分析器 + 通用文件阅读器**:上传券商交割单(同花顺/东财/富途/通用 CSV)→ 自动生成交易画像(持仓天数、胜率、盈亏比、最大回撤)+ 4 项行为偏差诊断(处置效应、过度交易、追涨杀跌、锚定效应)。`read_document` 现统一分发 PDF、Word、Excel、PowerPoint、图片(OCR)及 40+ 文本格式,一个调用走全部类型。
- **2026-04-16** 🧠 **Agent Harness**:跨会话持久记忆、FTS5 会话搜索、自进化技能(完整 CRUD)、5 层上下文压缩、读写工具批处理。27 工具,107 新测试。
- **2026-04-15** 🤖 **Z.ai + MiniMax**:新增 Z.ai 提供商([#35](https://github.com/HKUDS/Vibe-Trading/pull/35)),修复 MiniMax temperature 及模型更新([#33](https://github.com/HKUDS/Vibe-Trading/pull/33))。共 13 家提供商。
- **2026-04-14** 🔧 **MCP 稳定性**:修复回测工具在 stdio 传输中的 `Connection closed` 错误([#32](https://github.com/HKUDS/Vibe-Trading/pull/32))。
- **2026-04-13** 🌐 **跨市场复合回测**:新增 `CompositeEngine`,混合不同市场标的(如 A 股 + 加密货币)共享资金池回测,各市场规则按标的独立执行。同时修复 swarm 模板变量回退和前端超时问题。
- **2026-04-12** 🌍 **多平台指标导出**:`/pine` 一次性导出 TradingView (Pine Script v6)、通达信/同花顺/东方财富 (TDX)、MetaTrader 5 (MQL5) 三大平台。
- **2026-04-11** 🛡️ **可靠性与 DX**:`vibe-trading init` .env 引导([#19](https://github.com/HKUDS/Vibe-Trading/pull/19)),启动预检、数据源自动回退、回测引擎加固。多语言 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 工具,12 家 LLM 提供商,`akshare`/`ccxt` 依赖。已发布至 PyPI 和 ClawHub。
- **2026-04-09** 📊 **回测 Wave 2**:新增 ChinaFutures、GlobalFutures、Forex、Options v2 引擎。蒙特卡洛、Bootstrap CI、Walk-Forward 统计验证。
- **2026-04-08** 🔧 **多市场回测**:分市场规则,Pine Script v6 导出,5 数据源自动回退。
</details>
---
@@ -63,12 +82,12 @@
Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言请求转化为可执行的交易策略、研究洞见和跨全球市场的投资组合分析。
### 核心能力:
• **Strategy Generation** —— 从你的想法自动生成交易代码<br>
• **Smart Data Access** —— 5 大数据源自动回退;所有市场零配置<br>
• **Performance Testing** —— 使用历史行情测试你的策略<br>
• **TradingView Export** —— 一键转换为 TradingView 的 Pine Script v6<br>
• **Expert Teams** —— 部署专职 AI 代理处理复杂研究任务<br>
• **Live Updates** —— 全程实时查看分析过程
• **自然语言 → 策略** —— 描述想法,代理自动编写、测试、导出交易代码<br>
• **5 大数据源,零配置** —— A 股、港美股、加密、期货、外汇自动回退<br>
• **29 支专家团队** —— 预构建的多代理 swarm 工作流,覆盖投资、交易与风控<br>
• **跨会话记忆** —— 记住偏好与洞察;自动创建、进化可复用技能<br>
• **7 大回测引擎** —— 跨市场复合测试 + 统计验证 + 4 种优化器<br>
• **多平台导出** —— 一键到 TradingView、TDX(通达信/同花顺)和 MetaTrader 5
---
@@ -79,12 +98,12 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
<td align="center" width="25%" valign="top">
<img src="assets/scene-research.png" height="150" alt="Research"/><br>
<h3>🔍 面向交易的深度研究</h3>
<img src="https://img.shields.io/badge/68_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<img src="https://img.shields.io/badge/72_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• 覆盖多市场的多领域分析<br>
• 自动生成策略与信号<br>
• 宏观经济研究与洞察<br>
• 基于聊天的自然语言任务路由
• 72 个专长技能 + 跨会话持久记忆<br>
• 自进化:代理从经验中创建并优化工作流<br>
• 5 层上下文压缩——长对话不丢失信息<br>
• 覆盖全金融领域的自然语言任务路由
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,8 +113,8 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
<div align="left">
• 29 个开箱即用的交易团队预设<br>
• 基于 DAG 的多代理编排<br>
• 实时决策流式仪表盘<br>
• 通过 YAML 自定义团队
• 实时流式仪表盘,显示代理运行状态<br>
• FTS5 跨会话搜索全部历史对话
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -104,7 +123,7 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<div align="left">
• A 股、港美股、加密、期货与外汇<br>
• 7 个市场引擎:A 股、美港股、加密、中国期货、全球期货、外汇<br>
• 7 个市场引擎 + 跨市场复合引擎(共享资金池)<br>
• 统计验证:蒙特卡洛、Bootstrap 置信区间、Walk-Forward<br>
• 15+ 绩效指标与 4 种优化器
</div>
@@ -123,16 +142,16 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
</tr>
</table>
## 7 大类别中的 68 个技能
## 7 大类别中的 72 个技能
- 📊 68 个金融专长技能,划分 7 大类
- 📊 72 个金融专长技能,划分 7 大类
- 🌐 覆盖传统市场到加密与 DeFi
- 🔬 覆盖数据获取到量化研究的全链路能力
| Category | Skills | Examples |
|----------|--------|----------|
| Data Source | 6 | `data-routing`, `tushare`, `yfinance`, `okx-market`, `akshare`, `ccxt` |
| Strategy | 16 | `strategy-generate`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-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` |
@@ -221,7 +240,7 @@ vibe-trading-mcp # 启动 MCP 服务器(stdio)
- Path B 需 **Python 3.11+**
- Path A 需 **Docker**
> **支持的 LLM 提供商:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、智谱、Moonshot/Kimi、MiniMax、小米 MIMO、Ollama(本地)。参见 `.env.example` 配置。
> **支持的 LLM 提供商:** OpenRouter、OpenAI、DeepSeek、Gemini、Groq、DashScope/Qwen、智谱、Moonshot/Kimi、MiniMax、小米 MIMO、Z.ai、Ollama(本地)。参见 `.env.example` 配置。
> **提示:** 所有市场都可在无 API key 情况下运行,因自动回退。yfinance(港美股)、OKX(加密)、AKShare(A 股、美股、港股、期货、外汇)均免费。Tushare token 可选——A 股可回退到 AKShare 免费获取。
@@ -295,7 +314,7 @@ npx clawhub@latest install vibe-trading --force
| Variable | Required | Description |
|----------|:--------:|-------------|
| `LANGCHAIN_PROVIDER` | Yes | 提供商名称(`openrouter`、`deepseek`、`groq`、`ollama` 等) |
| `LANGCHAIN_PROVIDER` | Yes | 提供商名称(`openrouter`、`deepseek`、`groq`、`z.ai`、`ollama` 等) |
| `<PROVIDER>_API_KEY` | Yes* | API key(`OPENROUTER_API_KEY`、`DEEPSEEK_API_KEY` 等) |
| `<PROVIDER>_BASE_URL` | Yes | API 端点 URL |
| `LANGCHAIN_MODEL_NAME` | Yes | 模型名(如 `deepseek/deepseek-v3.2`) |
@@ -306,6 +325,18 @@ npx clawhub@latest install vibe-trading --force
**免费数据(无需 key):** A 股经 AKShare,港美股经 yfinance,加密经 OKX,100+ 加密交易所经 CCXT。系统会为每个市场自动选择最佳可用数据源。
### 🎯 推荐模型
Vibe-Trading 是重度依赖工具调用的 agent — skills、回测、记忆、swarm 全部通过 tool call 完成。模型选择直接决定 agent 是**真的在用工具**,还是从训练数据里编答案。
| 等级 | 示例 | 适用场景 |
|------|------|---------|
| **最佳** | `anthropic/claude-opus-4.7`、`anthropic/claude-sonnet-4.6`、`openai/gpt-5.4`、`google/gemini-3.1-pro-preview` | 复杂 swarm(3+ agent)、长研究会话、论文级分析 |
| **性价比**(默认) | `deepseek/deepseek-v3.2`、`x-ai/grok-4.20`、`z-ai/glm-5.1`、`moonshotai/kimi-k2.5`、`qwen/qwen3-max-thinking` | 日常使用 — tool-calling 稳定,成本约 1/10 |
| **不建议用于 agent** | `*-nano`、`*-flash-lite`、`*-coder-next`、小参数 / 蒸馏版 | tool-calling 不可靠 — agent 会"凭记忆回答"而不加载 skill 或跑回测 |
默认 `agent/.env.example` 使用 `deepseek/deepseek-v3.2` — 性价比档里最便宜的选项。
---
## 🖥 CLI 参考
@@ -322,7 +353,7 @@ vibe-trading serve # API 服务器
| Command | Description |
|---------|-------------|
| `/help` | 显示全部命令 |
| `/skills` | 列出 68 个金融技能 |
| `/skills` | 列出 72 个金融技能 |
| `/swarm` | 列出 29 个 swarm 团队预设 |
| `/swarm run <preset> [vars_json]` | 以流式输出运行一个 swarm 团队 |
| `/swarm list` | Swarm 运行历史 |
@@ -331,7 +362,7 @@ vibe-trading serve # API 服务器
| `/list` | 最近的运行 |
| `/show <run_id>` | 运行详情与指标 |
| `/code <run_id>` | 生成的策略代码 |
| `/pine <run_id>` | TradingView 的 Pine Script |
| `/pine <run_id>` | 导出指标代码(TradingView + TDX + MT5)|
| `/trace <run_id>` | 完整执行回放 |
| `/continue <run_id> <prompt>` | 带新指令继续运行 |
| `/sessions` | 列出聊天会话 |
@@ -358,7 +389,7 @@ 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> # TradingView 的 Pine Script
vibe-trading --pine <run_id> # 导出指标代码(TradingView + TDX + MT5)
vibe-trading --trace <run_id>
vibe-trading --continue <run_id> "refine the strategy"
vibe-trading --upload report.pdf
@@ -378,7 +409,7 @@ vibe-trading serve --port 8899
|--------|----------|-------------|
| `GET` | `/runs` | 列出运行 |
| `GET` | `/runs/{run_id}` | 运行详情 |
| `GET` | `/runs/{run_id}/pine` | Pine Script 导出 |
| `GET` | `/runs/{run_id}/pine` | 多平台指标导出 |
| `POST` | `/sessions` | 创建会话 |
| `POST` | `/sessions/{id}/messages` | 发送消息 |
| `GET` | `/sessions/{id}/events` | SSE 事件流 |
@@ -393,7 +424,7 @@ vibe-trading serve --port 8899
## 🔌 MCP 插件
Vibe-Trading 为任意 MCP 兼容客户端提供 17 个 MCP 工具。以 stdio 子进程运行——无需服务器部署。**17 个工具中有 16 个无需任何 API key**(港美股/加密)。仅 `run_swarm` 需要 LLM key。
Vibe-Trading 为任意 MCP 兼容客户端提供 22 个 MCP 工具。以 stdio 子进程运行——无需服务器部署。**22 个工具中有 21 个无需任何 API key**(港美股/加密)。仅 `run_swarm` 需要 LLM key。
<details>
<summary><b>Claude Desktop</b></summary>
@@ -435,7 +466,7 @@ vibe-trading-mcp --transport sse # 供 Web 客户端的 SSE
</details>
**已暴露的 MCP 工具(17):** `list_skills`, `load_skill`, `backtest`, `factor_analysis`, `analyze_options`, `pattern_recognition`, `get_market_data`, `web_search`, `read_url`, `read_document`, `read_file`, `write_file`, `list_swarm_presets`, `run_swarm`, `get_swarm_status`, `get_run_result`, `list_runs`。
**已暴露的 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>
@@ -455,7 +486,7 @@ npx clawhub@latest install vibe-trading --force
<details>
<summary><b>OpenSpace — 自进化技能</b></summary>
全部 68 个金融技能已发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自动演进。
全部 72 个金融技能已发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自动演进。
要在 OpenSpace 中使用,在代理配置中添加两个 MCP 服务器:
@@ -477,7 +508,7 @@ npx clawhub@latest install vibe-trading --force
}
```
OpenSpace 会自动发现全部 68 个技能,支持自动修复、自动改进与社区共享。在任意连接 OpenSpace 的代理中通过 `search_skills("finance backtest")` 搜索 Vibe-Trading 技能。
OpenSpace 会自动发现全部 72 个技能,支持自动修复、自动改进与社区共享。在任意连接 OpenSpace 的代理中通过 `search_skills("finance backtest")` 搜索 Vibe-Trading 技能。
</details>
@@ -493,41 +524,42 @@ Vibe-Trading/
├── agent/ # 后端(Python)
│ ├── cli.py # CLI 入口——交互式 TUI + 子命令
│ ├── api_server.py # FastAPI 服务器——运行、会话、上传、swarm、SSE
│ ├── mcp_server.py # MCP 服务器——为 OpenClaw / Claude Desktop 提供 17 个工具
│ ├── mcp_server.py # MCP 服务器——为 OpenClaw / Claude Desktop 提供 22 个工具
│ │
│ ├── src/
│ │ ├── agent/ # ReAct 代理核心
│ │ │ ├── loop.py # 主推理循环
│ │ │ ├── skills.py # 技能加载器(68 个 SKILL.md,7 类)
│ │ │ ├── tools.py # 工具编排
│ │ │ ├── context.py # 系统提示构建器
│ │ │ ├── memory.py # 运行记忆 / artifact 存储
│ │ │ ├── loop.py # 5 层压缩 + 读写工具批处理
│ │ │ ├── context.py # 系统提示 + 持久记忆自动召回
│ │ │ ├── skills.py # 技能加载器(69 内置 + 用户 CRUD 创建)
│ │ │ ├── tools.py # 工具基类 + 注册表
│ │ │ ├── memory.py # 单次运行轻量工作区状态
│ │ │ ├── frontmatter.py # 共享 YAML frontmatter 解析器
│ │ │ └── trace.py # 执行轨迹写入
│ │ │
│ │ ├── tools/ # 21 个代理工具
│ │ │ ├── backtest_tool.py # 运行回测
│ │ │ ├── factor_analysis_tool.py
│ │ │ ├── options_pricing_tool.py
│ │ │ ├── pattern_tool.py # 图表形态检测
│ │ │ ├── doc_reader_tool.py # PDF 阅读(OCR 回退)
│ │ │ ├── web_reader_tool.py # 网页读取(Jina)
│ │ │ ├── web_search_tool.py # DuckDuckGo 搜索
│ │ │ ├── swarm_tool.py # 启动 swarm 团队
│ │ │ └── ... # 文件 I/O、bash、任务等
│ │ ├── memory/ # 跨会话持久记忆
│ │ │ └── persistent.py # 基于文件的记忆(~/.vibe-trading/memory/)
│ │ │
│ │ ├── skills/ # 68 个金融技能(7 类,每个 SKILL.md)
│ │ ├── tools/ # 27 个自动发现的代理工具
│ │ │ ├── backtest_tool.py # 运行回测
│ │ │ ├── remember_tool.py # 跨会话记忆(保存/召回/遗忘)
│ │ │ ├── skill_writer_tool.py # 技能 CRUD(保存/修补/删除/文件)
│ │ │ ├── session_search_tool.py # FTS5 跨会话搜索
│ │ │ ├── swarm_tool.py # 启动 swarm 团队
│ │ │ ├── web_search_tool.py # DuckDuckGo 搜索
│ │ │ └── ... # bash、文件 I/O、因子分析、期权等
│ │ │
│ │ ├── skills/ # 72 个金融技能(7 类,每个 SKILL.md)
│ │ ├── swarm/ # Swarm DAG 执行引擎
│ │ ├── session/ # 多轮对话管理
│ │ │ └── presets/ # 29 个 swarm 预设 YAML 定义
│ │ ├── session/ # 多轮对话 + FTS5 会话搜索
│ │ └── providers/ # LLM 提供商抽象
│ │
│ ├── backtest/ # 回测引擎
│ │ ├── engines/ # 7 个引擎:china_a、global_equity、crypto、china_futures、global_futures、forex + options_portfolio
│ │ ├── loaders/ # 5 个数据源:tushare、okx、yfinance、akshare、ccxt
│ │ │ ├── base.py # DataLoader Protocol
│ │ │ └── registry.py # 注册表 + 自动回退链
│ │ └── optimizers/ # MVO、等波动、最大分散、风险平价
│ │
│ └── config/swarm/ # 29 个 swarm 预设 YAML 定义
│ └── backtest/ # 回测引擎
│ ├── engines/ # 7 个引擎 + 跨市场复合引擎 + options_portfolio
│ ├── loaders/ # 6 个数据源:tushare、okx、yfinance、akshare、ccxt、futu
│ │ ├── base.py # DataLoader Protocol
│ │ └── registry.py # 注册表 + 自动回退链
│ └── optimizers/ # MVO、等波动、最大分散、风险平价
│
├── frontend/ # Web UI(React 19 + Vite + TypeScript)
│ └── src/
@@ -578,17 +610,21 @@ Vibe-Trading 属于 **[HKUDS](https://github.com/HKUDS)** 代理生态的一部
| Phase | Feature | Status |
|-------|---------|--------|
| **Agent Harness** | 跨会话持久记忆(保存 / 召回 / 遗忘) | **Done** |
| | 自进化技能——代理自主创建、修补和删除工作流 | **Done** |
| | FTS5 跨会话搜索全部历史对话 | **Done** |
| | 5 层上下文压缩(微压缩 → 折叠 → 自动 → 手动 → 迭代) | **Done** |
| | 读写工具批处理——只读工具并行执行 | **Done** |
| **Next Up** | 自主研究循环——代理通宵迭代假设 | In Progress |
| | IM 集成(Slack / Telegram / 微信) | Planned |
| **Analysis & Viz** | 期权波动率曲面与 Greeks 三维可视化 | Planned |
| | 跨资产相关性热力图(滚动窗口 + 聚类) | Planned |
| | CLI 回测输出中的基准对比 | Planned |
| | 回测指标新增 Calmar Ratio 与 Omega Ratio | Planned |
| **Skills & Presets** | 分红分析技能 | Planned |
| | ESG / 可持续投资 swarm 预设 | Planned |
| | 新兴市场研究台 swarm 预设 | Planned |
| **Portfolio & Optimization** | 高级组合优化器:杠杆、行业上限、换手约束 | Planned |
| **Future** | 新手教程:“5 分钟自然语言回测” | Planned |
| **Future** | 策略市场(分享与发现) | Exploring |
| | 通过 WebSocket 的实时数据流 | Exploring |
| | 策略市场(分享与发现) | Exploring |
---
@@ -622,6 +658,12 @@ MIT 许可证——参见 [LICENSE](LICENSE)
---
## Star 历史
[![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> ✨
</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).
+58 -32
View File
@@ -1,89 +1,115 @@
# ============================================================================
# LLM Provider — uncomment ONE block below
# LLM Provider — uncomment ONE provider block below
# ============================================================================
LANGCHAIN_TEMPERATURE=0.0
# --- OpenRouter ---
# --- OpenRouter (recommended, multi-model gateway) ---
LANGCHAIN_PROVIDER=openrouter
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
LANGCHAIN_MODEL_NAME=deepseek/deepseek-v3.2
# --- DeepSeek ---
# LANGCHAIN_PROVIDER=deepseek
# DEEPSEEK_API_KEY=sk-xxx
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# LANGCHAIN_MODEL_NAME=deepseek-chat
# --- OpenAI ---
# LANGCHAIN_PROVIDER=openai
# LANGCHAIN_MODEL_NAME=gpt-4o
# OPENAI_API_KEY=sk-xxx
# OPENAI_BASE_URL=https://api.openai.com/v1
# LANGCHAIN_MODEL_NAME=gpt-4o
# --- DeepSeek ---
# LANGCHAIN_PROVIDER=deepseek
# LANGCHAIN_MODEL_NAME=deepseek-chat
# DEEPSEEK_API_KEY=sk-xxx
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# --- Gemini ---
# LANGCHAIN_PROVIDER=gemini
# LANGCHAIN_MODEL_NAME=gemini-2.5-flash
# GEMINI_API_KEY=xxx
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
# LANGCHAIN_MODEL_NAME=gemini-2.5-flash
# --- Groq ---
# LANGCHAIN_PROVIDER=groq
# LANGCHAIN_MODEL_NAME=llama-3.3-70b-versatile
# GROQ_API_KEY=gsk_xxx
# GROQ_BASE_URL=https://api.groq.com/openai/v1
# LANGCHAIN_MODEL_NAME=llama-3.3-70b-versatile
# --- DashScope / Qwen ---
# LANGCHAIN_PROVIDER=dashscope
# LANGCHAIN_MODEL_NAME=qwen-plus
# DASHSCOPE_API_KEY=sk-xxx
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# LANGCHAIN_MODEL_NAME=qwen-plus
# --- Zhipu ---
# LANGCHAIN_PROVIDER=zhipu
# LANGCHAIN_MODEL_NAME=glm-4-plus
# ZHIPU_API_KEY=xxx
# ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# LANGCHAIN_MODEL_NAME=glm-4-plus
# --- Moonshot / Kimi ---
# LANGCHAIN_PROVIDER=moonshot
# LANGCHAIN_MODEL_NAME=kimi-k2.5
# MOONSHOT_API_KEY=sk-xxx
# MOONSHOT_BASE_URL=https://api.moonshot.ai/v1
# LANGCHAIN_MODEL_NAME=kimi-k2.5
# --- 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
# LANGCHAIN_MODEL_NAME=MiniMax-Text-01
# 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
# LANGCHAIN_MODEL_NAME=MiMo-72B-A27B
# --- Ollama ---
# --- 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
# OLLAMA_BASE_URL=http://localhost:11434/v1
# LANGCHAIN_MODEL_NAME=qwen2.5:32b
# OLLAMA_BASE_URL=http://localhost:11434/v1
# ============================================================================
# Data Providers
# ============================================================================
# A-shares: Tushare Pro (requires 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)
# ============================================================================
# Runtime
# ============================================================================
# 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.
# Leave unset for Moonshot / DeepSeek official APIs — they return reasoning_content by default.
# LANGCHAIN_REASONING_EFFORT=medium
# ============================================================================
# Swarm / Subagent tuning (optional, defaults are sensible)
# Data Sources
# ============================================================================
# A-shares: Tushare Pro token (https://tushare.pro)
TUSHARE_TOKEN=your-tushare-token
# 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
# ============================================================================
# API Server (optional)
# ============================================================================
# Bearer token for API auth (empty = no auth, dev mode)
# 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
# ============================================================================
# Agent Tuning (optional, defaults are sensible)
# ============================================================================
# SWARM_WORKER_TIMEOUT=300
# SWARM_WORKER_MAX_ITER=50
+34 -15
View File
@@ -1,7 +1,7 @@
---
name: vibe-trading
version: 0.1.4
description: Professional finance research toolkit — backtesting (6 engines), factor analysis, options pricing, 68 finance skills, and 29 multi-agent swarm teams across 5 data sources (tushare, yfinance, okx, akshare, ccxt).
version: 0.1.6
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, options pricing, 72 finance skills, 29 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 6 data sources (tushare, yfinance, okx, akshare, ccxt, futu).
dependencies:
python: ">=3.11"
pip:
@@ -11,7 +11,7 @@ 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 16 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."
@@ -23,7 +23,7 @@ mcp:
# Vibe-Trading
Professional finance research toolkit with AI-powered backtesting (6 engines), multi-agent teams, and 68 specialized skills.
Professional finance research toolkit with AI-powered backtesting (7 engines), multi-agent teams, 72 specialized skills, and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
## Setup
@@ -53,7 +53,7 @@ Add to your agent's MCP config:
### API Key Requirements
**16 of 17 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, and all 68 skills are ready to use for HK/US equities and crypto.
**21 of 22 MCP tools work with zero API keys.** After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, and all 72 skills are ready to use for HK/US equities and crypto.
| Feature | Key needed | When |
|---------|-----------|------|
@@ -63,12 +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 6 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 5 data sources:
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 6 data sources:
- **HK/US equities** via yfinance (free, no API key)
- **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
@@ -89,34 +99,40 @@ Example workflow:
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
### Finance Skills (68)
### Finance Skills (72)
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 (17)
## Available MCP Tools (22)
| Tool | Description | API Key |
|------|-------------|---------|
| `list_skills` | List all 68 finance skills | None |
| `list_skills` | List all 72 finance skills | None |
| `load_skill` | Load full skill documentation | 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 5 sources (auto-detect + fallback) | 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 |
@@ -131,13 +147,16 @@ 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`, `web_search`, and all 68 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`, and all 72 skills immediately.
## 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
+81 -13
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request, Security, UploadFile, status
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
@@ -38,6 +38,7 @@ SESSIONS_DIR = Path(__file__).resolve().parent / "sessions"
UPLOADS_DIR = Path(__file__).resolve().parent / "uploads"
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
# Rich console for colored logs
console = Console()
@@ -501,13 +502,15 @@ async def list_runs(limit: int = 20):
try:
req_data = json.loads(req_file.read_text(encoding="utf-8"))
prompt = req_data.get("prompt")
except: pass
except:
pass
if not prompt and planner_file.exists():
try:
planner_data = json.loads(planner_file.read_text(encoding="utf-8"))
prompt = planner_data.get("user_goal") or planner_data.get("goal")
except: pass
except:
pass
if not prompt:
prompt_file = d / "user_prompt.txt"
@@ -814,24 +817,90 @@ async def session_events(
# File Upload
# ============================================================================
_BLOCKED_UPLOAD_EXT = {
# binaries / executables we should never accept
".exe", ".msi", ".bat", ".cmd", ".com", ".scr", ".app", ".dmg",
".so", ".dll", ".dylib",
# archives — don't auto-extract; user can unpack locally
".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".bz2", ".xz",
}
_SHADOW_ID_RE = __import__("re").compile(r"^shadow_[0-9a-f]{8}$")
@app.get("/shadow-reports/{shadow_id}")
async def get_shadow_report(shadow_id: str, format: str = "html"):
"""Serve a rendered Shadow Account report (HTML by default, PDF if available).
Reports live under ``~/.vibe-trading/shadow_reports/<shadow_id>.{html,pdf}``.
"""
if not _SHADOW_ID_RE.match(shadow_id):
raise HTTPException(status_code=400, detail="invalid shadow_id")
if format not in ("html", "pdf"):
raise HTTPException(status_code=400, detail="format must be html or pdf")
reports_dir = Path.home() / ".vibe-trading" / "shadow_reports"
path = reports_dir / f"{shadow_id}.{format}"
if not path.exists():
raise HTTPException(status_code=404, detail=f"Shadow report not found: {shadow_id}.{format}")
media_type = "text/html; charset=utf-8" if format == "html" else "application/pdf"
# Inline so browsers render HTML/PDF directly instead of forcing download.
return FileResponse(
path,
media_type=media_type,
headers={"Content-Disposition": f'inline; filename="{shadow_id}.{format}"'},
)
@app.post("/upload", dependencies=[Depends(require_auth)])
async def upload_file(file: UploadFile):
"""Upload a PDF; stored under uploads/ with a UUID name (max 50MB)."""
if not file.filename or not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
"""Upload any document or data file (max 50MB).
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
Accepts most common formats: PDF, Word, Excel, PowerPoint, images,
CSV/TSV, plain text, JSON/YAML/TOML, source code. Executables and
archives are rejected — unpack locally before uploading.
"""
if not file.filename:
raise HTTPException(status_code=400, detail="Missing filename")
ext = Path(file.filename).suffix.lower()
if ext in _BLOCKED_UPLOAD_EXT:
raise HTTPException(
status_code=413,
detail=f"File too large (limit {MAX_UPLOAD_SIZE // (1024 * 1024)} MB)",
status_code=400,
detail=f"File type {ext} is not allowed (executables/archives blocked).",
)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
safe_name = f"{uuid.uuid4().hex}.pdf"
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = UPLOADS_DIR / safe_name
dest.write_bytes(content)
total_size = 0
try:
with dest.open("wb") as handle:
while True:
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_UPLOAD_SIZE:
handle.close()
if dest.exists():
dest.unlink()
raise HTTPException(
status_code=413,
detail=f"File too large (limit {MAX_UPLOAD_SIZE // (1024 * 1024)} MB)",
)
handle.write(chunk)
except HTTPException:
raise
except OSError as exc:
if dest.exists():
dest.unlink()
raise HTTPException(status_code=500, detail=f"Failed to store upload: {exc}") from exc
finally:
await file.close()
return {
"status": "ok",
@@ -1025,4 +1094,3 @@ def serve_main(argv: list[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(serve_main())
+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
+5
View File
@@ -13,12 +13,17 @@ Wave 2:
- 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
+180
View File
@@ -0,0 +1,180 @@
"""Extracted per-bar market hooks as pure functions.
Both the original engines (CryptoEngine, ForexEngine) and CompositeEngine
call these same functions. Zero duplication — one source of truth.
"""
from __future__ import annotations
from typing import Dict
import pandas as pd
from backtest.models import Position
# ── 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
+67 -5
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import importlib
import json
import logging
import sys
from abc import ABC, abstractmethod
from pathlib import Path
@@ -18,6 +19,8 @@ from typing import Any, Callable, Dict, List, Optional
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
from backtest.metrics import (
by_exit_reason_stats,
by_symbol_stats,
@@ -26,6 +29,23 @@ from backtest.metrics import (
from backtest.models import EquitySnapshot, Position, TradeRecord
# ─── Market detection (lightweight, for signal alignment only) ───
import re as _re
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
_FOREX_RE = _re.compile(r"^[A-Z]{3}/[A-Z]{3}$|^[A-Z]{6}\.FX$")
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) ───
@@ -57,12 +77,28 @@ def _align(
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()
# 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(f"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:
raw = signal_map[c].reindex(dates).fillna(0.0).clip(-1.0, 1.0)
pos[c] = raw.shift(1).fillna(0.0)
# 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)
@@ -255,6 +291,9 @@ class BaseEngine(ABC):
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)
@@ -264,6 +303,26 @@ class BaseEngine(ABC):
index=[s.timestamp for s in self.equity_snapshots],
)
bench_ret = ret_df.mean(axis=1) if ret_df.shape[1] > 0 else pd.Series(0.0, index=dates)
# ── 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)
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
m["benchmark_ticker"] = bench_result.ticker
m["benchmark_return"] = bench_result.total_ret
# ── External benchmark fetch ──────────────────────────────────────────
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
# 6. Metrics
@@ -314,8 +373,11 @@ class BaseEngine(ABC):
# b. Rebalance each symbol to target weight
equity = self._calc_equity(close_df, ts)
for c in codes:
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)
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)
+217
View File
@@ -0,0 +1,217 @@
"""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
import re
from typing import Dict, List
import pandas as pd
from backtest.engines.base import BaseEngine
from backtest.engines._market_hooks import (
calc_crypto_funding_fee,
check_crypto_liquidation,
calc_forex_swap,
)
# ── Market detection (same patterns as runner.py) ──
_MARKET_PATTERNS = [
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
(re.compile(r"^[A-Za-z]{1,2}\d{3,4}\.(ZCE|DCE|SHFE|INE|CFFEX|GFEX)$", re.I), "futures"),
(re.compile(r"^[A-Z]{2,4}[FGHJKMNQUVXZ]\d{1,2}$", re.I), "futures"),
(re.compile(r"^[A-Z]{2,4}\d{4}$", re.I), "futures"),
(re.compile(r"^[A-Z]{2,4}\.(CME|CBOT|NYMEX|COMEX|ICE|EUREX)$", re.I), "futures"),
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
]
_CHINA_EXCHANGES = {"CFFEX", "SHFE", "DCE", "ZCE", "INE", "GFEX"}
def _detect_market(code: str) -> str:
"""Infer market type from symbol format."""
for pattern, market in _MARKET_PATTERNS:
if pattern.match(code):
return market
return "a_share"
def _is_china_futures(code: str) -> bool:
"""Check if futures code belongs to a Chinese exchange."""
parts = code.upper().split(".")
return len(parts) == 2 and parts[1] in _CHINA_EXCHANGES
def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]:
"""Instantiate one sub-engine per market type detected in codes."""
markets = {_detect_market(c) for c in codes}
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
+14 -90
View File
@@ -13,21 +13,10 @@ from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
# OKX tiered maintenance margin table (simplified)
# (max_notional_usd, maintenance_margin_rate)
_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 fee settlement hours (UTC)
_FUNDING_HOURS = {0, 8, 16}
from backtest.engines._market_hooks import (
calc_crypto_funding_fee,
check_crypto_liquidation,
)
class CryptoEngine(BaseEngine):
@@ -70,80 +59,15 @@ class CryptoEngine(BaseEngine):
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
"""Crypto per-bar hooks: funding fee + liquidation check."""
self._apply_funding_fee(symbol, bar, timestamp)
self._check_liquidation(symbol, bar, timestamp)
# ── Funding fee (exchange-enforced, every 8h) ──
def _apply_funding_fee(
self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp,
) -> None:
"""Deduct/credit funding fee at settlement hours.
Positive rate: longs pay shorts. Negative rate: shorts pay longs.
For intraday bars: applies at each 8h settlement (0/8/16 UTC).
For daily bars: applies once per calendar day regardless of hour.
Dedup is per-(symbol, date, hour) so multi-symbol portfolios work.
"""
if not hasattr(timestamp, "date"):
return
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 self._funding_applied:
return
self._funding_applied.add(key)
else:
# Non-settlement hour (e.g. daily bar at 12:00 UTC)
# Apply once per day as approximation
day_key = (symbol, current_date)
if day_key in self._funding_daily_done:
return
self._funding_daily_done.add(day_key)
pos = self.positions.get(symbol)
if pos is None:
return
mark_price = float(bar.get("close", pos.entry_price))
notional = pos.size * mark_price
fee = notional * self.funding_rate * pos.direction
fee = calc_crypto_funding_fee(
symbol, bar, timestamp, self.positions,
self.funding_rate, self._funding_applied, self._funding_daily_done,
)
self.capital -= fee
# ── Liquidation (exchange-enforced) ──
def _check_liquidation(
self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp,
) -> None:
"""Force-close when maintenance margin ratio drops to / below 100%."""
pos = self.positions.get(symbol)
if pos is None or pos.leverage <= 1.0:
return # spot has no liquidation
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)
# Maintenance margin rate (tiered)
notional = pos.size * mark_price
maint_rate = self._maintenance_rate(notional)
maint_margin = notional * maint_rate
# Margin ratio = (margin + unrealized) / maint_margin
equity_in_pos = margin + unrealized
if equity_in_pos <= maint_margin:
# Liquidation: close at mark price with taker fee
liq_price = self.apply_slippage(mark_price, -pos.direction)
self._close_position(symbol, liq_price, timestamp, "liquidation")
@staticmethod
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]
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")
+6 -46
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import pandas as pd
from backtest.engines.base import BaseEngine
from backtest.engines._market_hooks import calc_forex_swap
# ── Typical spreads in pips (1 pip = 0.0001 for most pairs, 0.01 for JPY) ──
@@ -33,18 +34,6 @@ _SPREAD_PIPS: dict[str, float] = {
}
_DEFAULT_SPREAD_PIPS = 2.0
# ── Daily swap points (per standard lot, in account currency) ──
# Positive = receive, negative = pay. Varies by broker/rates; simplified table.
_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,
}
# Standard lot size
STANDARD_LOT = 100_000
@@ -149,41 +138,12 @@ class ForexEngine(BaseEngine):
"""Apply daily swap/rollover at end of trading day."""
if not self.swap_enabled:
return
self._apply_swap(symbol, timestamp)
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
# ── Swap (overnight rollover) ──
def _apply_swap(self, symbol: str, timestamp: pd.Timestamp) -> None:
"""Deduct/credit swap at daily rollover (once per calendar day).
Triple swap on Wednesday (covers weekend).
"""
if not hasattr(timestamp, "date"):
return
current_date = timestamp.date()
if self._last_swap_dates.get(symbol) == current_date:
return
self._last_swap_dates[symbol] = current_date
pos = self.positions.get(symbol)
if pos is None:
return
pair = _normalize_symbol(symbol)
lots = pos.size / self.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
swap = lots * swap_per_lot * multiplier
self.capital += swap
+83
View File
@@ -11,6 +11,7 @@ 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__)
@@ -38,6 +39,38 @@ 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)."""
@@ -78,6 +111,8 @@ class DataLoader:
Returns:
Mapping symbol -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
@@ -94,12 +129,17 @@ class DataLoader:
"""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)
@@ -142,6 +182,49 @@ class DataLoader:
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)
+19
View File
@@ -11,6 +11,25 @@ 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})")
@runtime_checkable
class DataLoaderProtocol(Protocol):
"""Interface that every data source loader must satisfy."""
+3
View File
@@ -13,6 +13,7 @@ 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__)
@@ -73,6 +74,8 @@ class DataLoader:
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)
+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
+3
View File
@@ -10,6 +10,7 @@ from typing import Dict, List, Optional
import pandas as pd
import requests
from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register
BASE_URL = "https://www.okx.com/api/v5"
@@ -52,6 +53,8 @@ class DataLoader:
Returns:
Mapping symbol -> DataFrame.
"""
validate_date_range(start_date, end_date)
if fields:
print(f"[WARN] OKX ignores extra fields: {fields}")
+16 -4
View File
@@ -52,6 +52,7 @@ def _ensure_registered() -> None:
"backtest.loaders.yfinance_loader",
"backtest.loaders.akshare_loader",
"backtest.loaders.ccxt_loader",
"backtest.loaders.futu",
]
import importlib
for mod in _loader_modules:
@@ -68,7 +69,7 @@ def _ensure_registered() -> None:
FALLBACK_CHAINS: dict[str, list[str]] = {
"a_share": ["tushare", "akshare"],
"us_equity": ["yfinance", "akshare"],
"hk_equity": ["yfinance", "akshare"],
"hk_equity": ["yfinance", "futu", "akshare"],
"crypto": ["okx", "ccxt"],
"futures": ["tushare", "akshare"],
"fund": ["tushare", "akshare"],
@@ -98,8 +99,15 @@ def resolve_loader(market: str) -> Any:
for name in chain:
if name not in LOADER_REGISTRY:
continue
loader = LOADER_REGISTRY[name]()
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(
@@ -125,8 +133,12 @@ def get_loader_cls_with_fallback(source: str) -> Type[Any]:
raise NoAvailableSourceError(f"Unknown data source: {source}")
loader_cls = LOADER_REGISTRY[source]
instance = loader_cls()
if instance.is_available():
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
+3
View File
@@ -9,6 +9,7 @@ from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register
@@ -51,6 +52,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)
@@ -8,6 +8,7 @@ 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"]
@@ -230,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)
+12 -3
View File
@@ -147,7 +147,7 @@ def calc_metrics(
equity_curve: pd.Series,
trades: List[TradeRecord],
initial_cash: float,
bars_per_year: int = 252,
bars_per_year: Optional[int] = 252,
bench_ret: Optional[pd.Series] = None,
) -> Dict[str, Any]:
"""Full set of performance metrics.
@@ -156,7 +156,8 @@ def calc_metrics(
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.
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:
@@ -166,7 +167,15 @@ def calc_metrics(
return _empty_metrics(initial_cash)
n = len(equity_curve)
bpy = bars_per_year
# 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)
+125 -3
View File
@@ -13,7 +13,10 @@ import logging
import re
import sys
from pathlib import Path
from typing import Dict, List
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
try:
from dotenv import load_dotenv
@@ -22,6 +25,7 @@ except ImportError:
pass
from backtest.loaders.registry import (
FALLBACK_CHAINS,
LOADER_REGISTRY,
get_loader_cls_with_fallback,
resolve_loader,
@@ -30,6 +34,70 @@ from backtest.loaders.base import NoAvailableSourceError
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"
@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
@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):
"""Load a Python module from a file path via importlib.
@@ -192,7 +260,17 @@ def main(run_dir: Path) -> None:
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", [])
@@ -225,6 +303,25 @@ 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)
@@ -236,7 +333,12 @@ def main(run_dir: Path) -> None:
# Annualization bars
effective_source = _detect_primary_source(codes, source)
from backtest.metrics import calc_bars_per_year
bars_per_year = calc_bars_per_year(interval, effective_source)
# 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":
@@ -268,6 +370,11 @@ def _create_market_engine(source: str, config: dict, codes: List[str]):
# 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
@@ -399,6 +506,21 @@ def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
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
+112 -25
View File
@@ -51,7 +51,7 @@ EXIT_RUN_FAILED = 1
EXIT_USAGE_ERROR = 2
RICH_TAG_PATTERN = re.compile(r"\[/?[^\]]+\]")
_VERSION = "0.1.0"
_VERSION = "0.1.5"
# Agent color assignments for swarm display
_AGENT_STYLES = ["cyan", "magenta", "green", "yellow", "blue", "bright_red", "bright_cyan", "bright_magenta"]
@@ -172,17 +172,10 @@ def _format_tool_call_args(tool: str, args: Dict[str, str]) -> str:
if tool in ("bash", "background_run"):
cmd = args.get("command", "")[:80]
return f' [yellow]{cmd}[/yellow]'
if tool == "subagent":
desc = args.get("description", args.get("prompt", "")[:60])
return f' [magenta]{desc}[/magenta]'
if tool == "task_create":
return f' "{args.get("subject", "")}"'
if tool == "task_update":
return f' #{args.get("task_id", "")} -> {args.get("status", "")}'
if tool == "check_background":
tid = args.get("task_id", "")
return f' {tid}' if tid else ""
if tool in ("backtest", "compact", "task_list"):
if tool in ("backtest", "compact"):
return ""
for v in args.values():
if v and v != "None":
@@ -203,15 +196,18 @@ def _format_tool_result_preview(tool: str, status: str, preview: str) -> str:
if ret:
parts.append(f"return={float(ret.group(1))*100:.1f}%")
return ", ".join(parts) if parts else ""
if tool == "render_shadow_report":
url = re.search(r'"report_url":\s*"([^"]+)"', preview)
if url:
return f"[bold cyan]report:[/bold cyan] [link]{url.group(1)}[/link]"
return ""
if tool in ("extract_shadow_strategy", "run_shadow_backtest"):
sid = re.search(r'"shadow_id":\s*"([^"]+)"', preview)
return f"shadow_id={sid.group(1)}" if sid else ""
if tool in ("bash", "background_run"):
if "OK" in preview[:50]:
return "OK"
return preview[:60].replace("\n", " ")
if tool == "subagent":
m = re.search(r'"summary":\s*"(.{0,80})', preview)
return m.group(1).rstrip('"') if m else ""
if tool in ("task_create", "task_update"):
return preview[:60].replace("\n", " ")
if tool in ("read_file", "load_skill", "compact"):
return ""
return ""
@@ -283,11 +279,15 @@ def _run_agent(
tokens = data.get("tokens_before", "?")
console.print(f"\n [yellow]\u27f3 context compressed[/yellow] [dim]({tokens} tokens \u2192 summary)[/dim]\n")
from src.memory.persistent import PersistentMemory
pm = PersistentMemory()
agent = AgentLoop(
registry=build_registry(),
registry=build_registry(persistent_memory=pm),
llm=ChatLLM(),
event_callback=on_event,
max_iterations=max_iter,
persistent_memory=pm,
)
if run_dir_override:
agent.memory.run_dir = run_dir_override
@@ -295,23 +295,80 @@ def _run_agent(
return agent.run(user_message=prompt, history=history)
def _build_benchmark_table(m: dict) -> Optional[Table]:
"""Build a benchmark comparison table from metrics dict.
Args:
m: Metrics dictionary (from _read_metrics or result dict).
Returns:
Rich Table, or None if no benchmark data is present.
"""
bench_ticker = m.get("benchmark_ticker")
bench_ret_str = m.get("benchmark_return")
bench_ret_raw = m.get("_benchmark_return_raw")
# Fall back to equity.csv if benchmark cols not in metrics.csv yet
if not bench_ticker:
return None
# Parse benchmark return
if bench_ret_raw is not None:
bench_ret = bench_ret_raw
elif bench_ret_str is not None:
try:
bench_ret = float(bench_ret_str)
except (ValueError, TypeError):
bench_ret = None
else:
bench_ret = None
strategy_ret_str = m.get("total_return")
strategy_ret = float(strategy_ret_str) if strategy_ret_str else None
table = Table(show_header=False, padding=(0, 2))
table.add_column("Label", style="dim", width=20)
table.add_column("Value", style="white no_wrap")
table.add_row("[dim]Benchmark[/dim]", bench_ticker)
if bench_ret is not None:
table.add_row("[dim]Benchmark Return[/dim]", f"{bench_ret * 100:+.2f}%")
if strategy_ret is not None and bench_ret is not None:
excess = strategy_ret - bench_ret
sign = "+" if excess >= 0 else ""
style = "green" if excess >= 0 else "red"
table.add_row(
"[dim]vs Benchmark[/dim]",
f"[{style}]{sign}{excess * 100:+.2f}%[/{style}]",
)
ir_str = m.get("information_ratio")
if ir_str:
table.add_row("[dim]Info Ratio[/dim]", ir_str)
excess_str = m.get("excess_return")
if excess_str and excess_str != "0" and excess_str != "0.0000":
table.add_row("[dim]Excess Return[/dim]", f"{float(excess_str) * 100:+.2f}%")
return table
def _print_result(result: dict, elapsed: float, *, no_rich: bool = False) -> None:
"""Print execution result panel."""
status = result.get("status", "unknown")
ok = status == "success"
style = "green" if ok else "red"
lines = [f"Status: [bold {style}]{status.upper()}[/bold {style}] Time: {elapsed:.1f}s"]
if result.get("run_id"):
lines.append(f"ID: {result['run_id']}")
review = result.get("review")
if review and review.get("overall_score") is not None:
check = "\u2713" if review.get("passed") else "\u2717"
lines.append(f"Review: {review['overall_score']}pts {check}")
run_dir = result.get("run_dir")
m = {}
if run_dir:
m = _read_metrics(Path(run_dir) / "artifacts" / "metrics.csv")
parts = [f"{k}={m[k]}" for k in ("total_return", "sharpe", "max_drawdown", "trade_count") if k in m]
@@ -323,6 +380,17 @@ def _print_result(result: dict, elapsed: float, *, no_rich: bool = False) -> Non
console.print(Panel("\n".join(lines), border_style=style, title="Result"))
# ── Benchmark comparison panel ─────────────────────────────────────────────
bench_table = _build_benchmark_table(m)
if bench_table:
console.print(Panel(
bench_table,
border_style="cyan",
title="Benchmark Comparison",
padding=(0, 1),
))
# ── Benchmark comparison panel ─────────────────────────────────────────
content = result.get("content", "").strip()
if content:
console.print(f"\n{content}")
@@ -455,7 +523,9 @@ def _print_welcome() -> None:
"\n"
" [cyan]/skills[/cyan] List available skills [cyan]/swarm[/cyan] Agent team presets\n"
" [cyan]/list[/cyan] Recent runs [cyan]/settings[/cyan] Runtime config\n"
" [cyan]/help[/cyan] All commands [cyan]/quit[/cyan] Exit"
" [cyan]/help[/cyan] All commands [cyan]/quit[/cyan] Exit\n"
"\n"
"[dim]Try: \"analyze my trade journal\" or \"train my shadow account\"[/dim]"
)
console.print(Panel(welcome, border_style="cyan", padding=(1, 2)))
@@ -484,6 +554,10 @@ def _print_help() -> None:
("/settings", "Show runtime settings"),
("/clear", "Clear screen"),
("/quit", "Exit"),
("", ""),
("[dim]--- Natural language ---[/dim]", ""),
('"analyze journal.csv"', "Parse broker export → profile + 4 behavior diagnostics"),
('"train my shadow"', "Extract strategy → backtest → HTML/PDF report"),
]
for cmd, desc in cmds:
table.add_row(cmd, desc)
@@ -946,7 +1020,7 @@ def cmd_list(limit: int = 20) -> None:
st = _read_json(d / "state.json").get("status", "?")
m = _read_metrics(d / "artifacts" / "metrics.csv")
c = "green" if st == "success" else "red" if st == "failed" else "dim"
table.add_row(d.name, f"[{c}]{st}[/{c}]", m.get("total_return", ""), m.get("sharpe", ""), (_read_json(d / "req.json").get("prompt") or "")[:40])
table.add_row(d.name, f"[{c}]{st.upper()}[/{c}]", m.get("total_return", ""), m.get("sharpe", ""), (_read_json(d / "req.json").get("prompt") or "")[:40])
console.print(table)
@@ -964,8 +1038,9 @@ def cmd_show(run_id: str) -> None:
st = state.get("status", "unknown")
c = "green" if st == "success" else "red"
lines = [f"[bold]Status:[/bold] [{c}]{st.upper()}[/{c}]", f"[bold]Prompt:[/bold] {req.get('prompt', '?')}"]
lines = [f"[bold]Status:[/bold] [{c}]{st.upper()}[/{c}]"]
if req.get("prompt"):
lines.append(f"[bold]Prompt:[/bold] {req['prompt'][:500]}{'...' if len(req['prompt']) > 500 else ''}")
if metrics:
lines.append("\n[bold]Metrics:[/bold]")
lines.extend(f" {k}: {v}" for k, v in metrics.items())
@@ -1519,6 +1594,16 @@ _PROVIDER_CHOICES: list[dict[str, str | None]] = [
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Z.ai (Coding platform)",
"provider": "zai",
"key_env": "ZAI_API_KEY",
"base_env": "ZAI_BASE_URL",
"base_url": "https://api.z.ai/api/coding/paas/v4",
"model": "glm-5.1",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Ollama (local, free)",
"provider": "ollama",
@@ -1564,6 +1649,8 @@ def _render_env_content(config: dict[str, str]) -> str:
"MINIMAX_BASE_URL",
"MIMO_API_KEY",
"MIMO_BASE_URL",
"ZAI_API_KEY",
"ZAI_BASE_URL",
"OLLAMA_BASE_URL",
"LANGCHAIN_MODEL_NAME",
"TUSHARE_TOKEN",
@@ -1684,7 +1771,7 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "list":
return _coerce_exit_code(cmd_list(args.list_limit))
if args.command == "show":
return _coerce_exit_code(cmd_show(args.run_id))
return _coerce_exit_code(cmd_show(args.show))
if args.command == "chat":
return _coerce_exit_code(cmd_interactive(args.chat_max_iter))
@@ -1723,7 +1810,7 @@ def main(argv: list[str] | None = None) -> int:
if args.chat:
return _coerce_exit_code(cmd_interactive(args.max_iter))
if args.cont:
return cmd_continue(args.cont[0], args.cont[1], args.max_iter, json_mode=args.json, no_rich=args.no_rich)
return _coerce_exit_code(cmd_continue(args.cont[0], args.cont[1], args.max_iter, json_mode=args.json, no_rich=args.no_rich))
# No flags, no subcommand → check if prompt provided, otherwise interactive mode
if args.prompt or args.prompt_file or not sys.stdin.isatty():
+153 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Vibe-Trading MCP Server — expose 17 finance research tools to any MCP client.
"""Vibe-Trading MCP Server — expose 22 finance research tools to any MCP client.
Works with OpenClaw, Claude Desktop, Cursor, and any MCP-compatible client.
Zero API key required for HK/US/crypto markets (yfinance, OKX, AKShare are free).
@@ -75,7 +75,7 @@ def _get_registry():
def list_skills() -> str:
"""List all available finance skills with names and descriptions.
Returns a JSON array of {name, description} for all 68 skills.
Returns a JSON array of {name, description} for all 71 skills.
Use load_skill(name) to get the full documentation for any skill.
"""
loader = _get_skills_loader()
@@ -213,7 +213,7 @@ def pattern_recognition(run_dir: str) -> str:
run_dir: Path to run directory containing artifacts/ohlcv_*.csv.
"""
registry = _get_registry()
return registry.execute("pattern_recognition", {"run_dir": run_dir})
return registry.execute("pattern", {"run_dir": run_dir})
# ---------------------------------------------------------------------------
@@ -537,6 +537,156 @@ def list_runs(limit: int = 20) -> str:
return json.dumps(items, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Trade journal tool
# ---------------------------------------------------------------------------
@mcp.tool
def analyze_trade_journal(
file_path: str,
analysis_type: str = "full",
filter_expr: str = "",
) -> str:
"""Analyze a user's trade journal (CSV/Excel broker export) and return
a trading profile plus behavior diagnostics.
Parses 同花顺 / 东方财富 / 富途 / generic formats (encoding auto-detected).
Output (JSON):
- profile: holding days, frequency, win rate, PnL ratio, top symbols,
market distribution, hourly distribution
- behaviors: disposition effect, overtrading, chasing momentum,
anchoring (each with severity + numeric evidence)
Args:
file_path: Absolute path to the uploaded CSV/Excel file.
analysis_type: "full" | "profile" | "behavior" | "strategy".
filter_expr: Optional filter (e.g. "2026-01 to 2026-03",
"symbol=600519.SH", "market=china_a").
"""
registry = _get_registry()
return registry.execute("analyze_trade_journal", {
"file_path": file_path,
"analysis_type": analysis_type,
"filter_expr": filter_expr,
})
# ---------------------------------------------------------------------------
# Shadow Account tools (4)
# ---------------------------------------------------------------------------
@mcp.tool
def extract_shadow_strategy(
journal_path: str,
min_support: int = 3,
max_rules: int = 5,
) -> str:
"""Extract a Shadow Account profile (3-5 human-readable if-then rules)
from the user's profitable roundtrips in a trade journal.
Run `analyze_trade_journal` first if the journal hasn't been parsed.
Returns shadow_id + rules preview. Profile persists to
~/.vibe-trading/shadow_accounts/.
Args:
journal_path: Path to the CSV/Excel broker export.
min_support: Minimum profitable roundtrips required to back one rule.
max_rules: Maximum rules to return (typically 3-5).
"""
registry = _get_registry()
return registry.execute("extract_shadow_strategy", {
"journal_path": journal_path,
"min_support": min_support,
"max_rules": max_rules,
})
@mcp.tool
def run_shadow_backtest(
shadow_id: str,
window_start: str = "",
window_end: str = "",
markets: list[str] | None = None,
journal_path: str = "",
) -> str:
"""Run a multi-market backtest (A股/港股/美股/crypto) on a Shadow Account
profile and compute delta-PnL attribution vs the user's realized trades.
Requires `extract_shadow_strategy` to have run first.
Args:
shadow_id: ID returned by extract_shadow_strategy.
window_start: ISO date, default today-1y.
window_end: ISO date, default today.
markets: Subset of ["china_a", "hk", "us", "crypto"], default all four.
journal_path: Original journal path (enables attribution), optional.
"""
registry = _get_registry()
params: dict[str, Any] = {"shadow_id": shadow_id}
if window_start:
params["window_start"] = window_start
if window_end:
params["window_end"] = window_end
if markets:
params["markets"] = markets
if journal_path:
params["journal_path"] = journal_path
return registry.execute("run_shadow_backtest", params)
@mcp.tool
def render_shadow_report(
shadow_id: str,
include_today_signals: bool = True,
window_start: str = "",
window_end: str = "",
journal_path: str = "",
) -> str:
"""Render the Shadow Account HTML/PDF report (8 sections + charts) for
a shadow_id. If no cached backtest, one is run automatically.
Args:
shadow_id: Shadow Account ID.
include_today_signals: Include today's market scan section.
window_start: Optional backtest window override.
window_end: Optional backtest window override.
journal_path: Original journal path (for attribution), optional.
"""
registry = _get_registry()
params: dict[str, Any] = {
"shadow_id": shadow_id,
"include_today_signals": include_today_signals,
}
if window_start:
params["window_start"] = window_start
if window_end:
params["window_end"] = window_end
if journal_path:
params["journal_path"] = journal_path
return registry.execute("render_shadow_report", params)
@mcp.tool
def scan_shadow_signals(
shadow_id: str,
date: str = "",
per_market: int = 3,
) -> str:
"""List today's symbols that match the Shadow Account's entry cadence
(research use only — not a trade recommendation).
Args:
shadow_id: Shadow Account ID.
date: ISO YYYY-MM-DD target date, default today.
per_market: Max signals per market.
"""
registry = _get_registry()
params: dict[str, Any] = {"shadow_id": shadow_id, "per_market": per_market}
if date:
params["date"] = date
return registry.execute("scan_shadow_signals", params)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
+16 -1
View File
@@ -14,6 +14,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
@@ -33,8 +40,16 @@ ccxt>=4.0.0
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
+92 -10
View File
@@ -3,13 +3,20 @@
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 68 specialist skills, 21 tools, 5 data sources (with auto-fallback), and 29 multi-agent swarm teams.
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, 5 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,6 +52,21 @@ 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.
@@ -53,6 +74,19 @@ Decide which workflow to use based on the request:
- Output results as markdown tables. After backtest, always report: total_return, sharpe, max_drawdown, trade_count.
- 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 +100,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 +127,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 +165,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 +209,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 +242,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()
+533 -84
View File
@@ -1,13 +1,19 @@
"""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
@@ -28,6 +34,16 @@ TOKEN_THRESHOLD = int(os.getenv("TOKEN_THRESHOLD", "40000"))
KEEP_RECENT = 3
TOOL_RESULT_LIMIT = 10_000
# 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__)
@@ -58,9 +74,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:
@@ -80,6 +279,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.
@@ -89,6 +289,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
@@ -97,6 +298,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.
@@ -116,6 +319,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)
@@ -127,7 +335,8 @@ class AgentLoop:
state_store.save_request(run_dir, user_message, {"session_id": session_id})
context = ContextBuilder(self.registry, self.memory)
context = ContextBuilder(self.registry, self.memory,
persistent_memory=self._persistent_memory)
messages = context.build_messages(user_message, history)
react_trace: List[Dict[str, Any]] = []
@@ -157,8 +366,13 @@ 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)
@@ -189,58 +403,23 @@ class AgentLoop:
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}")
@@ -278,47 +457,323 @@ class AgentLoop:
"react_trace": react_trace,
}
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
def _run(tc_args: tuple) -> tuple:
tc, args = tc_args
t0 = _time.perf_counter()
result = self.registry.execute(tc.name, args)
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
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())})")
t0 = _time.perf_counter()
result = self.registry.execute(tc.name, args)
elapsed_ms = int((_time.perf_counter() - t0) * 1000)
self._finalize_tool_result(tc, result, elapsed_ms, context, messages, trace, react_trace, iteration)
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."""
@@ -328,12 +783,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
+12 -85
View File
@@ -1,7 +1,7 @@
"""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
@@ -12,53 +12,21 @@ from typing import Any, 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)}")
+36 -42
View File
@@ -53,36 +53,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]:
@@ -117,32 +88,45 @@ def _load_skill_dir(dir_path: Path) -> Optional[Skill]:
)
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 = [
@@ -176,6 +160,8 @@ class SkillsLoader:
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.
@@ -185,5 +171,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
+5 -20
View File
@@ -13,7 +13,7 @@ from typing import Any, Dict, Optional
from rich.console import Console
console = Console()
console = Console(stderr=True)
@dataclass
@@ -109,19 +109,6 @@ def _expand_artifacts_spec(spec: Dict[str, Any] | None) -> Dict[str, Dict[str, A
class Runner:
"""Execute entry scripts inside a run directory and collect outputs."""
_PROXY_ENV_KEYS = (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"GIT_HTTP_PROXY",
"GIT_HTTPS_PROXY",
"git_http_proxy",
"git_https_proxy",
)
def __init__(self, timeout: int = 300, artifacts_spec: Optional[Dict[str, Any]] = None) -> None:
"""Initialize runner.
@@ -147,6 +134,7 @@ class Runner:
try:
probe = subprocess.run(
[python_cmd, "-c", "import pandas,numpy; print('ok')"],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -201,13 +189,9 @@ class Runner:
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = str(pythonpath_extra) + (os.pathsep + existing if existing else "")
runtime_home = run_dir / ".home"
runtime_home.mkdir(parents=True, exist_ok=True)
env["HOME"] = str(runtime_home)
if os.name == "nt":
env["USERPROFILE"] = str(runtime_home)
# Preserve system proxy settings; data sources (OKX/yfinance) need network access
# NOTE: do NOT override HOME/USERPROFILE — data libraries (yfinance, akshare)
# cache downloads under ~/; overriding HOME causes full re-download every run.
return env
@@ -252,6 +236,7 @@ class Runner:
process = subprocess.run(
cmd,
cwd=str(effective_cwd),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
+3 -107
View File
@@ -1,19 +1,16 @@
"""Run state persistence: lightweight, no dependency on legacy agent types."""
"""Run state persistence: creates run directories and records status."""
from __future__ import annotations
import copy
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from typing import Any, Dict
class RunStateStore:
"""Run state store: records key files such as req/design/code/logs/review."""
"""Run state store: manages run directories and their lifecycle status."""
def create_run_dir(self, workspace: Path) -> Path:
"""Create a unique run directory.
@@ -48,59 +45,6 @@ class RunStateStore:
self._write_json(run_dir / "req.json", payload)
return payload
def save_planner_output(self, run_dir: Path, planner_output: Dict[str, Any]) -> Dict[str, Any]:
"""Save planner output.
Args:
run_dir: Run directory.
planner_output: Planner output dict.
Returns:
Saved payload.
"""
self._write_json(run_dir / "planner_output.json", planner_output)
return planner_output
def save_design(self, run_dir: Path, spec: Dict[str, Any], decision: Dict[str, Any]) -> None:
"""Save design spec and judge decision.
Args:
run_dir: Run directory.
spec: Strategy design spec.
decision: Judge decision result.
"""
self._write_json(run_dir / "design_spec.json", spec)
self._write_json(run_dir / "judge_decision.json", decision)
def save_rag_spec(
self,
run_dir: Path,
selection: Dict[str, Any],
spec: Dict[str, Any],
*,
candidates: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Save RAG results.
Args:
run_dir: Run directory.
selection: Selected API metadata.
spec: Enriched data API spec.
candidates: Candidate API list.
"""
enriched_spec = copy.deepcopy(spec) if spec else {}
enriched_spec["_rag_metadata"] = selection
spec_yaml = yaml.safe_dump(enriched_spec, allow_unicode=True, sort_keys=False)
(run_dir / "data_api_spec.yaml").write_text(spec_yaml, encoding="utf-8")
self._write_json(run_dir / "rag_metadata.json", selection)
if candidates:
candidate_yaml = yaml.safe_dump(
{"generated_at": datetime.now().isoformat(), "candidates": candidates},
allow_unicode=True,
sort_keys=False,
)
(run_dir / "rag_candidates.yaml").write_text(candidate_yaml, encoding="utf-8")
def mark_success(self, run_dir: Path) -> None:
"""Mark the run as successful.
@@ -118,54 +62,6 @@ class RunStateStore:
"""
self._write_json(run_dir / "state.json", {"status": "failed", "reason": reason})
# -----------------------------------------------------------------------
# Generic persistence: extract and save key artifacts by tool name
# -----------------------------------------------------------------------
_PERSIST_MAP: Dict[str, str] = {
"plan": "_persist_plan",
"search": "_persist_search",
"resolve": "_persist_resolve",
"design": "_persist_design",
}
def persist_tool_result(self, tool_name: str, result_data: Dict[str, Any], run_dir: Path) -> None:
"""Persist key artifacts by tool name.
Args:
tool_name: Tool name.
result_data: Parsed tool result dict.
run_dir: Run directory.
"""
method_name = self._PERSIST_MAP.get(tool_name)
if method_name:
method = getattr(self, method_name, None)
if method:
try:
method(result_data, run_dir)
except Exception:
pass
def _persist_plan(self, data: Dict[str, Any], run_dir: Path) -> None:
if data and "error" not in data:
self.save_planner_output(run_dir, data)
def _persist_search(self, data: Dict[str, Any], run_dir: Path) -> None:
if data.get("selections"):
sel = data["selections"][0] if data["selections"] else {}
spec = data.get("data_api_spec") or {}
self.save_rag_spec(run_dir, sel, spec, candidates=data.get("candidates"))
def _persist_resolve(self, data: Dict[str, Any], run_dir: Path) -> None:
if data and data.get("status") == "ok":
self._write_json(run_dir / "data_config.json", data)
def _persist_design(self, data: Dict[str, Any], run_dir: Path) -> None:
spec = data.get("spec") or {}
decision = data.get("judge_decision") or {}
if spec:
self.save_design(run_dir, spec, decision)
@staticmethod
def _write_json(path: Path, data: Any) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+1
View File
@@ -0,0 +1 @@
"""Persistent cross-session memory system."""
+224
View File
@@ -0,0 +1,224 @@
"""PersistentMemory: file-based cross-session memory, zero external dependencies.
Storage layout:
~/.vibe-trading/memory/
+-- MEMORY.md # Index (< 200 lines)
+-- user_prefs.md # Individual memory entries with YAML frontmatter
+-- project_btc.md
+-- ...
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from src.agent.frontmatter import parse_frontmatter as _parse_frontmatter
from typing import List, Optional
MEMORY_BASE = Path.home() / ".vibe-trading" / "memory"
MAX_INDEX_LINES = 200
MAX_ENTRY_CHARS = 8000
MAX_RESULTS = 5
METADATA_WEIGHT = 2.0
@dataclass(frozen=True)
class MemoryEntry:
"""A single memory entry on disk.
Attributes:
path: File path.
title: Memory title.
description: One-line description (used for retrieval scoring).
memory_type: Category (user/feedback/project/reference).
body: Body text content.
modified_at: File modification timestamp.
"""
path: Path
title: str
description: str
memory_type: str
body: str
modified_at: float
def _tokenize(text: str) -> set[str]:
"""Split text into searchable tokens.
ASCII words >= 3 chars + CJK individual characters.
Args:
text: Input text.
Returns:
Set of tokens.
"""
ascii_tokens = set(re.findall(r"[a-zA-Z0-9_]{3,}", text.lower()))
cjk_tokens = set(re.findall(r"[\u4e00-\u9fff\u3400-\u4dbf]", text))
return ascii_tokens | cjk_tokens
class PersistentMemory:
"""File-based persistent memory that survives across sessions.
Design:
- Frozen snapshot injected into system prompt at session start (preserves prompt cache).
- Disk writes via add()/remove() update files immediately but do NOT change the snapshot.
- Next session picks up the updated state.
Attributes:
snapshot: Frozen memory index text for system prompt injection.
"""
def __init__(self, memory_dir: Optional[Path] = None) -> None:
"""Initialize PersistentMemory.
Args:
memory_dir: Override memory directory (default: ~/.vibe-trading/memory/).
"""
self._dir = memory_dir or MEMORY_BASE
self._dir.mkdir(parents=True, exist_ok=True)
self._index_path = self._dir / "MEMORY.md"
self._snapshot: str = ""
self._load_snapshot()
def _load_snapshot(self) -> None:
"""Load index as frozen snapshot. Called once at init."""
if self._index_path.exists():
try:
text = self._index_path.read_text(encoding="utf-8")
lines = text.split("\n")[:MAX_INDEX_LINES]
self._snapshot = "\n".join(lines)
except OSError:
self._snapshot = ""
@property
def snapshot(self) -> str:
"""Frozen memory index for system prompt injection."""
return self._snapshot
def _scan_entries(self) -> List[MemoryEntry]:
"""Scan all .md files (except MEMORY.md) and parse frontmatter.
Returns:
List of parsed memory entries.
"""
entries: List[MemoryEntry] = []
for path in sorted(self._dir.glob("*.md")):
if path.name == "MEMORY.md":
continue
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
meta, body = _parse_frontmatter(text)
entries.append(MemoryEntry(
path=path,
title=meta.get("name", path.stem),
description=meta.get("description", ""),
memory_type=meta.get("type", "project"),
body=body[:MAX_ENTRY_CHARS],
modified_at=path.stat().st_mtime,
))
return entries
def find_relevant(self, query: str, max_results: int = MAX_RESULTS) -> List[MemoryEntry]:
"""Keyword search across all memory entries.
Scoring: metadata_hits * 2.0 + body_hits * 1.0.
Args:
query: Search query.
max_results: Maximum entries to return.
Returns:
Top-scoring memory entries.
"""
query_tokens = _tokenize(query)
if not query_tokens:
return []
scored: list[tuple[float, MemoryEntry]] = []
for entry in self._scan_entries():
meta_tokens = _tokenize(f"{entry.title} {entry.description}")
body_tokens = _tokenize(entry.body)
score = len(query_tokens & meta_tokens) * METADATA_WEIGHT + len(query_tokens & body_tokens)
if score > 0:
scored.append((score, entry))
scored.sort(key=lambda x: (-x[0], -x[1].modified_at))
return [entry for _, entry in scored[:max_results]]
def add(self, name: str, content: str, memory_type: str = "project",
description: str = "") -> Path:
"""Save a new memory entry and update the index.
Args:
name: Memory name (used as filename slug).
content: Memory body text.
memory_type: One of user/feedback/project/reference.
description: One-line description for retrieval scoring.
Returns:
Path to the created memory file.
"""
slug = re.sub(r"[^a-z0-9_-]", "_", name.lower().strip())[:60]
filename = f"{memory_type}_{slug}.md"
path = self._dir / filename
safe_name = name.replace("\n", " ").replace("\r", " ")
safe_desc = (description or name).replace("\n", " ").replace("\r", " ")
frontmatter = (
f"---\nname: {safe_name}\n"
f"description: {safe_desc}\n"
f"type: {memory_type}\n---\n\n"
f"{content}"
)
path.write_text(frontmatter, encoding="utf-8")
self._update_index(name, filename, description or name)
return path
def remove(self, name: str) -> bool:
"""Remove a memory entry by name.
Args:
name: Memory name to remove.
Returns:
True if found and removed.
"""
for entry in self._scan_entries():
if entry.title == name:
entry.path.unlink(missing_ok=True)
self._rebuild_index()
return True
return False
def _update_index(self, title: str, filename: str, description: str) -> None:
"""Append or update an entry in MEMORY.md."""
new_line = f"- [{title}]({filename}) — {description}"
if self._index_path.exists():
lines = self._index_path.read_text(encoding="utf-8").split("\n")
updated = False
for i, line in enumerate(lines):
if f"[{title}]" in line:
lines[i] = new_line
updated = True
break
if not updated:
lines.append(new_line)
text = "\n".join(lines[:MAX_INDEX_LINES])
else:
text = new_line
self._index_path.write_text(text, encoding="utf-8")
def _rebuild_index(self) -> None:
"""Rebuild MEMORY.md from all existing entry files."""
entries = self._scan_entries()
lines = [f"- [{e.title}]({e.path.name}) — {e.description}" for e in entries]
self._index_path.write_text("\n".join(lines[:MAX_INDEX_LINES]), encoding="utf-8")
+27 -17
View File
@@ -11,6 +11,18 @@ from typing import Any, Dict, List, Optional
from src.providers.llm import build_llm
def _dedupe_finish_reason(raw: str) -> str:
"""Relays (OpenRouter) emit finish_reason per chunk; AIMessageChunk.__add__
concatenates into 'stopstop', 'tool_callstool_calls', etc. Return the
canonical suffix so ReAct equality checks survive.
"""
return next(
(m for m in ("tool_calls", "function_call", "content_filter", "length", "stop")
if raw.endswith(m)),
raw,
)
@dataclass
class ToolCallRequest:
"""Tool call request returned by the LLM.
@@ -38,6 +50,7 @@ class LLMResponse:
content: Optional[str] = None
tool_calls: List[ToolCallRequest] = field(default_factory=list)
reasoning_content: Optional[str] = None
finish_reason: str = "stop"
@property
@@ -133,22 +146,19 @@ class ChatLLM:
@staticmethod
def _parse_response(ai_message: Any) -> LLMResponse:
"""Convert a LangChain AIMessage to ``LLMResponse``.
"""Convert a LangChain AIMessage (or AIMessageChunk) to ``LLMResponse``.
Args:
ai_message: LangChain AIMessage instance.
Returns:
``LLMResponse``.
Single source for reasoning: ``additional_kwargs["reasoning_content"]``,
populated by ``ChatOpenAIWithReasoning`` on both stream and non-stream paths.
"""
content = ai_message.content if hasattr(ai_message, "content") else None
raw_calls = getattr(ai_message, "tool_calls", None) or []
tool_calls = []
for tc in raw_calls:
tool_calls.append(ToolCallRequest(
id=tc.get("id", ""),
name=tc.get("name", ""),
arguments=tc.get("args", {}),
))
finish = getattr(ai_message, "response_metadata", {}).get("finish_reason", "stop")
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish)
return LLMResponse(
content=ai_message.content,
tool_calls=[
ToolCallRequest(id=tc["id"], name=tc["name"], arguments=tc["args"])
for tc in ai_message.tool_calls
],
reasoning_content=ai_message.additional_kwargs.get("reasoning_content"),
finish_reason=_dedupe_finish_reason(
ai_message.response_metadata.get("finish_reason", "stop")
),
)
+81 -5
View File
@@ -17,6 +17,75 @@ try:
except ImportError:
ChatOpenAI = None # type: ignore
if ChatOpenAI is not None:
class ChatOpenAIWithReasoning(ChatOpenAI): # type: ignore[misc,valid-type]
"""ChatOpenAI that preserves provider reasoning across invoke + stream.
langchain-openai 0.3.x drops non-standard fields in three paths:
* _convert_dict_to_message — invoke / ainvoke (inbound)
* _convert_delta_to_message_chunk — stream / astream (inbound)
* _convert_message_to_dict — request serialization (outbound)
Moonshot/DeepSeek emit `reasoning_content`; OpenRouter relays as
`reasoning`. Inbound paths normalize to additional_kwargs["reasoning_content"];
outbound path re-injects it so strict providers (kimi-k2.5) accept
multi-turn continuations.
"""
@staticmethod
def _capture(src: Any, msg: Any) -> None:
if value := src.get("reasoning_content") or src.get("reasoning"):
msg.additional_kwargs["reasoning_content"] = value
def _create_chat_result(self, response, generation_info=None): # type: ignore[override]
result = super()._create_chat_result(response, generation_info)
raw = response if isinstance(response, dict) else response.model_dump()
for gen, choice in zip(result.generations, raw["choices"]):
self._capture(choice["message"], gen.message)
return result
def _convert_chunk_to_generation_chunk( # type: ignore[override]
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: Optional[dict],
):
gen = super()._convert_chunk_to_generation_chunk(
chunk, default_chunk_class, base_generation_info
)
if gen is None:
return None
choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices")
if choices:
self._capture(choices[0]["delta"], gen.message)
return gen
def _get_request_payload( # type: ignore[override]
self,
input_: Any,
*,
stop: Optional[list[str]] = None,
**kwargs: Any,
) -> dict:
"""Re-inject reasoning_content and normalize assistant content.
LangChain strips ``reasoning_content`` when serializing AIMessages
back to OpenAI wire format. Moonshot kimi-k2.5 also rejects
assistant turns where ``content`` is null or ``reasoning_content``
is absent, breaking ReAct continuations after a tool call (#39).
"""
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
messages = super()._convert_input(input_).to_messages()
for i, m in enumerate(payload["messages"]):
if m.get("role") != "assistant":
continue
if m.get("content") is None:
m["content"] = ""
m["reasoning_content"] = messages[i].additional_kwargs.get("reasoning_content", "")
return payload
else:
ChatOpenAIWithReasoning = None # type: ignore
AGENT_DIR = Path(__file__).resolve().parents[2]
# .env search order: ~/.vibe-trading/.env → agent/.env → $CWD/.env
@@ -79,6 +148,7 @@ def _sync_provider_env() -> None:
"moonshot": ("MOONSHOT_API_KEY", "MOONSHOT_BASE_URL"),
"minimax": ("MINIMAX_API_KEY", "MINIMAX_BASE_URL"),
"mimo": ("MIMO_API_KEY", "MIMO_BASE_URL"),
"zai": ("ZAI_API_KEY", "ZAI_BASE_URL"),
"ollama": (None, "OLLAMA_BASE_URL"),
}
@@ -121,14 +191,20 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any
if not name:
raise RuntimeError("LANGCHAIN_MODEL_NAME is not set")
temperature = float(os.getenv("LANGCHAIN_TEMPERATURE", "0.0"))
timeout = int(os.getenv("TIMEOUT_SECONDS", "120"))
max_retries = int(os.getenv("MAX_RETRIES", "2"))
return ChatOpenAI(
# MiniMax requires temperature in (0.0, 1.0] — clamp to 0.01 when the
# default 0.0 is used to avoid an API validation error.
if os.getenv("LANGCHAIN_PROVIDER", "openai").lower() == "minimax" and temperature <= 0.0:
temperature = 0.01
# Optional reasoning activation for relays requiring opt-in (e.g. OpenRouter).
# Moonshot/DeepSeek official APIs emit reasoning by default and ignore this field.
effort = os.getenv("LANGCHAIN_REASONING_EFFORT", "").strip().lower()
return ChatOpenAIWithReasoning(
model=name,
temperature=temperature,
timeout=timeout,
max_retries=max_retries,
timeout=int(os.getenv("TIMEOUT_SECONDS", "120")),
max_retries=int(os.getenv("MAX_RETRIES", "2")),
callbacks=callbacks,
extra_body={"reasoning": {"effort": effort}} if effort else None,
)
+343
View File
@@ -0,0 +1,343 @@
"""SQLite FTS5 session search index for cross-session full-text search.
Stores an inverted index of all conversation messages. The primary data
remains in the file-based SessionStore; this module provides a fast search
layer on top.
Database location: ~/.vibe-trading/sessions.db (WAL mode for concurrent reads).
"""
from __future__ import annotations
import json
import logging
import sqlite3
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
_DB_PATH = Path.home() / ".vibe-trading" / "sessions.db"
@dataclass(frozen=True)
class SearchMatch:
"""A single search result from the FTS5 index.
Attributes:
session_id: Session ID.
title: Session title.
started_at: Human-readable start time.
message_count: Total messages in the session.
snippet: FTS5 snippet with match highlights (>>> <<<).
rank: FTS5 relevance rank (lower is better).
"""
session_id: str
title: str
started_at: str
message_count: int
snippet: str
rank: float
def to_dict(self) -> dict:
"""Serialize to JSON-safe dict."""
return {
"session_id": self.session_id,
"title": self.title,
"started_at": self.started_at,
"message_count": self.message_count,
"snippet": self.snippet,
}
class SessionSearchIndex:
"""SQLite FTS5 index for cross-session search.
Supports:
- Indexing individual messages as they arrive
- Full-text search with relevance ranking
- Bulk reindex from the file-based SessionStore
"""
def __init__(self, db_path: Path = _DB_PATH) -> None:
"""Initialize the search index.
Args:
db_path: Path to SQLite database file.
"""
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn: Optional[sqlite3.Connection] = None
self._init_db()
def _get_conn(self) -> sqlite3.Connection:
"""Get or create the SQLite connection (WAL mode)."""
if self._conn is None:
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
return self._conn
def _init_db(self) -> None:
"""Create tables and FTS5 virtual table if they don't exist."""
conn = self._get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
started_at REAL NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_name TEXT,
timestamp REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id);
""")
# FTS5 virtual table — create separately (not inside executescript with IF NOT EXISTS
# because FTS5 syntax varies across SQLite versions)
try:
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
USING fts5(content, content=messages, content_rowid=id)
""")
except sqlite3.OperationalError:
pass # already exists or FTS5 not available
# Auto-sync triggers
for trigger_sql in [
"""CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END""",
"""CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES ('delete', old.id, old.content);
END""",
]:
try:
conn.execute(trigger_sql)
except sqlite3.OperationalError:
pass
conn.commit()
def index_session(self, session_id: str, title: str = "") -> None:
"""Upsert a session record.
Args:
session_id: Session ID.
title: Session title.
"""
conn = self._get_conn()
conn.execute(
"INSERT OR REPLACE INTO sessions (id, title, started_at, message_count) "
"VALUES (?, ?, ?, COALESCE((SELECT message_count FROM sessions WHERE id = ?), 0))",
(session_id, title, time.time(), session_id),
)
conn.commit()
def index_message(self, session_id: str, role: str, content: str,
tool_name: Optional[str] = None) -> None:
"""Index a single message.
Args:
session_id: Session ID.
role: Message role (user/assistant/tool).
content: Message text.
tool_name: Tool name if this is a tool result.
"""
if not content or not content.strip():
return
conn = self._get_conn()
conn.execute(
"INSERT INTO messages (session_id, role, content, tool_name, timestamp) "
"VALUES (?, ?, ?, ?, ?)",
(session_id, role, content[:50_000], tool_name, time.time()),
)
conn.execute(
"UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
(session_id,),
)
conn.commit()
@staticmethod
def _sanitize_fts_query(query: str) -> str:
"""Sanitize a user query for FTS5 MATCH syntax.
- Splits on non-alphanumeric/CJK to extract tokens
- Joins with OR so any-word-matches (not all-words-required)
- Quotes each token to prevent FTS5 operator interpretation
Args:
query: Raw user query string.
Returns:
FTS5-safe MATCH expression.
"""
import re as _re
# Extract alphanumeric tokens (3+ chars) and CJK characters
tokens = _re.findall(r"[a-zA-Z0-9_]{2,}|[\u4e00-\u9fff\u3400-\u4dbf]", query)
if not tokens:
return '""'
# Quote each token and join with OR for broader matching
return " OR ".join(f'"{t}"' for t in tokens)
def search(self, query: str, max_sessions: int = 3) -> List[SearchMatch]:
"""Full-text search across all sessions.
Args:
query: Search query (keywords or phrase).
max_sessions: Maximum number of distinct sessions to return.
Returns:
List of SearchMatch results, grouped by session, ranked by relevance.
"""
conn = self._get_conn()
fts_query = self._sanitize_fts_query(query)
try:
cursor = conn.execute(
"""
SELECT
m.session_id,
s.title,
s.started_at,
s.message_count,
snippet(messages_fts, 0, '>>>', '<<<', '...', 64) AS snippet,
rank
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_id
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(fts_query, max_sessions * 5),
)
except sqlite3.OperationalError as exc:
logger.warning("FTS5 search failed: %s", exc)
return []
seen: dict[str, SearchMatch] = {}
for row in cursor.fetchall():
sid = row[0]
if sid in seen:
continue
seen[sid] = SearchMatch(
session_id=row[0],
title=row[1] or "(untitled)",
started_at=self._format_time(row[2]),
message_count=row[3],
snippet=row[4],
rank=row[5],
)
if len(seen) >= max_sessions:
break
return list(seen.values())
def reindex_from_store(self, store_base_dir: Path) -> int:
"""Rebuild the entire index from file-based session store.
Args:
store_base_dir: Root directory of the SessionStore (contains session subdirs).
Returns:
Number of messages indexed.
"""
if not store_base_dir.exists():
return 0
conn = self._get_conn()
conn.execute("DELETE FROM messages")
conn.execute("DELETE FROM sessions")
try:
conn.execute("INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')")
except sqlite3.OperationalError:
pass
conn.commit()
count = 0
for session_dir in store_base_dir.iterdir():
if not session_dir.is_dir():
continue
session_file = session_dir / "session.json"
messages_file = session_dir / "messages.jsonl"
if not session_file.exists():
continue
try:
session_data = json.loads(session_file.read_text(encoding="utf-8"))
sid = session_data.get("session_id", session_dir.name)
title = session_data.get("title", "")
try:
ts = datetime.fromisoformat(session_data.get("created_at", "")).timestamp()
except (ValueError, TypeError):
ts = time.time()
self.index_session(sid, title)
if messages_file.exists():
for line in messages_file.read_text(encoding="utf-8").strip().splitlines():
if not line.strip():
continue
try:
msg = json.loads(line)
role = msg.get("role", "")
content = msg.get("content", "")
if content and role in ("user", "assistant"):
self.index_message(sid, role, content)
count += 1
except json.JSONDecodeError:
continue
except Exception as exc:
logger.warning("Failed to index session %s: %s", session_dir.name, exc)
return count
@staticmethod
def _format_time(epoch: float) -> str:
"""Format epoch timestamp to human-readable string."""
try:
return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")
except (OSError, ValueError):
return "unknown"
def close(self) -> None:
"""Close the database connection."""
if self._conn:
self._conn.close()
self._conn = None
import threading as _threading
_shared_index: Optional[SessionSearchIndex] = None
_shared_lock = _threading.Lock()
def get_shared_index() -> SessionSearchIndex:
"""Return a process-wide singleton SessionSearchIndex.
Thread-safe via double-checked locking. Used by both
SessionService (indexing) and SessionSearchTool (searching)
so they share one SQLite connection.
"""
global _shared_index
if _shared_index is None:
with _shared_lock:
if _shared_index is None:
_shared_index = SessionSearchIndex()
return _shared_index
+10 -3
View File
@@ -21,6 +21,7 @@ from src.session.models import (
Message,
Session,
)
from src.session.search import get_shared_index
from src.session.store import SessionStore
@@ -50,6 +51,7 @@ class SessionService:
self.event_bus = event_bus
self.runs_dir = runs_dir
self._active_loops: Dict[str, "AgentLoop"] = {}
self._search_index = get_shared_index()
def create_session(self, title: str = "", config: Optional[Dict[str, Any]] = None) -> Session:
"""Create a new session.
@@ -63,6 +65,7 @@ class SessionService:
"""
session = Session(title=title, config=config or {})
self.store.create_session(session)
self._search_index.index_session(session.session_id, title)
self.event_bus.emit(session.session_id, "session.created", {"session_id": session.session_id, "title": title})
return session
@@ -96,6 +99,7 @@ class SessionService:
message = Message(session_id=session_id, role=role, content=content)
self.store.append_message(message)
self._search_index.index_message(session_id, role, content)
self.event_bus.emit(session_id, "message.received", {"message_id": message.message_id, "role": role, "content": content})
if role != "user":
@@ -200,6 +204,7 @@ class SessionService:
metadata=reply_metadata,
)
self.store.append_message(reply)
self._search_index.index_message(session.session_id, "assistant", reply.content)
self.event_bus.emit(
session.session_id,
"attempt.completed" if attempt.status == AttemptStatus.COMPLETED else "attempt.failed",
@@ -225,9 +230,10 @@ class SessionService:
from src.tools import build_registry
from src.providers.chat import ChatLLM
from src.agent.loop import AgentLoop
from src.memory.persistent import PersistentMemory
registry = build_registry()
llm = ChatLLM()
pm = PersistentMemory()
session_id = attempt.session_id
attempt_id = attempt.attempt_id
@@ -238,10 +244,11 @@ class SessionService:
self.event_bus.emit(session_id, event_type, data)
agent = AgentLoop(
registry=registry,
registry=build_registry(persistent_memory=pm),
llm=llm,
event_callback=event_callback,
max_iterations=50,
persistent_memory=pm,
)
self._active_loops[session_id] = agent
@@ -249,7 +256,7 @@ class SessionService:
history = self._convert_messages_to_history(messages) if messages else None
try:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
_AGENT_EXECUTOR,
lambda: agent.run(
+53
View File
@@ -0,0 +1,53 @@
"""Shadow Account — extract a user's profitable pattern as a re-runnable shadow.
Public API (for tools/tests/external callers):
extract_shadow_profile: journal → ShadowProfile
ShadowProfile / ShadowRule / ShadowBacktestResult / AttributionBreakdown
storage: save_profile / load_profile / find_by_journal_hash
"""
from src.shadow_account.backtester import (
SUPPORTED_MARKETS,
run_shadow_backtest,
select_multi_market_codes,
)
from src.shadow_account.codegen import (
render_config,
render_signal_engine,
validate_generated,
write_run_dir,
)
from src.shadow_account.extractor import extract_shadow_profile
from src.shadow_account.reporter import render_shadow_report
from src.shadow_account.models import (
AttributionBreakdown,
ShadowBacktestResult,
ShadowProfile,
ShadowRule,
)
from src.shadow_account.storage import (
find_by_journal_hash,
load_profile,
new_shadow_id,
save_profile,
)
__all__ = [
"AttributionBreakdown",
"SUPPORTED_MARKETS",
"ShadowBacktestResult",
"ShadowProfile",
"ShadowRule",
"extract_shadow_profile",
"find_by_journal_hash",
"load_profile",
"new_shadow_id",
"render_config",
"render_shadow_report",
"render_signal_engine",
"run_shadow_backtest",
"save_profile",
"select_multi_market_codes",
"validate_generated",
"write_run_dir",
]
+529
View File
@@ -0,0 +1,529 @@
"""Shadow Account — multi-market backtest driver + delta-PnL attribution.
Responsibilities:
1. Pick representative symbols per market based on the user's preferred
markets (with a liquid-basket fallback).
2. Render a run_dir (via ``codegen.write_run_dir``) and call
``src.tools.backtest_tool.run_backtest``.
3. Parse the emitted artifacts (metrics JSON / equity CSV) back into a
``ShadowBacktestResult``.
4. Compute attribution: noise trades, missed signals, early/late exits,
overtrading — each as signed PnL.
The attribution algorithm is deliberately arithmetic-only: no LLM, no
simulation rebuild. This keeps the numbers auditable and reproducible.
"""
from __future__ import annotations
import json
import logging
from dataclasses import replace
from pathlib import Path
from typing import Any
import pandas as pd
from src.shadow_account.codegen import write_run_dir
from src.shadow_account.models import (
AttributionBreakdown,
ShadowBacktestResult,
ShadowProfile,
)
from src.shadow_account.storage import runs_dir
from src.tools.trade_journal_parsers import parse_file, records_to_dataframe
from src.tools.trade_journal_tool import pair_trades_fifo
logger = logging.getLogger(__name__)
SUPPORTED_MARKETS: tuple[str, ...] = ("china_a", "hk", "us", "crypto")
_LIQUID_BASKETS: dict[str, list[str]] = {
"china_a": ["600519.SH", "000858.SZ", "300750.SZ", "600036.SH", "000001.SZ"],
"hk": ["00700.HK", "09988.HK", "03690.HK", "00388.HK", "01810.HK"],
"us": ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL"],
"crypto": ["BTC-USDT", "ETH-USDT", "SOL-USDT", "BNB-USDT", "XRP-USDT"],
}
# ---------------- Code selection ----------------
def select_multi_market_codes(
profile: ShadowProfile,
*,
per_market_count: int = 5,
markets: tuple[str, ...] = SUPPORTED_MARKETS,
) -> dict[str, list[str]]:
"""Pick representative tickers for each target market.
Priority:
1. If the profile's source_market is in the target set and is in the
liquid basket, surface it first.
2. Fill remaining markets from their liquid basket.
Args:
profile: Shadow profile (source_market guides prioritization).
per_market_count: Cap per market (clamped to basket size).
markets: Markets to include.
Returns:
Dict market → list of codes, non-empty for every requested market
that has a known basket.
"""
selection: dict[str, list[str]] = {}
for market in markets:
basket = _LIQUID_BASKETS.get(market)
if not basket:
continue
selection[market] = basket[: max(1, per_market_count)]
return selection
def flatten_codes(selection: dict[str, list[str]]) -> list[str]:
"""Flatten per-market selection into a unique, order-preserving code list."""
seen: set[str] = set()
out: list[str] = []
for codes in selection.values():
for c in codes:
if c not in seen:
out.append(c)
seen.add(c)
return out
# ---------------- Backtest execution ----------------
def run_shadow_backtest(
profile: ShadowProfile,
*,
window_start: str,
window_end: str,
markets: tuple[str, ...] = SUPPORTED_MARKETS,
per_market_count: int = 5,
source: str = "auto",
initial_capital: float = 1_000_000.0,
journal_path: str | Path | None = None,
run_backtest_fn: Any | None = None,
) -> ShadowBacktestResult:
"""Drive a multi-market backtest from a ShadowProfile.
Args:
profile: ShadowProfile to replay.
window_start / window_end: ISO dates.
markets: Target market buckets.
per_market_count: Codes per market.
source: Loader source (``auto`` routes by suffix).
initial_capital: Starting cash.
journal_path: Original journal path (used to compute attribution
against the user's realized trades). Attribution is skipped if
None or the file is missing.
run_backtest_fn: Injection point for tests — callable(run_dir_str)
returning the same JSON payload as
``src.tools.backtest_tool.run_backtest``. Defaults to the real
entrypoint.
Returns:
ShadowBacktestResult with per-market + combined metrics, equity
curves (when emitted), and attribution (zeros when unavailable).
"""
selection = select_multi_market_codes(
profile, per_market_count=per_market_count, markets=markets,
)
codes = flatten_codes(selection)
if not codes:
raise ValueError("No codes available for requested markets.")
run_dir = runs_dir(profile.shadow_id)
write_run_dir(
profile,
run_dir,
codes=codes,
start_date=window_start,
end_date=window_end,
source=source,
initial_capital=initial_capital,
)
backtest_fn = run_backtest_fn or _default_run_backtest_fn()
payload = json.loads(backtest_fn(str(run_dir)))
per_market, combined, equity_curves = _summarize_artifacts(
payload=payload, run_dir=run_dir, selection=selection,
)
attribution, shadow_pnl, real_pnl = _attribution_or_zero(
profile=profile,
journal_path=journal_path,
combined=combined,
)
result = ShadowBacktestResult(
shadow_id=profile.shadow_id,
per_market=per_market,
combined=combined,
equity_curves=equity_curves,
attribution=attribution,
shadow_total_pnl=shadow_pnl,
real_total_pnl=real_pnl,
delta_pnl=round(shadow_pnl - real_pnl, 2),
)
_cache_result(run_dir, result)
return result
def load_cached_result(shadow_id: str) -> ShadowBacktestResult | None:
"""Load the last cached backtest result for a shadow, if any."""
cache_path = runs_dir(shadow_id) / "shadow_result.json"
if not cache_path.exists():
return None
try:
data = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
attr = data.get("attribution") or {}
return ShadowBacktestResult(
shadow_id=data["shadow_id"],
per_market=data.get("per_market") or {},
combined=data.get("combined") or {},
equity_curves={
k: [(str(pt[0]), float(pt[1])) for pt in v]
for k, v in (data.get("equity_curves") or {}).items()
},
attribution=AttributionBreakdown(
missed_signals_pnl=float(attr.get("missed_signals_pnl", 0.0)),
noise_trades_pnl=float(attr.get("noise_trades_pnl", 0.0)),
early_exit_pnl=float(attr.get("early_exit_pnl", 0.0)),
late_exit_pnl=float(attr.get("late_exit_pnl", 0.0)),
overtrading_pnl=float(attr.get("overtrading_pnl", 0.0)),
counterfactual_trades=tuple(attr.get("counterfactual_trades") or ()),
),
shadow_total_pnl=float(data.get("shadow_total_pnl", 0.0)),
real_total_pnl=float(data.get("real_total_pnl", 0.0)),
delta_pnl=float(data.get("delta_pnl", 0.0)),
)
def _cache_result(run_dir: Path, result: ShadowBacktestResult) -> None:
"""Persist a ShadowBacktestResult so downstream tools don't re-backtest."""
from dataclasses import asdict as _asdict
payload = _asdict(result)
try:
(run_dir / "shadow_result.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
except OSError as exc: # pragma: no cover — disk failure is non-fatal
logger.warning("Failed to cache shadow result: %s", exc)
def _default_run_backtest_fn():
from src.tools.backtest_tool import run_backtest
return run_backtest
# ---------------- Artifact parsing ----------------
def _summarize_artifacts(
*,
payload: dict[str, Any],
run_dir: Path,
selection: dict[str, list[str]],
) -> tuple[dict[str, dict[str, float]], dict[str, float], dict[str, list[tuple[str, float]]]]:
"""Turn raw backtest output into (per_market, combined, equity_curves).
Gracefully degrades when artifacts are missing (e.g. data fetch failed):
returns empty dicts and a combined dict containing the error reason.
"""
artifacts = payload.get("artifacts") or {}
status = payload.get("status", "error")
combined = _load_metrics(artifacts, run_dir)
equity_points = _load_equity_curve(artifacts, run_dir)
# Only surface an error when we genuinely have no metrics. A non-ok
# status with usable metrics typically means a transient data-source
# warning (e.g. yfinance flaked on one market) — downgrading to ok is
# more faithful to what the user actually has.
if not combined and status != "ok":
combined = {"error": payload.get("stderr", "")[-200:] or "backtest failed"}
per_market = _per_market_breakdown(combined, selection)
equity_curves = {"combined": equity_points} if equity_points else {}
return per_market, combined, equity_curves
def _load_metrics(artifacts: dict[str, str], run_dir: Path) -> dict[str, float]:
"""Pull a numeric metrics dict from the run_dir.
Looks for ``metrics.json`` first (preferred), then ``metrics.csv``.
Unknown shape → empty dict (caller treats as failure).
"""
for key in ("metrics.json", "metrics", "metrics.csv"):
path_str = artifacts.get(key)
if not path_str:
continue
path = Path(path_str)
if not path.exists():
continue
try:
if path.suffix == ".json":
data = json.loads(path.read_text(encoding="utf-8"))
return _coerce_numeric(data)
if path.suffix == ".csv":
df = pd.read_csv(path)
if df.empty:
return {}
row = df.iloc[-1].to_dict()
return _coerce_numeric(row)
except (OSError, json.JSONDecodeError, ValueError) as exc:
logger.warning("Failed to parse metrics %s: %s", path, exc)
# Fallback: scan run_dir for a metrics file.
for path in run_dir.glob("**/metrics.*"):
try:
if path.suffix == ".json":
return _coerce_numeric(json.loads(path.read_text(encoding="utf-8")))
if path.suffix == ".csv":
df = pd.read_csv(path)
if not df.empty:
return _coerce_numeric(df.iloc[-1].to_dict())
except Exception:
continue
return {}
def _load_equity_curve(artifacts: dict[str, str], run_dir: Path) -> list[tuple[str, float]]:
"""Load the equity curve as [(iso_date, equity), ...]."""
candidates: list[Path] = []
for key in ("equity.csv", "equity", "equity_curve.csv"):
path_str = artifacts.get(key)
if path_str:
candidates.append(Path(path_str))
candidates.extend(run_dir.glob("**/equity*.csv"))
seen: set[Path] = set()
for path in candidates:
if path in seen or not path.exists():
continue
seen.add(path)
try:
df = pd.read_csv(path)
except (OSError, pd.errors.ParserError) as exc:
logger.warning("Failed to read equity csv %s: %s", path, exc)
continue
if df.empty:
continue
date_col = next((c for c in df.columns if c.lower() in ("date", "datetime", "timestamp")), df.columns[0])
equity_col = next(
(c for c in df.columns if c.lower() in ("equity", "equity_curve", "value", "net_value")),
df.columns[-1],
)
return [(str(row[date_col]), float(row[equity_col])) for _, row in df.iterrows()]
return []
def _coerce_numeric(data: dict[str, Any]) -> dict[str, float]:
"""Keep only the scalar numeric fields from a metrics dict."""
out: dict[str, float] = {}
for key, value in data.items():
if isinstance(value, bool):
continue
if isinstance(value, (int, float)):
out[str(key)] = float(value)
return out
def _per_market_breakdown(
combined: dict[str, float],
selection: dict[str, list[str]],
) -> dict[str, dict[str, float]]:
"""Project the combined metrics into each requested market.
v1 limitation: the runner emits a single combined metrics file regardless
of cross-market composition, so per-market rows reuse the combined
metrics. This is faithful (same backtest) but intentionally lossy; a
follow-up can split equity by market attribution.
"""
if not combined:
return {market: {} for market in selection}
return {market: dict(combined) for market in selection}
# ---------------- Attribution ----------------
def _attribution_or_zero(
*,
profile: ShadowProfile,
journal_path: str | Path | None,
combined: dict[str, float],
) -> tuple[AttributionBreakdown, float, float]:
"""Compute attribution if the journal is available, else return zeros."""
shadow_pnl = float(combined.get("total_return_abs") or combined.get("total_pnl") or 0.0)
if not journal_path:
return _zero_attribution(), shadow_pnl, 0.0
path = Path(journal_path)
if not path.exists():
return _zero_attribution(), shadow_pnl, 0.0
try:
_, records = parse_file(path)
trades_df = records_to_dataframe(records)
roundtrips = pair_trades_fifo(trades_df)
except Exception as exc:
logger.warning("Attribution skipped — journal parse failed: %s", exc)
return _zero_attribution(), shadow_pnl, 0.0
if not roundtrips:
return _zero_attribution(), shadow_pnl, 0.0
return _compute_attribution(profile=profile, roundtrips=roundtrips, shadow_pnl=shadow_pnl)
def _zero_attribution() -> AttributionBreakdown:
return AttributionBreakdown(
missed_signals_pnl=0.0,
noise_trades_pnl=0.0,
early_exit_pnl=0.0,
late_exit_pnl=0.0,
overtrading_pnl=0.0,
counterfactual_trades=(),
)
def _compute_attribution(
*,
profile: ShadowProfile,
roundtrips: list[dict[str, Any]],
shadow_pnl: float,
) -> tuple[AttributionBreakdown, float, float]:
"""Attribute the delta between user's real PnL and shadow PnL.
Decomposition (signed — positive means shadow would have earned more):
noise_trades_pnl = -Σ realized_pnl on rule-violating trades
(user's unexplained losses that shadow avoids)
early_exit_pnl = +Σ shortfall on winning trades exited before
the median rule holding range
late_exit_pnl = +Σ excess loss on losing trades held past the
median rule holding range
overtrading_pnl = -Σ realized_pnl on trades beyond the shadow's
expected trade budget (1 trade per 2*hold_days)
missed_signals_pnl = shadow_pnl - real_pnl - (noise+early+late+over)
(residual — everything the above can't explain)
``counterfactual_trades`` lists the top-5 |impact| roundtrips for
Section 6 of the report.
"""
rule_hold_lo, rule_hold_hi = _aggregate_holding_range(profile)
noise = 0.0
early = 0.0
late = 0.0
real_pnl = 0.0
counterfactuals: list[dict[str, Any]] = []
for rt in roundtrips:
pnl = float(rt["pnl"])
real_pnl += pnl
hold = float(rt["hold_days"])
within_rule = rule_hold_lo <= hold <= rule_hold_hi
impact = 0.0
reason = ""
if not within_rule:
noise += -pnl
impact += -pnl
reason = "rule_violation"
if pnl > 0 and hold < rule_hold_lo:
shortfall = pnl * max(0.0, (rule_hold_lo - hold) / max(rule_hold_lo, 1))
early += shortfall
impact += shortfall
reason = reason or "early_exit"
if pnl < 0 and hold > rule_hold_hi:
excess = -pnl * max(0.0, (hold - rule_hold_hi) / max(rule_hold_hi, 1))
late += excess
impact += excess
reason = reason or "late_exit"
if impact != 0.0:
counterfactuals.append({
"symbol": rt["symbol"],
"buy_dt": str(rt["buy_dt"]),
"sell_dt": str(rt["sell_dt"]),
"hold_days": hold,
"pnl": round(pnl, 2),
"impact": round(impact, 2),
"reason": reason,
})
overtrading = _overtrading_pnl(profile=profile, roundtrips=roundtrips)
explained = noise + early + late + overtrading
missed = round(shadow_pnl - real_pnl - explained, 2)
counterfactuals.sort(key=lambda r: abs(r["impact"]), reverse=True)
top5 = tuple(counterfactuals[:5])
return (
AttributionBreakdown(
missed_signals_pnl=round(missed, 2),
noise_trades_pnl=round(noise, 2),
early_exit_pnl=round(early, 2),
late_exit_pnl=round(late, 2),
overtrading_pnl=round(overtrading, 2),
counterfactual_trades=top5,
),
round(shadow_pnl, 2),
round(real_pnl, 2),
)
def _aggregate_holding_range(profile: ShadowProfile) -> tuple[float, float]:
"""Union holding-day ranges across all rules (lo=min, hi=max)."""
if not profile.rules:
return (1.0, 30.0)
los = [r.holding_days_range[0] for r in profile.rules]
his = [r.holding_days_range[1] for r in profile.rules]
return (float(min(los)), float(max(his)))
def _overtrading_pnl(
*,
profile: ShadowProfile,
roundtrips: list[dict[str, Any]],
) -> float:
"""Excess-frequency PnL: trades beyond the shadow's expected budget.
Shadow runs roughly 1 trade per ``2 * median_hold_days`` bars. We
compare against the user's actual roundtrip count over the same span.
Excess trades' PnL is totaled with a negative sign (shadow would've
skipped them, so real PnL — positive or negative — is "noise").
"""
if not roundtrips:
return 0.0
median_hold, _ = profile.typical_holding_days
if median_hold <= 0:
return 0.0
span_days = (
pd.Timestamp(roundtrips[-1]["sell_dt"]) - pd.Timestamp(roundtrips[0]["buy_dt"])
).total_seconds() / 86400.0
expected = max(1.0, span_days / max(2 * median_hold, 1.0))
actual = len(roundtrips)
if actual <= expected:
return 0.0
# Penalize the cheapest (lowest |pnl|) extras — those look like noise.
extras = sorted(roundtrips, key=lambda rt: abs(float(rt["pnl"])))
extra_count = int(actual - expected)
extra_pnl = sum(float(rt["pnl"]) for rt in extras[:extra_count])
return -extra_pnl
__all__ = [
"SUPPORTED_MARKETS",
"flatten_codes",
"load_cached_result",
"run_shadow_backtest",
"select_multi_market_codes",
]
# Re-export for convenience (see __init__.py).
def _touch_replace_reexport(): # pragma: no cover
return replace
+184
View File
@@ -0,0 +1,184 @@
"""Shadow Account — code generation (rules → signal_engine.py + config.json).
Inputs are always a ``ShadowProfile`` (contract-stable). Outputs are:
* ``signal_engine.py`` source, validated with ``ast.parse`` + a shape
check on ``class SignalEngine.generate``.
* ``config.json`` dict, ready to drop into a run_dir.
No external I/O here — callers (backtester) are responsible for writing
files and launching ``run_backtest``.
"""
from __future__ import annotations
import ast
import json
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from src.shadow_account.models import ShadowProfile, ShadowRule
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_SIGNAL_ENGINE_TEMPLATE = "signal_engine.py.j2"
def _env() -> Environment:
"""Jinja2 environment rooted at our templates directory."""
return Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(enabled_extensions=("html", "xml")),
trim_blocks=True,
lstrip_blocks=True,
)
def _rule_to_context(rule: ShadowRule) -> dict[str, Any]:
"""Flatten a ShadowRule into the scalar fields the template consumes."""
market = str(rule.entry_condition.get("market", "other"))
hour = rule.entry_condition.get("entry_hour") or {}
hold_lo, hold_hi = rule.holding_days_range
hold_days = max(1, int(round((hold_lo + hold_hi) / 2)))
return {
"rule_id": rule.rule_id,
"market": market,
"entry_hour_min": int(hour.get("min", 0)),
"entry_hour_max": int(hour.get("max", 23)),
"hold_days": hold_days,
"weight": float(rule.weight),
}
def render_signal_engine(profile: ShadowProfile) -> str:
"""Render the SignalEngine Python source for a profile.
Args:
profile: ShadowProfile carrying the rules to replay.
Returns:
Python source string. The caller is expected to ``validate_generated``
before writing to disk.
"""
template = _env().get_template(_SIGNAL_ENGINE_TEMPLATE)
return template.render(
shadow_id=profile.shadow_id,
profitable_roundtrips=profile.profitable_roundtrips,
date_range=list(profile.date_range),
preferred_markets=list(profile.preferred_markets),
rules=[_rule_to_context(r) for r in profile.rules],
)
def validate_generated(source: str) -> tuple[bool, str]:
"""Static-check a generated signal_engine source.
Checks:
1. ``ast.parse`` succeeds.
2. Module defines ``class SignalEngine``.
3. ``SignalEngine`` has a ``generate`` method with one positional
arg besides self.
Returns:
(ok, error_msg). ``error_msg`` is empty on success.
"""
try:
tree = ast.parse(source)
except SyntaxError as exc:
return False, f"SyntaxError: {exc}"
signal_cls: ast.ClassDef | None = None
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == "SignalEngine":
signal_cls = node
break
if signal_cls is None:
return False, "class SignalEngine not found"
generate: ast.FunctionDef | None = None
for node in signal_cls.body:
if isinstance(node, ast.FunctionDef) and node.name == "generate":
generate = node
break
if generate is None:
return False, "SignalEngine.generate method not found"
non_self_args = [a for a in generate.args.args if a.arg != "self"]
if len(non_self_args) < 1:
return False, "SignalEngine.generate must accept a data_map argument"
return True, ""
def render_config(
profile: ShadowProfile,
*,
codes: list[str],
start_date: str,
end_date: str,
source: str = "auto",
initial_capital: float = 1_000_000.0,
interval: str = "1D",
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build the ``config.json`` dict for a shadow backtest run.
Args:
profile: ShadowProfile (used only for metadata, not for engine logic).
codes: Symbols to backtest across.
start_date / end_date: ISO dates.
source: Loader source; ``auto`` routes by symbol suffix.
initial_capital: Starting cash.
interval: Bar size (daily default).
extra: Arbitrary overrides merged last.
"""
cfg: dict[str, Any] = {
"source": source,
"codes": codes,
"start_date": start_date,
"end_date": end_date,
"interval": interval,
"initial_capital": initial_capital,
"engine": "daily",
"shadow_id": profile.shadow_id,
}
if extra:
cfg.update(extra)
return cfg
def write_run_dir(
profile: ShadowProfile,
run_dir: Path,
*,
codes: list[str],
start_date: str,
end_date: str,
source: str = "auto",
initial_capital: float = 1_000_000.0,
) -> Path:
"""Materialize a full run_dir with ``code/signal_engine.py`` + ``config.json``.
Raises:
ValueError: Generated signal_engine fails static validation.
"""
run_dir = Path(run_dir)
(run_dir / "code").mkdir(parents=True, exist_ok=True)
source_code = render_signal_engine(profile)
ok, err = validate_generated(source_code)
if not ok:
raise ValueError(f"Generated signal_engine failed validation: {err}")
(run_dir / "code" / "signal_engine.py").write_text(source_code, encoding="utf-8")
cfg = render_config(
profile,
codes=codes,
start_date=start_date,
end_date=end_date,
source=source,
initial_capital=initial_capital,
)
(run_dir / "config.json").write_text(
json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8",
)
return run_dir
+371
View File
@@ -0,0 +1,371 @@
"""Shadow Account — strategy extraction from profitable roundtrips.
Pipeline:
trades_df → FIFO pair → filter (pnl > 0) → feature engineer
→ KMeans cluster (k auto 2-5) → per-cluster decision tree (max_depth=3)
→ path extraction → structured entry_condition dict
→ LLM-light natural-language translation (template fallback if no LLM)
Design constraints:
* No external price-data calls in v1. All features are derivable from
the journal itself (holding_days, pnl_pct, entry hour/weekday, market).
Price-dependent features (prior_N_return, rsi) are stubbed with None
and dropped from the feature matrix if unavailable.
* Must survive tiny samples: <5 profitable roundtrips → explicit error.
<2 clusters → degrade to a single-cluster heuristic rule.
* Rules are immutable ShadowRule objects — codegen's only input.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from src.shadow_account.models import ShadowProfile, ShadowRule
from src.shadow_account.storage import hash_journal, new_shadow_id, now_iso
from src.tools.trade_journal_parsers import parse_file, records_to_dataframe
from src.tools.trade_journal_tool import pair_trades_fifo
logger = logging.getLogger(__name__)
MIN_PROFITABLE_ROUNDTRIPS = 5
DEFAULT_MAX_RULES = 5
DEFAULT_MIN_SUPPORT = 3
_NUMERIC_FEATURES = ("holding_days", "pnl_pct", "entry_hour", "entry_weekday")
_CATEGORICAL_FEATURES = ("market",)
# ---------------- Public API ----------------
def extract_shadow_profile(
journal_path: str | Path,
*,
min_support: int = DEFAULT_MIN_SUPPORT,
max_rules: int = DEFAULT_MAX_RULES,
llm_translator: Any | None = None,
) -> ShadowProfile:
"""Extract a ShadowProfile from a broker journal file.
Args:
journal_path: CSV/Excel exported from a supported broker.
min_support: Minimum profitable roundtrips backing any single rule.
max_rules: Cap on the number of rules returned.
llm_translator: Optional callable (dict) -> str for translating
structured entry_condition into natural-language text. If None,
a deterministic f-string fallback is used.
Returns:
ShadowProfile (not yet persisted — caller decides whether to save).
Raises:
ValueError: Fewer than MIN_PROFITABLE_ROUNDTRIPS profitable roundtrips.
"""
path = Path(journal_path)
fmt, records = parse_file(path)
if not records:
raise ValueError(f"No trade records parsed from {path} (format={fmt})")
trades_df = records_to_dataframe(records)
roundtrips = pair_trades_fifo(trades_df)
total = len(roundtrips)
if total == 0:
raise ValueError("No complete buy→sell roundtrips found in journal.")
profitable = [rt for rt in roundtrips if rt["pnl"] > 0]
if len(profitable) < MIN_PROFITABLE_ROUNDTRIPS:
raise ValueError(
f"Insufficient profitable roundtrips: {len(profitable)} "
f"(need ≥{MIN_PROFITABLE_ROUNDTRIPS}).",
)
features_df = _compute_features(profitable, trades_df)
rules = _extract_rules(
features_df,
min_support=min_support,
max_rules=max_rules,
llm_translator=llm_translator,
)
source_market = _dominant(trades_df["market"])
preferred_markets = tuple(trades_df["market"].value_counts().index.tolist())
hold = features_df["holding_days"].dropna()
typical_holding = (
round(float(hold.median()), 2) if len(hold) else 0.0,
round(float(hold.quantile(0.75)), 2) if len(hold) else 0.0,
)
date_range = (
str(trades_df["datetime"].min()),
str(trades_df["datetime"].max()),
)
profile_text = _render_profile_text(
total_profitable=len(profitable),
total_all=total,
typical_holding=typical_holding,
source_market=source_market,
preferred_markets=preferred_markets,
)
return ShadowProfile(
shadow_id=new_shadow_id(),
created_at=now_iso(),
journal_hash=hash_journal(path),
source_market=source_market,
profitable_roundtrips=len(profitable),
total_roundtrips=total,
date_range=date_range,
profile_text=profile_text,
rules=tuple(rules),
preferred_markets=preferred_markets,
typical_holding_days=typical_holding,
)
# ---------------- Feature engineering ----------------
def _compute_features(
roundtrips: list[dict[str, Any]],
trades_df: pd.DataFrame,
) -> pd.DataFrame:
"""Compute a features row per profitable roundtrip.
Columns: symbol, market, holding_days, pnl, pnl_pct, entry_hour,
entry_weekday, buy_dt, sell_dt.
"""
market_by_symbol = (
trades_df.drop_duplicates("symbol").set_index("symbol")["market"].to_dict()
)
rows: list[dict[str, Any]] = []
for rt in roundtrips:
buy_dt = pd.Timestamp(rt["buy_dt"])
sell_dt = pd.Timestamp(rt["sell_dt"])
rows.append({
"symbol": rt["symbol"],
"market": market_by_symbol.get(rt["symbol"], "other"),
"holding_days": float(rt["hold_days"]),
"pnl": float(rt["pnl"]),
"pnl_pct": float(rt["pnl_pct"]),
"entry_hour": int(buy_dt.hour),
"entry_weekday": int(buy_dt.weekday()),
"buy_dt": buy_dt,
"sell_dt": sell_dt,
})
return pd.DataFrame(rows)
# ---------------- Cluster + decision-tree rule extraction ----------------
def _extract_rules(
features_df: pd.DataFrame,
*,
min_support: int,
max_rules: int,
llm_translator: Any | None,
) -> list[ShadowRule]:
"""Cluster profitable roundtrips, derive one rule per dense cluster."""
if len(features_df) < min_support:
return [_heuristic_single_rule(features_df, min_support, llm_translator)]
cluster_labels = _auto_cluster(features_df, max_k=min(max_rules, 5))
rules: list[ShadowRule] = []
total_profitable = len(features_df)
used_markets: set[str] = set()
for cluster_id in sorted(set(cluster_labels)):
cluster_mask = cluster_labels == cluster_id
cluster_df = features_df[cluster_mask]
if len(cluster_df) < min_support:
continue
rule = _cluster_to_rule(
cluster_df=cluster_df,
rule_index=len(rules) + 1,
total_profitable=total_profitable,
llm_translator=llm_translator,
)
# Deduplicate near-identical rules (same market + same holding band)
key = (rule.entry_condition.get("market"), rule.holding_days_range)
if key in used_markets:
continue
used_markets.add(key)
rules.append(rule)
if len(rules) >= max_rules:
break
if not rules:
rules = [_heuristic_single_rule(features_df, min_support, llm_translator)]
return rules
def _auto_cluster(features_df: pd.DataFrame, *, max_k: int) -> np.ndarray:
"""Pick a cluster count via simple silhouette heuristic (fallback k=2).
Uses only numeric features; scales by z-score to avoid holding_days
dominating pnl_pct.
"""
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
numeric = features_df[list(_NUMERIC_FEATURES)].astype(float).to_numpy()
if len(numeric) <= 2 or max_k < 2:
return np.zeros(len(numeric), dtype=int)
scaled = StandardScaler().fit_transform(numeric)
best_k, best_score = 2, -1.0
try:
from sklearn.metrics import silhouette_score
for k in range(2, min(max_k, len(numeric) - 1) + 1):
labels = KMeans(n_clusters=k, n_init=5, random_state=42).fit_predict(scaled)
if len(set(labels)) < 2:
continue
score = silhouette_score(scaled, labels)
if score > best_score:
best_k, best_score = k, score
except Exception as exc: # pragma: no cover — sklearn edge cases
logger.debug("silhouette selection failed, fallback k=2: %s", exc)
return KMeans(n_clusters=best_k, n_init=5, random_state=42).fit_predict(scaled)
def _cluster_to_rule(
*,
cluster_df: pd.DataFrame,
rule_index: int,
total_profitable: int,
llm_translator: Any | None,
) -> ShadowRule:
"""Summarize a cluster as one ShadowRule.
Entry condition uses p10–p90 numeric bounds + dominant market. This is
lighter than a decision tree and stays interpretable with tiny samples;
we can swap to DecisionTreeClassifier in v2 when features widen.
"""
market = _dominant(cluster_df["market"])
hold_days = cluster_df["holding_days"]
hold_lo = max(1, int(round(float(hold_days.quantile(0.10)))))
hold_hi = max(hold_lo, int(round(float(hold_days.quantile(0.90)))))
hours = cluster_df["entry_hour"]
hour_lo = int(round(float(hours.quantile(0.10))))
hour_hi = int(round(float(hours.quantile(0.90))))
entry_condition: dict[str, Any] = {
"market": market,
"entry_hour": {"min": hour_lo, "max": hour_hi},
}
exit_condition: dict[str, Any] = {
"holding_days": {"min": hold_lo, "max": hold_hi},
}
samples = tuple(
f"{row.symbol}@{pd.Timestamp(row.buy_dt).date().isoformat()}"
for row in cluster_df.head(3).itertuples(index=False)
)
support = int(len(cluster_df))
coverage = round(support / max(total_profitable, 1), 3)
human = _translate_rule(
entry_condition=entry_condition,
exit_condition=exit_condition,
holding_range=(hold_lo, hold_hi),
translator=llm_translator,
)
return ShadowRule(
rule_id=f"R{rule_index}",
human_text=human,
entry_condition=entry_condition,
exit_condition=exit_condition,
holding_days_range=(hold_lo, hold_hi),
support_count=support,
coverage_rate=coverage,
sample_trades=samples,
)
def _heuristic_single_rule(
features_df: pd.DataFrame,
min_support: int,
llm_translator: Any | None,
) -> ShadowRule:
"""Degenerate fallback when clustering/tree yield nothing usable."""
return _cluster_to_rule(
cluster_df=features_df,
rule_index=1,
total_profitable=max(len(features_df), min_support),
llm_translator=llm_translator,
)
# ---------------- Natural-language translation ----------------
_MARKET_LABELS = {
"china_a": "China A-share",
"us": "US equity",
"hk": "HK equity",
"crypto": "Crypto",
"other": "Other",
}
RULE_TEXT_MAX = 80
def _translate_rule(
*,
entry_condition: dict[str, Any],
exit_condition: dict[str, Any],
holding_range: tuple[int, int],
translator: Any | None,
) -> str:
"""Turn a structured rule dict into a concise English sentence (<=80 chars)."""
if translator is not None:
try:
text = translator({
"entry_condition": entry_condition,
"exit_condition": exit_condition,
"holding_range": holding_range,
})
if isinstance(text, str) and text.strip():
return text.strip()[:RULE_TEXT_MAX]
except Exception as exc: # pragma: no cover — LLM failure, fallback
logger.warning("LLM rule translator failed, falling back: %s", exc)
market_label = _MARKET_LABELS.get(entry_condition.get("market", "other"), "Other")
hour_range = entry_condition.get("entry_hour", {})
hour_text = ""
if hour_range:
lo, hi = hour_range.get("min"), hour_range.get("max")
hour_text = f" at {lo}:00" if lo == hi else f" between {lo}:00-{hi}:00"
hold_lo, hold_hi = holding_range
hold_text = f"hold {hold_lo}-{hold_hi}d" if hold_lo != hold_hi else f"hold {hold_lo}d"
entry_text = f"Enter {market_label}{hour_text}"
return f"{entry_text}, {hold_text}"[:RULE_TEXT_MAX]
# ---------------- Utilities ----------------
def _dominant(series: pd.Series) -> str:
"""Most frequent value in a series, or the first if tied."""
if series.empty:
return "other"
return str(series.value_counts().idxmax())
def _render_profile_text(
*,
total_profitable: int,
total_all: int,
typical_holding: tuple[float, float],
source_market: str,
preferred_markets: tuple[str, ...],
) -> str:
"""Build the Section 1 one-paragraph portrait (English)."""
median, p75 = typical_holding
markets_label = ", ".join(_MARKET_LABELS.get(m, m) for m in preferred_markets[:3])
source_label = _MARKET_LABELS.get(source_market, source_market)
return (
f"{total_profitable} of your {total_all} closed roundtrips were profitable. "
f"Primary market: {source_label} (also active in {markets_label}). "
f"Median holding period {median:.1f}d; most positions closed within {p75:.1f}d."
)
+143
View File
@@ -0,0 +1,143 @@
"""Shadow Account — font handling for PDF rendering.
Tries to guarantee that Chinese characters render; falls back to DejaVu Sans
(English-only, logs a warning) when CJK resources are unavailable. Matplotlib
and weasyprint both consume ``cjk_font_path()`` or at least see the family
name via ``apply_matplotlib_cjk_font``.
"""
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_NOTO_URL = (
"https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/"
"NotoSansCJKsc-Regular.otf"
)
_FONT_NAME = "NotoSansCJKsc-Regular.otf"
_FALLBACK_FAMILY = "DejaVu Sans"
def fonts_dir() -> Path:
"""Return the Shadow Account fonts cache dir (auto-created)."""
d = Path.home() / ".vibe-trading" / "fonts"
d.mkdir(parents=True, exist_ok=True)
return d
def _system_cjk_candidates() -> list[Path]:
"""Enumerate locally-installed CJK fonts we can reuse without downloading."""
candidates: list[Path] = []
env = os.environ
windir = env.get("WINDIR") or env.get("SystemRoot")
if windir:
win_fonts = Path(windir) / "Fonts"
for name in (
"msyh.ttc", "msyh.ttf", "msyhbd.ttc",
"simhei.ttf", "simsun.ttc", "simkai.ttf",
"NotoSansCJK-Regular.ttc",
):
candidates.append(win_fonts / name)
for p in (
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
):
candidates.append(Path(p))
return candidates
def cjk_font_path(*, allow_download: bool = True, timeout: float = 10.0) -> Optional[Path]:
"""Resolve a CJK font file path.
Resolution order:
1. Cached copy in ``fonts_dir()``.
2. First available system font from ``_system_cjk_candidates``.
3. Download Noto CJK (optional).
Returns ``None`` when every option fails — callers should then fall
back to the DejaVu family.
"""
cached = fonts_dir() / _FONT_NAME
if cached.exists() and cached.stat().st_size > 0:
return cached
for candidate in _system_cjk_candidates():
if candidate.exists():
try:
shutil.copy(candidate, cached)
return cached
except OSError as exc:
logger.debug("Failed to cache system font %s: %s", candidate, exc)
if not allow_download:
return None
try:
import urllib.request
req = urllib.request.Request(_NOTO_URL, headers={"User-Agent": "vibe-trading"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
cached.write_bytes(resp.read())
if cached.stat().st_size > 0:
return cached
except Exception as exc: # pragma: no cover — network-dependent
logger.warning("CJK font download failed (%s); falling back to DejaVu.", exc)
return None
def apply_matplotlib_cjk_font() -> str:
"""Configure matplotlib's default font family.
Returns the family name actually installed (``"sans-serif"`` meaning the
CJK face is resolved, or ``_FALLBACK_FAMILY`` when we gave up).
"""
try:
import matplotlib
matplotlib.use("Agg") # safe for headless rendering
from matplotlib import font_manager as fm
import matplotlib.pyplot as plt
except Exception as exc: # pragma: no cover — matplotlib optional
logger.warning("matplotlib unavailable: %s", exc)
return _FALLBACK_FAMILY
path = cjk_font_path()
if path is None:
plt.rcParams["font.family"] = _FALLBACK_FAMILY
return _FALLBACK_FAMILY
try:
fm.fontManager.addfont(str(path))
prop = fm.FontProperties(fname=str(path))
family = prop.get_name()
plt.rcParams["font.family"] = family
plt.rcParams["axes.unicode_minus"] = False
return family
except Exception as exc:
logger.warning("Failed to register %s with matplotlib: %s", path, exc)
plt.rcParams["font.family"] = _FALLBACK_FAMILY
return _FALLBACK_FAMILY
def cjk_css_font_face() -> str:
"""Emit an ``@font-face`` CSS block weasyprint can bundle.
Returns an empty string if no CJK font is resolvable.
"""
path = cjk_font_path()
if path is None:
return ""
uri = path.resolve().as_uri()
return (
"@font-face {\n"
" font-family: 'Shadow CJK';\n"
f" src: url('{uri}');\n"
"}\n"
)
+103
View File
@@ -0,0 +1,103 @@
"""Shadow Account data contracts (frozen dataclasses).
See `docs/shadow-account-spec.md` for the full contract. These types are the
stable boundary between extractor / codegen / backtester / reporter.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
@dataclass(frozen=True)
class ShadowRule:
"""One human-readable if-then rule distilled from profitable roundtrips.
Attributes:
rule_id: Stable ID like "R1", "R2".
human_text: Chinese natural-language description (<=30 chars).
entry_condition: Structured condition dict for codegen. Keys are
feature names (e.g. "prior_5d_return", "market"); values are
either scalars or (op, value) tuples (e.g. ("<=", -0.08)).
exit_condition: Structured exit condition dict.
holding_days_range: (min, max) integer days.
support_count: Number of profitable roundtrips supporting this rule.
coverage_rate: Fraction of all profitable roundtrips this rule covers.
sample_trades: Representative "<symbol>@<date>" strings.
weight: Signal weight for codegen (default 1.0).
"""
rule_id: str
human_text: str
entry_condition: dict[str, Any]
exit_condition: dict[str, Any]
holding_days_range: tuple[int, int]
support_count: int
coverage_rate: float
sample_trades: tuple[str, ...]
weight: float = 1.0
@dataclass(frozen=True)
class ShadowProfile:
"""User's extracted trading shadow.
Attributes:
shadow_id: "shadow_<8-hex>" unique ID.
created_at: ISO8601 UTC timestamp.
journal_hash: SHA1 of the source journal content for idempotency.
source_market: Primary market from the journal ("china_a" etc.).
profitable_roundtrips: Number of roundtrips used for extraction.
total_roundtrips: Total roundtrips in journal.
date_range: (start_iso, end_iso) of the journal.
profile_text: One-paragraph Chinese portrait for Section 1.
rules: Tuple of 3-5 ShadowRule.
preferred_markets: Markets the user actually traded, frequency-sorted.
typical_holding_days: (median, p75) in days.
"""
shadow_id: str
created_at: str
journal_hash: str
source_market: str
profitable_roundtrips: int
total_roundtrips: int
date_range: tuple[str, str]
profile_text: str
rules: tuple[ShadowRule, ...]
preferred_markets: tuple[str, ...]
typical_holding_days: tuple[float, float]
def to_dict(self) -> dict[str, Any]:
"""Serialize to JSON-safe dict."""
return asdict(self)
@dataclass(frozen=True)
class AttributionBreakdown:
"""Delta PnL attribution between user's realized trades and shadow.
All PnL fields are in the journal's account currency.
"""
missed_signals_pnl: float
noise_trades_pnl: float
early_exit_pnl: float
late_exit_pnl: float
overtrading_pnl: float
counterfactual_trades: tuple[dict[str, Any], ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class ShadowBacktestResult:
"""Output of multi-market shadow backtest + attribution."""
shadow_id: str
per_market: dict[str, dict[str, float]]
combined: dict[str, float]
equity_curves: dict[str, list[tuple[str, float]]]
attribution: AttributionBreakdown
shadow_total_pnl: float
real_total_pnl: float
delta_pnl: float
+335
View File
@@ -0,0 +1,335 @@
"""Shadow Account — 8-section report rendering (HTML + optional PDF).
The pipeline:
ShadowProfile + ShadowBacktestResult (+ optional today signals)
→ `_build_sections` build a strict dict for the template
→ `_render_charts` matplotlib PNG files on disk
→ Jinja2 render HTML
→ weasyprint → PDF (or HTML-only if weasyprint unusable)
Design:
* No hard dependency on weasyprint at import time — if importing or
rendering fails, we keep the HTML artifact and return its path.
* Charts are optional — any matplotlib failure downgrades gracefully
(that section just omits its `<img>`).
* Layout/style live in `templates/shadow_report.{html,css}`; this module
only decides *what* data to feed them.
"""
from __future__ import annotations
import logging
from base64 import b64encode
from dataclasses import asdict
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from src.shadow_account.fonts import apply_matplotlib_cjk_font, cjk_css_font_face
from src.shadow_account.models import ShadowBacktestResult, ShadowProfile
from src.shadow_account.storage import reports_dir
logger = logging.getLogger(__name__)
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_HTML_TEMPLATE = "shadow_report.html"
_CSS_TEMPLATE = "shadow_report.css"
_MARKET_LABELS = {
"china_a": "China A-share",
"us": "US equity",
"hk": "HK equity",
"crypto": "Crypto",
"other": "Other",
}
_REASON_LABELS = {
"rule_violation": "Outside any shadow rule",
"early_exit": "Exited winner too early",
"late_exit": "Held loser too long",
}
# ---------------- Public API ----------------
def render_shadow_report(
profile: ShadowProfile,
backtest_result: ShadowBacktestResult,
*,
today_signals: list[dict[str, Any]] | None = None,
output_dir: Path | None = None,
) -> dict[str, Any]:
"""Render the full Shadow Account report.
Returns:
Dict with keys:
``html_path`` : rendered HTML (always present)
``pdf_path`` : PDF path (present iff weasyprint succeeded)
``sections`` : structured payload (for frontend preview)
``engine`` : "weasyprint" | "html-only"
"""
output_dir = Path(output_dir) if output_dir else reports_dir()
output_dir.mkdir(parents=True, exist_ok=True)
# Matplotlib setup must happen before any chart renders.
apply_matplotlib_cjk_font()
assets_dir = output_dir / f"{profile.shadow_id}_assets"
assets_dir.mkdir(parents=True, exist_ok=True)
charts = _render_charts(profile, backtest_result, assets_dir)
sections = _build_sections(profile, backtest_result, today_signals or [])
css = _load_css()
html = _env().get_template(_HTML_TEMPLATE).render(
css=css,
charts=charts,
market_labels=_MARKET_LABELS,
reason_labels=_REASON_LABELS,
**sections,
)
html_path = output_dir / f"{profile.shadow_id}.html"
html_path.write_text(html, encoding="utf-8")
pdf_path, engine = _try_render_pdf(html, output_dir, profile.shadow_id)
return {
"html_path": str(html_path),
"pdf_path": str(pdf_path) if pdf_path else None,
"sections": sections,
"engine": engine,
}
# ---------------- Section data ----------------
def _build_sections(
profile: ShadowProfile,
result: ShadowBacktestResult,
today_signals: list[dict[str, Any]],
) -> dict[str, Any]:
"""Assemble the strict payload the Jinja template consumes.
The template expects numeric values for any metrics it formats with
``%f`` — so we strip out non-numeric leak-throughs (e.g. ``error``
strings inserted when the backtest runner failed). The error is
preserved separately under ``combined_error`` so the template can
surface it explicitly.
"""
combined_numeric, combined_error = _split_metrics(result.combined or {})
per_market_numeric = {
market: _split_metrics(metrics or {})[0]
for market, metrics in (result.per_market or {}).items()
}
return {
"profile": profile,
"combined_metrics": combined_numeric,
"combined_error": combined_error,
"per_market_metrics": per_market_numeric,
"attribution": result.attribution,
"shadow_pnl": result.shadow_total_pnl,
"real_pnl": result.real_total_pnl,
"delta_pnl": result.delta_pnl,
"today_signals": today_signals,
}
def _split_metrics(metrics: dict[str, Any]) -> tuple[dict[str, float], str]:
"""Partition a metrics dict into (numeric, error_message)."""
numeric: dict[str, float] = {}
error_parts: list[str] = []
for key, value in metrics.items():
if isinstance(value, bool):
continue
if isinstance(value, (int, float)):
numeric[key] = float(value)
elif isinstance(value, str):
error_parts.append(f"{key}: {value}")
return numeric, " | ".join(error_parts)
# ---------------- Charts ----------------
def _render_charts(
profile: ShadowProfile,
result: ShadowBacktestResult,
assets_dir: Path,
) -> dict[str, str]:
"""Render all charts and return a map section → file URI.
Any failure is logged and the affected chart is dropped from the map.
"""
charts: dict[str, str] = {}
for name, renderer in (
("equity_curve", lambda p: _render_equity_curve(result, p)),
("per_market_bar", lambda p: _render_per_market_bar(result, p)),
("attribution_waterfall", lambda p: _render_attribution_waterfall(result, p)),
):
path = assets_dir / f"{name}.png"
try:
renderer(path)
except Exception as exc:
logger.warning("Chart %s failed to render: %s", name, exc)
continue
if path.exists() and path.stat().st_size > 0:
charts[name] = path.resolve().as_uri()
return charts
def _render_equity_curve(result: ShadowBacktestResult, path: Path) -> None:
curve = result.equity_curves.get("combined") or []
if not curve:
return
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 3), dpi=150)
dates = [pt[0] for pt in curve]
values = [pt[1] for pt in curve]
ax.plot(dates, values, color="#1f7a3a", linewidth=1.3)
ax.fill_between(dates, values, color="#1f7a3a", alpha=0.08)
ax.set_title("Shadow — Portfolio Equity Curve")
ax.set_xlabel("")
ax.set_ylabel("Equity")
ax.grid(True, linestyle=":", alpha=0.4)
# Too many x-ticks looks cluttered; sample down.
if len(dates) > 8:
step = max(1, len(dates) // 8)
ax.set_xticks([dates[i] for i in range(0, len(dates), step)])
fig.tight_layout()
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
def _render_per_market_bar(result: ShadowBacktestResult, path: Path) -> None:
if not result.per_market:
return
import matplotlib.pyplot as plt
markets = list(result.per_market.keys())
sharpes = [result.per_market[m].get("sharpe", 0.0) for m in markets]
labels = [_MARKET_LABELS.get(m, m) for m in markets]
fig, ax = plt.subplots(figsize=(8, 3), dpi=150)
bars = ax.bar(labels, sharpes, color="#4a5fb0")
ax.axhline(0, color="#8a8f99", linewidth=0.8)
ax.set_title("Sharpe by Market")
ax.grid(True, axis="y", linestyle=":", alpha=0.4)
for bar, value in zip(bars, sharpes):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height(),
f"{value:.2f}",
ha="center",
va="bottom",
fontsize=9,
)
fig.tight_layout()
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
def _render_attribution_waterfall(result: ShadowBacktestResult, path: Path) -> None:
attr = result.attribution
components = [
("Real PnL", result.real_total_pnl),
("Noise Trades", attr.noise_trades_pnl),
("Early Exit", attr.early_exit_pnl),
("Late Exit", attr.late_exit_pnl),
("Overtrading", attr.overtrading_pnl),
("Missed Signals", attr.missed_signals_pnl),
("Shadow PnL", result.shadow_total_pnl),
]
if all(value == 0.0 for _, value in components):
return
import matplotlib.pyplot as plt
labels = [c[0] for c in components]
values = [c[1] for c in components]
# Waterfall bases: cumulative start for the intermediate bars.
bases = [0.0]
running = values[0]
for v in values[1:-1]:
bases.append(running)
running += v
bases.append(0.0)
accent = "#4a5fb0"
pos = "#1f7a3a"
neg = "#c1392b"
grid = "#8a8f99"
bg = "#ffffff"
colors = []
for idx, v in enumerate(values):
if idx in (0, len(values) - 1):
colors.append(accent)
else:
colors.append(pos if v > 0 else neg)
fig, ax = plt.subplots(figsize=(8, 3.2), dpi=150, facecolor=bg)
ax.bar(labels, values, bottom=bases, color=colors, edgecolor="none", width=0.55)
ax.axhline(0, color=grid, linewidth=0.8)
ax.set_title("Delta Attribution (positive = shadow would outperform)")
ax.grid(True, axis="y", linestyle=":", alpha=0.4)
plt.setp(ax.get_xticklabels(), rotation=18, ha="right")
fig.tight_layout()
fig.savefig(path, bbox_inches="tight", facecolor=bg)
plt.close(fig)
# ---------------- Templating ----------------
def _env() -> Environment:
return Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(enabled_extensions=("html", "xml")),
trim_blocks=True,
lstrip_blocks=True,
)
def _load_css() -> str:
"""Load CSS, prepending a CJK ``@font-face`` if we have a font."""
css = (_TEMPLATES_DIR / _CSS_TEMPLATE).read_text(encoding="utf-8")
font_face = cjk_css_font_face()
return font_face + "\n" + css
def _try_render_pdf(
html: str, output_dir: Path, shadow_id: str,
) -> tuple[Path | None, str]:
"""Render PDF via weasyprint, returning (path|None, engine_name)."""
try:
from weasyprint import HTML # type: ignore[import-not-found]
except Exception as exc: # pragma: no cover — import-level failure
logger.warning("weasyprint unavailable (%s); HTML-only output.", exc)
return None, "html-only"
pdf_path = output_dir / f"{shadow_id}.pdf"
try:
HTML(string=html, base_url=str(_TEMPLATES_DIR)).write_pdf(str(pdf_path))
except Exception as exc:
logger.warning("weasyprint render failed (%s); HTML-only output.", exc)
if pdf_path.exists():
try:
pdf_path.unlink()
except OSError:
pass
return None, "html-only"
return pdf_path, "weasyprint"
# ---------------- Convenience ----------------
def embed_image_as_data_uri(path: Path) -> str:
"""Inline an image as a data URI (useful when file URIs are rejected)."""
blob = Path(path).read_bytes()
encoded = b64encode(blob).decode("ascii")
return f"data:image/png;base64,{encoded}"
# Keep this helper exported to simplify downstream inspection.
def result_as_dict(result: ShadowBacktestResult) -> dict[str, Any]:
"""Dict-serialize a backtest result (handy for frontend debugging)."""
return asdict(result)
+74
View File
@@ -0,0 +1,74 @@
"""Shadow Account — today signals scanner.
Deterministic, data-free MVP: for each rule in a profile, pick up to N
symbols from the market's liquid basket that are "in phase" with the
rule's holding cadence on the target date. No external price fetches.
Future work: swap the phase test for a real feature check (e.g. the
rule's entry condition evaluated on the most recent bar).
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from src.shadow_account.backtester import _LIQUID_BASKETS, SUPPORTED_MARKETS
from src.shadow_account.models import ShadowProfile, ShadowRule
def scan_today_signals(
profile: ShadowProfile,
*,
target_date: str | date | None = None,
per_market: int = 3,
) -> list[dict[str, Any]]:
"""Return a list of {symbol, market, rule_id, reason} matches.
Args:
profile: ShadowProfile holding the rules.
target_date: ISO "YYYY-MM-DD" string, date object, or None (today).
per_market: Cap per market bucket.
Returns:
Possibly-empty list of matched candidates. Always research-only —
callers should surface the disclaimer from the skill.
"""
if target_date is None:
d = date.today()
elif isinstance(target_date, date):
d = target_date
else:
d = datetime.strptime(str(target_date), "%Y-%m-%d").date()
matches: list[dict[str, Any]] = []
for rule in profile.rules:
market = rule.entry_condition.get("market", "other")
if market not in SUPPORTED_MARKETS:
continue
basket = _LIQUID_BASKETS.get(market, [])
if not _date_in_phase(d, rule):
continue
lo, hi = rule.holding_days_range
reason = f"Within {rule.rule_id} entry window (hold {lo}-{hi}d)"
for symbol in basket[:per_market]:
matches.append({
"symbol": symbol,
"market": market,
"rule_id": rule.rule_id,
"reason": reason,
})
return matches
def _date_in_phase(target: date, rule: ShadowRule) -> bool:
"""True when `target`'s ordinal mod `2 * hold_median` is inside the entry band.
This keeps the scanner deterministic and reviewable without needing
real-time market data.
"""
lo, hi = rule.holding_days_range
hold = max(1, int((lo + hi) / 2))
period = max(2, 2 * hold)
phase = target.toordinal() % period
return phase < hold
+128
View File
@@ -0,0 +1,128 @@
"""Shadow Account persistence (~/.vibe-trading/shadow_accounts/).
Layout:
~/.vibe-trading/shadow_accounts/<shadow_id>.json ShadowProfile
~/.vibe-trading/shadow_runs/<shadow_id>/ backtest run dir
~/.vibe-trading/shadow_reports/<shadow_id>.pdf rendered report
"""
from __future__ import annotations
import hashlib
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from src.shadow_account.models import ShadowProfile, ShadowRule
def _root() -> Path:
"""Return the Shadow Account root directory (auto-created)."""
root = Path.home() / ".vibe-trading"
root.mkdir(parents=True, exist_ok=True)
return root
def profiles_dir() -> Path:
d = _root() / "shadow_accounts"
d.mkdir(parents=True, exist_ok=True)
return d
def runs_dir(shadow_id: str) -> Path:
d = _root() / "shadow_runs" / shadow_id
d.mkdir(parents=True, exist_ok=True)
return d
def reports_dir() -> Path:
d = _root() / "shadow_reports"
d.mkdir(parents=True, exist_ok=True)
return d
def new_shadow_id() -> str:
"""Mint a fresh shadow_id."""
return f"shadow_{uuid.uuid4().hex[:8]}"
def hash_journal(journal_path: Path | str) -> str:
"""SHA1 over the raw journal bytes for idempotent extraction."""
p = Path(journal_path)
h = hashlib.sha1()
with p.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def now_iso() -> str:
"""UTC ISO8601 timestamp (seconds precision)."""
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def save_profile(profile: ShadowProfile) -> Path:
"""Persist a ShadowProfile to disk as JSON."""
path = profiles_dir() / f"{profile.shadow_id}.json"
path.write_text(
json.dumps(profile.to_dict(), ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
return path
def load_profile(shadow_id: str) -> ShadowProfile:
"""Load a ShadowProfile back from disk.
Raises:
FileNotFoundError: No profile with that id.
"""
path = profiles_dir() / f"{shadow_id}.json"
if not path.exists():
raise FileNotFoundError(f"Shadow profile not found: {shadow_id}")
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
rules = tuple(
ShadowRule(
rule_id=r["rule_id"],
human_text=r["human_text"],
entry_condition=r["entry_condition"],
exit_condition=r["exit_condition"],
holding_days_range=tuple(r["holding_days_range"]),
support_count=r["support_count"],
coverage_rate=r["coverage_rate"],
sample_trades=tuple(r["sample_trades"]),
weight=r.get("weight", 1.0),
)
for r in data["rules"]
)
return ShadowProfile(
shadow_id=data["shadow_id"],
created_at=data["created_at"],
journal_hash=data["journal_hash"],
source_market=data["source_market"],
profitable_roundtrips=data["profitable_roundtrips"],
total_roundtrips=data["total_roundtrips"],
date_range=tuple(data["date_range"]),
profile_text=data["profile_text"],
rules=rules,
preferred_markets=tuple(data["preferred_markets"]),
typical_holding_days=tuple(data["typical_holding_days"]),
)
def find_by_journal_hash(journal_hash: str) -> ShadowProfile | None:
"""Return the most recent profile sharing this journal_hash, else None."""
latest: ShadowProfile | None = None
for path in profiles_dir().glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if data.get("journal_hash") != journal_hash:
continue
candidate = load_profile(data["shadow_id"])
if latest is None or candidate.created_at > latest.created_at:
latest = candidate
return latest
@@ -0,0 +1,339 @@
:root {
--bg: #0b0d12;
--surface: #12151c;
--surface2: #1a1f29;
--border: #242a36;
--text: #e6e9ef;
--text-dim: #9099a8;
--text-mute:#5f6878;
--accent: #6aa8ff;
--pos: #3dd68c;
--neg: #ff7b6b;
--warn: #f4b454;
--mono: 'JetBrains Mono', 'SF Mono', 'Consolas', 'Menlo', monospace;
--sans: 'Inter', 'SF Pro Text', -apple-system, 'Segoe UI', 'Roboto', sans-serif;
}
@page {
size: A4;
margin: 14mm 12mm;
@bottom-right {
content: "Shadow Account " counter(page) " / " counter(pages);
font-size: 7.5pt;
color: var(--text-mute);
font-family: var(--mono);
letter-spacing: 0.3pt;
}
}
* { box-sizing: border-box; }
body {
font-family: var(--sans);
color: var(--text);
background: var(--bg);
font-size: 10pt;
line-height: 1.55;
margin: 0;
-webkit-font-smoothing: antialiased;
}
.mono {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 9.5pt;
}
/* ============ Cover ============ */
header.cover {
padding: 20mm 14mm 18mm;
margin: -14mm -12mm 0 -12mm;
background:
radial-gradient(1200px 400px at 15% 0%, rgba(106,168,255,0.18), transparent 60%),
radial-gradient(900px 350px at 100% 0%, rgba(255,123,107,0.10), transparent 55%),
linear-gradient(180deg, #0e1119 0%, #0b0d12 100%);
border-bottom: 1pt solid var(--border);
page-break-after: always;
position: relative;
}
header.cover::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 2pt;
background: linear-gradient(90deg, var(--accent), #b46aff 50%, var(--neg));
}
.eyebrow {
font-family: var(--mono);
font-size: 8.5pt;
letter-spacing: 4pt;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 6mm;
}
h1 {
font-size: 38pt;
font-weight: 700;
letter-spacing: -1pt;
margin: 0 0 4mm;
line-height: 1.05;
color: #fafbfd;
}
.subtitle {
font-family: var(--mono);
font-size: 9pt;
color: var(--text-mute);
margin: 0 0 14mm;
letter-spacing: 0.3pt;
}
.cover-delta {
padding: 14mm 12mm;
background: var(--surface);
border: 1pt solid var(--border);
border-radius: 4pt;
text-align: center;
margin-bottom: 10mm;
position: relative;
overflow: hidden;
}
.cover-delta::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(600px 200px at 50% 120%, rgba(61,214,140,0.08), transparent 70%);
pointer-events: none;
}
.delta-label {
font-family: var(--mono);
font-size: 8.5pt;
color: var(--text-mute);
letter-spacing: 3pt;
text-transform: uppercase;
margin-bottom: 4mm;
}
.delta-value {
font-family: var(--mono);
font-size: 52pt;
font-weight: 700;
line-height: 1;
letter-spacing: -2pt;
margin: 2mm 0 4mm;
}
.delta-value.positive { color: var(--pos); }
.delta-value.negative { color: var(--neg); }
.delta-caption {
font-family: var(--mono);
font-size: 9pt;
color: var(--text-dim);
letter-spacing: 0.3pt;
}
header.cover .disclaimer {
font-size: 8pt;
color: var(--warn);
margin-top: 10mm;
padding: 3mm 5mm;
background: rgba(244,180,84,0.08);
border-left: 2pt solid var(--warn);
border-radius: 2pt;
}
/* ============ Panels ============ */
section.panel {
page-break-inside: avoid;
padding: 8mm 0 6mm;
border-bottom: 1pt solid var(--border);
}
section.panel:last-of-type {
border-bottom: none;
}
section.panel.gut-punch {
background: linear-gradient(180deg, rgba(255,123,107,0.06), rgba(255,123,107,0.02));
border-left: 2pt solid var(--neg);
padding: 7mm 7mm 6mm;
border-radius: 2pt;
margin: 3mm -3mm;
}
h2 {
font-size: 13pt;
font-weight: 600;
margin: 0 0 4mm;
letter-spacing: -0.2pt;
color: #f0f3f8;
}
h2::before {
content: "";
display: inline-block;
width: 3pt;
height: 10pt;
background: var(--accent);
margin-right: 3mm;
vertical-align: -1pt;
border-radius: 1pt;
}
.panel.gut-punch h2::before { background: var(--neg); }
.lede {
font-size: 10.5pt;
color: var(--text);
margin: 0 0 4mm;
max-width: 165mm;
}
.lede strong {
font-family: var(--mono);
font-weight: 600;
}
.lede strong.positive { color: var(--pos); }
.lede strong.negative { color: var(--neg); }
/* ============ Fact list ============ */
dl.facts {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 8mm;
row-gap: 2mm;
margin: 3mm 0 0;
padding: 4mm 5mm;
background: var(--surface);
border: 1pt solid var(--border);
border-radius: 3pt;
}
dl.facts dt {
font-family: var(--mono);
font-size: 8.5pt;
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.5pt;
}
dl.facts dd {
margin: 0;
font-size: 9.5pt;
color: var(--text);
}
/* ============ Tables ============ */
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 9pt;
margin-top: 2mm;
background: var(--surface);
border: 1pt solid var(--border);
border-radius: 3pt;
overflow: hidden;
}
thead th {
background: var(--surface2);
font-family: var(--mono);
font-size: 7.5pt;
font-weight: 500;
color: var(--text-mute);
letter-spacing: 1pt;
text-transform: uppercase;
padding: 2.5mm 3mm;
text-align: left;
border-bottom: 1pt solid var(--border);
}
tbody td, tbody th {
padding: 2mm 3mm;
border-bottom: 1pt solid rgba(36,42,54,0.6);
vertical-align: top;
}
tbody tr:last-child td, tbody tr:last-child th {
border-bottom: none;
}
table.metrics tbody th {
font-family: var(--mono);
font-size: 8.5pt;
color: var(--text-mute);
font-weight: 500;
width: 45%;
text-transform: none;
letter-spacing: 0;
}
tbody td.mono {
color: var(--text);
}
tbody td.pos, .pos { color: var(--pos); }
tbody td.neg, .neg { color: var(--neg); }
/* ============ Charts ============ */
img.chart {
display: block;
width: 100%;
max-height: 78mm;
object-fit: contain;
margin: 3mm 0;
background: var(--surface);
border: 1pt solid var(--border);
border-radius: 3pt;
padding: 2mm;
}
/* ============ Misc ============ */
.muted {
color: var(--text-mute);
font-size: 9pt;
font-style: italic;
padding: 2mm 3mm;
background: rgba(95,104,120,0.08);
border-radius: 2pt;
}
.panel .disclaimer {
font-size: 8pt;
color: var(--warn);
font-family: var(--mono);
margin: 3mm 0 0;
padding: 2mm 3mm;
background: rgba(244,180,84,0.07);
border-left: 2pt solid var(--warn);
letter-spacing: 0.2pt;
}
.caveats {
margin: 3mm 0 0;
padding-left: 6mm;
}
.caveats li {
margin-bottom: 2mm;
color: var(--text-dim);
font-size: 9.5pt;
}
.caveats li::marker {
color: var(--accent);
}
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Shadow Account Report - {{ profile.shadow_id }}</title>
<style>{{ css }}</style>
</head>
<body>
<header class="cover">
<p class="eyebrow">Shadow Account Report</p>
<h1>You vs Your Shadow</h1>
<p class="subtitle">{{ profile.shadow_id }} | {{ profile.created_at }}</p>
<div class="cover-delta">
<div class="delta-label">Delta PnL</div>
<div class="delta-value {{ 'positive' if delta_pnl > 0 else 'negative' }}">
{{ '%+.2f' | format(delta_pnl) }}
</div>
<div class="delta-caption">Shadow {{ '%.2f' | format(shadow_pnl) }} | Real {{ '%.2f' | format(real_pnl) }}</div>
</div>
<p class="disclaimer">Research use only - not investment advice. Data window: {{ profile.date_range[0] }} -> {{ profile.date_range[1] }}</p>
</header>
<section class="panel">
<h2>1. Your Shadow Profile</h2>
<p class="lede">{{ profile.profile_text }}</p>
<dl class="facts">
<dt>Profitable roundtrips</dt><dd>{{ profile.profitable_roundtrips }} / {{ profile.total_roundtrips }}</dd>
<dt>Primary market</dt><dd>{{ market_labels[profile.source_market] | default(profile.source_market) }}</dd>
<dt>Holding median / p75</dt><dd>{{ '%.1f' | format(profile.typical_holding_days[0]) }}d / {{ '%.1f' | format(profile.typical_holding_days[1]) }}d</dd>
<dt>Preferred markets</dt><dd>{% for m in profile.preferred_markets %}{{ market_labels[m] | default(m) }}{% if not loop.last %}, {% endif %}{% endfor %}</dd>
</dl>
</section>
<section class="panel">
<h2>2. Shadow Rules</h2>
<table class="rules">
<thead>
<tr><th>ID</th><th>Rule</th><th>Support</th><th>Coverage</th><th>Holding</th></tr>
</thead>
<tbody>
{% for rule in profile.rules %}
<tr>
<td class="mono">{{ rule.rule_id }}</td>
<td>{{ rule.human_text }}</td>
<td class="mono">{{ rule.support_count }}</td>
<td class="mono">{{ '%.0f%%' | format(rule.coverage_rate * 100) }}</td>
<td class="mono">{{ rule.holding_days_range[0] }}-{{ rule.holding_days_range[1] }}d</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>3. Shadow Backtest - Combined</h2>
{% if charts.equity_curve %}
<img class="chart" src="{{ charts.equity_curve }}" alt="equity curve"/>
{% endif %}
{% if combined_error %}
<p class="muted">Backtest warning: {{ combined_error }}</p>
{% endif %}
{% if combined_metrics %}
<table class="metrics">
{% for key, value in combined_metrics.items() %}
<tr><th>{{ key }}</th><td class="mono">{{ '%.4f' | format(value) }}</td></tr>
{% endfor %}
</table>
{% else %}
<p class="muted">Backtest produced no usable metrics (see warning above).</p>
{% endif %}
</section>
<section class="panel">
<h2>4. Shadow Backtest - By Market</h2>
{% if charts.per_market_bar %}
<img class="chart" src="{{ charts.per_market_bar }}" alt="per-market bar"/>
{% endif %}
<table class="metrics">
<thead>
<tr><th>Market</th><th>Sharpe</th><th>Annual</th><th>Max Drawdown</th></tr>
</thead>
<tbody>
{% for market, metrics in per_market_metrics.items() %}
<tr>
<td>{{ market_labels[market] | default(market) }}</td>
<td class="mono">{{ '%.2f' | format(metrics.get('sharpe', 0.0)) }}</td>
<td class="mono">{{ '%.2f%%' | format(metrics.get('annual_return', 0.0) * 100) }}</td>
<td class="mono">{{ '%.2f%%' | format(metrics.get('max_drawdown', 0.0) * 100) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel gut-punch">
<h2>5. You vs Shadow - Delta Attribution</h2>
<p class="lede">Shadow PnL <strong class="{{ 'pos' if shadow_pnl > 0 else 'neg' if shadow_pnl < 0 else '' }}">{{ '%+.2f' | format(shadow_pnl) }}</strong>, your real PnL <strong class="{{ 'pos' if real_pnl > 0 else 'neg' if real_pnl < 0 else '' }}">{{ '%+.2f' | format(real_pnl) }}</strong>, delta <strong class="{{ 'pos' if delta_pnl > 0 else 'neg' if delta_pnl < 0 else '' }}">{{ '%+.2f' | format(delta_pnl) }}</strong>.</p>
{% if charts.attribution_waterfall %}
<img class="chart" src="{{ charts.attribution_waterfall }}" alt="attribution waterfall"/>
{% endif %}
<table class="metrics">
<tr><th>Noise trades</th><td class="mono {{ 'pos' if attribution.noise_trades_pnl > 0 else 'neg' if attribution.noise_trades_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.noise_trades_pnl) }}</td></tr>
<tr><th>Early exit (winners)</th><td class="mono {{ 'pos' if attribution.early_exit_pnl > 0 else 'neg' if attribution.early_exit_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.early_exit_pnl) }}</td></tr>
<tr><th>Late exit (losers)</th><td class="mono {{ 'pos' if attribution.late_exit_pnl > 0 else 'neg' if attribution.late_exit_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.late_exit_pnl) }}</td></tr>
<tr><th>Overtrading</th><td class="mono {{ 'pos' if attribution.overtrading_pnl > 0 else 'neg' if attribution.overtrading_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.overtrading_pnl) }}</td></tr>
<tr><th>Missed signals (residual)</th><td class="mono {{ 'pos' if attribution.missed_signals_pnl > 0 else 'neg' if attribution.missed_signals_pnl < 0 else '' }}">{{ '%+.2f' | format(attribution.missed_signals_pnl) }}</td></tr>
</table>
</section>
<section class="panel">
<h2>6. Counterfactual Top 5</h2>
{% if attribution.counterfactual_trades %}
<table class="rules">
<thead>
<tr><th>#</th><th>Symbol</th><th>Buy</th><th>Sell</th><th>Hold</th><th>Real PnL</th><th>Impact</th><th>Reason</th></tr>
</thead>
<tbody>
{% for t in attribution.counterfactual_trades %}
<tr>
<td class="mono">{{ loop.index }}</td>
<td>{{ t.symbol }}</td>
<td class="mono">{{ t.buy_dt[:10] }}</td>
<td class="mono">{{ t.sell_dt[:10] }}</td>
<td class="mono">{{ '%.1f' | format(t.hold_days) }}d</td>
<td class="mono {{ 'pos' if t.pnl > 0 else 'neg' if t.pnl < 0 else '' }}">{{ '%+.2f' | format(t.pnl) }}</td>
<td class="mono {{ 'pos' if t.impact > 0 else 'neg' if t.impact < 0 else '' }}">{{ '%+.2f' | format(t.impact) }}</td>
<td>{{ reason_labels.get(t.reason, t.reason) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No material counterfactual trades - every roundtrip falls inside the shadow rules.</p>
{% endif %}
</section>
<section class="panel">
<h2>7. Today's Signal Scan</h2>
{% if today_signals %}
<table class="rules">
<thead>
<tr><th>Symbol</th><th>Market</th><th>Rule</th><th>Reason</th></tr>
</thead>
<tbody>
{% for s in today_signals %}
<tr>
<td class="mono">{{ s.symbol }}</td>
<td>{{ market_labels[s.market] | default(s.market) }}</td>
<td class="mono">{{ s.rule_id }}</td>
<td>{{ s.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No shadow-matching symbols today (or scan skipped).</p>
{% endif %}
<p class="disclaimer">For research reference only - not a buy/sell recommendation.</p>
</section>
<section class="panel">
<h2>8. Confidence &amp; Caveats</h2>
<ul class="caveats">
<li>Sample size: {{ profile.profitable_roundtrips }} profitable roundtrips support {{ profile.rules | length }} rule(s).</li>
<li>Backtest assumptions: slippage/fees use engine defaults; cross-market annualization uses calendar days.</li>
<li>Style decay: rules reflect the user's historical window and may not hold in future regimes.</li>
<li>Red line: this report is research-only and never routes to any live trading system.</li>
</ul>
</section>
</body>
</html>
@@ -0,0 +1,80 @@
"""Shadow Account generated signal engine — {{ shadow_id }}
Rules distilled from {{ profitable_roundtrips }} profitable roundtrips
over {{ date_range[0] }} → {{ date_range[1] }}.
This file is auto-generated; do not hand-edit. Regenerate via
`src.shadow_account.codegen.render_signal_engine`.
"""
from __future__ import annotations
import pandas as pd
SHADOW_ID = "{{ shadow_id }}"
PREFERRED_MARKETS = {{ preferred_markets | tojson }}
RULES = [
{% for rule in rules %} {
"rule_id": "{{ rule.rule_id }}",
"market": {{ rule.market | tojson }},
"entry_hour_min": {{ rule.entry_hour_min }},
"entry_hour_max": {{ rule.entry_hour_max }},
"hold_days": {{ rule.hold_days }},
"weight": {{ rule.weight }},
},
{% endfor %}]
def _market_of(code: str) -> str:
"""Infer a coarse market bucket from a symbol (matches backtest.runner)."""
c = (code or "").upper()
if c.endswith(".SH") or c.endswith(".SZ") or c.endswith(".BJ"):
return "china_a"
if c.endswith(".HK"):
return "hk"
if "-USDT" in c or "-USD" in c or "/" in c:
return "crypto"
if c and c[0].isalpha():
return "us"
return "other"
class SignalEngine:
"""Shadow Account signal engine (rule-replay, daily timeframe).
For each code we pick the first rule whose market matches, then emit
a long signal (= rule.weight) for ``hold_days`` bars every
``2 * hold_days`` bars — a deliberate period-replay of the user's
profitable holding cadence. Non-matching codes yield a zero series.
"""
def generate(self, data_map: dict) -> dict:
signals: dict[str, pd.Series] = {}
for code, df in data_map.items():
sig = pd.Series(0.0, index=df.index)
rule = self._pick_rule(code)
if rule is not None:
sig = self._replay_rule(df.index, rule)
signals[code] = sig
return signals
def _pick_rule(self, code: str) -> dict | None:
market = _market_of(code)
for rule in RULES:
if rule["market"] == market or rule["market"] == "other":
return rule
# Fallback: first rule, regardless of market match.
return RULES[0] if RULES else None
@staticmethod
def _replay_rule(index: pd.Index, rule: dict) -> pd.Series:
hold = max(1, int(rule["hold_days"]))
period = max(2, hold * 2)
weight = float(rule["weight"])
values = [0.0] * len(index)
i = 0
while i < len(index):
for j in range(i, min(i + hold, len(index))):
values[j] = weight
i += period
return pd.Series(values, index=index)
@@ -0,0 +1,113 @@
---
name: cross-market-strategy
description: Write signal_engine.py for portfolios spanning multiple markets (A-shares + crypto, equity + forex, etc.)
category: strategy
---
## When to Use
When the user requests a backtest with codes from **different markets** — e.g. `["000001.SZ", "BTC-USDT"]` or `["AAPL.US", "EUR/USD", "600519.SH"]`.
The `CompositeEngine` handles calendar alignment, shared capital, and market rules automatically. The strategy only needs to output per-symbol signals.
## Key Concepts
### 1. Market Classification in generate()
Group symbols by market type and apply market-specific indicator parameters:
```python
def generate(self, data_map):
groups = {}
for code, df in data_map.items():
market = self._detect_market(code)
groups.setdefault(market, {})[code] = df
signals = {}
for market, market_data in groups.items():
params = MARKET_PARAMS[market]
for code, df in market_data.items():
signals[code] = self._market_signal(df, params)
return signals
```
### 2. Per-Market Parameter Tables
Different markets have very different dynamics. Using the same parameters everywhere produces poor results.
| Parameter | A-Share | Crypto | US Equity | Forex |
|-----------|---------|--------|-----------|-------|
| MA fast | 5 | 7 | 10 | 10 |
| MA slow | 20 | 25 | 50 | 30 |
| RSI period | 14 | 10 | 14 | 14 |
| Vol lookback | 20 | 14 | 20 | 20 |
| Typical daily vol | 1-2% | 3-8% | 1-2% | 0.3-0.8% |
### 3. Volatility-Adjusted Weights (Critical)
BTC daily vol ~ 5%, A-share daily vol ~ 1.5%. Without vol-adjustment, crypto eats the entire risk budget.
```python
def _vol_adjust(self, signals, data_map):
vols = {}
for code, df in data_map.items():
ret = df["close"].pct_change().dropna()
vols[code] = ret.rolling(20).std().iloc[-1] if len(ret) > 20 else ret.std()
inv_vols = {c: 1.0 / (v + 1e-10) for c, v in vols.items()}
total_inv = sum(inv_vols.values())
adjusted = {}
for code, sig in signals.items():
weight = inv_vols[code] / total_inv * len(signals)
adjusted[code] = (sig * weight).clip(-1.0, 1.0)
return adjusted
```
### 4. Cross-Market Signal Patterns
1. **Momentum spillover**: BTC 7-day momentum as overlay for A-share tech sectors
2. **Risk-on/Risk-off**: USD/CNH rate + VIX proxy to reduce equity exposure
3. **Hedging**: Long A-shares + short crypto delta as tail hedge
4. **Correlation regime**: When rolling correlation > 0.6, reduce to single-market exposure; when < 0.2, maximize diversification
### 5. What the Engine Handles (Don't Worry About)
- **Trading calendar alignment**: signals are shifted on each symbol's own calendar, then ffill'd to unified dates
- **Market rules**: T+1 for A-shares, funding fees for crypto, swap for forex — all per-symbol
- **Capital allocation**: shared pool, strategy just sets target weights via signals
- **Commission/slippage**: dispatched to correct sub-engine per symbol
## config.json for Cross-Market
```json
{
"source": "auto",
"codes": ["000001.SZ", "BTC-USDT"],
"start_date": "2024-01-01",
"end_date": "2025-03-31",
"interval": "1D",
"initial_cash": 1000000,
"engine": "daily"
}
```
- `source` **must** be `"auto"` for cross-market (routes each symbol to its loader)
- `extra_fields` should be `null` (not all markets support fundamentals)
- `leverage` defaults to 1.0 (CompositeEngine inherits from config)
## Market Detection Heuristics
| Pattern | Market |
|---------|--------|
| `000001.SZ`, `600519.SH` | A-share |
| `AAPL.US` | US equity |
| `700.HK` | HK equity |
| `BTC-USDT` | Crypto |
| `IF2406.CFFEX` | China futures |
| `ESZ4` | Global futures |
| `EUR/USD` | Forex |
## Supporting Files
- [example_signal_engine.py](example_signal_engine.py) — complete cross-market strategy example
@@ -0,0 +1,80 @@
"""Cross-market strategy example: vol-adjusted dual-MA with per-market parameters.
Supports any combination of A-shares, crypto, US/HK equity, forex, and futures.
The CompositeEngine handles calendar alignment, market rules, and shared capital.
"""
import re
import numpy as np
import pandas as pd
# Per-market indicator parameters
MARKET_PARAMS = {
"a_share": {"ma_fast": 5, "ma_slow": 20, "vol_lookback": 20},
"crypto": {"ma_fast": 7, "ma_slow": 25, "vol_lookback": 14},
"us_equity": {"ma_fast": 10, "ma_slow": 50, "vol_lookback": 20},
"hk_equity": {"ma_fast": 10, "ma_slow": 50, "vol_lookback": 20},
"forex": {"ma_fast": 10, "ma_slow": 30, "vol_lookback": 20},
"futures": {"ma_fast": 5, "ma_slow": 20, "vol_lookback": 20},
}
_MARKET_PATTERNS = [
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "a_share"),
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$"), "forex"),
(re.compile(r"^[A-Z]{6}\.FX$"), "forex"),
]
class SignalEngine:
def generate(self, data_map: dict) -> dict:
# Step 1: raw signals per market
raw_signals = {}
for code, df in data_map.items():
market = self._detect_market(code)
params = MARKET_PARAMS.get(market, MARKET_PARAMS["a_share"])
raw_signals[code] = self._market_signal(df, params)
# Step 2: volatility-adjusted weights
return self._vol_adjust(raw_signals, data_map)
def _detect_market(self, code: str) -> str:
for pattern, market in _MARKET_PATTERNS:
if pattern.match(code):
return market
return "a_share"
def _market_signal(self, df: pd.DataFrame, params: dict) -> pd.Series:
close = df["close"]
ma_fast = close.rolling(params["ma_fast"]).mean()
ma_slow = close.rolling(params["ma_slow"]).mean()
sig = pd.Series(0.0, index=df.index)
sig[ma_fast > ma_slow] = 1.0
sig[ma_fast < ma_slow] = -1.0
return sig
def _vol_adjust(self, signals: dict, data_map: dict) -> dict:
vols = {}
for code, df in data_map.items():
ret = df["close"].pct_change().dropna()
vols[code] = (
ret.rolling(20).std().iloc[-1]
if len(ret) > 20
else ret.std()
)
inv_vols = {c: 1.0 / (v + 1e-10) for c, v in vols.items()}
total_inv = sum(inv_vols.values())
adjusted = {}
n = len(signals)
for code, sig in signals.items():
weight = inv_vols[code] / total_inv * n
adjusted[code] = (sig * weight).clip(-1.0, 1.0)
return adjusted
+73 -34
View File
@@ -1,75 +1,114 @@
---
name: doc-reader
description: Read PDF documents (papers, annual reports, research reports), automatically extracting text pages and applying OCR to image/scanned pages. Use the `read_document` tool.
description: Read any common document/data file — PDF, Word (.docx), Excel (.xlsx/.xls), PowerPoint (.pptx), images (OCR), CSV/TSV, plain text, JSON/YAML/TOML, HTML/XML, and most source-code files. Use the `read_document` tool.
category: tool
---
# PDF Document Reading
# Universal Document Reader
## Purpose
Read the full text of PDF documents and automatically handle two page types:
- **Text pages** (most papers and digital reports) → extracted directly in milliseconds
- **Image / scanned pages** (annual report charts, scanned research reports) → OCR recognition with Chinese and English support
Return extracted text from any supported file in a single unified JSON
envelope. The tool dispatches by file extension — you always call the same
tool regardless of format.
Applicable to PDF documents such as papers, annual reports, research reports, announcements, and contracts.
### Supported formats
| Category | Extensions | Notes |
|---|---|---|
| PDF | `.pdf` | Text pages extracted in ms; scanned/image pages fall back to OCR |
| Word | `.docx` | Paragraphs + table cells |
| Excel | `.xlsx`, `.xls` | All sheets, first 100 rows per sheet as preview |
| PowerPoint | `.pptx` | Slide text content |
| Images | `.png/.jpg/.jpeg/.gif/.bmp/.webp/.tiff` | OCR only |
| CSV / TSV | `.csv`, `.tsv` | Raw text with encoding fallback |
| Plain text | `.txt/.md/.log/.rst` | Encoding fallback |
| Config | `.json/.yaml/.yml/.toml/.ini/.cfg/.env` | Raw text |
| Markup | `.html/.htm/.xml` | Raw text (no HTML stripping) |
| Source code | `.py/.js/.ts/.tsx/.go/.rs/.java/.cpp/.c/.sql/.sh/...` | Raw text |
| Unknown extension | anything else | Best-effort read as UTF-8/GBK text |
**Blocked** (rejected at `/upload`): executables (`.exe/.dll/.so/...`) and
archives (`.zip/.tar/...`). Ask the user to unpack archives locally first.
## Usage
**Call the `read_document` tool directly (do not use bash to write a Python script):**
**Always call the tool directly — do not run Python from bash.**
```
read_document(file_path="uploads/paper.pdf")
read_document(file_path="uploads/annual_report.pdf", pages="1-10")
read_document(file_path="uploads/research.pdf", pages="1,3,15-20")
read_document(file_path="uploads/contract.docx")
read_document(file_path="uploads/sales.xlsx")
read_document(file_path="uploads/deck.pptx")
read_document(file_path="uploads/chart.png") # image → OCR
read_document(file_path="uploads/config.yaml")
read_document(file_path="uploads/notes.md")
```
**Forbidden**: do not run a Python script from bash to read PDFs. Call the tool directly.
The `pages` parameter only applies to PDF; other formats ignore it.
## Return Format
## Return envelope
All formats share this shape:
```json
{
"status": "ok",
"file": "paper.pdf",
"total_pages": 45,
"pages_read": 45,
"ocr_pages": 3,
"format": "pdf",
"char_count": 52000,
"truncated": true,
"text": "--- Page 1 ---\n...\n--- Page 5 [OCR] ---\n..."
"text": "..."
}
```
- `ocr_pages`: number of pages recognized via OCR (image / scanned pages)
- `truncated`: content is truncated when it exceeds 15000 characters
- `[OCR]` indicates that the page content was obtained via image recognition
Format-specific extra fields:
## Typical Workflows
| Format | Extra keys |
|---|---|
| `pdf` | `total_pages`, `pages_read`, `ocr_pages` |
| `docx` | `paragraphs`, `tables` |
| `excel` | `sheets` (array of `{name, rows, cols}`) |
| `pptx` | `slides` |
| `text` | `encoding`, `size` |
### Paper Summary
Content longer than 15000 chars is truncated; for PDFs use the `pages`
parameter to read slices.
## Workflows
### Paper / report summary
```
1. read_document(file_path="paper.pdf") → get the full text
2. Analyze the text and extract the abstract, methodology, and conclusion
3. Output the summary
1. read_document(file_path="paper.pdf") → full text
2. Extract abstract, methodology, conclusion → summarize
```
### Annual Report Analysis
### Contract review
```
1. read_document(file_path="annual_report.pdf", pages="1-5") → read the summary first
2. Determine the key sections from the summary
3. read_document(file_path="...", pages="15-25") → read the financial-data section
4. Extract key metrics
1. read_document(file_path="contract.docx") → paragraphs + tables
2. Flag key clauses (termination, liability, payment, IP)
```
### Research Report Review
### Spreadsheet quick-look
```
1. read_document(file_path="research.pdf") → full text
2. Extract the core thesis, target price, and risk factors
1. read_document(file_path="sales.xlsx") → all sheet previews
2. If user wants trade journal analysis specifically, pivot to
`analyze_trade_journal` tool instead (see trade-journal skill).
```
### Chart / screenshot / scanned PDF
```
1. read_document(file_path="scan.png") → OCR text
2. If OCR returns empty, tell the user; don't fabricate.
```
## Notes
- Content longer than 15000 characters will be truncated. For long documents, read them in chunks with the `pages` parameter
- OCR pages are slower (about 1-3 seconds per page), while pure text pages are processed in milliseconds
- OCR for charts and tables inside images may be imperfect, so complex tables should be checked manually
- Only PDF format is supported
- **Encoding fallback** order for text: utf-8 → utf-8-sig → gbk → gb2312 → big5 → latin-1.
- **OCR** uses RapidOCR; if the package is missing, image/scanned files
return empty `text` with a `note` field — tell the user to install
`rapidocr-onnxruntime`.
- **Excel previews** are limited to 100 rows per sheet to stay in budget.
If the user needs full data (e.g. trade journals), call
`analyze_trade_journal` instead.
- **Source-code files** are returned raw; do not re-format or re-indent.
+252 -97
View File
@@ -1,34 +1,73 @@
---
name: pine-script
description: Convert Python backtest strategies to TradingView Pine Script v6, or generate Pine Script from natural language descriptions.
description: Export backtest strategies to indicator/strategy code for major trading platforms — TradingView, 通达信, 同花顺, 东方财富, MT5.
category: tool
---
## Overview
This skill enables two workflows:
1. **Export**: convert an existing `signal_engine.py` (from a completed backtest run) into a TradingView Pine Script v6 strategy
2. **Generate**: write Pine Script v6 directly from a natural-language strategy description
This skill exports a Vibe-Trading strategy to **all major trading platforms** in one go.
Output file: `artifacts/strategy.pine` (inside the run directory).
Output file: `artifacts/strategy.pine` (inside the run directory, or a new run directory).
Supported platforms (always generate ALL):
| Group | Platforms | Language |
|-------|-----------|----------|
| International Charts | TradingView | Pine Script v6 |
| China Equities | 通达信 / 同花顺 / 东方财富 | TDX Formula |
| Forex / CFD | MetaTrader 5 | MQL5 |
## Workflow: Export from Backtest
1. `load_skill("pine-script")` — read this conversion guide
1. `load_skill("pine-script")` — read this guide
2. `read_file("config.json")` — understand instruments, dates, parameters
3. `read_file("code/signal_engine.py")` — understand the Python strategy logic
4. **Translate** the strategy to Pine Script v6 using the mapping table below
5. `write_file("artifacts/strategy.pine")` — save the output
6. Return the Pine Script in a code block with usage instructions
4. **Translate** the strategy to ALL platforms using the references below
5. `write_file("artifacts/strategy.pine")` — save the combined output
6. Return the code in a code block with usage instructions per platform
## Workflow: Generate from Description
1. `load_skill("pine-script")` — read this guide
2. Write Pine Script v6 directly based on the user's description
3. `write_file("artifacts/strategy.pine")` — save the output
2. Write indicator/strategy code for ALL platforms based on the user's description
3. `write_file("artifacts/strategy.pine")` — save the combined output
4. Return the code with usage instructions
## Pine Script v6 Strategy Template
## Output Format
The output file uses this structure (all platforms in one file):
```
================================================================================
TRADINGVIEW — Pine Script v6
Paste into: Pine Editor → New blank indicator → Add to Chart
================================================================================
[Pine Script code here]
================================================================================
通达信 / 同花顺 / 东方财富 (TDX Formula)
Paste into: 功能 → 公式管理器 → 新建指标公式
================================================================================
[TDX formula code here]
================================================================================
MT5 — MQL5
Save as: .mq5 file → MetaEditor → Compile → Navigator → Attach to Chart
================================================================================
[MQL5 code here]
```
---
# Platform Reference
## 1. TradingView — Pine Script v6
### Template
```pinescript
// This strategy was generated by Vibe-Trading
@@ -78,9 +117,7 @@ alertcondition(longCondition, title="Long Signal", message="Long entry signal tr
alertcondition(shortCondition, title="Short Signal", message="Short entry signal triggered")
```
## Python → Pine Script Mapping Table
### Technical Indicators
### Python → Pine Script Mapping
| Python (pandas/numpy) | Pine Script v6 |
|------------------------|----------------|
@@ -94,13 +131,13 @@ alertcondition(shortCondition, title="Short Signal", message="Short entry signal
| `df['close'].pct_change()` | `(close - close[1]) / close[1]` |
| `df['volume'].rolling(n).mean()` | `ta.sma(volume, n)` |
| `df['close'] > df['close'].shift(1)` | `close > close[1]` |
| Bollinger Bands (manual) | `[mid, upper, lower] = ta.bb(close, length, mult)` |
| ATR (manual or ta-lib) | `ta.atr(length)` |
| ADX | `ta.adx(high, low, close, length)` (returns adx value) |
| Bollinger Bands | `[mid, upper, lower] = ta.bb(close, length, mult)` |
| ATR | `ta.atr(length)` |
| ADX | `ta.adx(high, low, close, length)` |
| Stochastic | `ta.stoch(close, high, low, length, smoothK, smoothD)` |
| CCI | `ta.cci(close, length)` (Pine v6: `ta.cci(close, length)`) |
| CCI | `ta.cci(close, length)` |
| Williams %R | `ta.wpr(length)` |
| MFI | `ta.mfi(hlc3, volume, length)` — Pine v6: `ta.mfi(close, length)` |
| MFI | `ta.mfi(close, length)` |
| OBV | `ta.obv` |
| VWAP | `ta.vwap` |
@@ -138,94 +175,212 @@ alertcondition(shortCondition, title="Short Signal", message="Short entry signal
| Stop-loss | `strategy.exit("Exit", stop=entryPrice * (1 - stopPct))` |
| Take-profit | `strategy.exit("Exit", limit=entryPrice * (1 + tpPct))` |
## Pine Script v6 Syntax Rules
### Critical Rules (common errors)
### Syntax Rules (Critical)
1. **Version declaration must be first line**: `//@version=6`
2. **Ternary operators MUST stay on one line** or use intermediate variables:
```pinescript
// WRONG — causes "end of line without line continuation"
text = condition ? "value1" :
"value2"
// CORRECT
text = condition ? "value1" : "value2"
```
2. **Ternary operators MUST stay on one line**: `text = condition ? "a" : "b"`
3. **Line continuation**: continuation lines must be indented MORE than the starting line
4. **No plot() in local scope** (if/for/function):
```pinescript
// WRONG
if condition
plot(value)
4. **No plot() in local scope** (if/for/function) — use `plot(condition ? value : na)`
5. **var**: persistent state across bars; regular assignment recalculates each bar
6. **Avoid repainting**: use `barstate.isconfirmed`, `lookahead=barmerge.lookahead_off`
7. **Limits**: max 500 bars lookback, 500 plot calls, 64 entry/exit per bar, 40 request.security()
// CORRECT
plot(condition ? value : na)
```
5. **var vs regular variables**: use `var` for persistent state across bars, regular assignment recalculates each bar
6. **varip**: persistent even on real-time bar updates (intrabar persistence)
---
### Script Types
## 2. 通达信 / 同花顺 / 东方财富 — TDX Formula
| Type | Declaration | Use Case |
|------|-------------|----------|
| `strategy` | `strategy("Name", ...)` | Backtestable with entry/exit orders |
| `indicator` | `indicator("Name", ...)` | Visual indicators, no backtesting |
| `library` | `library("Name")` | Reusable functions |
These platforms share 95%+ identical formula syntax. Write ONE version that works on all three.
**Default output: `strategy`** (matches Vibe-Trading's backtest-first philosophy).
### Avoid Repainting
- Use `barstate.isconfirmed` for signals on confirmed bars only
- Use `request.security()` with `lookahead=barmerge.lookahead_off` (default in v6)
- Do NOT use `security()` on lower timeframes without understanding implications
### Platform Limits
- Max 500 bars lookback for `[]` operator
- Max 500 plot calls
- Max 64 `strategy.entry/exit` calls per bar
- Max 40 `request.security()` calls
## TradingView Symbol Format
When generating Pine Script, map Vibe-Trading codes to TradingView symbols:
| Vibe-Trading Format | TradingView Format | Exchange |
|---------------------|-------------------|----------|
| `000001.SZ` | `SZSE:000001` | Shenzhen |
| `600519.SH` | `SSE:600519` | Shanghai |
| `AAPL.US` | `NASDAQ:AAPL` | US (auto-detect) |
| `700.HK` | `HKEX:0700` | Hong Kong |
| `BTC-USDT` | `OKX:BTCUSDT.P` or `BINANCE:BTCUSDT` | Crypto |
**Note**: Pine Script strategies are chart-independent — the user applies them to whichever symbol they want on TradingView. Include a comment noting the original instrument.
## Usage Instructions (include in response)
After generating the Pine Script, always include:
### Template
```
How to use in TradingView:
1. Open TradingView → Pine Editor (bottom panel)
2. Click "Open" → "New blank indicator"
3. Delete all existing code
4. Paste the Pine Script above
5. Click "Add to Chart"
6. The strategy will appear on your chart with entry/exit markers
7. Open "Strategy Tester" tab to see backtest results
{Vibe-Trading 策略导出}
{策略名称: XXX}
{——————— 参数 ———————}
N:=14;
M:=6;
{——————— 指标计算 ———————}
RSI_VAL:=RSI(CLOSE,N);
MA_FAST:=MA(CLOSE,5);
MA_SLOW:=MA(CLOSE,20);
{——————— 买卖信号 ———————}
BUY:CROSS(MA_FAST,MA_SLOW) AND RSI_VAL<40,COLORRED;
SELL:CROSS(MA_SLOW,MA_FAST) AND RSI_VAL>60,COLORGREEN;
DRAWTEXT(BUY,LOW,'B'),COLORYELLOW;
DRAWTEXT(SELL,HIGH,'S'),COLORWHITE;
```
### Python → TDX Mapping
| Python | TDX Formula |
|--------|-------------|
| `df['close'].rolling(n).mean()` | `MA(CLOSE,N)` |
| `df['close'].ewm(span=n).mean()` | `EMA(CLOSE,N)` |
| RSI | `RSI(CLOSE,N)` (returns 0-100) |
| MACD | `MACD.DIF`, `MACD.DEA`, `MACD.MACD` or manual: `DIF:=EMA(CLOSE,12)-EMA(CLOSE,26); DEA:=EMA(DIF,9); MACD:=(DIF-DEA)*2;` |
| Bollinger Bands | `BOLL(N,M)` → `BOLL.UPPER`, `BOLL.MID`, `BOLL.LOWER` or manual |
| ATR | `ATR:=MA(MAX(MAX(HIGH-LOW,ABS(HIGH-REF(CLOSE,1))),ABS(LOW-REF(CLOSE,1))),N);` |
| `df['close'].shift(n)` | `REF(CLOSE,N)` |
| `df['high'].rolling(n).max()` | `HHV(HIGH,N)` |
| `df['low'].rolling(n).min()` | `LLV(LOW,N)` |
| crossover(fast, slow) | `CROSS(FAST,SLOW)` |
| crossunder(fast, slow) | `CROSS(SLOW,FAST)` |
| `df['volume']` | `VOL` |
| `abs(x)` | `ABS(X)` |
| `max(a,b)` | `MAX(A,B)` |
| `min(a,b)` | `MIN(A,B)` |
| conditional | `IF(COND,A,B)` |
| `df['close'].pct_change()` | `(CLOSE-REF(CLOSE,1))/REF(CLOSE,1)` |
| count true in N bars | `COUNT(COND,N)` |
| sum over N bars | `SUM(X,N)` |
| std over N bars | `STD(CLOSE,N)` |
| slope / linear regression | `SLOPE(CLOSE,N)` |
### Syntax Rules
1. **Assignment**: `:=` for intermediate variables, `:` for output (plotted) lines
2. **Comments**: `{comment}` — curly braces, NOT `//`
3. **No semicolons optional**: each statement ends with `;`
4. **Colors**: `COLORRED`, `COLORGREEN`, `COLORYELLOW`, `COLORWHITE`, `COLORBLUE`, `COLORCYAN`, `COLORMAGENTA`
5. **Line styles**: `LINETHICK2`, `POINTDOT`, `STICK`, `VOLSTICK`
6. **Draw text**: `DRAWTEXT(COND, PRICE, 'TEXT'), COLOR;`
7. **Draw icon**: `DRAWICON(COND, PRICE, ICON_ID);`
8. **All function/variable names UPPERCASE**
9. **No loops / no arrays** — everything is vectorized bar-by-bar
10. **Max formula length**: ~10,000 characters per formula
### Platform Differences
| Feature | 通达信 | 同花顺 | 东方财富 |
|---------|--------|--------|----------|
| MACD built-in | `MACD(12,26,9)` | `MACD(12,26,9)` | same |
| Stochastic | `KDJ(N,M1,M2)` | same | same |
| Custom color | `COLOR+RRGGBB` | `COLOR+RRGGBB` | limited |
| Strategy backtest | 条件选股 only | 条件选股 only | 条件选股 only |
For maximum compatibility, avoid platform-specific extensions. Stick to core functions.
---
## 3. MetaTrader 5 — MQL5
### Template (Custom Indicator)
```mql5
//+------------------------------------------------------------------+
//| Generated by Vibe-Trading |
//+------------------------------------------------------------------+
#property copyright "Vibe-Trading"
#property indicator_chart_window // or indicator_separate_window
#property indicator_buffers 2
#property indicator_plots 2
#property indicator_color1 clrDodgerBlue
#property indicator_color2 clrRed
input int InpPeriod = 14; // Period
double BuyBuffer[];
double SellBuffer[];
int OnInit()
{
SetIndexBuffer(0, BuyBuffer, INDICATOR_DATA);
SetIndexBuffer(1, SellBuffer, INDICATOR_DATA);
PlotIndexSetInteger(0, PLOT_ARROW, 233); // up arrow
PlotIndexSetInteger(1, PLOT_ARROW, 234); // down arrow
PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_ARROW);
PlotIndexSetInteger(1, PLOT_DRAW_TYPE, DRAW_ARROW);
return(INIT_SUCCEEDED);
}
int OnCalculate(const int rates_total,
const int prev_calculated,
const datetime &time[],
const double &open[],
const double &high[],
const double &low[],
const double &close[],
const long &tick_volume[],
const long &volume[],
const int &spread[])
{
int start = MathMax(prev_calculated - 1, InpPeriod);
for(int i = start; i < rates_total; i++)
{
BuyBuffer[i] = EMPTY_VALUE;
SellBuffer[i] = EMPTY_VALUE;
// === YOUR LOGIC HERE ===
// Example: if(buyCondition) BuyBuffer[i] = low[i];
// if(sellCondition) SellBuffer[i] = high[i];
}
return(rates_total);
}
```
### Python → MQL5 Mapping
| Python | MQL5 |
|--------|------|
| `df['close'].rolling(n).mean()` | `iMA(_Symbol, PERIOD_CURRENT, n, 0, MODE_SMA, PRICE_CLOSE)` or manual loop |
| EMA | `iMA(..., MODE_EMA, ...)` |
| RSI | `iRSI(_Symbol, PERIOD_CURRENT, n, PRICE_CLOSE)` |
| MACD | `iMACD(_Symbol, PERIOD_CURRENT, 12, 26, 9, PRICE_CLOSE)` |
| Bollinger | `iBands(_Symbol, PERIOD_CURRENT, n, 0, mult, PRICE_CLOSE)` |
| ATR | `iATR(_Symbol, PERIOD_CURRENT, n)` |
| Stochastic | `iStochastic(_Symbol, PERIOD_CURRENT, K, D, slowing, MODE_SMA, STO_LOWHIGH)` |
| `df['close'].shift(n)` | `close[i-n]` (in OnCalculate loop) |
| crossover | `buf[i] > ref[i] && buf[i-1] <= ref[i-1]` |
### Syntax Rules
1. **Indicator handles**: call `iMA()` etc. in `OnInit()`, use `CopyBuffer()` to get values
2. **Buffer direction**: MQL5 buffers are indexed 0=oldest by default; use `ArraySetAsSeries()` to reverse
3. **EMPTY_VALUE**: use for "no signal" on arrow plots
4. **Indicator vs EA**: generate indicator (`.mq5`), not Expert Advisor, to match "indicator export" purpose
5. **Handle-based API**: MQL5 uses handles — create in `OnInit`, read in `OnCalculate`
---
## Symbol Format Mapping
When generating code, map Vibe-Trading instrument codes appropriately:
| Vibe-Trading | TradingView | 通达信/同花顺 | MT5 |
|-------------|-------------|---------------|-----|
| `000001.SZ` | `SZSE:000001` | `000001` | N/A |
| `600519.SH` | `SSE:600519` | `600519` | N/A |
| `AAPL.US` | `NASDAQ:AAPL` | N/A | `AAPL` |
| `BTC-USDT` | `BINANCE:BTCUSDT` | N/A | `BTCUSD` |
**Note**: Most indicator code is instrument-agnostic — the user applies it to whatever chart they're viewing. Include a comment noting the original instrument for reference only.
## Limitations & Transparency
When a Python strategy uses features that can't be directly translated, clearly note it:
| Python Feature | Platform Limitation |
|---------------|-------------------|
| ML models (sklearn, etc.) | None — flag as "manual implementation required" |
| Custom pandas operations | TDX — limited to built-in functions |
| Multi-timeframe logic | TDX — no native MTF; Pine/MQL5 — supported |
| Dynamic position sizing | TDX — indicator only, no position control |
| External data (API calls) | All — indicators run offline on chart data only |
Always add a comment block at the top listing any features that could not be translated.
## Quality Checklist
Before outputting the Pine Script:
- [ ] `//@version=6` is the first line
- [ ] `strategy()` declaration includes commission and initial_capital matching config.json
- [ ] All indicator calculations use Pine built-in `ta.*` functions (not manual reimplementation)
- [ ] Entry/exit conditions match the Python signal logic semantically
- [ ] No `plot()` inside local scopes (if/for/function)
- [ ] Ternary operators are on single lines
- [ ] Inputs use `input.int()` / `input.float()` so users can tune parameters in TradingView UI
- [ ] Alert conditions are included for key signals
Before outputting:
- [ ] ALL 3 platform sections are included (Pine Script, TDX, MQL5)
- [ ] Each platform section has proper header with usage instructions
- [ ] Pine Script: `//@version=6` is first line, no plot() in local scope, ternary on single lines
- [ ] TDX: all uppercase functions, `:=` for intermediate, `:` for output, `{comments}`
- [ ] MQL5: proper handle-based API, `EMPTY_VALUE` for no-signal
- [ ] Entry/exit conditions match the Python signal logic semantically across ALL platforms
- [ ] Untranslatable features are clearly documented at the top of each section
- [ ] Comment header notes the original Vibe-Trading run_id and instrument
+79
View File
@@ -0,0 +1,79 @@
---
name: shadow-account
description: "Shadow Account — 从用户交割单提炼盈利模式(3-5 条人话规则)→ 跨 A股/港股/美股/crypto 多市场回测 → 差值归因 → 8-section PDF 报告。叙事:你的影子,没有情绪噪音。"
category: analysis
---
# Shadow Account — 影子账户
## 何时触发
当用户说 "提炼我的策略" / "训练影子" / "我的打法回测一下" / "我能多赚多少" / "我的盈利模式" 时,加载此 skill。
**前提**:用户已上传交割单且 `analyze_trade_journal` 已跑过。若没有,先跑 Phase 4a 工具。
## 工作流(四步)
1. `extract_shadow_strategy(journal_path=...)`
- 返回 `shadow_id` + 3-5 条人话规则
- 向用户 confirm:"这些规则像你本人吗?" 如果用户说"不像",提高 `min_support` 重跑
2. `run_shadow_backtest(shadow_id=..., journal_path=...)`
- 返回 per-market 指标 + `delta_pnl` + attribution breakdown
- 默认四市场并跑(china_a/hk/us/crypto)
3. `render_shadow_report(shadow_id=...)`
- 生成 HTML + PDF(weasyprint 失败时自动降级成 HTML-only)
- 返回 `html_path` / `pdf_path` / `delta_pnl`
4. (可选)`scan_shadow_signals(shadow_id=...)` — 今日落在影子入场窗口的标的列表(研究用)
## 产出解读
### 规则卡
每条规则含:`rule_id`、`human_text`(≤30 字)、`support_count`、`coverage_rate`、`holding_days_range`。规则不是"必赚公式",而是"用户盈利时的共性画像"。
### 回测矩阵
- `per_market`:四市场的 Sharpe/年化/最大回撤
- `combined`:合并池表现
- `equity_curve`:净值时序(进入 PDF Section 3)
### 差值归因(PDF Section 5 — gut punch)
所有数值 signed,正值=影子相对赚更多:
- `noise_trades_pnl`:不命中任何规则的真实交易累计 PnL(用户的情绪单)
- `early_exit_pnl`:赢单但持仓 < 规则下限,按不足比例折算的机会成本
- `late_exit_pnl`:亏单但持仓 > 规则上限,按超额比例折算的放大损失
- `overtrading_pnl`:超出规则频率的真实交易 PnL
- `missed_signals_pnl`:残差(shadow_pnl − real_pnl − 上面四项之和)
### 反事实 Top 5
按 `|impact|` 排序,列出 5 条"最该做没做 / 最不该做却做了"的交易,带具体日期、原因。
## 对话模板
**确认规则**:
> 从你 {profitable_roundtrips} 笔盈利回合中提炼出这些规则:{rules}。这些看起来像你本人的打法吗?
**展示差值**(Section 5):
> 影子 PnL **{shadow_pnl:+.0f}** / 你真实 **{real_pnl:+.0f}** / 差值 **{delta_pnl:+.0f}**。其中 **{noise_trades_pnl:+.0f}** 来自不符合你任何盈利规则的"情绪单"。
**今日扫描**(强制附带免责):
> 今日落在你影子入场节奏的标的:{symbols}。**仅研究用,不是买入建议。**
## 规则翻译 Prompt 模板
当 `extract_shadow_strategy` 被调用时,可以注入一个 `llm_translator` callable 以把结构化 entry_condition 翻译成中文自然语言:
```
[上下文] 一位散户的盈利回合中,{N} 笔满足同一组条件:
market = {market}
entry_hour ∈ [{hour_min}, {hour_max}]
持有 {hold_lo}-{hold_hi} 天
[任务] 用 ≤30 字的中文写一条规则,口吻像用户自述的交易习惯,不要堆术语。
[输出] 只返回一行规则文本,不要解释。
```
不注入时走 f-string 模板(见 `extractor._translate_rule`)。
## 红线
- **不落单**:这些工具永远不会对接任何下单通道,仅研究输出
- **不复制他人策略**:Shadow Account 是"用户自己"的影子,不从社区/公开策略提取规则
- **样本不足必报错**:profitable roundtrips < 5 → 直接 raise,不编造
@@ -183,6 +183,14 @@ If improvements are needed after evaluation, write `action_items`:
- `"Add stop-loss: force close when loss exceeds 5%"`
- `"Add volume filter in signal_engine.py: only trigger buy on high volume"`
## Cross-Market Strategies
When the user requests a backtest with codes from **different markets** (e.g. `["000001.SZ", "BTC-USDT"]`):
- Set `source: "auto"` in `config.json`
- The `CompositeEngine` handles calendar alignment, shared capital, and per-market rules automatically
- Use volatility-adjusted weights so high-vol assets (crypto) don't dominate the risk budget
- See the [cross-market-strategy](../cross-market-strategy/SKILL.md) skill for per-market parameters, vol-adjustment, and example code
## Supporting Files
- [examples.md](examples.md) — example call sequence
+213
View File
@@ -0,0 +1,213 @@
---
name: trade-journal
description: Analyze a user's trade journal (CSV/Excel broker export). Parses 同花顺/东方财富/富途/generic formats, produces a trading profile and 4 behavior diagnostics (disposition effect, overtrading, chasing, anchoring). Use the `analyze_trade_journal` tool.
category: tool
---
# Trade Journal Analysis
## Purpose
Users upload broker exports (交割单) and get an honest, data-grounded portrait
of their own trading. Two layers are live:
- **Profile** — holding days, frequency, win rate, PnL ratio, cumulative PnL,
max drawdown, top symbols, market/hourly distribution.
- **Behavior diagnostics** — 4 biases, each with severity (low/medium/high)
and numeric evidence: disposition effect, overtrading, chasing momentum,
anchoring.
Strategy extraction → backtest bridge lands in Phase 4c.
Supported formats (auto-detected):
- **同花顺** (Tonghuashun) — A-share CSV, typically GBK-encoded
- **东方财富** (Eastmoney) — A-share CSV, typically GBK-encoded
- **富途** (Futu) — HK/US CSV, UTF-8
- **Generic** — any CSV with columns like `datetime/symbol/side/qty/price`
## Usage
**Call the `analyze_trade_journal` tool directly. Never run Python from bash.**
```
analyze_trade_journal(file_path="uploads/xxx.csv")
analyze_trade_journal(file_path="uploads/xxx.csv", analysis_type="profile")
analyze_trade_journal(file_path="uploads/xxx.csv", filter_expr="2026-01 to 2026-03")
analyze_trade_journal(file_path="uploads/xxx.csv", filter_expr="symbol=600519.SH")
analyze_trade_journal(file_path="uploads/xxx.csv", filter_expr="market=china_a")
```
`analysis_type`:
- `full` (default) — profile + behavior (strategy still placeholder)
- `profile` — profile metrics only (fastest)
- `behavior` — 4 behavior diagnostics only
- `strategy` — Phase 4c placeholder
`filter_expr` (optional):
- Date range: `"YYYY-MM to YYYY-MM"` or `"YYYY-MM-DD to YYYY-MM-DD"`
- Symbol: `"symbol=600519.SH"` (exact match on qualified symbol)
- Market: `"market=china_a|us|hk|crypto"`
## Return shape (profile subset)
```json
{
"status": "ok",
"file": "xxx.csv",
"format_detected": "tonghuashun",
"total_records": 326,
"date_range": "2026-01-06 ~ 2026-03-28",
"symbols_count": 42,
"market": "china_a",
"profile": {
"total_trades": 326,
"total_roundtrips": 118,
"avg_holding_days": 3.2,
"trade_frequency_per_week": 4.1,
"win_rate": 0.48,
"profit_loss_ratio": 1.35,
"total_pnl": 18240.55,
"max_drawdown": -9820.10,
"top_symbols": [{"symbol": "600519.SH", "trades": 14, "total_amount": 1.02e6}, ...],
"market_distribution": {"china_a": 326},
"hourly_distribution": {9: 52, 10: 84, ...},
"roundtrips_sample": [{"symbol": "600519.SH", "buy_dt": "...", "sell_dt": "...", "pnl": 3400.1, "pnl_pct": 0.021, "hold_days": 2.5}, ...]
}
}
```
Note: PnL uses FIFO lot matching; unmatched open positions are excluded from
win rate / PnL ratio (only closed round-trips count).
## Presenting results to the user
Produce a **single markdown report** in the user's language. Lead with the
top-line numbers, then section-by-section. Keep it dense — this is retail
readers skimming on a phone.
### Report template
```
## 你的交易画像 — {date_range}
**总体**
- 交易笔数:{total_trades}(完整来回 {total_roundtrips} 次)
- 平均持仓:{avg_holding_days} 天
- 交易频率:{trade_frequency_per_week} 次/周
- 胜率:{win_rate:.0%}
- 盈亏比:{profit_loss_ratio}
- 累计盈亏:{total_pnl}
- 最大回撤:{max_drawdown}
**最常交易的标的**(前 5 名)
| 标的 | 笔数 | 成交额 |
|------|------|--------|
| ... | ... | ... |
**市场分布**
{market_distribution}
**交易时段**
{hourly_distribution — highlight peak hours}
**一句话观察**
(根据数据写 1-2 句:过度交易?只做窄范围标的?集中在某时段?)
```
Guidance:
- If `win_rate < 0.4` AND `profit_loss_ratio < 1.0` → explicit warning: losing
on both win rate and payoff. Ask whether they want behavior diagnostics
(Phase 4b) or a cooling-off reality check.
- If `avg_holding_days < 1` AND `trade_frequency_per_week > 15` → flag
intraday-heavy pattern, note that minute-level backtest would be better.
- If `symbols_count <= 3` → concentration risk; ask if they want a sector-
diversification check.
## Follow-up dialogue
After the initial report, users typically ask:
- **Time-slice**: "3 月份表现怎么样" → re-call with `filter_expr="2026-03-01 to 2026-03-31"`.
- **Symbol deep-dive**: "茅台这只赚了多少" → `filter_expr="symbol=600519.SH"`.
- **Market split**: "港股和美股分开看" → two calls, `market=hk` and `market=us`.
- **Hypothetical** ("如果我严格止损 -5%") → Phase 4b feature; for now tell the
user this is on the roadmap.
Do NOT re-upload — the file path is still valid for subsequent tool calls
in the same session.
## Error handling
- `File not found` / `Unsupported extension` — ask user to re-upload.
- `Unrecognized trade journal format` — share the detected columns back to
the user and ask them to rename the key columns to: `datetime, symbol,
side, quantity, price, amount, fee` (generic fallback).
- `No trade records parsed` — likely empty file or header-only; ask user to
confirm the export contains actual fills.
## Behavior diagnostics (shape)
Under `result["behavior"]`:
```json
{
"disposition_effect": {
"severity": "high",
"ratio_loss_to_win_hold": 1.69,
"avg_winner_hold_days": 7.4,
"avg_loser_hold_days": 12.5,
"evidence": "Losing roundtrips held 12.5d vs winning 7.4d (ratio 1.69). Classic disposition pattern."
},
"overtrading": {
"severity": "high",
"busy_day_avg_pnl": -2632,
"quiet_day_avg_pnl": 759,
"evidence": "On busy days (≥3 trades) avg PnL -2632; on quiet days (≤1) avg PnL +759. High activity hurts returns."
},
"chasing_momentum": {
"severity": "medium",
"chase_ratio": 0.5,
"buys_evaluated": 4,
"evidence": "2/4 buys (50%) came after a >3% price run-up in the same symbol. Some chasing tendency."
},
"anchoring": {
"severity": "high",
"anchored_symbol_ratio": 0.83,
"symbols_evaluated": 6,
"anchored_symbols": [...],
"evidence": "5/6 frequently-traded symbols stayed in a narrow price band (CV<5%). Strong anchoring."
}
}
```
### Detection logic (for user-facing explanation)
| Bias | Metric | Medium | High |
|------|--------|--------|------|
| **Disposition effect** | avg_loser_hold / avg_winner_hold | ≥ 1.2 | ≥ 1.5 |
| **Overtrading** | (quiet − busy) / \|quiet\| day-PnL gap | ≥ 0.3 | ≥ 1.0 |
| **Chasing** | fraction of buys after 3-trade rolling +3% move | ≥ 40% | ≥ 60% |
| **Anchoring** | fraction of ≥5-trade symbols with price CV < 5% | ≥ 33% | ≥ 66% |
### Report section (Chinese)
```
## 行为偏差诊断
| 偏差 | 严重程度 | 核心证据 |
|------|----------|----------|
| 处置效应 | {high/medium/low} | {evidence} |
| 过度交易 | {...} | {...} |
| 追涨杀跌 | {...} | {...} |
| 锚定效应 | {...} | {...} |
**改进建议**(根据检测到的 high/medium 项生成):
- 处置效应 high → 写死止损(例如 -8%),盈利持仓不要过早兑现
- 过度交易 high → 每日交易次数 <= N 的硬约束
- 追涨杀跌 high → 改买回调而不是新高,设置"涨幅 X% 以上当日不追"规则
- 锚定效应 high → 扩宽价格带,不要死守某个"心理价"
```
## Phase 4c preview (not yet implemented)
Strategy extraction → SignalEngine code gen → auto-backtest lands in Phase 4c.
When the user asks for it, respond honestly and offer the behavior diagnostics
instead (they're live).
+320
View File
@@ -0,0 +1,320 @@
---
name: vnpy-export
description: Export a Vibe-Trading backtest strategy to a runnable vnpy CtaTemplate Python class — supports A-share equities, futures, and crypto via BarGenerator + ArrayManager.
category: tool
---
## Overview
This skill translates a Vibe-Trading strategy into a **vnpy `CtaTemplate` subclass** `.py` file
that can be loaded directly into the vnpy CTA Strategy App for live trading or vnpy backtesting.
Output file: `artifacts/vnpy_strategy/<StrategyName>Strategy.py` (inside the run directory).
vnpy is the most widely-used open-source quant framework in mainland China (39k+ GitHub stars).
Use this skill when the user asks to export to vnpy, requests a `/vnpy` command, or wants to
run a Vibe-Trading strategy inside vnpy's CTA backtester or live trading engine.
---
## Workflow: Export from Backtest Run
1. `load_skill("vnpy-export")` — read this guide
2. `read_file("config.json")` — extract instrument, dates, parameters, interval
3. `read_file("code/signal_engine.py")` — understand the Python signal logic
4. Determine asset class from `config.json` → choose correct CtaTemplate convention (see below)
5. Translate signal logic to CtaTemplate using the reference tables
6. `write_file("artifacts/vnpy_strategy/<StrategyName>Strategy.py")` — save the output
7. Return the class in a code block with setup instructions
## Workflow: Generate from Description
1. `load_skill("vnpy-export")` — read this guide
2. Write a CtaTemplate class from the user's strategy description
3. `write_file("artifacts/vnpy_strategy/<StrategyName>Strategy.py")` — save the output
4. Return the class with setup and usage instructions
---
## Asset Class Conventions
vnpy uses the same `CtaTemplate` base class for all asset types, but parameter conventions differ:
| Asset Class | Instrument Example | `vt_symbol` Format | Position Unit |
|-------------|-------------------|---------------------|---------------|
| A-share stock | Ping An Bank | `000001.SZSE` | shares (整手, min 100) |
| Futures | IF2406 | `IF2406.CFFEX` | lots |
| Crypto | BTC/USDT | `BTC/USDT.BINANCE` | coin units |
For **stocks**: use `buy` / `sell` only (no short selling unless margin account).
For **futures / crypto**: use all four directions — `buy`, `sell`, `short`, `cover`.
---
## CtaTemplate Structure
Every strategy must subclass `CtaTemplate` and implement these methods:
| Method | Purpose |
|--------|---------|
| `__init__` | Declare parameters, variables, BarGenerator, ArrayManager |
| `on_init` | Called once at startup; call `load_bar(n)` to warm up indicators |
| `on_start` | Called when strategy is started by user |
| `on_stop` | Called when strategy is stopped |
| `on_tick` | Receives live tick data; forward to BarGenerator |
| `on_bar` | Main logic — called once per bar by BarGenerator |
| `on_order` | Order status updates |
| `on_trade` | Fill notifications |
| `on_stop_order` | Stop-order status (if using stop orders) |
**Always call** `self.cancel_all()` at the start of `on_bar` to avoid stale orders.
**Always call** `self.put_event()` at the end of `on_bar` to refresh the UI.
---
## Full Template
See `scripts/cta_template.py` for a complete, runnable example (MA crossover).
The template below is the canonical skeleton — replace the `# SIGNAL LOGIC` section:
```python
from vnpy.app.cta_strategy import (
CtaTemplate,
StopOrder,
TickData,
BarData,
TradeData,
OrderData,
BarGenerator,
ArrayManager,
)
class {{StrategyName}}Strategy(CtaTemplate):
"""
Vibe-Trading export — {{StrategyName}}
Generated from run: {{run_id}}
Instrument: {{vt_symbol}}
"""
author = "Vibe-Trading"
# ── Parameters (editable in vnpy UI) ──────────────────────────────────
{{param_name}} = {{param_default}} # add one line per parameter
parameters = [{{param_list_as_strings}}]
# ── Variables (displayed in vnpy UI, reset on strategy restart) ────────
{{var_name}} = 0.0 # add one line per runtime variable
variables = [{{var_list_as_strings}}]
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
self.bg = BarGenerator(self.on_bar)
self.am = ArrayManager()
# initialise variable attributes to match class-level defaults
# (vnpy requires instance attributes for variables declared above)
def on_init(self):
self.write_log("Strategy initialised")
self.load_bar({{warmup_bars}}) # load enough bars to warm up all indicators
def on_start(self):
self.write_log("Strategy started")
self.put_event()
def on_stop(self):
self.write_log("Strategy stopped")
def on_tick(self, tick: TickData):
self.bg.update_tick(tick)
def on_bar(self, bar: BarData):
self.cancel_all()
am = self.am
am.update_bar(bar)
if not am.inited:
return
# ── INDICATOR CALCULATIONS ──────────────────────────────────────────
# translate indicators from signal_engine.py using the mapping table
# ── SIGNAL LOGIC ───────────────────────────────────────────────────
# set cross_over / cross_under (or long_signal / short_signal) here
# ── ORDER EXECUTION ────────────────────────────────────────────────
if cross_over:
if self.pos == 0:
self.buy(bar.close_price, 1)
elif self.pos < 0:
self.cover(bar.close_price, 1)
self.buy(bar.close_price, 1)
elif cross_under:
if self.pos == 0:
self.short(bar.close_price, 1)
elif self.pos > 0:
self.sell(bar.close_price, 1)
self.short(bar.close_price, 1)
self.put_event()
def on_order(self, order: OrderData):
pass
def on_trade(self, trade: TradeData):
self.put_event()
def on_stop_order(self, stop_order: StopOrder):
pass
```
---
## Python → ArrayManager Indicator Mapping
`ArrayManager` is vnpy's built-in vectorised indicator library. Always prefer it over pandas
when the equivalent method exists — it is faster and avoids look-ahead bias.
| Python (Vibe-Trading / pandas / ta-lib) | vnpy ArrayManager |
|----------------------------------------|-------------------|
| `df['close'].rolling(n).mean()` | `am.sma(n)` |
| `df['close'].ewm(span=n).mean()` | `am.ema(n)` |
| `ta.RSI(close, n)` | `am.rsi(n)` |
| `ta.MACD(close, 12, 26, 9)` | `am.macd(12, 26, 9)` → `(macd, signal, hist)` |
| Bollinger Bands | `am.boll(n, dev)` → `(mid, upper, lower)` |
| ATR | `am.atr(n)` |
| ADX | `am.adx(n)` |
| `df['close'].rolling(n).std()` | `am.std(n)` |
| Stochastic K, D | `am.kd(n, m)` → `(k, d)` |
| `df['high'].rolling(n).max()` | `am.high_array[-n:].max()` |
| `df['low'].rolling(n).min()` | `am.low_array[-n:].min()` |
| Donchian channel | `am.donchian(n)` → `(upper, lower)` |
| `df['close'].shift(1)` (previous bar) | `am.close_array[-2]` |
| Last N bars as array | `am.sma(n, array=True)` (returns full array) |
**Using arrays**: pass `array=True` to get the full history array (e.g. for crossover detection):
```python
fast_ma = am.sma(self.fast_window, array=True)
cross_over = fast_ma[-1] > slow_ma[-1] and fast_ma[-2] <= slow_ma[-2]
```
---
## Signal → Order Mapping
| Vibe-Trading signal | Position check | vnpy call |
|---------------------|---------------|-----------|
| Long entry | `self.pos == 0` | `self.buy(price, volume)` |
| Long entry (reverse from short) | `self.pos < 0` | `self.cover(price, vol); self.buy(price, vol)` |
| Long exit | `self.pos > 0` | `self.sell(price, volume)` |
| Short entry | `self.pos == 0` | `self.short(price, volume)` |
| Short entry (reverse from long) | `self.pos > 0` | `self.sell(price, vol); self.short(price, vol)` |
| Short exit | `self.pos < 0` | `self.cover(price, volume)` |
| Close all (stop signal) | any | `self.cancel_all()` then `sell` / `cover` as needed |
**Price conventions**:
- For backtesting: use `bar.close_price` (market order equivalent)
- For live trading with limit orders: use `bar.close_price` ± a small offset (e.g. `* 1.001`)
- For stop orders: use `self.buy_stop(trigger, volume)` / `self.short_stop(trigger, volume)`
**Volume conventions**:
- Stocks: volume in shares; must be a multiple of 100 (round lots)
- Futures: volume in lots (usually 1 for CtaTemplate strategies)
- Crypto: volume in base-currency units (e.g., BTC)
---
## Multi-Timeframe Strategies
When the Vibe-Trading strategy uses multiple timeframes (e.g., daily signal, hourly entry):
```python
def __init__(self, ...):
super().__init__(...)
self.bg = BarGenerator(self.on_bar, 5, self.on_5min_bar) # 5-min bars
self.bg_d = BarGenerator(self.on_bar, window=1, on_window_bar=self.on_daily_bar,
interval=Interval.DAILY) # daily bars
self.am = ArrayManager()
self.am_d = ArrayManager(size=100) # daily ArrayManager
def on_bar(self, bar: BarData):
self.bg.update_bar(bar) # feeds 5-min generator
def on_5min_bar(self, bar: BarData):
self.bg_d.update_bar(bar) # feeds daily generator
# put intraday entry logic here
def on_daily_bar(self, bar: BarData):
self.am_d.update_bar(bar)
# put daily trend-filter logic here
```
---
## Output File Instructions
Save the generated file to: `artifacts/vnpy_strategy/<StrategyName>Strategy.py`
To load in vnpy:
1. Copy the file to your vnpy project's `strategies/` folder (or any folder on `sys.path`)
2. Open the vnpy Trader → CTA Strategy App
3. Click **Add Strategy** → select `<StrategyName>Strategy` from the dropdown
4. Set `vt_symbol` (e.g. `IF2406.CFFEX`) and adjust parameters
5. Click **Init** → **Start** to begin
To run the vnpy backtester:
```python
from vnpy.app.cta_backtester import BacktestingEngine
from vnpy.trader.constant import Interval
engine = BacktestingEngine()
engine.set_parameters(
vt_symbol="000001.SZSE",
interval=Interval.DAILY,
start=datetime(2020, 1, 1),
end=datetime(2024, 1, 1),
rate=0.0003,
slippage=0.02,
size=1,
pricetick=0.01,
capital=1_000_000,
)
engine.add_strategy({{StrategyName}}Strategy, {})
engine.load_data()
engine.run_backtesting()
df = engine.calculate_result()
engine.calculate_statistics()
engine.show_chart()
```
---
## Quality Checklist
Before saving the output file:
- [ ] Class name ends with `Strategy` and matches the filename
- [ ] All `parameters` entries have matching class-level defaults and `__init__` instance attributes
- [ ] All `variables` entries have matching instance attributes initialised in `__init__`
- [ ] `on_bar` calls `self.cancel_all()` at the start
- [ ] `on_bar` calls `self.put_event()` at the end
- [ ] `on_bar` returns early if `not am.inited`
- [ ] Position direction checked with `self.pos` before every order call
- [ ] Stocks: no `short` / `cover` calls unless margin trading is explicitly requested
- [ ] `load_bar(n)` warmup in `on_init` is at least `max(all indicator windows) + 2`
- [ ] Comment block at top of file notes the original Vibe-Trading `run_id` and instrument
---
## References
- vnpy CtaTemplate source: `vnpy/app/cta_strategy/template.py`
- ArrayManager source: `vnpy/app/cta_strategy/base.py`
- Official docs: https://www.vnpy.com/docs/cn/cta_strategy.html
- Example strategies (official): `vnpy/app/cta_strategy/strategies/`
@@ -0,0 +1,132 @@
"""
Vibe-Trading → vnpy export: MA crossover strategy template.
This file is the reference implementation for the vnpy-export skill.
Copy it to your vnpy project's strategies/ folder and adjust parameters.
Requirements:
pip install vnpy vnpy_ctp (or vnpy_tts for paper trading)
Usage in vnpy Trader:
CTA Strategy App → Add Strategy → MaCrossStrategy
Set vt_symbol, e.g. "IF2406.CFFEX" (futures) or "000001.SZSE" (A-share)
"""
from vnpy.app.cta_strategy import (
CtaTemplate,
StopOrder,
TickData,
BarData,
TradeData,
OrderData,
BarGenerator,
ArrayManager,
)
class MaCrossStrategy(CtaTemplate):
"""
Dual moving-average crossover — generated by Vibe-Trading.
Signals:
Long entry : fast MA crosses above slow MA (golden cross)
Long exit : fast MA crosses below slow MA (death cross)
Short entry : fast MA crosses below slow MA (futures / crypto only)
Short exit : fast MA crosses above slow MA
Supports equity (long-only) and futures/crypto (long + short).
Set `trade_long_only = True` for A-share stocks.
"""
author = "Vibe-Trading"
# ── Parameters (editable in the vnpy UI) ─────────────────────────────
fast_window: int = 10
slow_window: int = 30
trade_long_only: bool = False # True for A-share stocks (no short selling)
parameters = ["fast_window", "slow_window", "trade_long_only"]
# ── Variables (live state displayed in the vnpy UI) ───────────────────
fast_ma0: float = 0.0 # fast MA, current bar
fast_ma1: float = 0.0 # fast MA, previous bar
slow_ma0: float = 0.0 # slow MA, current bar
slow_ma1: float = 0.0 # slow MA, previous bar
variables = ["fast_ma0", "fast_ma1", "slow_ma0", "slow_ma1"]
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
self.bg = BarGenerator(self.on_bar)
self.am = ArrayManager()
self.fast_ma0 = 0.0
self.fast_ma1 = 0.0
self.slow_ma0 = 0.0
self.slow_ma1 = 0.0
def on_init(self):
self.write_log("Strategy initialised")
self.load_bar(self.slow_window + 2)
def on_start(self):
self.write_log("Strategy started")
self.put_event()
def on_stop(self):
self.write_log("Strategy stopped")
def on_tick(self, tick: TickData):
self.bg.update_tick(tick)
def on_bar(self, bar: BarData):
self.cancel_all()
am = self.am
am.update_bar(bar)
if not am.inited:
return
# ── Indicators ───────────────────────────────────────────────────
fast_ma = am.sma(self.fast_window, array=True)
slow_ma = am.sma(self.slow_window, array=True)
self.fast_ma0 = fast_ma[-1]
self.fast_ma1 = fast_ma[-2]
self.slow_ma0 = slow_ma[-1]
self.slow_ma1 = slow_ma[-2]
# ── Crossover detection ──────────────────────────────────────────
cross_over = self.fast_ma0 > self.slow_ma0 and self.fast_ma1 <= self.slow_ma1
cross_under = self.fast_ma0 < self.slow_ma0 and self.fast_ma1 >= self.slow_ma1
# ── Order execution ──────────────────────────────────────────────
if cross_over:
if self.pos == 0:
self.buy(bar.close_price, 1)
elif self.pos < 0:
self.cover(bar.close_price, 1)
self.buy(bar.close_price, 1)
elif cross_under:
if self.trade_long_only:
if self.pos > 0:
self.sell(bar.close_price, 1)
else:
if self.pos == 0:
self.short(bar.close_price, 1)
elif self.pos > 0:
self.sell(bar.close_price, 1)
self.short(bar.close_price, 1)
self.put_event()
def on_order(self, order: OrderData):
pass
def on_trade(self, trade: TradeData):
self.put_event()
def on_stop_order(self, stop_order: StopOrder):
pass
+5 -3
View File
@@ -1,7 +1,9 @@
"""Swarm YAML preset loader.
Reads YAML preset files from agent/config/swarm/ and parses them into
SwarmRun / SwarmAgentSpec / SwarmTask data models.
Reads YAML preset files from the bundled ``presets/`` directory next to this
module and parses them into SwarmRun / SwarmAgentSpec / SwarmTask data models.
Keeping the YAMLs inside the ``src.swarm`` package guarantees identical
behavior under editable installs and built wheels.
"""
from __future__ import annotations
@@ -14,7 +16,7 @@ import yaml
from src.swarm.models import RunStatus, SwarmAgentSpec, SwarmRun, SwarmTask, TaskStatus
PRESETS_DIR = Path(__file__).resolve().parents[2] / "config" / "swarm"
PRESETS_DIR = Path(__file__).resolve().parent / "presets"
def load_preset(name: str) -> dict:
+51 -14
View File
@@ -9,7 +9,12 @@ from __future__ import annotations
import logging
import threading
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from concurrent.futures import (
Future,
ThreadPoolExecutor,
TimeoutError as FuturesTimeoutError,
as_completed,
)
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
@@ -338,9 +343,14 @@ class SwarmRuntime:
def _event_callback(event: SwarmEvent) -> None:
self._emit_event(run.id, event)
with ThreadPoolExecutor(max_workers=self._max_workers) as executor:
futures: dict[Future[WorkerResult], str] = {}
# Manual executor lifecycle (not `with`) so KeyboardInterrupt and
# the layer deadline don't block main on `shutdown(wait=True)` —
# `wait=False + cancel_futures=True` lets pending work drop and
# the CLI return immediately. Running workers finish naturally.
executor = ThreadPoolExecutor(max_workers=self._max_workers)
futures: dict[Future[WorkerResult], str] = {}
layer_budget = 0 # seconds — max per-task (retries × timeout) across layer
try:
for tid in layer_task_ids:
task = task_store.load_task(tid)
agent_spec = agent_map.get(task.agent_id)
@@ -378,18 +388,45 @@ class SwarmRuntime:
run_id=run.id,
)
futures[future] = tid
per_task_budget = agent_spec.timeout_seconds * (agent_spec.max_retries + 1)
layer_budget = max(layer_budget, per_task_budget)
# Collect results
for future in as_completed(futures):
tid = futures[future]
try:
results[tid] = future.result()
except Exception as exc:
logger.error("Worker for task %s raised exception", tid, exc_info=True)
results[tid] = WorkerResult(
status="failed", summary="",
error=str(exc),
# Collect results with a hard layer-level deadline — defends against
# worker threads stuck in C extensions / blocked I/O that bypass the
# in-loop timeout check (issue #42).
deadline_buffer = 60
layer_deadline = layer_budget + deadline_buffer if layer_budget else None
try:
for future in as_completed(futures, timeout=layer_deadline):
tid = futures[future]
try:
results[tid] = future.result()
except Exception as exc:
logger.error("Worker for task %s raised exception", tid, exc_info=True)
results[tid] = WorkerResult(
status="failed", summary="",
error=str(exc),
)
except FuturesTimeoutError:
for pending, tid in futures.items():
if tid in results:
continue
pending.cancel()
logger.error(
"Worker for task %s exceeded layer deadline (%ds)",
tid, layer_deadline,
)
results[tid] = WorkerResult(
status="timeout", summary="",
error=f"Worker exceeded layer deadline of {layer_deadline}s",
)
except KeyboardInterrupt:
cancel_event.set()
logger.warning("Swarm layer interrupted — cancelling pending workers")
raise
finally:
executor.shutdown(wait=False, cancel_futures=True)
return results
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more