82 Commits
Author SHA1 Message Date
Haozhe Wu 946d9ae3b9 chore(release): prepare 0.1.7 2026-05-06 20:08:12 +08:00
Haozhe Wu f917b71960 chore: add local dev workflow 2026-05-06 19:04:43 +08:00
SiMinus ecf1e51915 feat: add swarm preset inspection command (#73) 2026-05-06 19:03:34 +08:00
Haozhe Wu 052277bb56 Harden supplemental security boundaries 2026-05-05 23:30:58 +08:00
Haozhe Wu 7e167ea238 docs: collapse older readme news 2026-05-04 12:15:27 +08:00
Haozhe Wu 688d388551 docs: refresh readme news for cli ux 2026-05-04 12:13:17 +08:00
Haozhe Wu 012a499f74 fix(tests): align CI path expectations 2026-05-04 11:45:55 +08:00
Haozhe Wu 14c97d7c19 Merge pull request #69 from RuifengFu/feat/cli-ux-improvements
feat(cli): add live streaming status indicator and arrow-key navigation to interactive mode
2026-05-04 11:30:58 +08:00
Haozhe Wu 9454d4a27a Harden API and tool security defaults 2026-05-03 22:42:20 +08:00
RuifengFu 5c3d5f98a2 feat(cli): add live status bar and arrow key navigation
Two improvements to the CLI interactive mode:

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

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

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

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

# Conflicts:
#	frontend/src/components/layout/Layout.tsx
#	frontend/src/router.tsx
2026-04-30 08:00:35 +00:00
Malibongwe Ndhlovu 6512c7ba2c fix(cli): remove broken rich.box import
table.box attribute doesn't exist in this rich version.
table uses default ASCII border instead.
2026-04-30 07:58:30 +00:00
Malibongwe Ndhlovu 27cc7e74a8 fix(cli): remove broken rich.box import 2026-04-30 07:58:30 +00:00
Haozhe Wu cb87c3a37d docs: sync README updates for settings UI and validation CLI 2026-04-30 03:39:17 +08:00
BigNounce f1f3e00c97 fix: validate backtest run_dir CLI input (#60)
Validate the backtest validation CLI run_dir argument and add regression coverage for missing, blank, malformed, missing-path, and non-directory inputs.
2026-04-30 03:26:22 +08:00
TaoMu 6af0a86e2f feat: add model and data source settings UI (#57)
Adds a Settings page for LLM provider configuration and data source credentials, with local/auth protection for settings reads and writes.
2026-04-30 03:22:16 +08:00
warren618 4dcb33034a fix(packaging): bundle swarm presets inside src.swarm (#55) + 0.1.6 release
Move 29 preset YAMLs from agent/config/swarm/ to agent/src/swarm/presets/
so they ship as package-data; loader resolves via
Path(__file__).parent / "presets". Identical behavior under editable
installs and built wheels — fixes "No presets available" reported in #55
after pip install / uv tool install vibe-trading-ai 0.1.5.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: clamp MiniMax temperature to 0.01 instead of 1.0

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

---------

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

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

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "Vibe-Trading",
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
}
},
"forwardPorts": [
8899,
5899
],
"portsAttributes": {
"8899": {
"label": "Vibe-Trading API",
"onAutoForward": "notify",
"visibility": "private"
},
"5899": {
"label": "Vibe-Trading Frontend",
"onAutoForward": "notify",
"visibility": "private"
}
},
"postCreateCommand": "python -m pip install --upgrade pip && python -m pip install -e . && cd frontend && npm install",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python"
}
}
}
}
+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
+24 -1
View File
@@ -1,4 +1,4 @@
.venv/
.venv/
agent/.venv/
__pycache__/
.pytest_cache/
@@ -16,10 +16,15 @@ agent/sessions/
agent/runs/
agent/.ui_runtime/
agent/.tasks/
agent/uploads/
agent/tests/*_result.json
.ruff_cache/
frontend/dist/
frontend/tsconfig.tsbuildinfo
.vibe-dev/
.vibe-record/
.cache/
.remotion/
# DuckDB data cache
data/*.duckdb
@@ -40,3 +45,21 @@ agent/vibe_trading_ai.egg-info/
agent/tests/e2e_backtest/
dist/
build/
# Local demo videos (too large for git; link to external host instead)
assets/*.mp4
# Local demo / promo production workspace
demo/
# Manual E2E smoke tests — require live API keys (OpenRouter / yfinance),
# would break CI if committed. Kept local; run explicitly when validating
# a release.
agent/tests/e2e_*.py
agent/tests/test_e2e_*.py
# Local working notes
NOTES.md
# Agent tool aliases (auto-generated mirror of CLAUDE.md)
AGENTS.md
+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/` | 74 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
+13
View File
@@ -14,6 +14,12 @@ RUN npm run build
# ============================================================================
FROM python:3.11-slim AS runtime
LABEL org.opencontainers.image.title="Vibe-Trading" \
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \
org.opencontainers.image.version="0.1.7" \
org.opencontainers.image.source="https://github.com/HKUDS/Vibe-Trading" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
# System deps
@@ -35,6 +41,13 @@ COPY --from=frontend-build /app/frontend/dist frontend/dist
# Install CLI entrypoint
RUN pip install --no-cache-dir -e .
# Runtime should not run as root. Keep writable app data directories owned by
# the service user so named Docker volumes inherit usable permissions.
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs \
&& chown -R vibe:vibe /app
USER vibe
# Default port
EXPOSE 8899
+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
+210 -75
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-74-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,35 @@
## 📰 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-05-05** 🛡️ **Security boundary follow-up**: Completes the remaining security-boundary hardening around explicit CORS origins, Settings credential indicators, web URL reading, and Shadow Account code generation, with regression tests added for each path. Normal localhost CLI/Web UI workflows stay the same; remote deployments should continue using `API_AUTH_KEY` and explicit trusted origins.
- **2026-05-04** 🖥️ **Interactive CLI UX + CI cleanup**: Interactive mode now has a live bottom status bar showing provider/model, session duration, last-run latency, and cumulative tool-call stats, plus prompt history navigation and cursor editing with arrow keys via `prompt_toolkit` ([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). The CLI still falls back to Rich prompts when `prompt_toolkit` or a TTY is unavailable. CI path expectations were also aligned with the hardened file-import sandbox and cross-platform `/tmp` resolution, returning main to green ([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
- **2026-05-03** 🛡️ **Security hardening patch**: Tightens default API authentication for non-local deployments, protects sensitive run/session/swarm reads, restricts upload and local file-reading boundaries, gates shell-capable tools by entry point, validates generated strategy loading before import, and runs the Docker image as a non-root user with a localhost-only published port by default. Local CLI and localhost Web UI workflows remain low-friction; remote API/Web deployments should set `API_AUTH_KEY`.
<details>
<summary>Earlier news</summary>
- **2026-05-02** 🧭 **Dividend analysis + sharper roadmap**: Added the `dividend-analysis` skill for income stocks, payout sustainability, dividend growth, shareholder yield, ex-dividend mechanics, and yield-trap checks, pinned by bundled-skill regression tests. The public roadmap now focuses on upcoming work: Research Autopilot, Data Bridge, Options Lab, Portfolio Studio, Alpha Zoo, Research Delivery, Trust Layer, and Community sharing.
- **2026-05-01** 🔥 **Correlation heatmap + OpenAI Codex OAuth + A-share pre-ST filter**: New correlation dashboard/API computes rolling return correlations and renders an ECharts heatmap for portfolio and symbol analysis ([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). OpenAI Codex provider support now uses ChatGPT OAuth via `vibe-trading provider login openai-codex`, with Settings metadata and adapter regression tests ([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). Added and hardened the `ashare-pre-st-filter` skill for A-share ST/*ST risk screening, including Sina penalty relevance filtering so securities-account mentions do not inflate E2 counts ([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
- **2026-04-30** ⚙️ **Web UI Settings + validation CLI hardening**: New Settings page for LLM provider/model, base URL, reasoning effort, and data source credentials, backed by local/auth-protected settings APIs and data-driven provider metadata ([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). Also hardens `python -m backtest.validation <run_dir>` so missing, blank, malformed, non-existent, and non-directory inputs fail with clear operator-facing messages before validation starts ([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
- **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)).
- **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 +88,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>
• **6 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 +104,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/74_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
• 74 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,17 +119,17 @@ 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">
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
<h3>📊 Cross-Market Backtest</h3>
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<img src="https://img.shields.io/badge/6_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,21 +148,22 @@ Vibe-Trading is an AI-powered multi-agent finance workspace that turns natural l
</tr>
</table>
## 68 Skills across 7 Categories
## 74 Skills across 8 Categories
- 📊 68 specialized finance skills organized into 7 categories
- 📊 74 specialized finance skills organized into 8 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` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
## 29 Agent Swarm Team Presets
@@ -222,8 +248,9 @@ vibe-trading-mcp # start MCP server (stdio)
- An **LLM API key** from any supported provider — or run locally with **Ollama** (no key needed)
- **Python 3.11+** for Path B
- **Docker** for Path A
- OpenAI Codex can also be used with ChatGPT OAuth: set `LANGCHAIN_PROVIDER=openai-codex`, then run `vibe-trading provider login openai-codex`. This does not use `OPENAI_API_KEY`.
> **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.
@@ -239,6 +266,8 @@ docker compose up --build
Open `http://localhost:8899`. Backend + frontend in one container.
Docker publishes the backend on `127.0.0.1:8899` by default and runs the app as a non-root container user. If you intentionally expose the API beyond your own machine, set a strong `API_AUTH_KEY` and send `Authorization: Bearer <key>` from clients.
### Path B: Local install
```bash
@@ -303,11 +332,27 @@ Copy `agent/.env.example` to `agent/.env` and uncomment the provider block you w
| `LANGCHAIN_MODEL_NAME` | Yes | Model name (e.g. `deepseek/deepseek-v3.2`) |
| `TUSHARE_TOKEN` | No | Tushare Pro token for A-share data (falls back to AKShare) |
| `TIMEOUT_SECONDS` | No | LLM call timeout, default 120s |
| `API_AUTH_KEY` | Recommended for network deployments | Bearer token required when the API is reachable from non-local clients |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | Explicit opt-in for shell-capable tools in remote API/MCP-SSE style deployments |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | Extra comma-separated roots for document and broker-journal imports |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | Extra comma-separated roots for generated-code run directories |
<sub>* Ollama does not require an API key.</sub>
<sub>* Ollama does not require an API key. OpenAI Codex uses ChatGPT OAuth and stores tokens via `oauth-cli-kit`, not in `agent/.env`.</sub>
**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 +369,7 @@ vibe-trading serve # API server
| Command | Description |
|---------|-------------|
| `/help` | Show all commands |
| `/skills` | List all 68 finance skills |
| `/skills` | List all 74 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 +378,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 +406,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 +416,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 +496,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 |
@@ -389,14 +504,30 @@ vibe-trading serve --port 8899
| `GET` | `/swarm/presets` | List swarm presets |
| `POST` | `/swarm/runs` | Start swarm run |
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE stream |
| `GET` | `/settings/llm` | Read Web UI LLM settings |
| `PUT` | `/settings/llm` | Update local LLM settings |
| `GET` | `/settings/data-sources` | Read local data source settings |
| `PUT` | `/settings/data-sources` | Update local data source settings |
Interactive docs: `http://localhost:8899/docs`
### Security defaults
For localhost development, `vibe-trading serve` keeps the browser workflow simple. For any non-local client, sensitive API endpoints require `API_AUTH_KEY`; use `Authorization: Bearer <key>` for JSON/upload requests. Browser EventSource streams are handled by the Web UI after you enter the same key once in Settings.
Shell-capable tools are available to local CLI and trusted localhost workflows, but are not exposed to remote API sessions unless you explicitly set `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`. Document and journal readers are limited to upload/import roots by default; place files under `agent/uploads`, `agent/runs`, `./uploads`, `./data`, `~/.vibe-trading/uploads`, or `~/.vibe-trading/imports`, or add a dedicated directory through `VIBE_TRADING_ALLOWED_FILE_ROOTS`.
### Web UI Settings
The Web UI Settings page lets local users update the LLM provider/model, base URL, generation parameters, reasoning effort, and optional market data credentials such as the Tushare token. Settings are persisted to `agent/.env`; provider defaults are loaded from `agent/src/providers/llm_providers.json`.
Settings reads are side-effect free: `GET /settings/llm` and `GET /settings/data-sources` never create `agent/.env`, and they only return project-relative paths. Settings reads and writes can expose credential state or update credentials/runtime environment, so they require `API_AUTH_KEY` when configured. If `API_AUTH_KEY` is unset for dev mode, settings access is accepted only from loopback clients.
---
## 🔌 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 +569,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 +589,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 74 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 +611,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 74 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 +627,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 (74 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/ # 74 finance skills in 8 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 +713,14 @@ Vibe-Trading is part of the **[HKUDS](https://github.com/HKUDS)** agent ecosyste
| Phase | Feature | Status |
|-------|---------|--------|
| **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 |
| | Live data streaming via WebSocket | Exploring |
| | Strategy marketplace (share & discover) | Exploring |
| **Research Autopilot** | Overnight research loop: hypothesis → data pull → backtest → evidence report | In Progress |
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Planned |
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Planned |
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 factor libraries with screening + IC tests | Planned |
| **Research Delivery** | Scheduled briefs to Slack / Telegram / email-style channels | Planned |
| **Trust Layer** | Reproducible run cards: tool trace, data sources, assumptions, citations | Planned |
| **Community** | Shareable skills, presets, and strategy cards | Exploring |
---
@@ -625,6 +754,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>
+697
View File
@@ -0,0 +1,697 @@
<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-74-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-05-05** 🛡️ **متابعة لتعزيز الحدود الأمنية**: يستكمل تعزيز الحدود الأمنية حول origins الصريحة في CORS، ومؤشرات بيانات الاعتماد في Settings، وقراءة عناوين URL على الويب، وتوليد كود Shadow Account، مع إضافة اختبارات انحدار لكل مسار. تبقى تدفقات CLI وWeb UI على localhost كما هي؛ ويجب أن تستمر عمليات النشر البعيدة في استخدام `API_AUTH_KEY` وorigins موثوقة صريحة.
- **2026-05-04** 🖥️ **تحسين تجربة CLI التفاعلية + تنظيف CI**: أصبح الوضع التفاعلي يعرض شريط حالة سفلياً مباشراً يوضح provider/model ومدة الجلسة ووقت آخر تشغيل وإحصاءات استدعاءات الأدوات التراكمية، مع دعم تصفح السجل وتحرير المؤشر بأسهم لوحة المفاتيح عبر `prompt_toolkit` ([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). وعند غياب `prompt_toolkit` أو TTY، يعود CLI إلى Rich prompts. كما تمت مواءمة توقعات مسارات CI مع صندوق استيراد الملفات المعزز وحل `/tmp` عبر المنصات، فعاد main إلى الحالة الخضراء ([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
- **2026-05-03** 🛡️ **تصحيح لتعزيز الأمان**: يشدد المصادقة الافتراضية للـ API في النشر غير المحلي، ويحمي قراءات run/session/swarm الحساسة، ويقيّد حدود الرفع وقراءة الملفات المحلية، ويتحكم في أدوات shell بحسب نقطة الدخول، ويتحقق من الاستراتيجيات المولدة قبل الاستيراد، ويجعل صورة Docker تعمل افتراضياً كمستخدم غير root وبمنفذ منشور على localhost فقط. تبقى تجربة CLI وWeb UI المحلي منخفضة الاحتكاك؛ ويجب على نشر API/Web البعيد ضبط `API_AUTH_KEY`.
<details>
<summary>أخبار سابقة</summary>
- **2026-05-02** 🧭 **تحليل التوزيعات + خارطة طريق أوضح**: تمت إضافة مهارة `dividend-analysis` لأسهم الدخل، واستدامة التوزيعات، ونمو التوزيعات، وعائد المساهمين، وآليات تاريخ الاستحقاق السابق، وفحص فخاخ العائد المرتفع، مع تثبيتها باختبارات انحدار للمهارات المضمنة. تركز خارطة الطريق العامة الآن على Research Autopilot وData Bridge وOptions Lab وPortfolio Studio وAlpha Zoo وResearch Delivery وTrust Layer ومشاركة Community.
- **2026-05-01** 🔥 **خريطة ارتباط حرارية + OpenAI Codex OAuth + مرشح pre-ST لأسهم A**: لوحة/API ارتباط جديدة تحسب ارتباطات العوائد المتحركة وتعرضها كخريطة حرارية ECharts لتحليل المحافظ والرموز ([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). مزود OpenAI Codex يدعم الآن ChatGPT OAuth عبر `vibe-trading provider login openai-codex`، مع بيانات Settings واختبارات انحدار للمحوّل ([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). تمت إضافة وتعزيز مهارة `ashare-pre-st-filter` لفحص مخاطر ST/*ST في أسهم A، مع فلترة صلة عقوبات Sina حتى لا تضخم إشارات قوائم حسابات الأوراق المالية عدّادات E2 ([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
- **2026-04-30** ⚙️ **إعدادات Web UI + تعزيز validation CLI**: تمت إضافة صفحة Settings لإعداد LLM provider/model وBase URL وreasoning effort وبيانات اعتماد مصادر البيانات محلياً. واجهات settings API محمية الآن عبر local/auth، كما أصبحت بيانات مزودي النماذج إعدادات مدفوعة بالبيانات ([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). كذلك تم تعزيز `python -m backtest.validation <run_dir>` لرفض غياب الوسيط، والمسار الفارغ، والمسار غير الصالح، والمسار غير الموجود، والمسار الذي ليس دليلاً برسائل واضحة قبل بدء التحقق ([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
- **2026-04-28** 🚀 **إصدار v0.1.6** (`pip install -U vibe-trading-ai`): إصلاح إرجاع `vibe-trading --swarm-presets` فارغًا بعد `pip install` / `uv tool install` ([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)) — ملفات 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)).
- **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/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="المهارات" /><br><br>
<div align="left" style="font-size: 4px;">
• 74 مهارة متخصصة مع ذاكرة دائمة عبر الجلسات<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/6_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>
## 74 مهارة عبر 8 فئات
- 📊 74 مهارة مالية متخصصة منظمة في 8 فئات
- 🌐 تغطية شاملة من الأسواق التقليدية إلى العملات المشفرة و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` |
| التحليل | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-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` |
| الأدوات | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
## 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`. الخلفية + الواجهة الأمامية في حاوية واحدة.
ينشر Docker الخلفية افتراضياً على `127.0.0.1:8899` فقط، ويشغل التطبيق كمستخدم حاوية غير root. إذا كنت تنوي تعريض الـ API خارج جهازك، فاضبط `API_AUTH_KEY` قوياً وأرسل `Authorization: Bearer <key>` من العملاء.
### المسار B: التثبيت المحلي
```bash
git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
python -m venv .venv
# التفعيل
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 ثانية |
| `API_AUTH_KEY` | موصى به للنشر الشبكي | Bearer token مطلوب عندما يكون الـ API قابلاً للوصول من عملاء غير محليين |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | لا | تفعيل صريح لأدوات shell في نشر API / MCP-SSE البعيد |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | لا | جذور إضافية مفصولة بفواصل لاستيراد المستندات وسجلات الوسطاء |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | لا | جذور إضافية مفصولة بفواصل لأدلة تشغيل الكود المولد |
<sub>* Ollama لا يتطلب مفتاح API.</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` | عرض جميع مهارات التداول الـ 74 |
| `/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 للسرب |
| `GET` | `/settings/llm` | قراءة إعدادات LLM في واجهة الويب |
| `PUT` | `/settings/llm` | تحديث إعدادات LLM المحلية |
| `GET` | `/settings/data-sources` | قراءة إعدادات مصادر البيانات المحلية |
| `PUT` | `/settings/data-sources` | تحديث إعدادات مصادر البيانات المحلية |
توثيق تفاعلي: `http://localhost:8899/docs`
### الإعدادات الأمنية الافتراضية
في التطوير المحلي، يحافظ `vibe-trading serve` على بساطة سير عمل المتصفح. أي عميل غير محلي يصل إلى واجهات API الحساسة يحتاج إلى `API_AUTH_KEY`؛ استخدم `Authorization: Bearer <key>` لطلبات JSON والرفع. يتعامل Web UI مع بث EventSource بعد إدخال المفتاح نفسه مرة واحدة في Settings.
أدوات shell متاحة للـ CLI المحلي وسير عمل localhost الموثوق، لكنها لا تُعرض افتراضياً لجلسات API البعيدة إلا إذا ضبطت صراحة `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`. قارئات المستندات وسجلات التداول مقيدة افتراضياً بجذور الرفع/الاستيراد؛ ضع الملفات تحت `agent/uploads` أو `agent/runs` أو `./uploads` أو `./data` أو `~/.vibe-trading/uploads` أو `~/.vibe-trading/imports`، أو أضف دليلاً مخصصاً عبر `VIBE_TRADING_ALLOWED_FILE_ROOTS`.
### إعدادات Web UI
تتيح صفحة Settings في واجهة الويب للمستخدمين المحليين تحديث LLM provider/model وBase URL ومعلمات التوليد وreasoning effort وبيانات اعتماد مصادر السوق الاختيارية مثل رمز Tushare. تُحفظ الإعدادات في `agent/.env`، وتُحمّل القيم الافتراضية للمزودين من `agent/src/providers/llm_providers.json`.
قراءة Settings بلا آثار جانبية: لا تنشئ `GET /settings/llm` و`GET /settings/data-sources` ملف `agent/.env`، وتعيدان فقط مسارات نسبية للمشروع. قد تكشف قراءة وكتابة Settings حالة بيانات الاعتماد أو تحدّث بيانات الاعتماد/بيئة التشغيل، لذلك تتطلب `API_AUTH_KEY` عند ضبطه. إذا لم يُضبط `API_AUTH_KEY` في وضع التطوير، فلا يُسمح بالوصول إلى Settings إلا من عملاء loopback المحليين.
---
## 🔌 إضافة MCP
يقدم 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>
جميع مهارات التداول الـ 74 منشورة على [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 تلقائياً جميع المهارات الـ 74، مما يتيح الإصلاح التلقائي والتحسين التلقائي والمشاركة المجتمعية. ابحث عن مهارات 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 # محمل المهارات (74 مدمجة + إنشاء 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/ # 74 مهارة مالية في 8 فئات (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) عند بدء العمل.
| المرحلة | الميزة | الحالة |
|-------|---------|--------|
| **Research Autopilot** | حلقة بحث ليلية: فرضية → جلب بيانات → اختبار رجعي → تقرير أدلة | قيد التنفيذ |
| **Data Bridge** | أحضر بياناتك: موصلات CSV/Parquet/SQL محلية مع schema mapping | مخطط |
| **Options Lab** | سطح تقلب، لوحة Greeks، ومستكشف العوائد/السيناريوهات | مخطط |
| **Portfolio Studio** | أشعة مخاطر، قيود، محسّن يراعي الدوران، وملاحظات إعادة التوازن | مخطط |
| **Alpha Zoo** | مكتبات عوامل Alpha101 / Alpha158 / Alpha191 مع فحص واختبارات IC | مخطط |
| **Research Delivery** | موجزات مجدولة إلى Slack / Telegram / قنوات شبيهة بالبريد | مخطط |
| **Trust Layer** | بطاقات تشغيل قابلة للإعادة: أثر الأدوات، مصادر البيانات، الافتراضات، الاستشهادات | مخطط |
| **Community** | skills وpresets وstrategy cards قابلة للمشاركة | قيد الاستكشاف |
---
## المساهمة
نرحب بالمساهمات! راجع [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>
+138 -74
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-74-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,35 @@
## 📰 ニュース
- **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-05-05** 🛡️ **セキュリティ境界の追加強化**: 明示的な CORS origin、Settings の認証情報ステータス表示、Web URL 読み取り、Shadow Account コード生成まわりの残りのセキュリティ境界を補強し、それぞれに回帰テストを追加しました。localhost の CLI/Web UI ワークフローは従来どおりです。リモートデプロイでは引き続き `API_AUTH_KEY` と明示的な信頼済み origin を設定してください。
- **2026-05-04** 🖥️ **インタラクティブCLI UX + CI整理**: インタラクティブモードに、provider/model、セッション時間、直近実行時間、累計ツール呼び出し統計を表示するライブ下部ステータスバーを追加。さらに `prompt_toolkit` により上下キーの履歴移動と左右キーのカーソル編集に対応しました([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。`prompt_toolkit` またはTTYが利用できない場合は、従来どおりRich promptにフォールバックします。CIのパス期待値も強化済みファイルimportサンドボックスとクロスプラットフォームな `/tmp` 解決に合わせ、mainはグリーンに戻りました([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
- **2026-05-03** 🛡️ **セキュリティハードニングパッチ**: 非ローカルデプロイ向けの既定API認証を強化し、機密性の高いrun/session/swarm読み取りを保護、アップロードとローカルファイル読み取り境界を制限、shell系ツールをエントリーポイント別に制御、生成戦略をimport前に検証し、Dockerイメージは既定で非rootユーザーかつlocalhost限定ポート公開で動作します。CLIとlocalhost Web UIは低摩擦のままです。リモートAPI/Webデプロイでは`API_AUTH_KEY`を設定してください。
<details>
<summary>過去のニュース</summary>
- **2026-05-02** 🧭 **配当分析 + ロードマップ刷新**: インカム株、配当の持続性、増配、株主還元利回り、権利落ちメカニクス、利回りの罠チェックに対応する `dividend-analysis` スキルを追加し、バンドルスキル回帰テストで固定しました。公開ロードマップは Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer、Community 共有に絞りました。
- **2026-05-01** 🔥 **相関ヒートマップ + OpenAI Codex OAuth + A株 pre-ST フィルター**: 新しい相関ダッシュボード/APIでローリングリターン相関を計算し、ポートフォリオや銘柄分析向けに ECharts ヒートマップで可視化します([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider は `vibe-trading provider login openai-codex` による ChatGPT OAuth に対応し、Settings メタデータとアダプター回帰テストも追加([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。A株の ST/*ST リスクスクリーニング用 `ashare-pre-st-filter` スキルを追加・強化し、Sina 処分公告の関連性フィルターにより証券口座リスト内の言及が E2 回数を水増ししないようにしました([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
- **2026-04-30** ⚙️ **Web UI設定 + validation CLI強化**: LLM provider/model、Base URL、reasoning effort、データソース認証情報をローカルで設定できる Settings ページを追加。settings API は local/auth で保護され、provider メタデータもデータ駆動設定に移行([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。さらに `python -m backtest.validation <run_dir>` を強化し、引数なし・空パス・不正パス・存在しないパス・ディレクトリでないパスを検証開始前に分かりやすく失敗させます([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
- **2026-04-28** 🚀 **v0.1.6 リリース**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 後に `vibe-trading --swarm-presets` が空を返す問題を修正([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))— プリセット YAML を `src.swarm` パッケージ内に同梱、6 件の回帰テストでピン留め。加えて AKShare ローダーが 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))を追加。
- **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 +88,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>
• **6データソース、ゼロ設定** — A株、HK/US、暗号、先物、FX対応、自動フォールバック<br>
• **29の専門チーム** — 投資・トレーディング・リスク向けマルチエージェントスウォームワークフロー<br>
• **クロスセッションメモリ** — ユーザーの好みやインサイトを記憶し、再利用可能なスキルを自動生成・進化<br>
• **7つのバックテストエンジン** — クロスマーケット複合テスト + 統計検証 + 4種オプティマイザー<br>
• **マルチプラットフォームエクスポート** — ワンクリックでTradingView、TDX、MetaTrader 5へ
---
@@ -79,12 +104,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/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• 市場横断のマルチドメイン分析<br>
• 自動ストラテジー/シグナル生成<br>
• マクロ経済リサーチとインサイト<br>
• チャットによる自然言語タスクルーティング
• 74の専門スキル + クロスセッション永続メモリ<br>
• 自己進化: エージェントが経験からワークフローを構築・改善<br>
• 5層コンテキスト圧縮 — 長い会話でも情報損失なし<br>
• 全金融ドメインにわたる自然言語タスクルーティング
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,17 +119,17 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
<div align="left">
• 即戦力の29種トレーディングチームプリセット<br>
• DAGベースのマルチエージェントオーケストレーション<br>
• リアルタイム意思決定ストリーミングダッシュボード<br>
• YAMLによるカスタムチーム構築
• リアルタイムストリーミングダッシュボード(エージェントステータス表示)<br>
• FTS5クロスセッション検索で過去の全会話を横断検索
</div>
</td>
<td align="center" width="25%" valign="top">
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
<h3>📊 クロスマーケットバックテスト</h3>
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<img src="https://img.shields.io/badge/6_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,21 +148,22 @@ Vibe-Tradingは、自然言語リクエストをグローバル市場向けの
</tr>
</table>
## 7カテゴリにわたる68スキル
## 8カテゴリにわたる74スキル
- 📊 68の金融特化スキルを7カテゴリに整理
- 📊 74の金融特化スキルを8カテゴリに整理
- 🌐 伝統的市場から暗号・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` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
## 29種のエージェントスウォームチームプリセット
@@ -221,7 +247,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で無料フォールバック。
@@ -237,6 +263,8 @@ docker compose up --build
`http://localhost:8899`を開きます。バックエンドとフロントエンドを1コンテナで提供。
Dockerは既定でバックエンドを`127.0.0.1:8899`にのみ公開し、非rootコンテナユーザーでアプリを実行します。APIを自分のマシン外へ意図的に公開する場合は、強い`API_AUTH_KEY`を設定し、クライアントから`Authorization: Bearer <key>`を送ってください。
### Path B: ローカルインストール
```bash
@@ -301,11 +329,27 @@ npx clawhub@latest install vibe-trading --force
| `LANGCHAIN_MODEL_NAME` | Yes | モデル名(例: `deepseek/deepseek-v3.2`) |
| `TUSHARE_TOKEN` | No | A株データ用Tushare Proトークン(AKShareにフォールバック) |
| `TIMEOUT_SECONDS` | No | LLM呼び出しタイムアウト(既定120s) |
| `API_AUTH_KEY` | ネットワークデプロイでは推奨 | APIが非ローカルクライアントから到達可能な場合に必要なBearer token |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | リモートAPI / MCP-SSE系デプロイでshell系ツールを明示的に有効化 |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 文書・ブローカー取引明細インポート用の追加ルート(カンマ区切り) |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 生成コードrunディレクトリ用の追加ルート(カンマ区切り) |
<sub>* OllamaはAPIキー不要。</sub>
**無料データ(キー不要):** 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 +366,7 @@ vibe-trading serve # APIサーバー
| Command | Description |
|---------|-------------|
| `/help` | すべてのコマンドを表示 |
| `/skills` | 68の金融スキルを一覧表示 |
| `/skills` | 74の金融スキルを一覧表示 |
| `/swarm` | 29のスウォームチームプリセットを一覧表示 |
| `/swarm run <preset> [vars_json]` | スウォームチームをライブストリーミングで実行 |
| `/swarm list` | スウォーム実行履歴 |
@@ -331,7 +375,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 +402,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 +422,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イベントストリーム |
@@ -386,14 +430,30 @@ vibe-trading serve --port 8899
| `GET` | `/swarm/presets` | スウォームプリセット一覧 |
| `POST` | `/swarm/runs` | スウォーム実行開始 |
| `GET` | `/swarm/runs/{id}/events` | スウォームSSEストリーム |
| `GET` | `/settings/llm` | Web UIのLLM設定を読み取り |
| `PUT` | `/settings/llm` | ローカルLLM設定を更新 |
| `GET` | `/settings/data-sources` | ローカルデータソース設定を読み取り |
| `PUT` | `/settings/data-sources` | ローカルデータソース設定を更新 |
インタラクティブドキュメント: `http://localhost:8899/docs`
### セキュリティ既定値
localhost開発では、`vibe-trading serve`はブラウザワークフローをシンプルに保ちます。非ローカルクライアントから機密APIへアクセスする場合は`API_AUTH_KEY`が必要です。JSON/アップロードリクエストでは`Authorization: Bearer <key>`を使ってください。ブラウザEventSourceストリームは、Web UIのSettingsで同じキーを一度入力すると処理されます。
shell系ツールはローカルCLIと信頼済みlocalhostワークフローでは利用できますが、リモートAPIセッションには既定で公開されません。必要な場合のみ`VIBE_TRADING_ENABLE_SHELL_TOOLS=1`を明示的に設定してください。文書・取引明細リーダーは既定でアップロード/インポートルートに制限されます。ファイルは`agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads`、`~/.vibe-trading/imports`へ置くか、`VIBE_TRADING_ALLOWED_FILE_ROOTS`で専用ディレクトリを追加してください。
### Web UI Settings
Web UI Settingsページでは、ローカルユーザーがLLM provider/model、Base URL、生成パラメータ、reasoning effort、Tushare tokenなどの任意の市場データ認証情報を更新できます。設定は`agent/.env`に保存され、providerのデフォルト値は`agent/src/providers/llm_providers.json`から読み込まれます。
Settingsの読み取りは副作用なしです。`GET /settings/llm`と`GET /settings/data-sources`は`agent/.env`を作成せず、プロジェクト相対パスのみを返します。Settingsの読み取り/書き込みは認証情報の状態を公開したり認証情報・実行時環境を更新したりする可能性があるため、`API_AUTH_KEY`が設定されている場合は認証が必要です。開発モードで`API_AUTH_KEY`が未設定の場合、settingsアクセスはloopbackローカルクライアントのみに許可されます。
---
## 🔌 MCP プラグイン
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 +495,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 +515,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の自己進化エンジンで自律的に進化します。
全74金融スキルは[open-space.cloud](https://open-space.cloud)に公開され、OpenSpaceの自己進化エンジンで自律的に進化します。
OpenSpaceで使うには、両方のMCPサーバーをエージェント設定に追加:
@@ -477,7 +537,7 @@ OpenSpaceで使うには、両方のMCPサーバーをエージェント設定
}
```
OpenSpaceが全68スキルを自動検出し、auto-fix/auto-improve/コミュニティ共有を有効化。`search_skills("finance backtest")`でVibe-Tradingスキルを検索可能。
OpenSpaceが全74スキルを自動検出し、auto-fix/auto-improve/コミュニティ共有を有効化。`search_skills("finance backtest")`でVibe-Tradingスキルを検索可能。
</details>
@@ -493,41 +553,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 # スキルローダー(74バンドル + ユーザー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/ # 8カテゴリに渡る74金融スキル (各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 +639,14 @@ Vibe-Tradingは**[HKUDS](https://github.com/HKUDS)**エージェントエコシ
| Phase | Feature | Status |
|-------|---------|--------|
| **Analysis & Viz** | オプションボラティリティサーフェスとギリシャ値3D可視化 | Planned |
| | ローリングウィンドウ+クラスタリングによるクロスアセット相関ヒートマップ | Planned |
| | CLIバックテスト出力でのベンチマーク比較 | Planned |
| | バックテスト指標へのCalmar Ratio & Omega Ratio追加 | Planned |
| **Skills & Presets** | 配当分析スキル | Planned |
| | ESG/サステナブル投資スウォームプリセット | Planned |
| | 新興市場リサーチデスクスウォームプリセット | Planned |
| **Portfolio & Optimization** | レバレッジ・セクター上限・回転率制約付き高度ポートフォリオオプティマイザー | Planned |
| **Future** | 初心者チュートリアル: 「5分で自然言語バックテスト」 | Planned |
| | WebSocket経由のライブデータストリーミング | Exploring |
| | ストラテジーマーケットプレイス(共有・発見) | Exploring |
| **Research Autopilot** | 夜間リサーチループ: 仮説 → データ取得 → バックテスト → 証拠レポート | In Progress |
| **Data Bridge** | 持ち込みデータ: ローカル CSV/Parquet/SQL コネクタ + schema mapping | Planned |
| **Options Lab** | ボラティリティサーフェス、Greeks ダッシュボード、損益/シナリオ探索 | Planned |
| **Portfolio Studio** | リスクX線、制約、回転率考慮オプティマイザー、リバランスノート | Planned |
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 因子ライブラリ、スクリーニング + IC テスト | Planned |
| **Research Delivery** | Slack / Telegram / メール型チャネルへの定期ブリーフ配信 | Planned |
| **Trust Layer** | 再現可能な run card: ツール履歴、データソース、仮定、引用 | Planned |
| **Community** | 共有可能な skills、presets、strategy cards | Exploring |
---
@@ -622,6 +680,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>
+139 -75
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-74-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,35 @@
## 📰 뉴스
- **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-05-05** 🛡️ **보안 경계 후속 강화**: 명시적 CORS origin, Settings 자격 증명 상태 표시, 웹 URL 읽기, Shadow Account 코드 생성 주변의 남은 보안 경계를 보강하고 각 경로에 회귀 테스트를 추가했습니다. localhost CLI/Web UI 흐름은 그대로 유지됩니다. 원격 배포에서는 계속 `API_AUTH_KEY`와 명시적인 신뢰 origin을 사용하세요.
- **2026-05-04** 🖥️ **대화형 CLI UX + CI 정리**: 대화형 모드에 provider/model, 세션 시간, 직전 실행 시간, 누적 도구 호출 통계를 보여주는 실시간 하단 상태 표시줄이 추가되었습니다. 또한 `prompt_toolkit`을 통해 위/아래 방향키 히스토리 탐색과 좌/우 방향키 커서 편집을 지원합니다([#69](https://github.com/HKUDS/Vibe-Trading/pull/69)). `prompt_toolkit` 또는 TTY를 사용할 수 없으면 기존 Rich prompt로 자동 폴백합니다. CI 경로 기대값도 강화된 파일 import 샌드박스와 크로스플랫폼 `/tmp` 해석에 맞춰 정리되어 main이 다시 green 상태가 되었습니다([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758)).
- **2026-05-03** 🛡️ **보안 강화 패치**: 비로컬 배포의 기본 API 인증을 강화하고, 민감한 run/session/swarm 읽기 API를 보호하며, 업로드와 로컬 파일 읽기 경계를 제한하고, shell 가능 도구를 진입점별로 제어합니다. 생성된 전략은 import 전에 검증되며 Docker 이미지는 기본적으로 비root 사용자와 localhost 전용 포트 공개로 실행됩니다. CLI와 localhost Web UI 흐름은 낮은 마찰을 유지합니다. 원격 API/Web 배포에서는 `API_AUTH_KEY`를 설정하세요.
<details>
<summary>이전 뉴스</summary>
- **2026-05-02** 🧭 **배당 분석 + 더 선명한 로드맵**: 인컴 주식, 배당 지속 가능성, 배당 성장, 주주환원 수익률, 배당락 메커니즘, 고배당 함정 점검을 다루는 `dividend-analysis` 스킬을 추가하고 bundled skill 회귀 테스트로 고정했습니다. 공개 로드맵은 Research Autopilot, Data Bridge, Options Lab, Portfolio Studio, Alpha Zoo, Research Delivery, Trust Layer, Community 공유에 집중하도록 정리했습니다.
- **2026-05-01** 🔥 **상관관계 히트맵 + OpenAI Codex OAuth + A주 pre-ST 필터**: 새 상관관계 대시보드/API가 롤링 수익률 상관관계를 계산하고, 포트폴리오 및 종목 분석용 ECharts 히트맵으로 렌더링합니다([#64](https://github.com/HKUDS/Vibe-Trading/pull/64)). OpenAI Codex provider는 이제 `vibe-trading provider login openai-codex`로 ChatGPT OAuth를 사용할 수 있으며, Settings 메타데이터와 어댑터 회귀 테스트도 추가되었습니다([#65](https://github.com/HKUDS/Vibe-Trading/pull/65)). A주 ST/*ST 리스크 스크리닝을 위한 `ashare-pre-st-filter` 스킬을 추가하고 강화했으며, Sina 제재 공시 관련성 필터링으로 증권 계좌 목록 언급이 E2 횟수를 부풀리지 않도록 했습니다([#63](https://github.com/HKUDS/Vibe-Trading/pull/63)).
- **2026-04-30** ⚙️ **Web UI 설정 + validation CLI 강화**: LLM provider/model, Base URL, reasoning effort, 데이터 소스 자격 증명을 로컬에서 설정할 수 있는 Settings 페이지를 추가했습니다. settings API는 local/auth로 보호되며 provider 메타데이터도 데이터 기반 설정으로 분리되었습니다([#57](https://github.com/HKUDS/Vibe-Trading/pull/57)). 또한 `python -m backtest.validation <run_dir>`가 인자 없음, 빈 경로, 잘못된 경로, 존재하지 않는 경로, 디렉터리가 아닌 경로를 검증 시작 전에 명확한 메시지로 실패하도록 강화했습니다([#60](https://github.com/HKUDS/Vibe-Trading/pull/60)).
- **2026-04-28** 🚀 **v0.1.6 릴리스**(`pip install -U vibe-trading-ai`): `pip install` / `uv tool install` 설치 후 `vibe-trading --swarm-presets`가 비어 있는 문제 수정([#55](https://github.com/HKUDS/Vibe-Trading/issues/55)) — 프리셋 YAML을 `src.swarm` 패키지 내부에 번들링, 6개 회귀 테스트로 고정. 또한 AKShare 로더가 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)) 추가.
- **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 +88,12 @@
Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자연어 요청을 전 세계 시장의 실행 가능한 트레이딩 전략, 리서치 인사이트, 포트폴리오 분석으로 전환합니다.
### 핵심 역량:
• **전략 생성** — 아이디어를 자동으로 트레이딩 코드로 작성<br>
• **스마트 데이터 접근** — 자동 폴백이 있는 5개 데이터 소스; 모든 시장 무설정<br>
• **성능 테스트** — 전략을 과거 시장 데이터로 검증<br>
• **TradingView 내보내기** — 클릭 한 번으로 전략을 Pine Script v6로 변환<br>
• **전문 팀** — 복잡한 리서치 작업에 특화 에이전트 팀 배치<br>
• **실시간 업데이트** — 전체 분석 과정을 실시간 스트리밍
• **자연어 → 전략** — 아이디어를 설명하면 에이전트가 코드 작성, 테스트, 내보내기까지 실행<br>
• **6개 데이터 소스, 무설정** — A주, HK/US, 크립토, 선물, FX 자동 폴백<br>
• **29개 전문 팀** — 투자, 트레이딩, 리스크를 위한 멀티 에이전트 스웜 워크플로우<br>
• **크로스세션 메모리** — 선호도와 인사이트를 기억하고 재사용 가능한 스킬을 자동 생성·진화<br>
• **7개 백테스트 엔진** — 크로스마켓 복합 테스트 + 통계 검증 + 4개 옵티마이저<br>
• **멀티 플랫폼 내보내기** — 클릭 한 번으로 TradingView, TDX(통달신/동화순), MetaTrader 5
---
@@ -79,12 +104,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/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• 시장 전반을 아우르는 다중 도메인 분석<br>
• 자동 전략 및 시그널 생성<br>
• 거시경제 리서치와 인사이트<br>
• 대화형 자연어 태스크 라우팅
• 74개 전문 스킬 + 크로스세션 영구 메모리<br>
• 자가 진화: 에이전트가 경험으로부터 워크플로우를 생성·개선<br>
• 5계층 컨텍스트 압축 — 긴 대화에서도 정보 손실 없음<br>
• 전 금융 도메인에 걸친 자연어 태스크 라우팅
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,17 +119,17 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
<div align="left">
• 29개 즉시 사용 가능한 트레이딩 팀 프리셋<br>
• DAG 기반 멀티 에이전트 오케스트레이션<br>
• 실시간 의사결정 스트리밍 대시보드<br>
• YAML을 통한 커스텀 팀 구성
• 실시간 스트리밍 대시보드(에이전트 상태 표시)<br>
• FTS5 크로스세션 검색으로 모든 과거 대화 검색
</div>
</td>
<td align="center" width="25%" valign="top">
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
<h3>📊 크로스마켓 백테스트</h3>
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<img src="https://img.shields.io/badge/6_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,21 +148,22 @@ Vibe-Trading은 AI 기반 멀티 에이전트 금융 워크스페이스로, 자
</tr>
</table>
## 7개 카테고리에 걸친 68개 스킬
## 8개 카테고리에 걸친 74개 스킬
- 📊 7개 카테고리에 조직된 68개 금융 스킬
- 📊 8개 카테고리에 조직된 74개 금융 스킬
- 🌐 전통 시장부터 크립토·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` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
## 29개 에이전트 스웜 팀 프리셋
@@ -221,7 +247,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주 무료 폴백을 제공합니다.
@@ -237,6 +263,8 @@ docker compose up --build
`http://localhost:8899`를 엽니다. 백엔드 + 프런트엔드가 하나의 컨테이너에 있습니다.
Docker는 기본적으로 백엔드를 `127.0.0.1:8899`에만 게시하고, 비root 컨테이너 사용자로 앱을 실행합니다. API를 자신의 머신 밖으로 의도적으로 노출하는 경우 강한 `API_AUTH_KEY`를 설정하고 클라이언트에서 `Authorization: Bearer <key>`를 보내세요.
### 경로 B: 로컬 설치
```bash
@@ -295,17 +323,33 @@ 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`) |
| `TUSHARE_TOKEN` | No | A주 데이터용 Tushare Pro 토큰(AKShare 폴백) |
| `TIMEOUT_SECONDS` | No | LLM 호출 타임아웃, 기본 120초 |
| `API_AUTH_KEY` | 네트워크 배포 권장 | API가 비로컬 클라이언트에서 접근 가능한 경우 필요한 Bearer token |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | 원격 API / MCP-SSE 유형 배포에서 shell 가능 도구를 명시적으로 활성화 |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 문서와 브로커 거래 명세서 import용 추가 루트(쉼표 구분) |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 생성 코드 run 디렉터리용 추가 루트(쉼표 구분) |
<sub>* Ollama는 API 키가 필요 없습니다.</sub>
**무료 데이터(키 불필요):** 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 +366,7 @@ vibe-trading serve # API 서버
| Command | Description |
|---------|-------------|
| `/help` | 모든 명령 표시 |
| `/skills` | 68개 금융 스킬 목록 |
| `/skills` | 74개 금융 스킬 목록 |
| `/swarm` | 29개 스웜 팀 프리셋 목록 |
| `/swarm run <preset> [vars_json]` | 라이브 스트리밍으로 스웜 팀 실행 |
| `/swarm list` | 스웜 실행 이력 |
@@ -331,7 +375,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 +403,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 +423,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 이벤트 스트림 |
@@ -387,14 +431,30 @@ vibe-trading serve --port 8899
| `GET` | `/swarm/presets` | 스웜 프리셋 목록 |
| `POST` | `/swarm/runs` | 스웜 실행 시작 |
| `GET` | `/swarm/runs/{id}/events` | 스웜 SSE 스트림 |
| `GET` | `/settings/llm` | Web UI LLM 설정 읽기 |
| `PUT` | `/settings/llm` | 로컬 LLM 설정 업데이트 |
| `GET` | `/settings/data-sources` | 로컬 데이터 소스 설정 읽기 |
| `PUT` | `/settings/data-sources` | 로컬 데이터 소스 설정 업데이트 |
인터랙티브 문서: `http://localhost:8899/docs`
### 보안 기본값
localhost 개발에서는 `vibe-trading serve`가 브라우저 워크플로를 단순하게 유지합니다. 비로컬 클라이언트가 민감한 API에 접근하려면 `API_AUTH_KEY`가 필요합니다. JSON/업로드 요청에는 `Authorization: Bearer <key>`를 사용하세요. 브라우저 EventSource 스트림은 Web UI Settings에 같은 키를 한 번 입력하면 처리됩니다.
shell 가능 도구는 로컬 CLI와 신뢰된 localhost 워크플로에서 사용할 수 있지만, 원격 API 세션에는 기본적으로 노출되지 않습니다. 필요한 경우에만 `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`을 명시적으로 설정하세요. 문서와 거래 명세서 리더는 기본적으로 업로드/import 루트로 제한됩니다. 파일은 `agent/uploads`, `agent/runs`, `./uploads`, `./data`, `~/.vibe-trading/uploads`, `~/.vibe-trading/imports`에 두거나, `VIBE_TRADING_ALLOWED_FILE_ROOTS`로 전용 디렉터리를 추가하세요.
### Web UI Settings
Web UI Settings 페이지에서는 로컬 사용자가 LLM provider/model, Base URL, 생성 파라미터, reasoning effort, Tushare token 같은 선택적 시장 데이터 자격 증명을 업데이트할 수 있습니다. 설정은 `agent/.env`에 저장되며 provider 기본값은 `agent/src/providers/llm_providers.json`에서 로드됩니다.
Settings 읽기는 부작용이 없습니다. `GET /settings/llm`과 `GET /settings/data-sources`는 `agent/.env`를 만들지 않고 프로젝트 상대 경로만 반환합니다. Settings 읽기와 쓰기는 자격 증명 상태를 노출하거나 자격 증명/런타임 환경을 업데이트할 수 있으므로 `API_AUTH_KEY`가 설정되어 있으면 인증이 필요합니다. 개발 모드에서 `API_AUTH_KEY`가 설정되지 않은 경우 settings 접근은 loopback 로컬 클라이언트에만 허용됩니다.
---
## 🔌 MCP 플러그인
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 +496,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 +516,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의 자가 진화 엔진을 통해 스스로 발전합니다.
모든 74개 금융 스킬은 [open-space.cloud](https://open-space.cloud)에 게시되어 OpenSpace의 자가 진화 엔진을 통해 스스로 발전합니다.
OpenSpace와 함께 사용하려면 두 MCP 서버를 에이전트 설정에 추가하세요:
@@ -478,7 +538,7 @@ OpenSpace와 함께 사용하려면 두 MCP 서버를 에이전트 설정에 추
}
```
OpenSpace는 모든 68개 스킬을 자동으로 탐지하여 자동 수정, 자동 개선, 커뮤니티 공유를 활성화합니다. OpenSpace 연결 에이전트에서 `search_skills("finance backtest")`로 Vibe-Trading 스킬을 검색하세요.
OpenSpace는 모든 74개 스킬을 자동으로 탐지하여 자동 수정, 자동 개선, 커뮤니티 공유를 활성화합니다. OpenSpace 연결 에이전트에서 `search_skills("finance backtest")`로 Vibe-Trading 스킬을 검색하세요.
</details>
@@ -494,41 +554,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 # 스킬 로더(74 번들 + 사용자 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/ # 8개 카테고리의 74개 금융 스킬(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 +640,14 @@ Vibe-Trading은 **[HKUDS](https://github.com/HKUDS)** 에이전트 생태계의
| Phase | Feature | Status |
|-------|---------|--------|
| **Analysis & Viz** | 옵션 변동성 서피스 및 그릭스 3D 시각화 | Planned |
| | 롤링 윈도우 + 클러스터링 기반 크로스자산 상관 히트맵 | Planned |
| | CLI 백테스트 출력에 벤치마크 비교 | Planned |
| | 백테스트 지표에 Calmar Ratio & Omega Ratio | Planned |
| **Skills & Presets** | 배당 분석 스킬 | Planned |
| | ESG / 지속가능 투자 스웜 프리셋 | Planned |
| | 이머징 마켓 리서치 데스크 스웜 프리셋 | Planned |
| **Portfolio & Optimization** | 레버리지, 섹터 캡, 턴오버 제약을 포함한 고급 포트폴리오 옵티마이저 | Planned |
| **Future** | 초보자 튜토리얼: "5분 자연어 백테스트" | Planned |
| | WebSocket 기반 실시간 데이터 스트리밍 | Exploring |
| | 전략 마켓플레이스(공유 & 발견) | Exploring |
| **Research Autopilot** | 야간 리서치 루프: 가설 → 데이터 수집 → 백테스트 → 근거 리포트 | In Progress |
| **Data Bridge** | 사용자 데이터 연결: 로컬 CSV/Parquet/SQL 커넥터 + schema mapping | Planned |
| **Options Lab** | 변동성 서피스, 그릭스 대시보드, 페이오프/시나리오 탐색기 | Planned |
| **Portfolio Studio** | 리스크 엑스레이, 제약 조건, 턴오버 고려 옵티마이저, 리밸런싱 노트 | Planned |
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 팩터 라이브러리와 스크리닝 + IC 테스트 | Planned |
| **Research Delivery** | Slack / Telegram / 이메일형 채널로 예약 브리프 전달 | Planned |
| **Trust Layer** | 재현 가능한 run card: 도구 추적, 데이터 소스, 가정, 인용 | Planned |
| **Community** | 공유 가능한 skills, presets, strategy cards | Exploring |
---
@@ -623,6 +681,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>
+139 -75
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-74-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,35 @@
## 📰 新闻
- **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-05-05** 🛡️ **安全边界补充加固**:补齐显式 CORS origin、Settings 凭据状态提示、网页 URL 读取与 Shadow Account 代码生成相关的安全边界,并为这些路径加入回归测试。本机 CLI/Web UI 使用方式不变;远程部署仍应使用 `API_AUTH_KEY` 和显式可信 origin。
- **2026-05-04** 🖥️ **交互式 CLI 体验 + CI 清理**:交互模式新增实时底部状态栏,可显示 provider/model、会话时长、上次运行耗时和累计工具调用统计;同时通过 `prompt_toolkit` 支持方向键历史浏览与光标编辑([#69](https://github.com/HKUDS/Vibe-Trading/pull/69))。当 `prompt_toolkit` 或 TTY 不可用时,CLI 仍会回退到 Rich prompt。CI 路径断言也已对齐新的文件导入沙箱和跨平台 `/tmp` 解析,main 已恢复绿色([`bb67dc7`](https://github.com/HKUDS/Vibe-Trading/commit/bb67dc7cfcc11553c57d8962bee56381dca43758))。
- **2026-05-03** 🛡️ **安全加固补丁**:收紧非本地部署的默认 API 鉴权,保护敏感的 run/session/swarm 读取接口,限制上传与本地文件读取边界,按入口类型控制 shell 能力工具,在导入前校验生成策略,并让 Docker 镜像默认以非 root 用户运行且只发布到 localhost。CLI 与本机 Web UI 仍保持低摩擦;远程 API/Web 部署应配置 `API_AUTH_KEY`。
<details>
<summary>更早的新闻</summary>
- **2026-05-02** 🧭 **分红分析 + 更清晰的路线图**:新增 `dividend-analysis` 技能,覆盖收益型股票、分红可持续性、股息增长、股东回报率、除息机制与高息陷阱检查,并用 bundled skill 回归测试固定。公开路线图现在聚焦未来工作:Research Autopilot、Data Bridge、Options Lab、Portfolio Studio、Alpha Zoo、Research Delivery、Trust Layer 和 Community 分享。
- **2026-05-01** 🔥 **相关性热力图 + OpenAI Codex OAuth + A 股 pre-ST 过滤器**:新增相关性仪表盘/API,可计算滚动收益相关性,并用 ECharts 热力图展示组合与标的相关结构([#64](https://github.com/HKUDS/Vibe-Trading/pull/64))。OpenAI Codex provider 现支持通过 `vibe-trading provider login openai-codex` 使用 ChatGPT OAuth,并补齐 Settings 元数据与适配器回归测试([#65](https://github.com/HKUDS/Vibe-Trading/pull/65))。新增并加固 `ashare-pre-st-filter` 技能,用于 A 股 ST/*ST 风险筛查;Sina 处罚公告相关性过滤会避免证券账户名单提及误计入 E2 频次([#63](https://github.com/HKUDS/Vibe-Trading/pull/63))。
- **2026-04-30** ⚙️ **Web UI 设置页 + validation CLI 加固**:新增 Settings 页面,可在本地配置 LLM provider/model、Base URL、reasoning effort 以及数据源凭据;对应 settings API 已加本地/鉴权保护,并把 provider 元数据改为数据驱动配置([#57](https://github.com/HKUDS/Vibe-Trading/pull/57))。同时加固 `python -m backtest.validation <run_dir>`:缺参、空路径、非法路径、不存在路径、非目录路径都会在验证开始前给出明确错误([#60](https://github.com/HKUDS/Vibe-Trading/pull/60))。
- **2026-04-28** 🚀 **v0.1.6 发布**(`pip install -U vibe-trading-ai`):修复 `pip install` / `uv tool install` 安装后 `vibe-trading --swarm-presets` 返回空的问题([#55](https://github.com/HKUDS/Vibe-Trading/issues/55))—— 预设 YAML 现已打包进 `src.swarm` 包内,配套 6 个回归测试。同时 AKShare 加载器正确路由 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))。
- **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 +88,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>
• **6 大数据源,零配置** —— A 股、港美股、加密、期货、外汇自动回退<br>
• **29 支专家团队** —— 预构建的多代理 swarm 工作流,覆盖投资、交易与风控<br>
• **跨会话记忆** —— 记住偏好与洞察;自动创建、进化可复用技能<br>
• **7 大回测引擎** —— 跨市场复合测试 + 统计验证 + 4 种优化器<br>
• **多平台导出** —— 一键到 TradingView、TDX(通达信/同花顺)和 MetaTrader 5
---
@@ -79,12 +104,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/74_Skills-FF6B6B?style=for-the-badge&logo=bookstack&logoColor=white" alt="Skills" /><br><br>
<div align="left" style="font-size: 4px;">
• 覆盖多市场的多领域分析<br>
• 自动生成策略与信号<br>
• 宏观经济研究与洞察<br>
• 基于聊天的自然语言任务路由
• 74 个专长技能 + 跨会话持久记忆<br>
• 自进化:代理从经验中创建并优化工作流<br>
• 5 层上下文压缩——长对话不丢失信息<br>
• 覆盖全金融领域的自然语言任务路由
</div>
</td>
<td align="center" width="25%" valign="top">
@@ -94,17 +119,17 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
<div align="left">
• 29 个开箱即用的交易团队预设<br>
• 基于 DAG 的多代理编排<br>
• 实时决策流式仪表盘<br>
• 通过 YAML 自定义团队
• 实时流式仪表盘,显示代理运行状态<br>
• FTS5 跨会话搜索全部历史对话
</div>
</td>
<td align="center" width="25%" valign="top">
<img src="assets/scene-backtest.png" height="150" alt="Backtest"/><br>
<h3>📊 跨市场回测</h3>
<img src="https://img.shields.io/badge/5_Data_Sources-FFD93D?style=for-the-badge&logo=bitcoin&logoColor=black" alt="Backtest" /><br><br>
<img src="https://img.shields.io/badge/6_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,21 +148,22 @@ Vibe-Trading 是一个由 AI 驱动的多代理金融工作台,将自然语言
</tr>
</table>
## 7 大类别中的 68 个技能
## 8 大类别中的 74 个技能
- 📊 68 个金融专长技能,划分 7 大类
- 📊 74 个金融专长技能,划分 8 大类
- 🌐 覆盖传统市场到加密与 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` |
| Analysis | 15 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis` |
| Strategy | 17 | `strategy-generate`, `cross-market-strategy`, `technical-basic`, `candlestick`, `ichimoku`, `elliott-wave`, `smc`, `multi-factor`, `ml-strategy` |
| Analysis | 17 | `factor-research`, `macro-analysis`, `global-macro`, `valuation-model`, `earnings-forecast`, `credit-analysis`, `dividend-analysis` |
| Asset Class | 9 | `options-strategy`, `options-advanced`, `convertible-bond`, `etf-analysis`, `asset-allocation`, `sector-rotation` |
| Crypto | 7 | `perp-funding-basis`, `liquidation-heatmap`, `stablecoin-flow`, `defi-yield`, `onchain-analysis` |
| Flow | 7 | `hk-connect-flow`, `us-etf-flow`, `edgar-sec-filings`, `financial-statement`, `adr-hshare` |
| Tool | 8 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader` |
| Tool | 10 | `backtest-diagnose`, `report-generate`, `pine-script`, `doc-reader`, `web-reader`, `vnpy-export` |
| Risk Analysis | 1 | `ashare-pre-st-filter` |
## 29 个 Agent Swarm 团队预设
@@ -221,7 +247,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 免费获取。
@@ -237,6 +263,8 @@ docker compose up --build
打开 `http://localhost:8899`。后端与前端同一容器。
Docker 默认只把后端发布到 `127.0.0.1:8899`,并以非 root 容器用户运行应用。如果你有意把 API 暴露到本机之外,请设置强 `API_AUTH_KEY`,客户端通过 `Authorization: Bearer <key>` 调用。
### Path B: 本地安装
```bash
@@ -295,17 +323,33 @@ 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`) |
| `TUSHARE_TOKEN` | No | A 股数据的 Tushare Pro token(可回退 AKShare) |
| `TIMEOUT_SECONDS` | No | LLM 调用超时,默认 120s |
| `API_AUTH_KEY` | 网络部署建议设置 | API 可被非本地客户端访问时所需的 Bearer token |
| `VIBE_TRADING_ENABLE_SHELL_TOOLS` | No | 远程 API / MCP-SSE 类部署显式启用 shell 能力工具 |
| `VIBE_TRADING_ALLOWED_FILE_ROOTS` | No | 文档和券商交割单导入的额外逗号分隔目录 |
| `VIBE_TRADING_ALLOWED_RUN_ROOTS` | No | 生成代码 run 目录的额外逗号分隔目录 |
<sub>* Ollama 不需要 API key。</sub>
**免费数据(无需 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 +366,7 @@ vibe-trading serve # API 服务器
| Command | Description |
|---------|-------------|
| `/help` | 显示全部命令 |
| `/skills` | 列出 68 个金融技能 |
| `/skills` | 列出 74 个金融技能 |
| `/swarm` | 列出 29 个 swarm 团队预设 |
| `/swarm run <preset> [vars_json]` | 以流式输出运行一个 swarm 团队 |
| `/swarm list` | Swarm 运行历史 |
@@ -331,7 +375,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 +402,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 +422,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 事件流 |
@@ -386,14 +430,30 @@ vibe-trading serve --port 8899
| `GET` | `/swarm/presets` | 列出 swarm 预设 |
| `POST` | `/swarm/runs` | 启动 swarm 运行 |
| `GET` | `/swarm/runs/{id}/events` | Swarm SSE 流 |
| `GET` | `/settings/llm` | 读取 Web UI LLM 设置 |
| `PUT` | `/settings/llm` | 更新本地 LLM 设置 |
| `GET` | `/settings/data-sources` | 读取本地数据源设置 |
| `PUT` | `/settings/data-sources` | 更新本地数据源设置 |
交互式文档:`http://localhost:8899/docs`
### 安全默认值
本机开发时,`vibe-trading serve` 会尽量保持浏览器工作流简单。任何非本地客户端访问敏感 API 时都需要 `API_AUTH_KEY`;JSON/上传请求请使用 `Authorization: Bearer <key>`。浏览器 EventSource 流由 Web UI 在 Settings 中保存同一个 key 后处理。
Shell 能力工具对本地 CLI 和可信 localhost 工作流可用,但远程 API session 默认不会暴露,除非显式设置 `VIBE_TRADING_ENABLE_SHELL_TOOLS=1`。文档和交割单读取默认限制在上传/导入目录中;请把文件放到 `agent/uploads`、`agent/runs`、`./uploads`、`./data`、`~/.vibe-trading/uploads` 或 `~/.vibe-trading/imports`,也可以通过 `VIBE_TRADING_ALLOWED_FILE_ROOTS` 添加专用目录。
### Web UI 设置
Web UI Settings 页面允许本地用户更新 LLM provider/model、Base URL、生成参数、reasoning effort,以及 Tushare token 等可选市场数据凭据。设置会保存到 `agent/.env`;provider 默认值来自 `agent/src/providers/llm_providers.json`。
Settings 读取是无副作用的:`GET /settings/llm` 和 `GET /settings/data-sources` 不会创建 `agent/.env`,并且只返回项目相对路径。Settings 读取和写入可能暴露凭据状态或更新凭据/运行时环境,因此配置 `API_AUTH_KEY` 时必须携带认证;开发模式未配置 `API_AUTH_KEY` 时,仅允许 loopback 本地客户端访问。
---
## 🔌 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 +495,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 +515,7 @@ npx clawhub@latest install vibe-trading --force
<details>
<summary><b>OpenSpace — 自进化技能</b></summary>
全部 68 个金融技能已发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自动演进。
全部 74 个金融技能已发布在 [open-space.cloud](https://open-space.cloud),并通过 OpenSpace 的自进化引擎自动演进。
要在 OpenSpace 中使用,在代理配置中添加两个 MCP 服务器:
@@ -477,7 +537,7 @@ npx clawhub@latest install vibe-trading --force
}
```
OpenSpace 会自动发现全部 68 个技能,支持自动修复、自动改进与社区共享。在任意连接 OpenSpace 的代理中通过 `search_skills("finance backtest")` 搜索 Vibe-Trading 技能。
OpenSpace 会自动发现全部 74 个技能,支持自动修复、自动改进与社区共享。在任意连接 OpenSpace 的代理中通过 `search_skills("finance backtest")` 搜索 Vibe-Trading 技能。
</details>
@@ -493,41 +553,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 # 技能加载器(74 内置 + 用户 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/ # 74 个金融技能(8 类,每个 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 +639,14 @@ Vibe-Trading 属于 **[HKUDS](https://github.com/HKUDS)** 代理生态的一部
| Phase | Feature | Status |
|-------|---------|--------|
| **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 |
| | 通过 WebSocket 的实时数据流 | Exploring |
| | 策略市场(分享与发现) | Exploring |
| **Research Autopilot** | 通宵研究循环:假设 → 拉取数据 → 回测 → 证据报告 | In Progress |
| **Data Bridge** | 自带数据接入:本地 CSV/Parquet/SQL 连接器 + schema 映射 | Planned |
| **Options Lab** | 波动率曲面、Greeks 仪表盘、收益结构/情景探索器 | Planned |
| **Portfolio Studio** | 风险透视、约束优化、换手感知优化器、调仓说明 | Planned |
| **Alpha Zoo** | Alpha101 / Alpha158 / Alpha191 因子库,支持筛选与 IC 测试 | Planned |
| **Research Delivery** | 定时研究简报推送到 Slack / Telegram / 邮件式渠道 | Planned |
| **Trust Layer** | 可复现实验卡片:工具轨迹、数据来源、假设与引用 | Planned |
| **Community** | 可分享的 skills、presets 与 strategy cards | Exploring |
---
@@ -622,6 +680,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).
+79 -32
View File
@@ -1,89 +1,136 @@
# ============================================================================
# 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
# --- OpenAI Codex (ChatGPT OAuth; not the OpenAI API key path) ---
# Login first: vibe-trading provider login openai-codex
# Requires a ChatGPT account with Codex access. OAuth tokens are stored by oauth-cli-kit.
# LANGCHAIN_PROVIDER=openai-codex
# LANGCHAIN_MODEL_NAME=openai-codex/gpt-5.3-codex
# OPENAI_CODEX_BASE_URL=https://chatgpt.com/backend-api/codex/responses
# --- DeepSeek ---
# LANGCHAIN_PROVIDER=deepseek
# LANGCHAIN_MODEL_NAME=deepseek-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 / max.
# 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 dev mode is loopback-only. Set this before exposing port 8899 beyond localhost.
# API_AUTH_KEY=
# CORS allowed origins (comma-separated)
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
# Enable session runtime
# ENABLE_SESSION_RUNTIME=true
# Docker compose sets this together with a 127.0.0.1 port bind so browser
# requests from the host machine still count as local dev-mode access.
# Do not enable this when publishing port 8899 on 0.0.0.0 without API_AUTH_KEY.
# VIBE_TRADING_TRUST_DOCKER_LOOPBACK=0
# Advanced local-only opt-in: expose shell execution tools to the agent.
# VIBE_TRADING_ENABLE_SHELL_TOOLS=0
# Optional import roots for read_document/analyze_trade_journal.
# Use comma-separated absolute paths. Defaults include agent/uploads, agent/runs,
# ./uploads, ./data, ~/.vibe-trading/uploads, and ~/.vibe-trading/imports.
# VIBE_TRADING_ALLOWED_FILE_ROOTS=
# Optional run roots for generated-code tools such as write_file/backtest.
# Defaults include agent/runs, ./runs, and ~/.vibe-trading/shadow_runs.
# VIBE_TRADING_ALLOWED_RUN_ROOTS=
# ============================================================================
# Agent Tuning (optional, defaults are sensible)
# ============================================================================
# SWARM_WORKER_TIMEOUT=300
# SWARM_WORKER_MAX_ITER=50
+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.7
description: Professional finance research toolkit — backtesting (7 engines + benchmark comparison panel), factor analysis, options pricing, 74 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, 74 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 74 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 (74)
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 74 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 74 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
+760 -45
View File
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
"""Benchmark ticker resolution and fetch for backtest comparison.
Provides a lightweight, zero-dependency way to fetch benchmark reference
data given a set of strategy codes and a data source.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pandas as pd
from backtest.loaders.yfinance_loader import DataLoader as YfinanceLoader
# -------------------------------------------------------------------
# Benchmark map: market type → default ticker
# -------------------------------------------------------------------
MARKET_BENCHMARKS: dict[str, Optional[str]] = {
"us_equity": "SPY",
"hk_equity": "HK.03100", # Hang Seng China Enterprises ETF
"a_share": "000300.SH", # CSI 300 (China A-share core index)
"crypto": "BTC-USDT",
"futures": "ES.CME", # E-mini S&P 500 futures
"forex": None, # no universal benchmark
}
@dataclass
class BenchmarkResult:
ticker: str
ret_series: pd.Series # per-bar returns, index = timestamps
total_ret: float # total return over the period
def resolve_benchmark(
strategy_codes: list[str],
source: str,
start_date: str,
end_date: str,
interval: str = "1D",
explicit: Optional[str] = None,
) -> Optional[BenchmarkResult]:
"""Resolve the appropriate benchmark ticker and fetch its return series.
Args:
strategy_codes: Instruments being backtested (used for market inference).
source: Data source name (tushare / yfinance / okx / akshare / ccxt).
start_date: Backtest start date.
end_date: Backtest end date.
interval: Bar interval (1m / 5m / 15m / 30m / 1H / 4H / 1D).
explicit: Override ticker (e.g. "SPY" passed via config).
Returns:
BenchmarkResult with return series and total return, or None if no
benchmark applies (forex, or fetch failure).
"""
ticker = _resolve_ticker(strategy_codes, source, explicit)
if ticker is None:
return None
try:
bench_df = _fetch_benchmark(ticker, start_date, end_date, interval)
except Exception:
return None
if bench_df.empty or "close" not in bench_df.columns:
return None
close = bench_df["close"].dropna()
if len(close) < 2:
return None
ret_series = close.pct_change().fillna(0.0)
total_ret = float((1 + ret_series).prod() - 1)
return BenchmarkResult(ticker=ticker, ret_series=ret_series, total_ret=total_ret)
# -------------------------------------------------------------------
# Internal helpers
# -------------------------------------------------------------------
def _resolve_ticker(
codes: list[str],
source: str,
explicit: Optional[str],
) -> Optional[str]:
"""Pick the benchmark ticker to use."""
if explicit:
return explicit
# Infer market from source + first code pattern
market = _infer_market(codes, source)
ticker = MARKET_BENCHMARKS.get(market)
# yfinance is the universal fallback for benchmark fetch
# but it only works for us_equity / hk_equity market types
if ticker and market not in {"us_equity", "hk_equity"}:
# Only use benchmark if we can actually fetch it
pass
return ticker
def _infer_market(codes: list[str], source: str) -> str:
"""Rough market inference from symbol patterns and source."""
if not codes:
return "us_equity"
first = codes[0].upper()
if source in ("okx", "ccxt") or "-" in first or "/" in first:
return "crypto"
if first.endswith(".US"):
return "us_equity"
if first.endswith(".HK"):
return "hk_equity"
if source in ("tushare", "akshare"):
if first.isdigit() and len(first) == 6:
return "a_share"
if first.startswith(("IF", "IC", "IH", "IM", "T", "TF")):
return "futures"
return "a_share"
return "us_equity"
def _fetch_benchmark(
ticker: str,
start_date: str,
end_date: str,
interval: str,
) -> pd.DataFrame:
"""Fetch benchmark OHLCV data via yfinance (single symbol, no auth)."""
loader = YfinanceLoader()
result = loader.fetch([ticker], start_date, end_date, interval=interval)
if isinstance(result, dict):
df = result.get(ticker)
elif isinstance(result, pd.DataFrame):
df = result
else:
return pd.DataFrame()
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
return pd.DataFrame()
return df
+175
View File
@@ -0,0 +1,175 @@
"""Cross-asset correlation matrix computation.
Computes pairwise Pearson or Spearman correlation of daily returns
over a configurable lookback window. Used by the /correlation API endpoint.
"""
from __future__ import annotations
from typing import Dict, Literal
import pandas as pd
import numpy as np
from scipy.stats import spearmanr
def infer_market(code: str) -> str:
"""Infer market key from a ticker symbol."""
code_upper = code.upper()
crypto_suffixes = ("USDT", "BTC", "ETH", "BNB", "SOL", "ADA", "DOGE")
if any(code_upper.endswith(s) for s in crypto_suffixes) or "/" in code:
return "crypto"
# Check .HK suffix FIRST so leading-zero tickers like 0700.HK / 0005.HK
# are correctly classified before the A-share prefix checks
if code_upper.endswith(".HK"):
return "hk_equity"
if code_upper.startswith(("6", "000", "001", "002")):
return "a_share"
if code_upper.startswith(("0", "399")):
return "a_share"
if code_upper.startswith(("0", "1", "2", "3", "4")):
return "hk_equity"
return "us_equity"
def _rolling_correlation_matrix(
price_series: Dict[str, pd.DataFrame],
window: int,
method: Literal["pearson", "spearman"],
) -> tuple[list[str], list[list[float]]]:
"""Compute correlation matrix for multiple price series.
Args:
price_series: Mapping of asset code -> DataFrame with a ``close`` column.
window: Rolling window size in days.
method: "pearson" or "spearman".
Returns:
(labels, matrix) where labels is the sorted list of codes and matrix
is a symmetric NxN matrix of correlation coefficients.
"""
if not price_series:
return [], []
codes = sorted(price_series.keys())
# Build a aligned returns DataFrame (row index = date)
returns_frames = []
closes = {}
for code, df in price_series.items():
if df.empty:
raise ValueError(f"Price series for '{code}' is empty")
if "close" not in df.columns and "close" not in df.index.names:
raise ValueError(f"No 'close' column in price series for '{code}'")
# Support both column-based and index-based trade_date
if "trade_date" in df.index.names and "trade_date" not in df.columns:
ts = df["close"]
else:
ts = df.set_index("trade_date")["close"]
closes[code] = ts.sort_index()
for code in codes:
ts = closes[code]
rets = ts.pct_change().dropna()
rets.name = code
returns_frames.append(rets)
# Align all series to a common index (inner join)
aligned = pd.concat(returns_frames, axis=1).dropna()
if aligned.empty:
raise ValueError("No overlapping return data between assets")
# Apply the trailing window — only use the last `window` rows of aligned data
if len(aligned) > window:
aligned = aligned.iloc[-window:]
n = len(aligned)
if n < 2:
raise ValueError("Not enough data points to compute correlation")
labels = codes
n_assets = len(labels)
matrix = [[1.0] * n_assets for _ in range(n_assets)]
for i in range(n_assets):
for j in range(i + 1, n_assets):
xi = aligned.iloc[:, i].values
xj = aligned.iloc[:, j].values
if method == "spearman":
corr, _ = spearmanr(xi, xj)
else:
corr = np.corrcoef(xi, xj)[0, 1]
if np.isnan(corr):
corr = 0.0
matrix[i][j] = round(corr, 4)
matrix[j][i] = round(corr, 4)
return labels, matrix
def compute_correlation_matrix(
codes: list[str],
days: int = 90,
method: Literal["pearson", "spearman"] = "pearson",
) -> Dict[str, object]:
"""Fetch price data and compute correlation matrix for a list of assets.
Args:
codes: List of asset codes (e.g. ["BTC-USDT", "ETH-USDT", "SPY"]).
days: Lookback window in days (default 90).
method: Correlation method.
Returns:
Dict with keys: labels, matrix, window, method.
"""
from datetime import datetime, timedelta
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days + 60)).strftime("%Y-%m-%d")
# Import here to avoid circular
from backtest.loaders.registry import resolve_loader
price_series: Dict[str, pd.DataFrame] = {}
for code in codes:
market = infer_market(code)
try:
loader = resolve_loader(market)
except Exception:
# Fall back to yfinance for us_equity / hk_equity
try:
from backtest.loaders.registry import LOADER_REGISTRY
if "yfinance" in LOADER_REGISTRY:
loader = LOADER_REGISTRY["yfinance"]()
else:
continue
except Exception:
continue
try:
result = loader.fetch(
codes=[code],
start_date=start_date,
end_date=end_date,
interval="1D",
fields=["trade_date", "open", "high", "low", "close", "volume"],
)
if code in result and not result[code].empty:
price_series[code] = result[code]
except Exception:
continue
if len(price_series) < 2:
raise ValueError(
f"Could not fetch price data for at least 2 assets. "
f"Fetched: {list(price_series.keys())}"
)
labels, matrix = _rolling_correlation_matrix(price_series, days, method)
return {
"labels": labels,
"matrix": matrix,
"window": days,
"method": method,
}
+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
+7 -1
View File
@@ -9,9 +9,13 @@ from typing import Dict, List, Optional
import pandas as pd
from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
@register
class DataLoader:
"""Tushare-backed OHLCV loader."""
@@ -22,7 +26,7 @@ class DataLoader:
def is_available(self) -> bool:
"""Available when TUSHARE_TOKEN is set."""
return bool(os.getenv("TUSHARE_TOKEN", ""))
return os.getenv("TUSHARE_TOKEN", "").strip() not in TUSHARE_TOKEN_PLACEHOLDERS
def __init__(self) -> None:
"""Initialize Tushare pro API."""
@@ -51,6 +55,8 @@ class DataLoader:
Returns:
Mapping code -> OHLCV DataFrame.
"""
validate_date_range(start_date, end_date)
if interval != "1D":
return self._fetch_minutes(codes, start_date, end_date, interval)
@@ -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)
+232 -3
View File
@@ -7,13 +7,17 @@ Supports ``engine`` for backtest engine (daily/options, default daily).
Usage: ``python -m backtest.runner <run_dir>``
"""
import ast
import importlib.util
import json
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 +26,7 @@ except ImportError:
pass
from backtest.loaders.registry import (
FALLBACK_CHAINS,
LOADER_REGISTRY,
get_loader_cls_with_fallback,
resolve_loader,
@@ -30,6 +35,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.
@@ -41,6 +110,7 @@ def _load_module_from_file(file_path: Path, module_name: str):
Returns:
Loaded module object.
"""
_validate_signal_engine_source(file_path)
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
@@ -48,6 +118,111 @@ def _load_module_from_file(file_path: Path, module_name: str):
return module
def _is_literal_node(node: ast.AST) -> bool:
"""Return whether an AST node is made only from literal values."""
if isinstance(node, ast.Constant):
return True
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
return all(_is_literal_node(item) for item in node.elts)
if isinstance(node, ast.Dict):
return all(
(key is None or _is_literal_node(key)) and _is_literal_node(value)
for key, value in zip(node.keys, node.values)
)
return False
def _is_safe_constant_assignment(node: ast.AST) -> bool:
"""Return whether a top-level assignment is literal-only."""
if isinstance(node, ast.Assign):
return _is_literal_node(node.value)
if isinstance(node, ast.AnnAssign):
return node.value is None or _is_literal_node(node.value)
return False
def _is_safe_reference(node: ast.AST | None) -> bool:
"""Return whether an annotation/base expression cannot call code."""
if node is None:
return True
if isinstance(node, (ast.Name, ast.Attribute, ast.Constant)):
return True
if isinstance(node, ast.Subscript):
return _is_safe_reference(node.value) and _is_safe_reference(node.slice)
if isinstance(node, ast.Tuple):
return all(_is_safe_reference(item) for item in node.elts)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return _is_safe_reference(node.left) and _is_safe_reference(node.right)
return False
def _validate_function_def(node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
"""Reject import-time execution in function definitions."""
if node.decorator_list:
raise ValueError(f"Decorators are not allowed on function {node.name!r}")
for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]:
if not _is_literal_node(default):
raise ValueError(f"Non-literal default is not allowed on function {node.name!r}")
annotations = [node.returns]
annotations.extend(arg.annotation for arg in node.args.posonlyargs)
annotations.extend(arg.annotation for arg in node.args.args)
annotations.extend(arg.annotation for arg in node.args.kwonlyargs)
annotations.append(node.args.vararg.annotation if node.args.vararg else None)
annotations.append(node.args.kwarg.annotation if node.args.kwarg else None)
for annotation in annotations:
if not _is_safe_reference(annotation):
raise ValueError(f"Unsafe annotation is not allowed on function {node.name!r}")
def _validate_class_body(node: ast.ClassDef) -> None:
"""Reject import-time execution inside class bodies."""
if node.decorator_list:
raise ValueError(f"Decorators are not allowed on class {node.name!r}")
for base in node.bases:
if not _is_safe_reference(base):
raise ValueError(f"Unsafe base class is not allowed on class {node.name!r}")
if node.keywords:
raise ValueError(f"Class keywords are not allowed on class {node.name!r}")
for child in node.body:
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
continue
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
_validate_function_def(child)
continue
if _is_safe_constant_assignment(child):
continue
if isinstance(child, ast.Pass):
continue
raise ValueError(
f"Executable class-level statement {type(child).__name__} is not allowed"
)
def _validate_signal_engine_source(file_path: Path) -> None:
"""Reject import-time executable statements before loading signal_engine.py."""
try:
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
except SyntaxError as exc:
raise ValueError(f"Invalid signal_engine.py syntax: {exc}") from exc
for node in tree.body:
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
continue
if isinstance(node, (ast.Import, ast.ImportFrom)):
continue
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
_validate_function_def(node)
continue
if isinstance(node, ast.ClassDef):
_validate_class_body(node)
continue
if _is_safe_constant_assignment(node):
continue
raise ValueError(
f"Executable top-level statement {type(node).__name__} is not allowed"
)
# --- Market detection (returns market type, NOT source name) ---
_MARKET_PATTERNS = [
@@ -192,7 +367,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 +410,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 +440,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 +477,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 +613,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
+27 -6
View File
@@ -5,7 +5,7 @@ Three independent tools:
- Bootstrap Sharpe CI: how stable is the risk-adjusted return?
- Walk-Forward analysis: is performance consistent across time windows?
Usage: called automatically by BaseEngine.run_backtest when config["validation"]
Usage: called automatically by BaseEngine.run_backtest when config[\"validation\"]
is present, or invoked directly on backtest outputs.
"""
@@ -330,6 +330,31 @@ def _load_trades(run_dir: Path) -> List[TradeRecord]:
return trades
def _parse_run_dir(argv: List[str]) -> Path:
"""Validate CLI input and return a usable run directory path."""
if len(argv) < 2:
raise SystemExit("Usage: python -m backtest.validation <run_dir>")
raw_run_dir = argv[1]
if not raw_run_dir.strip():
raise SystemExit("run_dir must be a non-empty path")
if "\0" in raw_run_dir:
raise SystemExit("Invalid run_dir path: embedded NUL byte")
try:
run_dir = Path(raw_run_dir).expanduser()
exists = run_dir.exists()
is_dir = run_dir.is_dir() if exists else False
except (OSError, RuntimeError, ValueError) as exc:
raise SystemExit(f"Invalid run_dir path: {exc}") from exc
if not exists:
raise SystemExit(f"run_dir does not exist: {run_dir}")
if not is_dir:
raise SystemExit(f"run_dir is not a directory: {run_dir}")
return run_dir
def main(run_dir: Path) -> Dict[str, Any]:
"""Run all three validations on existing backtest artifacts.
@@ -370,9 +395,5 @@ def main(run_dir: Path) -> Dict[str, Any]:
if __name__ == "__main__":
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("Usage: python -m backtest.validation <run_dir>")
sys.exit(1)
main(Path(sys.argv[1]))
main(_parse_run_dir(sys.argv))
+491 -42
View File
@@ -19,6 +19,7 @@ import os
import re
import shutil
import sys
import threading
import time
import uuid
from pathlib import Path
@@ -51,12 +52,135 @@ EXIT_RUN_FAILED = 1
EXIT_USAGE_ERROR = 2
RICH_TAG_PATTERN = re.compile(r"\[/?[^\]]+\]")
_VERSION = "0.1.0"
_VERSION = "0.1.7"
# Agent color assignments for swarm display
_AGENT_STYLES = ["cyan", "magenta", "green", "yellow", "blue", "bright_red", "bright_cyan", "bright_magenta"]
_agent_color_map: dict[str, str] = {}
_HAS_PROMPT_TOOLKIT = False
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.history import InMemoryHistory
_HAS_PROMPT_TOOLKIT = True
except ImportError:
pass
class _SessionStats:
"""Mutable container for interactive session statistics.
Shared between the status bar renderer and the agent loop so that
tool callbacks can update counters in-place.
"""
__slots__ = ("session_start", "last_elapsed", "total_tool_ms", "tool_count")
def __init__(self, session_start: float) -> None:
self.session_start = session_start
self.last_elapsed: Optional[float] = None
self.total_tool_ms = 0
self.tool_count = 0
def _build_status_parts(stats: _SessionStats) -> list[str]:
"""Build plain-text status bar segments.
Args:
stats: Session statistics.
Returns:
List of status text segments.
"""
provider = os.getenv("LANGCHAIN_PROVIDER", "")
model = os.getenv("LANGCHAIN_MODEL_NAME", "")
model_short = model.split("/")[-1] if "/" in model else model
label = f"{provider}/{model_short}" if provider else model_short or "unknown"
session_s = int(time.monotonic() - stats.session_start)
mins, secs = divmod(session_s, 60)
session_str = f"{mins}m{secs:02d}s" if mins else f"{secs}s"
parts = [label, session_str]
if stats.last_elapsed is not None:
parts.append(f"last {stats.last_elapsed:.1f}s")
if stats.tool_count > 0:
total_s = stats.total_tool_ms / 1000
parts.append(f"{stats.tool_count} tools ({total_s:.1f}s)")
return parts
def _ptk_toolbar(stats: _SessionStats) -> FormattedText:
"""prompt_toolkit bottom_toolbar callback — called on every render.
Args:
stats: Session statistics.
Returns:
FormattedText for the toolbar.
"""
segments = _build_status_parts(stats)
text = " │ ".join(segments)
return FormattedText([("class:bottom-toolbar.text", f" {text} ")])
def _print_status_bar(stats: _SessionStats) -> None:
"""Print a static status bar using Rich (fallback without prompt_toolkit).
Args:
stats: Session statistics.
"""
parts = _build_status_parts(stats)
bar = "[dim] │ [/dim]".join(
f"[bold]{parts[0]}[/bold]" if i == 0 else p for i, p in enumerate(parts)
)
console.print(bar)
def _create_prompt_session(stats: _SessionStats) -> Any:
"""Create a prompt_toolkit PromptSession with history and live toolbar.
Args:
stats: Session statistics for the live bottom toolbar.
Returns:
A PromptSession instance, or None if prompt_toolkit is not available.
"""
if not _HAS_PROMPT_TOOLKIT:
return None
return PromptSession(
history=InMemoryHistory(),
bottom_toolbar=lambda: _ptk_toolbar(stats),
refresh_interval=1.0,
)
def _read_input(prompt_session: Any, prompt_str: str = "> ") -> str:
"""Read user input with arrow key support if prompt_toolkit is available.
Falls back to Rich Prompt.ask() when prompt_toolkit is not installed or
when stdin is not a tty.
Args:
prompt_session: A prompt_toolkit PromptSession, or None.
prompt_str: Prompt text to display.
Returns:
User input string (not stripped).
Raises:
EOFError: When the user presses Ctrl-D.
KeyboardInterrupt: When the user presses Ctrl-C.
"""
if prompt_session is not None and sys.stdin.isatty():
return prompt_session.prompt(prompt_str)
return Prompt.ask(f"[bold]{prompt_str}[/bold]")
def serve_main(argv: list[str] | None = None) -> int:
"""Delegate server startup to api_server."""
@@ -172,17 +296,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 +320,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 +403,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, include_shell_tools=True),
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 +419,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 +504,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 +647,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)))
@@ -477,6 +671,7 @@ def _print_help() -> None:
("/continue <run_id> <prompt>", "Continue an existing run"),
("/swarm", "List swarm team presets"),
("/swarm run <preset> {vars}", "Run a swarm team"),
("/swarm inspect <preset>", "Inspect preset DAG and validation"),
("/swarm list", "List swarm run history"),
("/swarm show <run_id>", "Show swarm run details"),
("/swarm cancel <run_id>", "Cancel a swarm run"),
@@ -484,6 +679,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)
@@ -581,6 +780,11 @@ def _handle_swarm_command(arg: str) -> None:
preset = run_parts[0]
vars_json = run_parts[1] if len(run_parts) > 1 else None
cmd_swarm_run_live(preset, vars_json)
elif sub == "inspect":
if sub_arg:
cmd_swarm_inspect(sub_arg)
else:
console.print("[red]Usage: /swarm inspect <preset>[/red]")
elif sub == "list":
cmd_swarm_list()
elif sub == "show":
@@ -607,10 +811,14 @@ def cmd_interactive(max_iter: int) -> None:
return
history: List[Dict[str, str]] = []
stats = _SessionStats(session_start=time.monotonic())
prompt_session = _create_prompt_session(stats)
while True:
if prompt_session is None:
_print_status_bar(stats)
try:
user_input = Prompt.ask("\n[bold]>[/bold]").strip()
user_input = _read_input(prompt_session).strip()
except (KeyboardInterrupt, EOFError):
break
@@ -628,14 +836,90 @@ def cmd_interactive(max_iter: int) -> None:
continue
# Natural language -> agent
start = time.perf_counter()
try:
result = _run_agent(user_input, history=history[-6:], max_iter=max_iter)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
from src.tools import build_registry
from src.providers.chat import ChatLLM
from src.agent.loop import AgentLoop
from src.memory.persistent import PersistentMemory
_print_result(result, time.perf_counter() - start)
run_start = time.perf_counter()
run_state = {"label": "connecting"}
pending_tool = {"name": "", "args_preview": ""}
stop_timer = threading.Event()
def _status_event_callback(event_type: str, data: Dict[str, Any]) -> None:
if event_type == "tool_result":
stats.total_tool_ms += data.get("elapsed_ms", 0)
stats.tool_count += 1
run_state["label"] = "thinking"
elif event_type == "tool_call":
run_state["label"] = f"running {data.get('tool', '?')}"
elif event_type == "text_delta":
run_state["label"] = "streaming"
elif event_type == "compact":
run_state["label"] = "compressing context"
_on_event_interactive(event_type, data)
def _on_event_interactive(event_type: str, data: Dict[str, Any]) -> None:
if event_type == "text_delta":
console.print(data.get("delta", ""), end="", style="dim")
elif event_type == "thinking_done":
console.print()
elif event_type == "tool_call":
pending_tool["name"] = data.get("tool", "")
pending_tool["args_preview"] = _format_tool_call_args(
data.get("tool", ""), data.get("arguments", {})
)
elif event_type == "tool_result":
tool = data.get("tool", "") or pending_tool["name"]
name = tool
args_preview = pending_tool["args_preview"]
status_str = data.get("status", "ok")
elapsed_ms = data.get("elapsed_ms", 0)
elapsed_s = elapsed_ms / 1000
ok = status_str == "ok"
mark = "[green]\u2713[/green]" if ok else "[red]\u2717[/red]"
preview = _format_tool_result_preview(tool, status_str, data.get("preview", ""))
suffix = f" {preview}" if preview else ""
console.print(f" [cyan]\u25b6 {name}[/cyan]{args_preview} {mark} [dim]{elapsed_s:.1f}s[/dim]{suffix}")
pending_tool["name"] = ""
pending_tool["args_preview"] = ""
elif event_type == "compact":
tokens = data.get("tokens_before", "?")
console.print(f"\n [yellow]\u27f3 context compressed[/yellow] [dim]({tokens} tokens \u2192 summary)[/dim]\n")
def _timer_loop(status_ref: Any) -> None:
while not stop_timer.is_set():
elapsed = time.perf_counter() - run_start
label = run_state["label"]
try:
status_ref.update(f"[bold cyan]\u23f3 {label}... {elapsed:.1f}s[/bold cyan]")
except Exception:
pass
stop_timer.wait(1.0)
with console.status("[bold cyan]\u23f3 Connecting...[/bold cyan]") as spinner:
timer_thread = threading.Thread(target=_timer_loop, args=(spinner,), daemon=True)
timer_thread.start()
try:
pm = PersistentMemory()
agent = AgentLoop(
registry=build_registry(persistent_memory=pm),
llm=ChatLLM(),
event_callback=_status_event_callback,
max_iterations=max_iter,
persistent_memory=pm,
)
result = agent.run(user_message=user_input, history=history[-6:])
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
finally:
stop_timer.set()
timer_thread.join(timeout=1)
stats.last_elapsed = time.perf_counter() - run_start
_print_result(result, stats.last_elapsed)
history.append({"role": "user", "content": user_input})
if result.get("content"):
history.append({"role": "assistant", "content": result["content"]})
@@ -849,7 +1133,12 @@ def cmd_swarm_run_live(preset: str, vars_json: Optional[str] = None) -> None:
dashboard = _SwarmDashboard(preset, "")
try:
run = runtime.start_run(preset, user_vars, live_callback=dashboard.handle_event)
run = runtime.start_run(
preset,
user_vars,
live_callback=dashboard.handle_event,
include_shell_tools=True,
)
except FileNotFoundError as exc:
console.print(f"[red]{exc}[/red]")
return
@@ -946,7 +1235,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 +1253,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())
@@ -1125,6 +1415,78 @@ def cmd_swarm_run(preset: str, vars_json: Optional[str] = None) -> None:
cmd_swarm_run_live(preset, vars_json)
def cmd_swarm_inspect(preset: str) -> int:
"""Inspect a swarm preset without starting workers."""
from src.swarm.presets import inspect_preset
try:
report = inspect_preset(preset)
except FileNotFoundError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR
except Exception as exc:
console.print(f"[red]Failed to inspect preset:[/red] {exc}")
return EXIT_RUN_FAILED
status = "OK" if report["valid"] else "INVALID"
status_color = "green" if report["valid"] else "red"
lines = [
f"[bold]Preset:[/bold] {report['name']}",
f"[bold]Title:[/bold] {report.get('title') or '-'}",
f"[bold]Status:[/bold] [{status_color}]{status}[/{status_color}]",
f"[bold]Agents:[/bold] {len(report['agents'])}",
f"[bold]Tasks:[/bold] {len(report['tasks'])}",
f"[bold]Variables:[/bold] {', '.join(report['variables']) or '-'}",
]
if report.get("description"):
lines.append(f"[bold]Description:[/bold] {report['description']}")
console.print(Panel("\n".join(lines), border_style=status_color, title="Swarm Preset Inspect"))
agent_table = Table(title="Agents", show_lines=False)
agent_table.add_column("ID", style="cyan", no_wrap=True)
agent_table.add_column("Role")
agent_table.add_column("Tools", max_width=40)
for agent in report["agents"]:
agent_table.add_row(
agent["id"],
agent.get("role", ""),
", ".join(agent.get("tools", [])),
)
console.print(agent_table)
dag_table = Table(title="DAG Execution Plan", show_lines=False)
dag_table.add_column("Layer", justify="right", width=6)
dag_table.add_column("Task", style="cyan")
dag_table.add_column("Agent")
dag_table.add_column("Depends On")
task_details = {task["id"]: task for task in report["tasks"]}
for idx, layer in enumerate(report["layers"], start=1):
for item in layer:
task = task_details[item["task_id"]]
dag_table.add_row(
str(idx),
item["task_id"],
item["agent_id"],
", ".join(task.get("depends_on", [])) or "-",
)
console.print(dag_table)
validation_table = Table(title="Validation", show_lines=False)
validation_table.add_column("Level", width=8)
validation_table.add_column("Message")
if report["errors"]:
for error in report["errors"]:
validation_table.add_row("[red]ERROR[/red]", error)
if report["warnings"]:
for warning in report["warnings"]:
validation_table.add_row("[yellow]WARN[/yellow]", warning)
if not report["errors"] and not report["warnings"]:
validation_table.add_row("[green]OK[/green]", "No issues found")
console.print(validation_table)
return EXIT_SUCCESS if report["valid"] else EXIT_RUN_FAILED
def cmd_swarm_list() -> None:
"""List swarm run history."""
from src.swarm.store import SwarmStore
@@ -1285,22 +1647,47 @@ def cmd_session_chat(session_id: str, max_iter: int) -> None:
border_style="cyan",
))
stats = _SessionStats(session_start=time.monotonic())
prompt_session = _create_prompt_session(stats)
while True:
if prompt_session is None:
_print_status_bar(stats)
try:
prompt = Prompt.ask("\n[bold]>[/bold]").strip()
prompt = _read_input(prompt_session).strip()
except (KeyboardInterrupt, EOFError):
break
if not prompt or prompt.lower() in ("q", "quit", "exit"):
break
start = time.perf_counter()
try:
result = _run_agent(prompt, history=history[-6:], max_iter=max_iter)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
run_start = time.perf_counter()
_run_state = {"label": "running"}
_stop_timer = threading.Event()
_print_result(result, time.perf_counter() - start)
def _session_event_timer(status_ref: Any) -> None:
while not _stop_timer.is_set():
elapsed = time.perf_counter() - run_start
label = _run_state["label"]
try:
status_ref.update(f"[bold cyan]\u23f3 {label}... {elapsed:.1f}s[/bold cyan]")
except Exception:
pass
_stop_timer.wait(1.0)
with console.status("[bold cyan]\u23f3 Running...[/bold cyan]") as spinner:
_timer = threading.Thread(target=_session_event_timer, args=(spinner,), daemon=True)
_timer.start()
try:
result = _run_agent(prompt, history=history[-6:], max_iter=max_iter)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
finally:
_stop_timer.set()
_timer.join(timeout=1)
stats.last_elapsed = time.perf_counter() - run_start
_print_result(result, stats.last_elapsed)
history.append({"role": "user", "content": prompt})
if result.get("content"):
history.append({"role": "assistant", "content": result["content"]})
@@ -1331,6 +1718,28 @@ def cmd_upload(file_path: str) -> None:
console.print(f"[green]Uploaded:[/green] {dest}")
def cmd_provider_login(provider: str) -> int:
"""Authenticate OAuth-backed LLM providers."""
normalized = provider.strip().lower().replace("_", "-")
if normalized != "openai-codex":
console.print("[red]Unknown OAuth provider.[/red] Supported: openai-codex")
return EXIT_USAGE_ERROR
try:
from src.providers.openai_codex import login_openai_codex
console.print("[cyan]Starting OpenAI Codex OAuth login...[/cyan]\n")
token = login_openai_codex(
print_fn=lambda text: console.print(text),
prompt_fn=lambda text: Prompt.ask(text),
)
account = getattr(token, "account_id", None) or "ChatGPT"
console.print(f"[green]Authenticated with OpenAI Codex[/green] [dim]{account}[/dim]")
return EXIT_SUCCESS
except Exception as exc:
console.print(f"[red]Authentication error:[/red] {exc}")
return EXIT_RUN_FAILED
# ---------------------------------------------------------------------------
# CLI entrypoint
# ---------------------------------------------------------------------------
@@ -1338,6 +1747,7 @@ def cmd_upload(file_path: str) -> None:
def _build_parser() -> argparse.ArgumentParser:
"""Build the CLI parser with subcommands and compatibility flags."""
parser = argparse.ArgumentParser(description="Vibe-Trading CLI")
parser.add_argument("--version", action="version", version=f"vibe-trading {_VERSION}")
parser.add_argument("-p", "--prompt", type=str, help="Prompt text")
parser.add_argument("-f", "--prompt-file", type=Path, help="Read prompt text from a file")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON output")
@@ -1353,6 +1763,7 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("--max-iter", type=int, default=50, help="Maximum agent iterations")
parser.add_argument("--swarm-presets", action="store_true", help="List swarm presets")
parser.add_argument("--swarm-inspect", metavar="PRESET", help="Inspect a swarm preset without running it")
parser.add_argument("--swarm-run", nargs="+", metavar=("PRESET", "VARS"), help="Run a swarm preset")
parser.add_argument("--swarm-list", action="store_true", help="List swarm runs")
parser.add_argument("--swarm-show", metavar="RUN_ID", help="Show a swarm run")
@@ -1376,6 +1787,11 @@ def _build_parser() -> argparse.ArgumentParser:
serve_parser.add_argument("--port", type=int, default=8000, help="Listen port")
serve_parser.add_argument("--dev", action="store_true", help="Start the Vite dev server")
provider_parser = subparsers.add_parser("provider", help="Manage OAuth providers")
provider_subparsers = provider_parser.add_subparsers(dest="provider_command")
login_parser = provider_subparsers.add_parser("login", help="Authenticate with an OAuth provider")
login_parser.add_argument("provider", help="OAuth provider name, e.g. openai-codex")
list_parser = subparsers.add_parser("list", help="List runs")
list_parser.add_argument("--limit", dest="list_limit", type=int, default=20, help="Maximum number of runs")
@@ -1519,6 +1935,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",
@@ -1529,6 +1955,16 @@ _PROVIDER_CHOICES: list[dict[str, str | None]] = [
"key_prefix": None,
"key_placeholder": None,
},
{
"label": "OpenAI Codex (ChatGPT OAuth)",
"provider": "openai-codex",
"key_env": None,
"base_env": "OPENAI_CODEX_BASE_URL",
"base_url": "https://chatgpt.com/backend-api/codex/responses",
"model": "openai-codex/gpt-5.3-codex",
"key_prefix": None,
"key_placeholder": None,
},
]
@@ -1550,6 +1986,7 @@ def _render_env_content(config: dict[str, str]) -> str:
"DEEPSEEK_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_CODEX_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"GROQ_API_KEY",
@@ -1564,6 +2001,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",
@@ -1630,6 +2069,9 @@ def cmd_init() -> int:
console.print(
f"[red]That key doesn't look right.[/red] Expected it to start with [bold]{key_prefix}[/bold]."
)
elif provider == "openai-codex":
console.print("[dim]OpenAI Codex uses ChatGPT OAuth, not an API key.[/dim]")
console.print("[dim]After setup, run: vibe-trading provider login openai-codex[/dim]")
else:
console.print("[dim]Ollama does not require an API key.[/dim]")
@@ -1673,6 +2115,11 @@ def main(argv: list[str] | None = None) -> int:
return cmd_init()
if args.command == "serve":
return serve_main(raw_argv[1:])
if args.command == "provider":
if args.provider_command == "login":
return cmd_provider_login(args.provider)
console.print("[red]provider requires a subcommand.[/red] Try: vibe-trading provider login openai-codex")
return EXIT_USAGE_ERROR
if args.command == "run":
return _handle_prompt_command(
args.run_prompt,
@@ -1684,7 +2131,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))
@@ -1703,6 +2150,8 @@ def main(argv: list[str] | None = None) -> int:
if args.swarm_presets:
return _coerce_exit_code(cmd_swarm_presets())
if args.swarm_inspect:
return _coerce_exit_code(cmd_swarm_inspect(args.swarm_inspect))
if args.swarm_run:
preset_name = args.swarm_run[0]
vars_json = args.swarm_run[1] if len(args.swarm_run) > 1 else None
@@ -1723,7 +2172,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():
+163 -4
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).
@@ -49,6 +49,12 @@ mcp = FastMCP("Vibe-Trading")
_skills_loader = None
_registry = None
_include_shell_tools = True
def _env_shell_tools_enabled() -> bool:
"""Return whether shell tools were explicitly enabled for network MCP."""
return os.getenv("VIBE_TRADING_ENABLE_SHELL_TOOLS", "").strip().lower() in {"1", "true", "yes", "on"}
def _get_skills_loader():
@@ -63,7 +69,7 @@ def _get_registry():
global _registry
if _registry is None:
from src.tools import build_registry
_registry = build_registry()
_registry = build_registry(include_shell_tools=_include_shell_tools)
return _registry
@@ -75,7 +81,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 loaded skills.
Use load_skill(name) to get the full documentation for any skill.
"""
loader = _get_skills_loader()
@@ -213,7 +219,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,12 +543,163 @@ 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
# ---------------------------------------------------------------------------
def main():
"""Entry point for `vibe-trading-mcp` CLI command."""
global _include_shell_tools, _registry
import argparse
parser = argparse.ArgumentParser(description="Vibe-Trading MCP Server")
@@ -551,6 +708,8 @@ def main():
parser.add_argument("--port", type=int, default=8900,
help="SSE port (only used with --transport sse)")
args = parser.parse_args()
_include_shell_tools = True if args.transport == "stdio" else _env_shell_tools_enabled()
_registry = None
if args.transport == "sse":
mcp.run(transport="sse", port=args.port)
+18 -1
View File
@@ -7,6 +7,8 @@ langchain-openai>=0.0.5
langgraph>=0.2.50,<0.3
langgraph-checkpoint>=2.0.0
python-dotenv>=1.0.0
httpx>=0.28.0
oauth-cli-kit>=0.1.3
# Data & Computation
pandas>=2.0.0
@@ -14,6 +16,13 @@ numpy>=1.24.0
scipy>=1.10.0
duckdb>=1.2.0
# Document Readers (doc_reader_tool)
openpyxl>=3.1.0
python-docx>=1.1.0
python-pptx>=0.6.23
pypdfium2>=4.0.0
Pillow>=10.0.0
# ML Strategy
scikit-learn>=1.3.0
joblib>=1.3.0
@@ -33,8 +42,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")
+29
View File
@@ -54,6 +54,35 @@ def _check_llm_provider() -> CheckResult:
_sync_provider_env()
base_url = os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "")
if provider.lower() in {"openai-codex", "openai_codex"}:
try:
from src.providers.openai_codex import get_openai_codex_login_status
token = get_openai_codex_login_status()
except Exception as exc:
return CheckResult(
name=f"LLM ({provider})",
status="error",
message=f"OAuth status unavailable: {exc}",
impact="run `vibe-trading provider login openai-codex`",
critical=True,
)
if not token:
return CheckResult(
name=f"LLM ({provider})",
status="not_configured",
message="ChatGPT OAuth login not found",
impact="run `vibe-trading provider login openai-codex`",
critical=True,
)
account = getattr(token, "account_id", None) or "authenticated account"
return CheckResult(
name=f"LLM ({provider})",
status="ready",
message=f"{model} via ChatGPT OAuth ({account})",
impact="",
)
if not base_url:
return CheckResult(
name=f"LLM ({provider})",
+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")
),
)
+102 -7
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
@@ -66,6 +135,13 @@ def _sync_provider_env() -> None:
_ensure_dotenv()
provider = os.getenv("LANGCHAIN_PROVIDER", "openai").lower()
if provider in {"openai-codex", "openai_codex"}:
codex_url = os.getenv("OPENAI_CODEX_BASE_URL", "https://chatgpt.com/backend-api/codex/responses")
os.environ["OPENAI_API_BASE"] = codex_url
os.environ["OPENAI_BASE_URL"] = codex_url
os.environ.pop("OPENAI_API_KEY", None)
return
# (api_key_env, base_url_env)
_PROVIDER_MAP: dict[str, tuple[str | None, str]] = {
"openai": ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
@@ -79,6 +155,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"),
}
@@ -114,21 +191,39 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any
Raises:
RuntimeError: If langchain-openai is missing or LANGCHAIN_MODEL_NAME is unset.
"""
if ChatOpenAI is None:
raise RuntimeError("langchain-openai is not installed")
_sync_provider_env()
name = model_name or os.getenv("LANGCHAIN_MODEL_NAME", "").strip()
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(
provider = os.getenv("LANGCHAIN_PROVIDER", "openai").lower()
if provider in {"openai-codex", "openai_codex"}:
from src.providers.openai_codex import OpenAICodexLLM
effort = os.getenv("LANGCHAIN_REASONING_EFFORT", "").strip().lower()
return OpenAICodexLLM(
model=name,
temperature=temperature,
timeout=int(os.getenv("TIMEOUT_SECONDS", "120")),
reasoning_effort=effort or None,
)
if ChatOpenAI is None:
raise RuntimeError("langchain-openai is not installed")
# 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 provider == "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,
)
+130
View File
@@ -0,0 +1,130 @@
[
{
"name": "openrouter",
"label": "OpenRouter",
"api_key_env": "OPENROUTER_API_KEY",
"base_url_env": "OPENROUTER_BASE_URL",
"default_model": "deepseek/deepseek-v3.2",
"default_base_url": "https://openrouter.ai/api/v1",
"api_key_required": true
},
{
"name": "openai",
"label": "OpenAI",
"api_key_env": "OPENAI_API_KEY",
"base_url_env": "OPENAI_BASE_URL",
"default_model": "gpt-4o",
"default_base_url": "https://api.openai.com/v1",
"api_key_required": true
},
{
"name": "openai-codex",
"label": "OpenAI Codex (ChatGPT OAuth)",
"api_key_env": null,
"base_url_env": "OPENAI_CODEX_BASE_URL",
"default_model": "openai-codex/gpt-5.3-codex",
"default_base_url": "https://chatgpt.com/backend-api/codex/responses",
"api_key_required": false,
"auth_type": "oauth",
"login_command": "vibe-trading provider login openai-codex"
},
{
"name": "deepseek",
"label": "DeepSeek",
"api_key_env": "DEEPSEEK_API_KEY",
"base_url_env": "DEEPSEEK_BASE_URL",
"default_model": "deepseek-chat",
"default_base_url": "https://api.deepseek.com/v1",
"api_key_required": true
},
{
"name": "gemini",
"label": "Gemini",
"api_key_env": "GEMINI_API_KEY",
"base_url_env": "GEMINI_BASE_URL",
"default_model": "gemini-2.5-flash",
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
"api_key_required": true
},
{
"name": "groq",
"label": "Groq",
"api_key_env": "GROQ_API_KEY",
"base_url_env": "GROQ_BASE_URL",
"default_model": "llama-3.3-70b-versatile",
"default_base_url": "https://api.groq.com/openai/v1",
"api_key_required": true
},
{
"name": "dashscope",
"label": "DashScope / Qwen",
"api_key_env": "DASHSCOPE_API_KEY",
"base_url_env": "DASHSCOPE_BASE_URL",
"default_model": "qwen-plus",
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key_required": true
},
{
"name": "qwen",
"label": "Qwen",
"api_key_env": "DASHSCOPE_API_KEY",
"base_url_env": "DASHSCOPE_BASE_URL",
"default_model": "qwen-plus",
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key_required": true
},
{
"name": "zhipu",
"label": "Zhipu",
"api_key_env": "ZHIPU_API_KEY",
"base_url_env": "ZHIPU_BASE_URL",
"default_model": "glm-4-plus",
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key_required": true
},
{
"name": "moonshot",
"label": "Moonshot / Kimi",
"api_key_env": "MOONSHOT_API_KEY",
"base_url_env": "MOONSHOT_BASE_URL",
"default_model": "kimi-k2.5",
"default_base_url": "https://api.moonshot.ai/v1",
"api_key_required": true
},
{
"name": "minimax",
"label": "MiniMax",
"api_key_env": "MINIMAX_API_KEY",
"base_url_env": "MINIMAX_BASE_URL",
"default_model": "MiniMax-M2.7",
"default_base_url": "https://api.minimax.io/v1",
"api_key_required": true
},
{
"name": "mimo",
"label": "Xiaomi MIMO",
"api_key_env": "MIMO_API_KEY",
"base_url_env": "MIMO_BASE_URL",
"default_model": "MiMo-72B-A27B",
"default_base_url": "https://api.xiaomimimo.com/v1",
"api_key_required": true
},
{
"name": "zai",
"label": "Z.ai",
"api_key_env": "ZAI_API_KEY",
"base_url_env": "ZAI_BASE_URL",
"default_model": "glm-5.1",
"default_base_url": "https://api.z.ai/api/coding/paas/v4",
"api_key_required": true
},
{
"name": "ollama",
"label": "Ollama",
"api_key_env": null,
"base_url_env": "OLLAMA_BASE_URL",
"default_model": "qwen2.5:32b",
"default_base_url": "http://localhost:11434/v1",
"api_key_required": false
}
]
+412
View File
@@ -0,0 +1,412 @@
"""OpenAI Codex OAuth provider.
This provider follows nanobot's OpenAI Codex OAuth path: a ChatGPT account is
authenticated by oauth-cli-kit, then requests are sent to the ChatGPT Codex
Responses endpoint. It is intentionally separate from the standard OpenAI API
key path because ChatGPT OAuth tokens are not OpenAI API keys.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Optional
from urllib.parse import urlparse
try:
import httpx
except ImportError:
httpx = None # type: ignore
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "vibe-trading"
@dataclass
class CodexToolCall:
"""Internal tool-call representation compatible with ChatLLM parsing."""
id: str
name: str
arguments: dict[str, Any]
def as_langchain_tool_call(self) -> dict[str, Any]:
return {"id": self.id, "name": self.name, "args": self.arguments}
@dataclass
class CodexAIMessage:
"""Small LangChain-like message object used by ChatLLM."""
content: str = ""
tool_calls: list[dict[str, Any]] = field(default_factory=list)
additional_kwargs: dict[str, Any] = field(default_factory=dict)
response_metadata: dict[str, Any] = field(default_factory=lambda: {"finish_reason": "stop"})
def __add__(self, other: "CodexAIMessage") -> "CodexAIMessage":
finish_reason = other.response_metadata.get(
"finish_reason",
self.response_metadata.get("finish_reason", "stop"),
)
reasoning = (
self.additional_kwargs.get("reasoning_content", "")
+ other.additional_kwargs.get("reasoning_content", "")
)
return CodexAIMessage(
content=(self.content or "") + (other.content or ""),
tool_calls=[*self.tool_calls, *other.tool_calls],
additional_kwargs={"reasoning_content": reasoning} if reasoning else {},
response_metadata={"finish_reason": finish_reason},
)
def login_openai_codex(
print_fn: Callable[[str], None] | None = None,
prompt_fn: Callable[[str], str] | None = None,
) -> Any:
"""Run interactive ChatGPT/Codex OAuth login and persist the token."""
try:
from oauth_cli_kit import get_token, login_oauth_interactive
except ImportError as exc:
raise RuntimeError("oauth-cli-kit is not installed. Run: pip install oauth-cli-kit") from exc
token = None
try:
token = get_token()
except Exception:
pass
if token and getattr(token, "access", None):
return token
return login_oauth_interactive(print_fn=print_fn or print, prompt_fn=prompt_fn or input)
def get_openai_codex_login_status() -> Any | None:
"""Return the persisted OAuth token, if available."""
try:
from oauth_cli_kit import get_token
except ImportError:
return None
try:
token = get_token()
except Exception:
return None
if token and getattr(token, "access", None):
return token
return None
def _get_codex_token() -> Any:
try:
from oauth_cli_kit import get_token
except ImportError as exc:
raise RuntimeError(
"OpenAI Codex OAuth requires oauth-cli-kit. Install dependencies, then run: "
"vibe-trading provider login openai-codex"
) from exc
try:
token = get_token()
except Exception as exc:
raise RuntimeError("OpenAI Codex is not logged in. Run: vibe-trading provider login openai-codex") from exc
if not (token and getattr(token, "access", None) and getattr(token, "account_id", None)):
raise RuntimeError("OpenAI Codex is not logged in. Run: vibe-trading provider login openai-codex")
return token
def validate_codex_base_url(url: str) -> str:
"""Validate the only supported ChatGPT Codex OAuth endpoint.
ChatGPT OAuth tokens must not be sent to arbitrary OpenAI-compatible base
URLs. The standard OpenAI API remains API-key authenticated; this provider
is limited to the ChatGPT Codex backend endpoint used by Codex OAuth.
"""
value = (url or DEFAULT_CODEX_URL).strip().rstrip("/")
parsed = urlparse(value)
if (
parsed.scheme != "https"
or parsed.netloc != "chatgpt.com"
or parsed.path != "/backend-api/codex/responses"
):
raise ValueError(
"OpenAI Codex OAuth only supports https://chatgpt.com/backend-api/codex/responses"
)
return value
def _build_headers(account_id: str, access_token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {access_token}",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": DEFAULT_ORIGINATOR,
"User-Agent": "vibe-trading (python)",
"accept": "text/event-stream",
"content-type": "application/json",
}
def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
return model.split("/", 1)[1]
return model
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _convert_user_message(content: Any) -> dict[str, Any]:
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
if isinstance(content, list):
converted: list[dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
converted.append({"type": "input_text", "text": item.get("text", "")})
elif item.get("type") == "image_url":
url = (item.get("image_url") or {}).get("url")
if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
if converted:
return {"role": "user", "content": converted}
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
if isinstance(tool_call_id, str) and tool_call_id:
if "|" in tool_call_id:
call_id, item_id = tool_call_id.split("|", 1)
return call_id, item_id or None
return tool_call_id, None
return "call_0", None
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
input_items: list[dict[str, Any]] = []
for idx, msg in enumerate(messages):
role = msg.get("role")
content = msg.get("content")
if role == "system":
system_prompt = content if isinstance(content, str) else ""
elif role == "user":
input_items.append(_convert_user_message(content))
elif role == "assistant":
if isinstance(content, str) and content:
input_items.append({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
"status": "completed",
"id": f"msg_{idx}",
})
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
call_id, item_id = _split_tool_call_id(tool_call.get("id"))
input_items.append({
"type": "function_call",
"id": item_id or f"fc_{idx}",
"call_id": call_id or f"call_{idx}",
"name": fn.get("name"),
"arguments": fn.get("arguments") or "{}",
})
elif role == "tool":
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
output = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
input_items.append({"type": "function_call_output", "call_id": call_id, "output": output})
return system_prompt, input_items
def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
converted: list[dict[str, Any]] = []
for tool in tools or []:
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
name = fn.get("name")
if not name:
continue
params = fn.get("parameters") or {}
converted.append({
"type": "function",
"name": name,
"description": fn.get("description") or "",
"parameters": params if isinstance(params, dict) else {},
})
return converted
def _decode_tool_args(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str) or not raw:
return {}
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {"raw": raw}
return parsed if isinstance(parsed, dict) else {"raw": raw}
def _map_finish_reason(status: str | None) -> str:
return {
"completed": "stop",
"incomplete": "length",
"failed": "error",
"cancelled": "error",
}.get(status or "completed", "stop")
def _events_from_lines(lines: Iterable[str]) -> Iterable[dict[str, Any]]:
buffer: list[str] = []
def flush() -> dict[str, Any] | None:
data_lines = [line[5:].strip() for line in buffer if line.startswith("data:")]
buffer.clear()
if not data_lines:
return None
data = "\n".join(data_lines).strip()
if not data or data == "[DONE]":
return None
try:
return json.loads(data)
except json.JSONDecodeError:
return None
for line in lines:
if line == "":
if buffer:
event = flush()
if event is not None:
yield event
continue
buffer.append(line)
if buffer:
event = flush()
if event is not None:
yield event
def _message_chunks_from_events(events: Iterable[dict[str, Any]]) -> Iterable[CodexAIMessage]:
tool_buffers: dict[str, dict[str, Any]] = {}
for event in events:
event_type = event.get("type")
if event_type == "response.output_item.added":
item = event.get("item") or {}
if item.get("type") == "function_call" and item.get("call_id"):
tool_buffers[item["call_id"]] = {
"id": item.get("id") or "fc_0",
"name": item.get("name") or "",
"arguments": item.get("arguments") or "",
}
elif event_type == "response.output_text.delta":
delta = event.get("delta") or ""
if delta:
yield CodexAIMessage(content=delta)
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id in tool_buffers:
tool_buffers[call_id]["arguments"] += event.get("delta") or ""
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id in tool_buffers:
tool_buffers[call_id]["arguments"] = event.get("arguments") or ""
elif event_type == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "function_call" and item.get("call_id"):
call_id = item["call_id"]
buf = tool_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
tool = CodexToolCall(
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
name=buf.get("name") or item.get("name") or "",
arguments=_decode_tool_args(args_raw),
)
yield CodexAIMessage(tool_calls=[tool.as_langchain_tool_call()])
elif event_type == "response.completed":
status = (event.get("response") or {}).get("status")
yield CodexAIMessage(response_metadata={"finish_reason": _map_finish_reason(status)})
elif event_type in {"error", "response.failed"}:
detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"OpenAI Codex response failed: {str(detail)[:500]}")
class OpenAICodexLLM:
"""Minimal LangChain-compatible adapter for Vibe-Trading's ChatLLM."""
def __init__(
self,
*,
model: str,
temperature: float = 0.0,
timeout: int = 120,
tools: list[dict[str, Any]] | None = None,
reasoning_effort: str | None = None,
codex_url: str | None = None,
) -> None:
if httpx is None:
raise RuntimeError("OpenAI Codex OAuth requires httpx. Install dependencies first.")
self.model = model
self.temperature = temperature
self.timeout = timeout
self.tools = tools or []
self.reasoning_effort = reasoning_effort
self.codex_url = validate_codex_base_url(
codex_url or os.getenv("OPENAI_CODEX_BASE_URL", DEFAULT_CODEX_URL)
)
def bind_tools(self, tools: list[dict[str, Any]]) -> "OpenAICodexLLM":
return OpenAICodexLLM(
model=self.model,
temperature=self.temperature,
timeout=self.timeout,
tools=tools,
reasoning_effort=self.reasoning_effort,
codex_url=self.codex_url,
)
def _body(self, messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
system_prompt, input_items = _convert_messages(messages)
body: dict[str, Any] = {
"model": _strip_model_prefix(self.model),
"store": False,
"stream": stream,
"instructions": system_prompt,
"input": input_items,
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": _prompt_cache_key(messages),
"tool_choice": "auto",
"parallel_tool_calls": True,
}
tools = _convert_tools(self.tools)
if tools:
body["tools"] = tools
if self.reasoning_effort and self.reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": self.reasoning_effort.lower()}
return body
def _headers(self) -> dict[str, str]:
token = _get_codex_token()
return _build_headers(str(token.account_id), str(token.access))
def stream(self, messages: list[dict[str, Any]], config: Optional[dict[str, Any]] = None) -> Iterable[CodexAIMessage]:
timeout = (config or {}).get("timeout") or self.timeout
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
with client.stream("POST", self.codex_url, headers=self._headers(), json=self._body(messages, stream=True)) as response:
if response.status_code != 200:
raw = response.read().decode("utf-8", "ignore")
raise RuntimeError(f"OpenAI Codex HTTP {response.status_code}: {raw[:500]}")
yield from _message_chunks_from_events(_events_from_lines(response.iter_lines()))
def invoke(self, messages: list[dict[str, Any]], config: Optional[dict[str, Any]] = None) -> CodexAIMessage:
accumulated: CodexAIMessage | None = None
for chunk in self.stream(messages, config=config):
accumulated = chunk if accumulated is None else accumulated + chunk
return accumulated or CodexAIMessage()
async def ainvoke(self, messages: list[dict[str, Any]], config: Optional[dict[str, Any]] = None) -> CodexAIMessage:
return await asyncio.to_thread(self.invoke, messages, config)
+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
+33 -9
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
@@ -79,13 +82,21 @@ class SessionService:
self.event_bus.clear(session_id)
return self.store.delete_session(session_id)
async def send_message(self, session_id: str, content: str, role: str = "user") -> Dict[str, Any]:
async def send_message(
self,
session_id: str,
content: str,
role: str = "user",
*,
include_shell_tools: bool = False,
) -> Dict[str, Any]:
"""Send a message to a session and trigger execution.
Args:
session_id: Session ID.
content: Message content.
role: Message role.
include_shell_tools: Whether this attempt may use shell tools.
Returns:
Dictionary containing message_id and attempt_id.
@@ -96,6 +107,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":
@@ -103,12 +115,13 @@ class SessionService:
attempt = Attempt(session_id=session_id, parent_attempt_id=session.last_attempt_id, prompt=content)
self.store.create_attempt(attempt)
session.config["include_shell_tools"] = include_shell_tools
session.last_attempt_id = attempt.attempt_id
session.updated_at = datetime.now().isoformat()
self.store.update_session(session)
self.event_bus.emit(session_id, "attempt.created", {"attempt_id": attempt.attempt_id, "prompt": content})
asyncio.create_task(self._run_attempt(session, attempt))
asyncio.create_task(self._run_attempt(session, attempt, include_shell_tools=include_shell_tools))
return {"message_id": message.message_id, "attempt_id": attempt.attempt_id}
async def resume_attempt(self, session_id: str, attempt_id: str, user_input: str) -> Dict[str, Any]:
@@ -140,7 +153,8 @@ class SessionService:
self.store.update_attempt(attempt)
self.event_bus.emit(session_id, "attempt.resumed", {"attempt_id": attempt_id, "user_input": user_input})
asyncio.create_task(self._run_attempt(session, attempt))
include_shell_tools = bool(session.config.get("include_shell_tools", False))
asyncio.create_task(self._run_attempt(session, attempt, include_shell_tools=include_shell_tools))
return {"status": "resumed", "attempt_id": attempt_id}
def get_messages(self, session_id: str, limit: int = 100) -> list[Message]:
@@ -170,7 +184,7 @@ class SessionService:
loop.cancel()
return True
async def _run_attempt(self, session: Session, attempt: Attempt) -> None:
async def _run_attempt(self, session: Session, attempt: Attempt, *, include_shell_tools: bool = False) -> None:
"""Execute an Attempt in the background."""
attempt.mark_running()
self.store.update_attempt(attempt)
@@ -178,7 +192,7 @@ class SessionService:
try:
messages = self.store.get_messages(session.session_id)
result = await self._run_with_agent(attempt, messages=messages)
result = await self._run_with_agent(attempt, messages=messages, include_shell_tools=include_shell_tools)
if result.get("status") == "success":
attempt.mark_completed(summary=result.get("content", ""))
else:
@@ -200,6 +214,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",
@@ -212,12 +227,19 @@ class SessionService:
self.store.update_attempt(attempt)
self.event_bus.emit(session.session_id, "attempt.failed", {"attempt_id": attempt.attempt_id, "error": str(exc)})
async def _run_with_agent(self, attempt: Attempt, messages: list = None) -> Dict[str, Any]:
async def _run_with_agent(
self,
attempt: Attempt,
messages: list = None,
*,
include_shell_tools: bool = False,
) -> Dict[str, Any]:
"""Execute an attempt with the V5 AgentLoop.
Args:
attempt: Current execution attempt.
messages: Session message history.
include_shell_tools: Whether the registry may include shell tools.
Returns:
Result dictionary containing status, run_dir, run_id, metrics, and related fields.
@@ -225,9 +247,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 +261,11 @@ class SessionService:
self.event_bus.emit(session_id, event_type, data)
agent = AgentLoop(
registry=registry,
registry=build_registry(persistent_memory=pm, include_shell_tools=include_shell_tools),
llm=llm,
event_callback=event_callback,
max_iterations=50,
persistent_memory=pm,
)
self._active_loops[session_id] = agent
@@ -249,7 +273,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
+219
View File
@@ -0,0 +1,219 @@
"""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
import math
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 _literal_safe_value(value: Any) -> Any:
"""Return a value that can be faithfully represented as a Python literal."""
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise ValueError("Non-finite floats cannot be rendered as Python literals")
return value
if isinstance(value, list):
return [_literal_safe_value(item) for item in value]
if isinstance(value, tuple):
return tuple(_literal_safe_value(item) for item in value)
if isinstance(value, dict):
return {
_literal_safe_value(key): _literal_safe_value(item)
for key, item in value.items()
}
raise TypeError(
f"Unsupported generated Python literal type: {type(value).__name__}",
)
def _python_literal(value: Any) -> str:
"""Render ``value`` as source text for a safe Python literal."""
literal = repr(_literal_safe_value(value))
try:
ast.literal_eval(literal)
except (SyntaxError, ValueError) as exc:
raise ValueError(f"Invalid generated Python literal: {literal!r}") from exc
return literal
def _env() -> Environment:
"""Jinja2 environment rooted at our templates directory."""
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(enabled_extensions=("html", "xml")),
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["py_literal"] = _python_literal
return env
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,70 @@
"""Shadow Account generated signal engine.
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 | py_literal }}
PROFITABLE_ROUNDTRIPS = {{ profitable_roundtrips | py_literal }}
DATE_RANGE = {{ date_range | py_literal }}
PREFERRED_MARKETS = {{ preferred_markets | py_literal }}
RULES = {{ rules | py_literal }}
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,514 @@
---
name: ashare-pre-st-filter
description: A 股 ST/*ST 风险预测框架 — 基于最新中报/三季报或业绩预告/快报,预测下一财年是否会因营收、利润、净资产、分红不达标而被风险警示,并将新浪监管处罚记录作为独立证据面纳入风险等级。仅适用于 A 股,不预测财务造假。
category: risk-analysis
---
> **依赖**:本 skill 必须配合 [tushare skill](../tushare/SKILL.md) 使用,缺数据时回退到 [akshare skill](../akshare/SKILL.md)。
# A 股 ST/*ST 风险预测
## 适用范围
- 仅 A 股**主板 / 创业板 / 科创板**。港股、美股、加密、商品、**北交所**(`8xxxxx.BJ` 监管规则与本 skill 阈值不一致)均不适用,本 skill 直接拒绝。
- 仅做"下一财年是否会被 ST/*ST"的前瞻预测,不做财务造假预测。
- 输出双轴结果:**触线风险等级** + **预测可信度**。
## 触发条件
用户提问形如:
- "分析 000729.SZ 的 ST 风险"
- "600xxx 下个年报会不会被 ST"
- "帮我看一下 300xxx 是否有退市风险"
收到此类请求时,按下面"分析流程"执行;其他场景不要主动启用本 skill。
## 数据获取规范(tushare 优先 → akshare 兜底)
**铁律**:
1. **所有财务/基本面/分红/审计/ST 状态数据,必须先调用 [tushare skill](../tushare/SKILL.md)**,按下表指定的接口名、`ts_code`、`period` 拉取。
2. 当 tushare 接口因积分不足、token 缺失或返回空时,**回退到 [akshare skill](../akshare/SKILL.md)** 对应函数。
3. **akshare 兜底数据必须在最终输出"备注"中显式标注**:`数据源:akshare(非官方聚合,不保证完整性,建议核对原始公告)`。**不允许只用 akshare 给出"高可信度"预测**——akshare 兜底的红线项可信度强制降一档(高→中高,中高→中,中→低)。
4. 监管处罚(E2)走本 skill 自带的 [scripts/fetch_sina_penalties.py](scripts/fetch_sina_penalties.py),不经过 tushare/akshare。
### 数据需求映射表
| 数据需求 | tushare 接口(首选) | 抓取参数 / 时间窗口 | akshare 兜底 | akshare 注意事项 |
|---------|---------------------|-------------------|-------------|----------------|
| 当前 ST 状态 | `stock_st` | 最近一个交易日 | `stock_zh_a_st_em()` | 仅返回当前 ST/退市整理板列表,历史不可查 |
| 历史改名 | `namechange` | `ts_code` 全历史 | `stock_zh_a_new_em()`(仅新股,无改名) | akshare 无完整改名接口,必要时跳过 |
| 股票基础信息(板块判断) | `stock_basic` | `list_status='L'` | `stock_individual_info_em(symbol)` | 字段名不同,需自行映射 |
| 业绩预告 | `forecast` | `ts_code` + 最近 2 个 period(`{Y}0630`/`{Y}0930`/`{Y}1231`) | `stock_yjyg_em(date='YYYYMMDD')` | 按报告期日期反查,需先按 period 拉全市场再筛 ts_code |
| 业绩快报 | `express` | 同上,最近 2 个 period | `stock_yjbb_em(date='YYYYMMDD')` | 同上,全市场快照需筛 |
| 利润表 | `income` | `ts_code` + 最近 5 个 period(过去 4 年年报 `{Y}1231` + 最新中报/三季报) | `stock_financial_report_sina(stock='sh600000', symbol='利润表')` | 单位不一致(万元 vs 元),需做归一;季报缺失常见 |
| 资产负债表 | `balancesheet` | 同上 | `stock_financial_report_sina(stock='sh600000', symbol='资产负债表')` | 同上 |
| 现金流量表 | `cashflow` | 同上 | `stock_financial_report_sina(stock='sh600000', symbol='现金流量表')` | 同上 |
| 财务指标(扣非净利润等) | `fina_indicator` | `ts_code` + 最近 5 个 period | `stock_financial_analysis_indicator(symbol='600000')` | 字段中文,扣非字段名为"扣除非经常性损益后的净利润" |
| 审计意见 | `fina_audit` | `ts_code` + 最近 2 个年报 period | **akshare 无对应接口** | 缺失时在输出中标注"审计意见数据缺失",**不得跳过**——必须提示用户去官网查 |
| 分红 | `dividend` | `ts_code` + 近 5 年 `div_proc='实施'` | `stock_history_dividend_detail(symbol='600000', indicator='分红')` | 字段中文,需把"派息"金额×总股本得到现金分红总额 |
| 日线行情(1 元退市预警) | `daily` | `ts_code` + 最近 30 个交易日 | `stock_zh_a_hist(symbol='600000', period='daily', adjust='')` | 必须用**不复权**价格判断 1 元退市线 |
| 每日指标(市值) | `daily_basic` | `ts_code` + 最近一个交易日 | `stock_zh_a_spot_em()` 全市场快照筛 `代码=600000` | 字段名为"总市值"(**单位:元**,东方财富 push2 原始口径,akshare 不做缩放),换算成亿元需 `/1e8`。⚠️ 不要与 tushare `daily_basic.total_mv`(万元)混淆——本列是 akshare 兜底口径 |
| 财报披露日期 | `disclosure_date` | `ts_code` + 当前年度 | **akshare 无对应接口** | 缺失时按经验估算(4/30 年报、8/31 中报、10/31 三季报) |
| 监管处罚 | — | — | **走本 skill 自带 sina 脚本** | 见 E2 章节 |
### 时间窗口口径统一
按数据类型分别处理,**禁止用单一 60 天经验值统一对齐**:
- **财务报表(income / balancesheet / cashflow / fina_indicator)**:以 `disclosure_date` 实际公告日为准;该接口不可用时按法定截止日(年报 4/30、中报 8/31、三季报 10/31、一季报 4/30)做兜底。在公告日 +1 天起方可视该 period 为"最新可得"。
- **"过去 N 年年报"**:一律 `period = {Y}1231`,不要用日历年起止。
- **业绩预告 forecast / 业绩快报 express**:只取 `ann_date ≥ 当前年度 1 月 1 日` 的记录;按 `ann_date` 倒序取最新一条,老 period 的预告对预测无效。
- **dividend**:按 `(end_date, ann_date, cash_div_tax)` 三元组**强制去重**——tushare 同一笔分红会出现 3-4 行(预案/股东大会通过/实施),直接累加会三倍误算。
## 第一步:当前状态核查(M0)
**判断公司当前是否已被风险警示,并据此调整分析方向(不一刀切结束)**:
调用顺序:
1. 优先调用 `stock_st`(tushare skill,3000 积分),按当前最近交易日查询;记录 `type` / `type_name`。
2. 如积分不足,回退到 `namechange`(免费):拉取 `ts_code` 全部历史改名记录,取按 `start_date` 排序的最新一条;判断 `name` 前缀。
3. 兜底用 `stock_basic.name` 做名称确认。
**分支处理**:
| 当前状态 | 分析方向 | 是否继续 |
|---------|---------|---------|
| 正常股票 | 预测下一财年是否会被 ST/*ST | **是**(默认流程) |
| 已 ST(普通) | 改为预测:是否会进一步转 *ST 或退市;R1-R4 阈值需更严格(亏损链门槛降一年) | **是** |
| 已 *ST | 改为预测:是否会被强制退市;重点看 R1 营收 + R2 净资产 + E1 审计意见 | **是** |
| 已退市整理期 / 已摘牌 | 直接结束 | **否** |
## 第二步:板块阈值表(决定红线)
**先用 `ts_code` 前缀判断板块,再套用对应阈值。不要混用。**
| 板块 | 代码识别 | 营收红线 | 市值退市线 | 三年累计分红阈值 |
|------|---------|---------|-----------|----------------|
| 主板 | `60xxxx.SH` / `00xxxx.SZ`(除 30 / 688 开头外) | 净利润为负 且 营收 < 3 亿 | 市值 < 5 亿 | 累计 < 5000 万 且 < 年均净利润 30% |
| 创业板 | `30xxxx.SZ` | 净利润为负 且 营收 < 1 亿 | 市值 < 3 亿 | 累计 < 3000 万 且 < 年均净利润 30%(研发豁免:研发投入占营收 ≥ 5% 或近三年累计 ≥ 6000 万) |
| 科创板 | `688xxx.SH` | 净利润为负 且 营收 < 1 亿 | 市值 < 3 亿 | 累计 < 3000 万 且 < 年均净利润 30%(研发豁免:研发投入占营收 ≥ 5% 或近三年累计 ≥ 6000 万) |
## 第三步:预测时点选择
按"最新可得证据"原则确定基准时点,从下到上择优:
| 基准时点 | 数据接口 | 可信度基线 | 适用季节 |
|---------|---------|-----------|---------|
| **业绩预告 forecast** | `forecast` | **高** | 1 月底 / 7 月中旬 / 10 月底高发期 |
| **业绩快报 express** | `express` | **高** | 1-4 月年报前夕 |
| **三季报** | `income/balancesheet/cashflow` (`period=YYYYMMDD`,9 月末) | **中高** | 10 月底披露后到次年年报前 |
| **中报** | 同上,6 月末 period | **中** | 8 月底披露后到三季报前 |
| **去年年报** | 同上,12 月末 period | **低** | 仅作为基准对照 |
**证据优先级**(同一指标多个来源时,高的覆盖低的):
```
forecast / express > 三季报 > 中报 > 去年年报 > 经验外推
```
输出时**必须在每个预测项明示采用了哪一级证据**。如果存在 forecast 给出的全年净利润区间,**禁止再用三季报机械年化覆盖**。
## 第四步:四项可预测红线
### R1 营收 + 净利润红线
**目标**:预测全年营收、归母净利润和**扣非净利润**,按监管口径"扣非前后孰低者"判定。
**口径关键**:监管原文是 `min(n_income, profit_dedt) < 0` 且 `revenue < 板块阈值` —— **必须同时预测扣非净利润**,不能只用归母。仅看归母会漏掉"归母为正但扣非为负"的造壳公司。
**预测方法(按基准时点选用,至少一种)**:
- **forecast 直采法**:若 `forecast` 给出 `net_profit_min` / `net_profit_max`,直接取区间作为全年归母预测;扣非用过去两年扣非率(`profit_dedt / n_income` 中位数)折算;可信度=高。营收红线仍需结合最新报表外推。
- **express 直采法**:若 `express` 已披露,`revenue` / `n_income` 即为全年快报值;扣非同样按过去两年率折算(express 一般无扣非字段);可信度=高。
- **Q4 单季回补法**(三季报基准):
- `Q1Q3_revenue` = 三季报营业收入
- `Q4_revenue_est` = 过去两年 Q4 营收占全年比例的中位数 × 当前年化预估值
- `revenue_full_year_est = Q1Q3_revenue + Q4_revenue_est`
- `n_income` / `profit_dedt` 同法处理
- 可信度=中高
- **下半年情景外推法**(中报基准,强季节性公司必须用):
- 取过去两年 H2 营收占全年比例均值,给出悲观/基准/乐观三情景
- `revenue_full_year_est = H1_revenue / H1_share_avg`
- 可信度=中
- **同比延续法**(中报基准,弱季节性公司可用):
- `revenue_full_year_est = H1_revenue × (last_year_full_revenue / last_year_H1_revenue)`
- 可信度=中
**禁止**:H1 直接乘 2、Q1-Q3 直接乘 4/3。任何机械年化**强制降到"低"可信度**并在输出中注明。
**触线判定**(口径:`worst_profit = min(n_income_est, profit_dedt_est)`):
- 高风险:`worst_profit < 0` 且 `revenue_est < 板块阈值`
- 中风险:`worst_profit < 0` 但营收达标;或营收逼近阈值(< 阈值 × 1.2)
- 低风险:上述均不满足
> **系数说明**:R1 中风险的 1.2 是预测项预警 buffer(预测本身有误差,需窄 buffer);R4 高风险的 1.5 是双年联动判定 buffer(两年都亏且营收接近阈值才是真退市信号,需宽 buffer)。两个系数不同是有意为之,**不要错误地统一**。
### R2 年末净资产红线
**目标**:判断年末归母股东权益是否可能转负。
**方法**:
1. 取最新一期 `balancesheet.total_hldr_eqy_exc_min_int`(最新中报或三季报),记为 `current_eq`。
2. 计算 `eq_year_end_est = current_eq + 剩余季度净利润预测 − 当前年度已实施现金分红总额`。
- 剩余季度净利润:中报基准用 R1 的 H2 预测,三季报基准用 R1 的 Q4 预测。
- 已实施分红总额:见 R3 的去重 + 量纲公式 `cash_div_tax / 10 × total_share`。
**触线判定**(统一相对阈值,单位:元):
- 高风险:`current_eq < 0` 或 `eq_year_end_est < 0`
- 中风险:`0 ≤ eq_year_end_est < 1e8`(1 亿元)
- 低风险:`eq_year_end_est ≥ 1e8`
**注意**:商誉减值、长期股权投资减值在 Q3/Q4 集中确认,应在备注提示"若计提大额减值,净资产可能进一步下行",但**不得在没有公告依据时擅自减值**。
### R3 分红达标前瞻
**目标**:判断"近三年累计现金分红 < 年均净利润 30% 且 < 板块累计阈值"是否会在下一次年度考核时同时满足(监管原文:两条件**同时**触发才警示)。
**单笔分红量纲公式**(**必须用此公式,不要用 `cash_div_tax × base_share`**):
```
cash_dividend_total = cash_div_tax / 10 × total_share
```
其中 `cash_div_tax` 来自 `dividend`(每 10 股派现金额,含税),`total_share` 来自 `daily_basic.total_share`(最新总股本,单位万股 → 需 ×1e4 换成股)或 `stock_basic`。tushare `dividend.base_share` 字段对很多公司为 None,**禁止直接使用**。
**方法**:
1. 用 `dividend` 拉取该 `ts_code` 近五年记录,按 `(end_date, ann_date, cash_div_tax)` 三元组**强制去重**——同一笔分红 tushare 会返回"预案/股东大会通过/实施"3-4 行。
2. 折算系数(按 `div_proc` 状态):`实施=1.0` / `股东大会通过=0.7` / `预案=0.5`;同一 `end_date` 取系数最高那条。
3. 用 `income` 拉取近三年完整年度归母净利润(period=YYYY1231)。
4. 计算考核窗口 = `[当前年-2, 当前年]`,即"过去两年已落地分红 + 本年度预计分红"。
5. 本年度预计分红:
- 若 `dividend` 已查到本年度记录:按上面折算系数计入。
- 否则用"过去两年平均分红率"× R1 预测全年净利润(基准情景)做估算,可信度降一档。
6. 与年均净利润比较:
- 条件 A:`cumulative_div_3y < 30% × avg_n_income_3y`
- 条件 B:`cumulative_div_3y < 板块累计阈值`
7. 创业板 / 科创板若研发投入占比满足豁免条件,备注提示"研发达标可能豁免"。
**触线判定**:
- 高风险:A 和 B 同时满足 **且** 过去三年至少一年净利润为正(即"盈利年份未达标"——监管以盈利年为基准)。
- 中风险:仅一个条件满足;或本年度分红尚未公告。
- 低风险:累计分红已稳定达标;或过去三年累计净利润 ≤ 0(无分红基础,按 R1 联动)。
**禁止**:本年度尚未公告分红时,不得断言"必然不达标",只能给出概率。
### R4 连续亏损 / 扣非亏损链
**目标**:判断是否会触发"连续两年扣非前后净利润孰低者为负" → *ST。
**口径关键**:监管原文是**连续两年**(不是三年),且 *ST 财务类强制退市的法定条件是 **"亏损 + 营收<板块阈值" 必须同时满足**——只看亏损链会系统性高估风险(实证:闻泰科技 2024 年亏损但营收超千亿,不会被 *ST)。
**方法**:
1. 取过去一年完整年报的 `n_income`、`fina_indicator.profit_dedt`,记 `worst_last = min(n_income, profit_dedt)`、`revenue_last`。
2. 用 R1 已经预测好的 `worst_profit_est` 和 `revenue_full_year_est`(基准情景),对应当前年度。
3. 检查两年的 `worst < 0` 组合,**并叠加营收联动条件**。
**触线判定**(双条件,亏损链 + 营收联动):
- 高风险:`worst_last < 0` 且 `worst_profit_est < 0`,**且**(R1 当前年命中高风险 **或** `revenue_full_year_est < 板块阈值 × 1.5`)——即两年均亏 + 营收处于退市风险区间。
- 中风险:(a) 连续两年均亏但营收远超阈值(≥ 阈值 × 1.5)——亏损链成立但不会触发财务类 *ST,仍可能因经营恶化转 ST;(b) 仅 `worst_profit_est < 0`(当前年预测亏损,去年盈利,未形成连续);(c) 仅 `worst_last < 0` 但当前年预测为正且接近 0(< 板块阈值 × 0.5)。
- 低风险:`worst_last ≥ 0` 且 `worst_profit_est ≥ 0`。
**为什么要叠加营收**:A 股 *ST 财务类强制退市的法定条件是"扣非前后净利润孰低 < 0 **且** 营收 < 板块阈值",二者必须同时满足。仅亏损不达营收阈值的公司(如大型周期股短期亏损)会被市场和监管视作"周期性亏损"而非"持续经营存疑"。
## 第五步:三项事实/临界证据面(不参与预测可信度)
### E1 审计意见(事实)
调用 `fina_audit`,取最近 2 个完整年报:
- 高风险:最新年报 `audit_result` 含"无法表示意见"或"否定意见";**或单次出现"保留意见"**(实证支持:上交所近年规范类 *ST 案例多为单次保留即触发)。
- 中风险:连续两年同一负面结论但本期已修正;或带强调事项段的无保留意见涉及持续经营。
- 低风险:标准无保留意见。
**禁止预测下一份审计意见**——审计是事后行为。
**akshare 兜底缺失时的人工核查路径**(`fina_audit` 无 akshare 替代接口,必须给出可点击 URL):
| 来源 | URL 模板 | 查询方式 |
|------|---------|---------|
| 巨潮资讯网(公告原文,权威) | `http://www.cninfo.com.cn/new/disclosure/stock?orgId=&stockCode={code6}` | 进入页面后筛选"定期报告 → 年度报告",下载 PDF 在第十节"财务报告 → 审计报告"查 `审计意见类型` |
| 巨潮高级搜索(按关键词) | `http://www.cninfo.com.cn/new/fulltextSearch/full?searchkey={code6}+审计报告&sdate=&edate=&isfulltext=false&sortName=time&sortType=desc` | 直接命中含"审计报告"标题的公告 |
| 上交所信息披露 | `http://www.sse.com.cn/disclosure/listedinfo/regular/?stockCode={code6}` | 仅限 60xxxx / 688xxx |
| 深交所信息披露 | `http://www.szse.cn/disclosure/listed/notice/index.html?stock={code6}` | 仅限 00xxxx / 30xxxx |
其中 `{code6}` 为 6 位股票代码(去掉 `.SH` / `.SZ` 后缀)。**强制要求**:当 `fina_audit` 返回空时,输出报告的"备注"必须列出上面巨潮的两条 URL 之一,便于用户人工核查;不得只写一句"审计意见数据缺失"了事。
### E2 监管处罚(事实,双窗口:上一财年 + 过去 12 个月)
**这是本 skill 的关键证据面。**
**窗口规则(双窗口策略,单次抓取在内存中切片)**:
- 窗口 A — `[当前年-1 的 1 月 1 日, 当前年-1 的 12 月 31 日]`(上一个完整财年):用于 reason_normalized 单条评级。
- 窗口 B — `[分析日 - 365 天, 分析日]`(过去 12 个月滚动):用于**频次增强规则**。
- 若分析时点已经在 5 月之后且最新年报已披露完毕,窗口 A 可改为 `[最新年报 end_date - 365 天, 最新年报 end_date]`。
**调用方式(推荐:单次拉全量 + 内存切片)**:
```bash
python agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py \
--ts-code 000729.SZ --stock-name 燕京啤酒 --no-filter
```
返回页面全量记录后,由 LLM 在内存中按窗口 A、窗口 B 切两份。也可以用 `--start-date / --end-date` 单独抓取,但同一次分析**避免发起两次 HTTP**。
输出 JSON 列表,字段:`ann_date / event_type / title / reason / reason_normalized / content / issuer / issuer_normalized / subject_normalized / target_relevance / e2_countable / source_url`。
**相关性铁律**:调用脚本时必须传入 `--stock-name {股票简称}`(来自 `stock_basic.name`),必要时用 `--alias` 补充曾用名/简称。新浪页面可能收录“某个人证券账户交易列表里出现目标股票”的记录,这类记录只说明目标股票被交易过,**不是上市公司/股东/董监高处罚**。脚本会将其标为 `target_relevance=security_mention_only` 且 `e2_countable=false`,E2 频次统计必须排除。
**`subject_normalized` 字段**(用于频次权重折算):
- `company` — 处罚主体是公司本身(默认;不含个人身份关键词时归此类)
- `shareholder` — 控股股东 / 实控人 / 5% 以上大股东
- `officer` — 董事 / 监事 / 高管(董事长、总经理、董秘、财务总监等)
**风险等级判定**:
| 命中 `reason_normalized` | 等级贡献 |
|---|---|
| 财务造假 / 虚假陈述 / 信息披露违规 | 高(强烈预警) |
| 违规担保 / 占用资金 | 高(直接触及 ST 红线) |
| 内幕交易 / 市场操纵 / 违规减持 | 中 |
| 其他/unknown | 低(仅作背景) |
**频次增强规则(治理质量预警,窗口 B)**:
监管函本身反映公司治理与内控质量,密集出现是"规范类 ST"(保留意见、内控非标、信披重大缺陷)的强领先指标。但**处罚主体不同,治理含义不同**:股东减持违规、董监高短线交易反映的是个人合规问题,不应等同于"公司治理失序"。
**主体加权计数**(按 `subject_normalized` 折算后再查表):
```
加权条数 = Σ 单条权重
其中:subject_normalized == "company" → 权重 1.0
subject_normalized == "officer" → 权重 0.5
subject_normalized == "shareholder" → 权重 0.5
```
| 过去 12 个月监管函/问询/警示函**加权条数** | 频次等级 |
|---|---|
| ≥ 5.0 | **极高** |
| 3.0 - 4.5 | 高 |
| 2.0 - 2.5 | 中 |
| < 2.0 | 低 |
**判定来源**:仅当 `e2_countable=true` 时,脚本输出的 `event_type ∈ {警示, 问讯, 监管关注, 监管函}` 或 `issuer_normalized ∈ {上交所, 深交所, 北交所, 证监会, 地方证监局}` 才计入条数;条数按 `subject_normalized` 折算后再查表。`target_relevance=security_mention_only` 的记录必须排除。
**输出要求**:必须同时给出"原始条数"和"加权条数"两个数字,便于用户审阅折算逻辑。例如:`窗口 B 命中 6 条(公司 2 条 + 股东 3 条 + 高管 1 条 → 加权 4.0 条),频次等级:高`。
**E2 综合等级合成规则**(解决单条评级与频次叠加的问题):
```
E2_单条等级 = 窗口 A 内所有 reason_normalized 单条等级的最大值
E2_频次等级 = 窗口 B 频次表查得
E2_综合等级 = max(E2_单条等级, E2_频次等级)
```
**叠加加成**:若同时满足 `E2_频次等级 ≥ 高` 且 `E2_单条等级 ≥ 高`(含财务造假/虚假陈述/违规担保/占用资金)→ **直升极高**。
**为什么要这条规则**:纯量化红线只能捕捉"财务类 ST",无法预警"规范类 ST"。频次规则把"治理失序"作为前瞻信号纳入。
**注意**:处罚证据**只抬升风险等级,不影响预测可信度**——这是事实证据,不是预测项。
### E3 交易类临界预警(事实)
调用 `daily`(近 30 个交易日,**必须 `adjust=''` 不复权**)和 `daily_basic`(最新一日,单位:万元):
**1 元退市预警**(用 `daily.close`,**不复权**):
- 高风险:近 20 个交易日中收盘价 < 1 元的天数 ≥ 10
- 中风险:≥ 1 但 < 10
- 低风险:0 天
**市值退市预警**(用 `daily_basic.total_mv`,单位万元 → ×1e4 换算成元 → /1e8 换算成亿元):
| 板块 | 高风险阈值 | 中风险阈值 |
|---|---|---|
| 主板 | < 5 亿 | < 5 × 1.5 = 7.5 亿 |
| 创业板 / 科创板 | < 3 亿 | < 3 × 1.5 = 4.5 亿 |
## 第六步:双轴输出模板
每次分析必须按下面模板输出。**风险等级 + 可信度两个维度都要给**。
```markdown
## ST 风险预测:{name}({ts_code})
### 当前状态
- 是否已 ST/*ST:{是 / 否}
- 板块:{主板 / 创业板 / 科创板}
- 基准预测时点:{forecast / express / 三季报(period) / 中报(period)}
### 量化红线预测(双轴)
| 红线 | 基准情景结论 | 风险等级 | 可信度 | 主要证据 |
|------|-------------|---------|-------|---------|
| R1 营收+净利润 | 全年营收预测 X 亿,净利润 Y 亿 | 高/中/低 | 高/中高/中/低 | forecast/三季报/... |
| R2 年末净资产 | 当前 X 亿,年末预计 Y 亿 | 高/中/低 | 高/中高/中/低 | balancesheet + R1 |
| R3 分红达标 | 三年累计预计 X,对应阈值 Y | 高/中/低 | 中/低 | dividend + income |
| R4 连续亏损链 | 过去两年 + 当前年是否均亏 | 高/中/低 | 同 R1 | income + fina_indicator |
### 事实证据面(不参与预测可信度)
| 证据 | 结论 | 影响 |
|------|------|------|
| E1 审计意见 | 最新结论 | 抬升/无影响 |
| E2 监管处罚(窗口 A 上一财年 / 窗口 B 过去 12 个月) | A 命中 Na 条(最高单条等级:…),B 原始 Nb_raw 条 / 加权 Nb_w 条(公司 a + 股东 b + 高管 c → a×1.0+b×0.5+c×0.5),频次等级:…,E2_综合等级:… | 抬升/无影响 |
| E3 交易类临界 | 收盘价/市值情况 | 抬升/无影响 |
### 综合结论
- **下一财年被 ST/*ST 风险等级**:{极高 / 高 / 中 / 低}
- **预测综合可信度**:{高 / 中高 / 中 / 低}
- **关键风险点**(最多 3 条):...
- **缓解信号**(如有):...
### 备注
- 数据时点:{所用最新报告期}
- 已忽略的不可预测项:财务造假、未来审计意见
- 季节性提醒(如适用):...
```
## 禁止事项
1. 不预测财务造假(事前不可知)。
2. 不预测下一份审计意见(事后才出)。
3. 不在没有公告依据时擅自计提减值。
4. 不混用板块阈值(主板 vs 创业板/科创板的营收/市值/分红阈值不同)。
5. 不把机械年化(H1×2、Q1Q3×4/3)伪装成"高可信度"。
6. 不在港股 / 美股 / 加密 / **北交所**(`8xxxxx.BJ`,监管规则与本 skill 阈值不一致)上启用本 skill。
7. 不把"违规处罚记录"计入预测可信度(仅抬升风险等级)。
8. 不在 R3 分红计算中使用 `cash_div_tax × base_share`(base_share 经常为 None,会得 0);必须用 `cash_div_tax / 10 × total_share`,且按 `(end_date, ann_date, cash_div_tax)` 三元组去重。
9. 不在 R1 触线判定中只用 `n_income`;必须用 `min(n_income, profit_dedt)`,否则会漏掉造壳公司。
10. 不把 R4 写成"连续三年";监管原文是"连续两年"。**且 R4 高风险必须叠加营收联动条件**——仅"连续两年亏损"不构成 *ST 财务类强制退市,必须同时满足"营收 < 板块阈值 × 1.5"或 R1 已命中高风险,否则只能给中风险。
11. 不把 E2 监管处罚"按主体一刀切"计数——必须用 `subject_normalized` 加权(公司 ×1.0 / 股东 ×0.5 / 董监高 ×0.5),否则股东动荡的公司会被系统性高估。
## 依赖的 tushare 接口清单
| 用途 | 接口 | 备注 |
|------|------|------|
| 当前 ST 状态 | `stock_st` | 3000 积分;不可用时回退 `namechange` |
| 历史改名 | `namechange` | 免费 |
| 板块判断 | `stock_basic` | 免费 |
| 利润表 | `income` | 2000 积分,多 period 拉取 |
| 资产负债表 | `balancesheet` | 2000 积分 |
| 财务指标(扣非等) | `fina_indicator` | 2000 积分 |
| 业绩预告 | `forecast` | 2000 积分 |
| 业绩快报 | `express` | 2000 积分 |
| 审计意见 | `fina_audit` | 500 积分 |
| 分红 | `dividend` | 2000 积分 |
| 日线 | `daily` | 免费 |
| 每日指标(市值) | `daily_basic` | 免费 |
| 财报披露日期 | `disclosure_date` | 500 积分 |
## 自带脚本
- [scripts/fetch_sina_penalties.py](scripts/fetch_sina_penalties.py) — 抓取新浪财经 vGP_GetOutOfLine 处罚页,stdlib 实现,无外部依赖;reason / issuer 标准化。
调用方式:
```bash
# 单次拉全量(推荐:本 skill 双窗口策略由 LLM 在内存中切片)
python agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py \
--ts-code 000729.SZ --stock-name 燕京啤酒 --no-filter
# 或指定单一窗口
python agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py \
--ts-code 000729.SZ --stock-name 燕京啤酒 --start-date 2025-01-01 --end-date 2025-12-31
```
正常情况下输出 JSON 到 stdout;网络/解析失败或参数非法时返回 `{"source": "unavailable", "error": "..."}` 到 stdout 并以非零退出码结束(不抛 traceback),便于 LLM 兜底处理。
## 端到端调用伪代码
用于减少 LLM 调用顺序漂移,按下列骨架组织:
```python
import os, json, subprocess
import tushare as ts
ts_code = '000729.SZ'
pro = ts.pro_api(os.environ['TUSHARE_TOKEN'])
# === Step 0: M0 当前状态 ===
basic = pro.stock_basic(ts_code=ts_code, fields='ts_code,name,market').to_dict('records')[0]
st_hit = pro.stock_st() # 失败则回退 namechange
name_chg = pro.namechange(ts_code=ts_code).sort_values('start_date', ascending=False)
# === Step 1: 板块判断 ===
board = '科创板' if ts_code.startswith('688') else '创业板' if ts_code.startswith('30') else '主板'
threshold_revenue = 1e8 if board != '主板' else 3e8
threshold_mv = 3e8 if board != '主板' else 5e8
# === Step 2: 拉财务(最新 5 期 + 过去 4 年年报) ===
income = pro.income(ts_code=ts_code).sort_values('end_date', ascending=False).drop_duplicates('end_date')
balance = pro.balancesheet(ts_code=ts_code).sort_values('end_date', ascending=False).drop_duplicates('end_date')
fina_ind = pro.fina_indicator(ts_code=ts_code).sort_values('end_date', ascending=False).drop_duplicates('end_date')
forecast = pro.forecast(ts_code=ts_code).sort_values('ann_date', ascending=False)
express = pro.express(ts_code=ts_code).sort_values('ann_date', ascending=False)
# === Step 3: R1-R4 计算(按各章节口径,注意 min(n_income, profit_dedt)) ===
# ... 略
# === Step 4: E1 审计 ===
audit = pro.fina_audit(ts_code=ts_code).sort_values('end_date', ascending=False).head(2)
# === Step 5: E2 监管处罚(双窗口 + 主体加权)===
from datetime import date, timedelta
result = subprocess.run([
'python', 'agent/src/skills/ashare-pre-st-filter/scripts/fetch_sina_penalties.py',
'--ts-code', ts_code, '--stock-name', basic['name'], '--no-filter',
], capture_output=True, text=True)
penalties = json.loads(result.stdout).get('records', [])
# 在内存中切双窗口(避免两次 HTTP)
this_year = date.today().year
win_a_start, win_a_end = f'{this_year-1}-01-01', f'{this_year-1}-12-31'
win_b_start = (date.today() - timedelta(days=365)).isoformat()
win_b_end = date.today().isoformat()
def _in(rec, s, e):
d = rec.get('ann_date') or ''
return bool(d) and s <= d <= e
win_a = [r for r in penalties if _in(r, win_a_start, win_a_end)]
win_b = [r for r in penalties if _in(r, win_b_start, win_b_end)]
# 窗口 A:单条等级取最高
REASON_LEVEL = {
'财务造假': 3, '虚假陈述': 3, '信息披露违规': 3,
'违规担保': 3, '占用资金': 3,
'内幕交易': 2, '市场操纵': 2, '违规减持': 2,
}
levels_a = [REASON_LEVEL.get(r['reason_normalized'], 1) for r in win_a]
e2_single = max(levels_a) if levels_a else 0
# 窗口 B:原始条数 + 主体加权条数
FREQ_EVENT_TYPES = {'警示', '问讯', '监管关注', '监管函', '警示函'}
FREQ_ISSUERS = {'上交所', '深交所', '北交所', '证监会', '地方证监局'}
SUBJECT_WEIGHT = {'company': 1.0, 'shareholder': 0.5, 'officer': 0.5}
freq_pool = [r for r in win_b
if r.get('e2_countable', True)
and (r['event_type'] in FREQ_EVENT_TYPES
or r['issuer_normalized'] in FREQ_ISSUERS)]
nb_raw = len(freq_pool)
nb_weighted = round(sum(SUBJECT_WEIGHT.get(r['subject_normalized'], 1.0)
for r in freq_pool), 1)
# 频次等级(基于加权条数)
if nb_weighted >= 5.0: e2_freq = 4 # 极高
elif nb_weighted >= 3.0: e2_freq = 3 # 高
elif nb_weighted >= 2.0: e2_freq = 2 # 中
else: e2_freq = 1 # 低
e2_overall = max(e2_single, e2_freq)
# 叠加加成:频次高 + 单条高同时命中 → 直升极高
if e2_freq >= 3 and e2_single >= 3:
e2_overall = 4
# === Step 6: E3 交易临界 ===
daily = pro.daily(ts_code=ts_code).sort_values('trade_date', ascending=False).head(30)
daily_basic = pro.daily_basic(ts_code=ts_code).sort_values('trade_date', ascending=False).head(1).iloc[0]
total_mv_yi = daily_basic['total_mv'] * 1e4 / 1e8 # 万元 → 亿元
# === Step 7: 双轴合成输出 ===
# 风险等级 = max(R1, R2, R3, R4, E1, E2_综合, E3)
# 可信度 = 加权平均(R1, R2, R3, R4 各项可信度),akshare 兜底项降一档
```
@@ -0,0 +1,627 @@
#!/usr/bin/env python3
"""新浪财经 A 股监管处罚记录抓取脚本(独立 stdlib 实现)。
用途:为 ashare-pre-st-filter skill 的 E2 证据面提供输入。
数据源:vip.stock.finance.sina.com.cn/corp/go.php/vGP_GetOutOfLine/stockid/<6位>.phtml
解析目标:HTML 表格 <table id="collectFund_1">,每条记录由 <thead>(类型/公告日期) + 4 行 (标题/批复原因/批复内容/处理人) 构成。
安全约束:
- SSRF 防护:仅允许 host == vip.stock.finance.sina.com.cn
- GBK 解码(新浪页面)
- 节流 + 指数退避重试
输出:JSON 到 stdout;失败时返回 {"source": "unavailable", "error": "..."}。
"""
from __future__ import annotations
import argparse
import gzip
import json
import re
import sys
import time
import unicodedata
from html.parser import HTMLParser
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
ALLOWED_HOST = "vip.stock.finance.sina.com.cn"
URL_TEMPLATE = (
"https://vip.stock.finance.sina.com.cn/corp/go.php/vGP_GetOutOfLine/stockid/{code6}.phtml"
)
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36"
)
DEFAULT_TIMEOUT = 15
MAX_RETRIES = 3
THROTTLE_SECONDS = 0.4
REASON_KEYWORDS = [
("信息披露违规", ["信息披露", "未及时披露", "未披露", "披露不", "披露违规"]),
("内幕交易", ["内幕交易", "内幕信息"]),
("市场操纵", ["操纵市场", "操纵股价", "操纵证券"]),
("财务造假", ["财务造假", "虚增收入", "虚增利润", "财务舞弊"]),
("虚假陈述", ["虚假记载", "虚假陈述", "误导性陈述"]),
("违规减持", ["违规减持", "短线交易", "违规增减持"]),
("占用资金", ["占用资金", "资金占用", "非经营性占用"]),
("违规担保", ["违规担保", "对外担保"]),
]
ISSUER_KEYWORDS = [
("上交所", ["上海证券交易所", "上交所"]),
("深交所", ["深圳证券交易所", "深交所"]),
("北交所", ["北京证券交易所", "北交所"]),
# 地方证监局必须优先于证监会,否则"中国证监会北京监管局"会被误归为证监会。
("地方证监局", ["证监局"]),
("证监会", ["中国证券监督管理委员会", "证监会"]),
]
# 处罚主体识别。匹配优先级:shareholder > officer > company(默认)。
# 用于 E2 频次规则的权重折算——非公司主体(股东/董监高)权重 ×0.5,
# 因为这类处罚反映个人行为,不等同于"公司治理失序"。
#
# 优先级决策依据(CR P1-2):身份叠加(如"董事长 X 兼控股股东")时,
# 监管语境取最重责任主体——shareholder 责任高于 officer,故先扫 shareholder。
SUBJECT_KEYWORDS = [
("shareholder", [
"控股股东", "实际控制人", "实控人", "原实际控制人", "原实控人",
"持股5%以上", "持股 5%以上", "5%以上股东", "5%以上的股东",
"第一大股东", "二股东", "大股东", "股东减持",
"股东收到", "股东因", "原股东", "前股东", "一致行动人",
]),
("officer", [
"董事长", "副董事长", "总经理", "副总经理", "总裁",
"财务总监", "董事会秘书", "董秘", "证券事务代表",
"监事会主席", "独立董事",
"时任董事", "时任监事", "时任高管", "时任董秘",
"高级管理人员", "高管人员", "聘任的高级管理人员",
]),
]
DATE_RE = re.compile(r"公告日期[::]\s*(\d{4}-\d{1,2}-\d{1,2})")
SECURITY_MENTION_ONLY_KEYWORDS = [
"证券从业",
"证券账户",
"控制使用",
"持有并交易",
"交易股票",
"买入",
"卖出",
"账户交易",
]
def _norm_text(text: str) -> str:
"""文本归一化:NFKC 折叠全角/半角 + 去除内部空白。
应用场景(CR P2):新浪页面会出现“5%以上股东”、“控股 股东”等变体,
原吝始“kw in text”会漏命中。归一化后“5%”→“5%”,中文间空格被吃掉。
"""
if not text:
return ""
s = unicodedata.normalize("NFKC", text)
return re.sub(r"\s+", "", s)
def _validate_stockid(ts_code: str) -> str:
"""从 600000.SH / 000001.SZ / 600000 中抽取 6 位股票代码。"""
if not ts_code:
raise ValueError("ts_code is required")
s = ts_code.strip().upper()
s = re.sub(r"\.(SH|SZ|BJ)$", "", s)
if not re.fullmatch(r"\d{6}", s):
raise ValueError(f"invalid ts_code: {ts_code!r} (expect 6-digit code)")
return s
def _normalize_reason(text: str) -> str:
if not text:
return "unknown"
norm = _norm_text(text)
for label, kws in REASON_KEYWORDS:
for kw in kws:
if _norm_text(kw) in norm:
return label
return "unknown"
def _normalize_issuer(text: str) -> str:
if not text:
return "unknown"
norm = _norm_text(text)
for label, kws in ISSUER_KEYWORDS:
for kw in kws:
if _norm_text(kw) in norm:
return label
return "unknown"
def _normalize_subject(text: str) -> str:
"""从标题/原因/内容文本推断处罚主体。
返回值:
- "shareholder" 控股股东 / 实控人 / 5% 以上大股东
- "officer" 董事 / 监事 / 高管(董秘、财务总监等)
- "company" 默认;当文本无任何上述关键词时归为公司本身
供 E2 频次规则做主体权重折算(非公司主体 ×0.5)。
"""
if not text:
return "company"
norm = _norm_text(text)
for label, kws in SUBJECT_KEYWORDS:
for kw in kws:
if _norm_text(kw) in norm:
return label
return "company"
def _build_target_aliases(stock_name: str | None, aliases: list[str] | None = None) -> list[str]:
"""Build normalized target-name aliases for relevance checks.
Args:
stock_name: Official short name from ``stock_basic.name``.
aliases: Extra caller-provided aliases.
Returns:
Deduplicated normalized aliases.
"""
candidates = [stock_name or "", *(aliases or [])]
out: list[str] = []
seen: set[str] = set()
for candidate in candidates:
norm = _norm_text(candidate)
if not norm or norm in seen:
continue
seen.add(norm)
out.append(norm)
return out
def _contains_any_alias(text: str, aliases: list[str]) -> bool:
norm = _norm_text(text)
return bool(norm and any(alias in norm for alias in aliases))
def _classify_target_relevance(
record: dict[str, Any],
target_aliases: list[str],
) -> tuple[str, bool, str]:
"""Classify whether a Sina penalty record is countable for this stock.
Args:
record: Parsed penalty record.
target_aliases: Normalized stock-name aliases.
Returns:
``(target_relevance, e2_countable, relevance_reason)``.
"""
if not target_aliases:
return "unknown", True, "stock_name_not_provided"
title = str(record.get("title") or "")
reason = str(record.get("reason") or "")
content = str(record.get("content") or "")
combined = " ".join([title, reason, content])
if not _contains_any_alias(combined, target_aliases):
return "unknown", False, "target_alias_not_found"
title_hits_target = _contains_any_alias(title, target_aliases)
subject = str(record.get("subject_normalized") or "company")
looks_like_security_mention = any(
_norm_text(keyword) in _norm_text(combined)
for keyword in SECURITY_MENTION_ONLY_KEYWORDS
)
if not title_hits_target and looks_like_security_mention:
return "security_mention_only", False, "target_only_appears_in_security_trade_list"
if subject in {"shareholder", "officer"}:
return "related_party", True, f"target_named_with_{subject}"
return "issuer_company", True, "target_named_as_company_record"
def _annotate_relevance(
records: list[dict[str, Any]],
target_aliases: list[str],
) -> list[dict[str, Any]]:
"""Add target relevance fields used by E2 frequency counting."""
for rec in records:
relevance, countable, reason = _classify_target_relevance(rec, target_aliases)
rec["target_relevance"] = relevance
rec["e2_countable"] = countable
rec["relevance_reason"] = reason
if relevance == "security_mention_only" and rec.get("subject_normalized") == "company":
rec["subject_normalized"] = "unknown"
return records
def _http_get_gbk(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise ValueError(f"disallowed scheme: {parsed.scheme}")
if parsed.hostname != ALLOWED_HOST:
raise ValueError(f"disallowed host: {parsed.hostname}")
last_err: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
req = Request(
url,
headers={
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml",
"Accept-Encoding": "gzip",
"Accept-Language": "zh-CN,zh;q=0.9",
},
)
with urlopen(req, timeout=timeout) as resp: # noqa: S310 — host is whitelisted
raw = resp.read()
if resp.headers.get("Content-Encoding", "").lower() == "gzip":
raw = gzip.decompress(raw)
try:
return raw.decode("gbk", errors="replace")
except LookupError:
return raw.decode("utf-8", errors="replace")
except (HTTPError, URLError, TimeoutError) as exc:
last_err = exc
time.sleep(THROTTLE_SECONDS * (2 ** (attempt - 1)))
raise RuntimeError(f"http get failed after {MAX_RETRIES} retries: {last_err}")
class _TableExtractor(HTMLParser):
"""提取 <table id="collectFund_1"> 的内部 HTML 文本(包含子标签)。"""
def __init__(self) -> None:
super().__init__(convert_charrefs=False)
self._depth = 0
self._capture = False
self._buf: list[str] = []
self.found: str | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag == "table":
attrs_d = dict(attrs)
if not self._capture and attrs_d.get("id") == "collectFund_1":
self._capture = True
self._depth = 1
return
if self._capture:
self._depth += 1
if self._capture:
attr_str = "".join(f' {k}="{v}"' if v is not None else f" {k}" for k, v in attrs)
self._buf.append(f"<{tag}{attr_str}>")
def handle_endtag(self, tag: str) -> None:
if not self._capture:
return
if tag == "table":
self._depth -= 1
if self._depth <= 0:
self.found = "".join(self._buf)
self._capture = False
return
self._buf.append(f"</{tag}>")
def handle_data(self, data: str) -> None:
if self._capture:
self._buf.append(data)
def handle_entityref(self, name: str) -> None:
if self._capture:
self._buf.append(f"&{name};")
def handle_charref(self, name: str) -> None:
if self._capture:
self._buf.append(f"&#{name};")
# event_type 黑名单:head 文本首个中文片段如果落在这些"结构性字面量"上,
# 说明该 thead 没有显式标注事件类型,应回退到"未分类"。
# (CR P0-3:避免把"公告日期"误识别为 event_type,导致 E2 频次规则漏命中。)
_EVENT_TYPE_BLOCKLIST = {
"公告日期", "日期", "批复", "批复内容", "批复原因",
"处罚日期", "处罚原因", "处罚内容", "处罚机关",
"标题", "处理人", "类型",
}
def _extract_event_type(head_text: str) -> str:
"""从 thead 文本中提取事件类型,跳过结构性字面量。"""
for m in re.finditer(r"[\u4e00-\u9fa5]+", head_text):
token = m.group(0)
if token in _EVENT_TYPE_BLOCKLIST:
continue
return token
return "未分类"
class _RecordParser(HTMLParser):
"""以 HTMLParser 切分 collectFund_1 表格中的单条记录。
替代原 `_DATA_ROW_RE` + `_RECORD_HEAD_RE` 正则方案——后者
在 `re.DOTALL + (?:(?!</tr>).)*?` 下属于灾难性回溯模式,
遇到未闭合 `<tr>` 的脏 HTML 会指数级回溯(CR P0-1)。
解析模型:
- 状态机以 `<thead>` 为记录起点;遇到下一个 `<thead>` 落盘上一条。
- 记录内的 `<tr>` 抓取 `<strong>KEY</strong>` 与同行第一个 `<td>VALUE</td>`。
"""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.records: list[dict[str, str]] = []
self._cur: dict[str, Any] | None = None
self._mode: str | None = None # 'thead' | 'tr_key' | 'tr_val' | None
self._buf: list[str] = []
self._cur_key: str = ""
self._val_captured: bool = False # 同一 tr 内只取第一个 value td
def _flush(self) -> None:
if self._cur is not None:
self.records.append(self._cur)
self._cur = {"_head": "", "_fields": {}}
def _capture(self, data: str) -> None:
if self._mode in ("thead", "tr_key", "tr_val"):
self._buf.append(data)
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: # noqa: ARG002
if tag == "thead":
self._flush()
self._mode = "thead"
self._buf = []
elif tag == "tr":
self._cur_key = ""
self._val_captured = False
elif tag == "strong" and self._mode not in ("thead", "tr_val"):
# 仅在 row 起始(尚未进入 value td)时把 strong 视为 key;
# 否则可能是 value 内的 <strong> 嵌套,应保留为 value 文本。
self._mode = "tr_key"
self._buf = []
elif tag == "td":
# 进入 td:仅当已读到 key 且本行尚未捕获 value 时切到 tr_val
if self._mode != "thead" and self._cur_key and not self._val_captured:
self._mode = "tr_val"
self._buf = []
def handle_endtag(self, tag: str) -> None:
if tag == "thead" and self._mode == "thead":
head_text = re.sub(r"\s+", " ", "".join(self._buf)).strip()
if self._cur is None:
self._cur = {"_head": "", "_fields": {}}
self._cur["_head"] = head_text
self._mode = None
self._buf = []
elif tag == "strong" and self._mode == "tr_key":
self._cur_key = "".join(self._buf).strip().rstrip("::")
self._mode = None
self._buf = []
elif tag == "td" and self._mode == "tr_val":
val = re.sub(r"\s+", " ", "".join(self._buf)).strip()
if self._cur is not None and self._cur_key:
self._cur["_fields"][self._cur_key] = val
self._val_captured = True
self._cur_key = ""
self._mode = None
self._buf = []
elif tag == "tr":
self._cur_key = ""
self._val_captured = False
def handle_data(self, data: str) -> None:
self._capture(data)
def close(self) -> None: # type: ignore[override]
super().close()
# 落盘最后一条
if self._cur is not None and (self._cur.get("_head") or self._cur.get("_fields")):
self.records.append(self._cur)
self._cur = None
def _build_record(raw: dict[str, Any]) -> dict[str, Any] | None:
head = raw.get("_head") or ""
fields: dict[str, str] = raw.get("_fields") or {}
date_m = DATE_RE.search(head)
ann_date = ""
if date_m:
try:
y, mo, d = date_m.group(1).split("-")
ann_date = f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
except Exception:
ann_date = date_m.group(1)
# event_type:先剥掉"公告日期: YYYY-MM-DD"再扫剩余首个中文片段;
# 双重防御——剥不干净时再用黑名单兜底(CR P0-3)。
head_wo_date = DATE_RE.sub("", head)
head_wo_date = re.sub(r"公告日期[::]?", "", head_wo_date)
event_type = _extract_event_type(head_wo_date) or _extract_event_type(head)
title = fields.get("标题", "")
reason = fields.get("批复原因", "") or fields.get("处罚原因", "")
content = fields.get("批复内容", "") or fields.get("处罚内容", "")
issuer = fields.get("处理人", "") or fields.get("处罚机关", "")
if not (title or reason or content):
return None
combined_for_reason = " ".join([title, reason, content])
return {
"ann_date": ann_date,
"event_type": event_type,
"title": title,
"reason": reason,
"reason_normalized": _normalize_reason(combined_for_reason),
"content": content,
"issuer": issuer,
"issuer_normalized": _normalize_issuer(issuer),
"subject_normalized": _normalize_subject(combined_for_reason),
}
def _parse_penalty_list(html: str) -> list[dict[str, Any]]:
extractor = _TableExtractor()
extractor.feed(html)
if not extractor.found:
return []
parser = _RecordParser()
parser.feed(extractor.found)
parser.close()
out: list[dict[str, Any]] = []
for raw in parser.records:
rec = _build_record(raw)
if rec is not None:
out.append(rec)
return out
def _apply_date_filter(
records: list[dict[str, Any]],
start_date: str | None,
end_date: str | None,
) -> list[dict[str, Any]]:
if not start_date and not end_date:
return records
out: list[dict[str, Any]] = []
for rec in records:
d = rec.get("ann_date") or ""
if not d:
# 公告日缺失时保留记录,但添加标记供 LLM 识别。
rec.setdefault("_warning", "missing_ann_date")
out.append(rec)
continue
if start_date and d < start_date:
continue
if end_date and d > end_date:
continue
out.append(rec)
return out
def fetch_penalty_list(
ts_code: str,
*,
start_date: str | None = None,
end_date: str | None = None,
stock_name: str | None = None,
aliases: list[str] | None = None,
timeout: int = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
code6 = _validate_stockid(ts_code)
url = URL_TEMPLATE.format(code6=code6)
try:
html = _http_get_gbk(url, timeout=timeout)
except Exception as exc: # noqa: BLE001
return {
"source": "unavailable",
"ts_code": ts_code,
"url": url,
"error": f"http_failed: {exc}",
"records": [],
}
try:
records = _parse_penalty_list(html)
except Exception as exc: # noqa: BLE001
return {
"source": "unavailable",
"ts_code": ts_code,
"url": url,
"error": f"parse_failed: {exc}",
"records": [],
}
target_aliases = _build_target_aliases(stock_name, aliases)
filtered = _apply_date_filter(_annotate_relevance(records, target_aliases), start_date, end_date)
for rec in filtered:
rec["source_url"] = url
return {
"source": "sina",
"ts_code": ts_code,
"url": url,
"target_aliases": target_aliases,
"start_date": start_date,
"end_date": end_date,
"total_in_page": len(records),
"matched": len(filtered),
"records": filtered,
}
def _normalize_date_arg(s: str | None) -> str | None:
if not s:
return None
s = s.strip()
if not s:
return None
m = re.fullmatch(r"(\d{4})[-/]?(\d{1,2})[-/]?(\d{1,2})", s)
if not m:
raise ValueError(f"invalid date: {s!r}")
y, mo, d = m.groups()
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ts-code", required=True, help="例如 600000.SH / 000001.SZ / 688001")
parser.add_argument("--stock-name", default=None, help="股票简称,例如 闻泰科技;用于过滤误召处罚记录")
parser.add_argument(
"--alias",
action="append",
default=[],
help="额外目标别名;可重复传入",
)
parser.add_argument("--start-date", default=None, help="YYYY-MM-DD,含端点")
parser.add_argument("--end-date", default=None, help="YYYY-MM-DD,含端点")
parser.add_argument(
"--no-filter",
action="store_true",
help="返回页面全量记录、忽略 --start-date / --end-date(推荐:由调用方在内存中切双窗口,避免两次 HTTP)",
)
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument("--indent", type=int, default=2)
args = parser.parse_args(argv)
try:
if args.no_filter:
start_date = None
end_date = None
else:
start_date = _normalize_date_arg(args.start_date)
end_date = _normalize_date_arg(args.end_date)
except ValueError as exc:
print(json.dumps({"source": "unavailable", "error": str(exc)}, ensure_ascii=False))
return 2
try:
result = fetch_penalty_list(
args.ts_code,
start_date=start_date,
end_date=end_date,
stock_name=args.stock_name,
aliases=args.alias,
timeout=args.timeout,
)
except ValueError as exc:
# _validate_stockid 等参数校验异常:保持脚本契约
# (永远输出 JSON,不抛 traceback),返回非零退出码。
json.dump(
{"source": "unavailable", "ts_code": args.ts_code, "error": f"invalid_input: {exc}", "records": []},
sys.stdout,
ensure_ascii=False,
indent=args.indent,
)
sys.stdout.write("\n")
return 2
json.dump(result, sys.stdout, ensure_ascii=False, indent=args.indent)
sys.stdout.write("\n")
return 0 if result.get("source") == "sina" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -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
+196
View File
@@ -0,0 +1,196 @@
---
name: dividend-analysis
description: Dividend stock analysis for income, dividend-growth, and shareholder-return strategies, including yield quality, payout sustainability, ex-dividend mechanics, and yield-trap checks.
category: analysis
---
# Dividend Analysis
## Purpose
Use this skill when the user asks about dividend stocks, income portfolios, dividend growth, high-yield screening, payout safety, ex-dividend dates, or whether a dividend is sustainable. The goal is to separate durable shareholder returns from yield traps.
Dividend analysis should never stop at headline yield. A good answer explains how the dividend is funded, how stable the underlying business is, whether management has room to keep paying, and how valuation changes the expected total return.
## Core Questions
1. What is the current cash yield, and is it normal for this company or sector?
2. Is the payout covered by earnings, operating cash flow, and free cash flow?
3. Is the balance sheet strong enough to absorb a down cycle?
4. Has management grown, held, cut, or suspended the dividend across cycles?
5. Does the valuation still leave room for total return after taxes and reinvestment assumptions?
## Key Metrics
| Metric | Formula | Healthy Signal | Warning Signal |
|--------|---------|----------------|----------------|
| Dividend yield | annual DPS / current price | Above peer median with stable coverage | Extremely high vs history or peers |
| Earnings payout ratio | dividends / net income, or DPS / EPS | 30-70% for mature non-financials | Above 90%, negative earnings |
| Free-cash-flow payout | dividends / FCF | Below 70% through a cycle | Dividend exceeds FCF for 2+ years |
| CFO coverage | operating cash flow / dividends paid | Above 1.5x | Below 1.0x |
| Dividend CAGR | DPS growth over 3/5/10 years | Positive and below EPS/FCF growth | Growth funded by leverage |
| Net debt / EBITDA | net debt / EBITDA | Sector-appropriate leverage | Leverage rising while payout rises |
| Buyback plus dividend yield | (dividends + net buybacks) / market cap | Balanced capital return | Buybacks funded by debt at high valuation |
For REITs, utilities, banks, MLPs, and insurers, adapt the payout metric to the sector. For example, use AFFO payout for REITs, distributable cash flow for MLPs, and regulatory capital ratios for banks and insurers.
## Analysis Workflow
### Step 1: Normalize the Dividend
- Use forward indicated dividend for recurring payments.
- Separate ordinary dividends from special dividends.
- Check whether the latest declared dividend is annual, semiannual, quarterly, monthly, or irregular.
- For ADRs and cross-listed shares, account for depositary ratios, withholding tax, and FX conversion.
```python
annual_dividend = regular_dividend_per_period * payments_per_year
dividend_yield = annual_dividend / current_price
```
### Step 2: Check Coverage
Start with earnings coverage, then confirm with cash coverage.
```python
earnings_payout = dividends_paid / net_income
fcf_payout = dividends_paid / free_cash_flow
cfo_coverage = operating_cash_flow / dividends_paid
```
Interpretation:
- Good: net income, CFO, and FCF all cover dividends across multiple years.
- Watch: earnings cover dividends but FCF does not, especially during capex-heavy periods.
- Avoid: dividends are paid while both earnings and FCF are negative, unless there is a clear one-time reason and a strong balance sheet.
### Step 3: Diagnose Dividend Growth Quality
Dividend growth is high quality when it follows business growth.
```python
dividend_cagr = (dps_end / dps_start) ** (1 / years) - 1
eps_cagr = (eps_end / eps_start) ** (1 / years) - 1
fcf_cagr = (fcf_end / fcf_start) ** (1 / years) - 1
```
Quality rules:
- Dividend CAGR below EPS and FCF CAGR usually leaves room for future increases.
- Dividend CAGR above EPS/FCF CAGR means payout ratio is expanding.
- Flat dividend with rising FCF may imply hidden capacity or conservative management.
- Repeated small increases can still be fragile if leverage is rising.
### Step 4: Check Balance Sheet Flexibility
Look for the ability to maintain dividends during stress.
| Item | Why It Matters |
|------|----------------|
| Cash and short-term investments | Near-term cushion |
| Net debt / EBITDA | Debt burden against operating earnings |
| Interest coverage | Ability to service debt before shareholder returns |
| Debt maturity wall | Refinancing risk in high-rate environments |
| Credit rating or covenant language | External constraints on payout policy |
### Step 5: Separate Dividend Yield from Total Return
Dividend stocks can underperform if the yield comes from a falling price. Always connect income to valuation and growth.
```python
expected_total_return = dividend_yield + expected_eps_growth + valuation_rerating
```
Do not present this as a guarantee. Use it as a scenario framework.
## Yield-Trap Checklist
Flag a potential yield trap when several of these are true:
- Dividend yield is more than 2x the company's 5-year median or sector median.
- Payout ratio is above 90%, or FCF payout is above 100%.
- Revenue, EPS, or FCF has declined for 2+ years.
- Net debt / EBITDA is rising while interest coverage is falling.
- Management has recently issued equity or debt while maintaining dividends.
- The stock price fell before the yield became attractive.
- Dividend history includes cuts, suspensions, or frequent special dividends labeled as ordinary income.
- Sector faces structural pressure, regulation risk, or commodity down-cycle exposure.
## Strategy Types
### Dividend Growth
Prioritize moderate yield, strong dividend CAGR, low payout ratio, and durable business quality.
Good for users seeking compounding and lower cut risk.
### High-Yield Quality
Prioritize yield, but require cash coverage, balance sheet resilience, and sector-aware payout norms.
Good for users seeking current income, but the answer must discuss cut risk.
### Shareholder Yield
Combine dividends, net buybacks, and debt reduction.
Useful when companies return capital mostly through buybacks rather than cash dividends.
```python
shareholder_yield = dividend_yield + net_buyback_yield + debt_paydown_yield
```
### Dividend Capture
Buying before the ex-dividend date only to collect the dividend is not a free-money strategy. Prices usually adjust around the ex-dividend date, and taxes, spreads, and slippage can erase the gross dividend.
Use this only as an event-risk analysis, not as a default recommendation.
## Data Sources
| Market | Useful Fields |
|--------|---------------|
| A-shares | Tushare `dividend`, `daily_basic.dv_ttm`, `fina_indicator`, `cashflow` |
| US/HK | yfinance `Ticker.dividends`, `Ticker.info`, financial statements, cash flow |
| ETFs | distribution yield, SEC yield, holdings yield, expense ratio, distribution history |
| REITs | FFO, AFFO, occupancy, debt maturities, AFFO payout |
When live data is unavailable, state the limitation and provide the analysis template instead of inventing dividend figures.
## Output Template
```markdown
### Dividend Analysis: [ticker/company]
**Verdict:** [sustainable / watchlist / yield-trap risk]
| Metric | Value | Interpretation |
|--------|-------|----------------|
| Dividend yield | ... | ... |
| Earnings payout | ... | ... |
| FCF payout | ... | ... |
| Dividend growth | ... | ... |
| Balance sheet | ... | ... |
**What supports the dividend**
- ...
**What could break the dividend**
- ...
**Scenario view**
- Base: ...
- Downside: ...
- Upside: ...
**Research note:** This is investment research, not live trading advice.
```
## Common Mistakes
- Treating high yield as cheap valuation without checking why the price fell.
- Mixing special dividends with regular dividends.
- Comparing REIT payout ratios to ordinary industrial companies.
- Ignoring withholding tax, ADR ratios, currency conversion, or ETF expense drag.
- Forgetting that ex-dividend capture is usually offset by price adjustment and transaction costs.
- Recommending a dividend stock without discussing total return and dividend-cut risk.
+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
+2 -1
View File
@@ -12,7 +12,7 @@ from src.swarm.models import (
TaskStatus,
WorkerResult,
)
from src.swarm.presets import build_run_from_preset, list_presets, load_preset
from src.swarm.presets import build_run_from_preset, inspect_preset, list_presets, load_preset
from src.swarm.runtime import SwarmRuntime
from src.swarm.store import SwarmStore
from src.swarm.worker import run_worker
@@ -29,6 +29,7 @@ __all__ = [
"TaskStatus",
"WorkerResult",
"build_run_from_preset",
"inspect_preset",
"list_presets",
"load_preset",
"run_worker",
+149 -3
View File
@@ -1,20 +1,26 @@
"""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
import uuid
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from string import Formatter
import yaml
from src.swarm.models import RunStatus, SwarmAgentSpec, SwarmRun, SwarmTask, TaskStatus
from src.swarm.task_store import topological_layers, validate_dag
PRESETS_DIR = Path(__file__).resolve().parents[2] / "config" / "swarm"
PRESETS_DIR = Path(__file__).resolve().parent / "presets"
_INTERNAL_TEMPLATE_VARS = {"upstream_context"}
def load_preset(name: str) -> dict:
@@ -65,6 +71,146 @@ def list_presets() -> list[dict]:
return results
def _declared_variable_names(raw_variables: list) -> set[str]:
"""Extract variable names from the YAML variables section."""
names: set[str] = set()
for item in raw_variables:
if isinstance(item, dict):
name = item.get("name")
else:
name = str(item)
if name:
names.add(str(name))
return names
def _template_variables(template: str) -> set[str]:
"""Return Python format fields referenced by a prompt template."""
variables: set[str] = set()
for _, field_name, _, _ in Formatter().parse(template or ""):
if not field_name:
continue
root = field_name.split(".", 1)[0].split("[", 1)[0]
if root and root not in _INTERNAL_TEMPLATE_VARS:
variables.add(root)
return variables
def inspect_preset(name: str) -> dict:
"""Validate a swarm preset and return a dry-run execution plan.
This does not start workers or call an LLM. It catches common YAML/DAG
mistakes early and exposes the topological task layers used by the runtime.
"""
data = load_preset(name)
run = build_run_from_preset(name, {})
errors: list[str] = []
warnings: list[str] = []
agent_ids = [agent.id for agent in run.agents]
task_ids = [task.id for task in run.tasks]
agent_id_set = set(agent_ids)
task_id_set = set(task_ids)
for duplicate in sorted(item for item, count in Counter(agent_ids).items() if count > 1):
errors.append(f"Duplicate agent id: {duplicate}")
for duplicate in sorted(item for item, count in Counter(task_ids).items() if count > 1):
errors.append(f"Duplicate task id: {duplicate}")
for task in run.tasks:
if task.agent_id not in agent_id_set:
errors.append(f"Task '{task.id}' references unknown agent '{task.agent_id}'")
for _, upstream_task_id in task.input_from.items():
if upstream_task_id not in task_id_set:
errors.append(
f"Task '{task.id}' input_from references unknown task '{upstream_task_id}'"
)
layers: list[list[str]] = []
try:
validate_dag(run.tasks)
layers = topological_layers(run.tasks)
except ValueError as exc:
errors.append(str(exc))
dependents: dict[str, list[str]] = defaultdict(list)
for task in run.tasks:
for dep in task.depends_on:
dependents[dep].append(task.id)
def is_upstream(candidate: str, task_id: str) -> bool:
"""Return whether candidate can reach task_id through dependency edges."""
seen: set[str] = set()
stack = [candidate]
while stack:
current = stack.pop()
if current == task_id:
return True
if current in seen:
continue
seen.add(current)
stack.extend(dependents.get(current, []))
return False
for task in run.tasks:
for key, upstream_task_id in task.input_from.items():
if upstream_task_id in task_id_set and not is_upstream(upstream_task_id, task.id):
warnings.append(
f"Task '{task.id}' input_from '{key}' references '{upstream_task_id}', "
"which is not upstream in the DAG"
)
declared_variables = _declared_variable_names(data.get("variables", []))
used_variables: set[str] = set()
for task in data.get("tasks", []):
try:
used_variables.update(_template_variables(task.get("prompt_template", "")))
except ValueError as exc:
errors.append(f"Task '{task.get('id', '?')}' has invalid prompt template: {exc}")
missing_declarations = sorted(used_variables - declared_variables)
unused_declarations = sorted(declared_variables - used_variables)
if missing_declarations:
warnings.append(
"Prompt templates use undeclared variables: " + ", ".join(missing_declarations)
)
if unused_declarations:
warnings.append(
"Declared variables are not used by task prompt templates: "
+ ", ".join(unused_declarations)
)
task_agent = {task.id: task.agent_id for task in run.tasks}
return {
"name": data.get("name", name),
"title": data.get("title", ""),
"description": data.get("description", ""),
"valid": not errors,
"errors": errors,
"warnings": warnings,
"variables": sorted(declared_variables),
"used_variables": sorted(used_variables),
"agents": [
{"id": agent.id, "role": agent.role, "tools": agent.tools, "skills": agent.skills}
for agent in run.agents
],
"tasks": [
{
"id": task.id,
"agent_id": task.agent_id,
"depends_on": task.depends_on,
"input_from": task.input_from,
}
for task in run.tasks
],
"layers": [
[{"task_id": task_id, "agent_id": task_agent.get(task_id, "")} for task_id in layer]
for layer in layers
],
}
def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmRun:
"""Create a SwarmRun from a preset with user variables applied.
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more